diff --git a/CHANGELOG b/CHANGELOG index 008c5c64284..7ea2631f9f4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,13 +1,17 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.11.0 (unreleased) + - Add test coverage report badge. !5708 - Remove the http_parser.rb dependency by removing the tinder gem. !5758 (tbalthazar) - Ability to specify branches for Pivotal Tracker integration (Egor Lynko) - Fix don't pass a local variable called `i` to a partial. !20510 (herminiotorres) + - Add delimiter to project stars and forks count (ClemMakesApps) - Fix rename `add_users_into_project` and `projects_ids`. !20512 (herminiotorres) - Fix the title of the toggle dropdown button. !5515 (herminiotorres) - Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz) - Update to Ruby 2.3.1. !4948 + - Add Issues Board !5548 + - Allow resolving merge conflicts in the UI !5479 - Improve diff performance by eliminating redundant checks for text blobs - Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi) - Convert switch icon into icon font (ClemMakesApps) @@ -15,7 +19,9 @@ v 8.11.0 (unreleased) - API: List access requests, request access, approve, and deny access requests to a project or a group. !4833 - Use long options for curl examples in documentation !5703 (winniehell) - Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell) + - GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository - Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell) + - Allow naming U2F devices !5833 - Ignore URLs starting with // in Markdown links !5677 (winniehell) - Fix CI status icon link underline (ClemMakesApps) - The Repository class is now instrumented @@ -24,25 +30,37 @@ v 8.11.0 (unreleased) - Expand commit message width in repo view (ClemMakesApps) - Cache highlighted diff lines for merge requests - Pre-create all builds for a Pipeline when the new Pipeline is created !5295 + - Allow merge request diff notes and discussions to be explicitly marked as resolved + - API: Add deployment endpoints + - API: Add Play endpoint on Builds - Fix of 'Commits being passed to custom hooks are already reachable when using the UI' - Show member roles to all users on members page - Project.visible_to_user is instrumented again - Fix awardable button mutuality loading spinners (ClemMakesApps) - Add support for using RequestStore within Sidekiq tasks via SIDEKIQ_REQUEST_STORE env variable - Optimize maximum user access level lookup in loading of notes + - Send notification emails to users newly mentioned in issue and MR edits !5800 - Add "No one can push" as an option for protected branches. !5081 - Improve performance of AutolinkFilter#text_parse by using XPath - Add experimental Redis Sentinel support !1877 + - Rendering of SVGs as blobs is now limited to SVGs with a size smaller or equal to 2MB - Fix branches page dropdown sort initial state (ClemMakesApps) - Environments have an url to link to - Various redundant database indexes have been removed - Update `timeago` plugin to use multiple string/locale settings - Remove unused images (ClemMakesApps) + - Get issue and merge request description templates from repositories + - Add hover state to todos !5361 (winniehell) + - Fix icon alignment of star and fork buttons !5451 (winniehell) + - Enforce 2FA restrictions on API authentication endpoints !5820 - Limit git rev-list output count to one in forced push check + - Show deployment status on merge requests with external URLs - Clean up unused routes (Josef Strzibny) - Fix issue on empty project to allow developers to only push to protected branches if given permission + - API: Add enpoints for pipelines - Add green outline to New Branch button. !5447 (winniehell) - Optimize generating of cache keys for issues and notes + - Fix repository push email formatting in Outlook - Improve performance of syntax highlighting Markdown code blocks - Update to gitlab_git 10.4.1 and take advantage of preserved Ref objects - Remove delay when hitting "Reply..." button on page with a lot of discussions @@ -51,9 +69,12 @@ v 8.11.0 (unreleased) - Upgrade Grape from 0.13.0 to 0.15.0. !4601 - Trigram indexes for the "ci_runners" table have been removed to speed up UPDATE queries - Fix devise deprecation warnings. + - Check for 2FA when using Git over HTTP and only allow PersonalAccessTokens as password in that case !5764 - Update version_sorter and use new interface for faster tag sorting - Optimize checking if a user has read access to a list of issues !5370 - Store all DB secrets in secrets.yml, under descriptive names !5274 + - Fix syntax highlighting in file editor + - Support slash commands in issue and merge request descriptions as well as comments. !5021 - Nokogiri's various parsing methods are now instrumented - Add archived badge to project list !5798 - Add simple identifier to public SSH keys (muteor) @@ -70,6 +91,9 @@ v 8.11.0 (unreleased) - The overhead of instrumented method calls has been reduced - Remove `search_id` of labels dropdown filter to fix 'Missleading URI for labels in Merge Requests and Issues view'. !5368 (Scott Le) - Load project invited groups and members eagerly in `ProjectTeam#fetch_members` + - Add pipeline events hook + - Award emoji tooltips containing more than 10 usernames are now truncated !4780 (jlogandavison) + - Fix duplicate "me" in award emoji tooltip !5218 (jlogandavison) - Bump gitlab_git to speedup DiffCollection iterations - Rewrite description of a blocked user in admin settings. (Elias Werberich) - Make branches sortable without push permission !5462 (winniehell) @@ -79,18 +103,24 @@ v 8.11.0 (unreleased) - Make "New issue" button in Issue page less obtrusive !5457 (winniehell) - Gitlab::Metrics.current_transaction needs to be public for RailsQueueDuration - Fix search for notes which belongs to deleted objects + - Allow Akismet to be trained by submitting issues as spam or ham !5538 - Add GitLab Workhorse version to admin dashboard (Katarzyna Kobierska Ula Budziszewska) - Allow branch names ending with .json for graph and network page !5579 (winniehell) - Add the `sprockets-es6` gem - Improve OAuth2 client documentation (muteor) + - Fix diff comments inverted toggle bug (ClemMakesApps) - Multiple trigger variables show in separate lines (Katarzyna Kobierska Ula Budziszewska) - Profile requests when a header is passed - Avoid calculation of line_code and position for _line partial when showing diff notes on discussion tab. - Speedup DiffNote#active? on discussions, preloading noteables and avoid touching git repository to return diff_refs when possible - Add commit stats in commit api. !5517 (dixpac) - Add CI configuration button on project page + - Fix merge request new view not changing code view rendering style + - edit_blob_link will use blob passed onto the options parameter - Make error pages responsive (Takuya Noguchi) + - The performance of the project dropdown used for moving issues has been improved - Fix skip_repo parameter being ignored when destroying a namespace + - Add all builds into stage/job dropdowns on builds page - Change requests_profiles resource constraint to catch virtually any file - Bump gitlab_git to lazy load compare commits - Reduce number of queries made for merge_requests/:id/diffs @@ -103,14 +133,26 @@ v 8.11.0 (unreleased) - Speed up and reduce memory usage of Commit#repo_changes, Repository#expire_avatar_cache and IrkerWorker - Add unfold links for Side-by-Side view. !5415 (Tim Masliuchenko) - Adds support for pending invitation project members importing projects + - Add pipeline visualization/graph on pipeline page - Update devise initializer to turn on changed password notification emails. !5648 (tombell) - Avoid to show the original password field when password is automatically set. !5712 (duduribeiro) - Fix importing GitLab projects with an invalid MR source project - Sort folders with submodules in Files view !5521 - Each `File::exists?` replaced to `File::exist?` because of deprecate since ruby version 2.2.0 - Add auto-completition in pipeline (Katarzyna Kobierska Ula Budziszewska) + - Add pipelines tab to merge requests - Fix a memory leak caused by Banzai::Filter::SanitizationFilter - Speed up todos queries by limiting the projects set we join with + - Ensure file editing in UI does not overwrite commited changes without warning user + - Eliminate unneeded calls to Repository#blob_at when listing commits with no path + - Update gitlab_git gem to 10.4.7 + - Simplify SQL queries of marking a todo as done + +v 8.10.6 + - Upgrade Rails to 4.2.7.1 for security fixes. !5781 + - Restore "Largest repository" sort option on Admin > Projects page. !5797 + - Fix privilege escalation via project export. + - Require administrator privileges to perform a project import. v 8.10.5 - Add a data migration to fix some missing timestamps in the members table. !5670 @@ -132,6 +174,9 @@ v 8.10.3 - Fix importer for GitHub Pull Requests when a branch was removed. !5573 - Ignore invalid IPs in X-Forwarded-For when trusted proxies are configured. !5584 - Trim extra displayed carriage returns in diffs and files with CRLFs. !5588 + - Fix label already exist error message in the right sidebar. + +v 8.10.3 (unreleased) v 8.10.2 - User can now search branches by name. !5144 @@ -279,6 +324,7 @@ v 8.10.0 - Metrics for Rouge::Plugins::Redcarpet and Rouge::Formatters::HTMLGitlab - RailsCache metris now includes fetch_hit/fetch_miss and read_hit/read_miss info. - Allow [ci skip] to be in any case and allow [skip ci]. !4785 (simon_w) + - Made project list visibility icon fixed width - Set import_url validation to be more strict - Memoize MR merged/closed events retrieval - Don't render discussion notes when requesting diff tab through AJAX @@ -325,6 +371,10 @@ v 8.10.0 - Fix migration corrupting import data for old version upgrades - Show tooltip on GitLab export link in new project page +v 8.9.7 + - Upgrade Rails to 4.2.7.1 for security fixes. !5781 + - Require administrator privileges to perform a project import. + v 8.9.6 - Fix importing of events under notes for GitLab projects. !5154 - Fix log statements in import/export. !5129 @@ -590,6 +640,9 @@ v 8.9.0 - Add tooltip to pin/unpin navbar - Add new sub nav style to Wiki and Graphs sub navigation +v 8.8.8 + - Upgrade Rails to 4.2.7.1 for security fixes. !5781 + v 8.8.7 - Fix privilege escalation issue with OAuth external users. - Ensure references to private repos aren't shown to logged-out users. diff --git a/Gemfile b/Gemfile index 8b44b54e22c..68547b6fac8 100644 --- a/Gemfile +++ b/Gemfile @@ -20,7 +20,7 @@ gem 'pg', '~> 0.18.2', group: :postgres # Authentication libraries gem 'devise', '~> 4.0' -gem 'doorkeeper', '~> 4.0' +gem 'doorkeeper', '~> 4.2.0' gem 'omniauth', '~> 1.3.1' gem 'omniauth-auth0', '~> 1.4.1' gem 'omniauth-azure-oauth2', '~> 0.0.6' @@ -53,7 +53,7 @@ gem 'browser', '~> 2.2' # Extracting information from a git repository # Provide access to Gitlab::Git library -gem 'gitlab_git', '~> 10.4.5' +gem 'gitlab_git', '~> 10.4.7' # LDAP Auth # GitLab fork with several improvements to original library. For full list of changes @@ -77,7 +77,7 @@ gem 'rack-cors', '~> 0.4.0', require: 'rack/cors' gem 'kaminari', '~> 0.17.0' # HAML -gem 'hamlit', '~> 2.5' +gem 'hamlit', '~> 2.6.1' # Files attachments gem 'carrierwave', '~> 0.10.0' @@ -201,7 +201,7 @@ gem 'licensee', '~> 8.0.0' gem 'rack-attack', '~> 4.3.1' # Ace editor -gem 'ace-rails-ap', '~> 4.0.2' +gem 'ace-rails-ap', '~> 4.1.0' # Keyboard shortcuts gem 'mousetrap-rails', '~> 1.4.6' @@ -209,7 +209,8 @@ gem 'mousetrap-rails', '~> 1.4.6' # Detect and convert string character encoding gem 'charlock_holmes', '~> 0.7.3' -# Parse duration +# Parse time & duration +gem 'chronic', '~> 0.10.2' gem 'chronic_duration', '~> 0.10.6' gem 'sass-rails', '~> 5.0.0' @@ -314,6 +315,7 @@ end group :test do gem 'shoulda-matchers', '~> 2.8.0', require: false gem 'email_spec', '~> 1.6.0' + gem 'json-schema', '~> 2.6.2' gem 'webmock', '~> 1.21.0' gem 'test_after_commit', '~> 0.4.2' gem 'sham_rack', '~> 1.3.6' diff --git a/Gemfile.lock b/Gemfile.lock index 3ba6048143c..5511d718938 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,7 @@ GEM remote: https://rubygems.org/ specs: RedCloth (4.3.2) - ace-rails-ap (4.0.2) + ace-rails-ap (4.1.0) actionmailer (4.2.7.1) actionpack (= 4.2.7.1) actionview (= 4.2.7.1) @@ -128,6 +128,7 @@ GEM mime-types (>= 1.16) cause (0.1) charlock_holmes (0.7.3) + chronic (0.10.2) chronic_duration (0.10.6) numerizer (~> 0.1.1) chunky_png (1.3.5) @@ -175,7 +176,7 @@ GEM diff-lcs (1.2.5) diffy (3.0.7) docile (1.1.5) - doorkeeper (4.0.0) + doorkeeper (4.2.0) railties (>= 4.2) dropzonejs-rails (0.7.2) rails (> 3.1) @@ -278,7 +279,7 @@ GEM diff-lcs (~> 1.1) mime-types (>= 1.16, < 3) posix-spawn (~> 0.3) - gitlab_git (10.4.5) + gitlab_git (10.4.7) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) @@ -321,7 +322,7 @@ GEM grape-entity (0.4.8) activesupport multi_json (>= 1.3.2) - hamlit (2.5.0) + hamlit (2.6.1) temple (~> 0.7.6) thor tilt @@ -338,7 +339,7 @@ GEM httparty (0.13.7) json (~> 1.8) multi_xml (>= 0.5.2) - httpclient (2.7.0.1) + httpclient (2.8.2) i18n (0.7.0) ice_nine (0.11.1) influxdb (0.2.3) @@ -356,6 +357,8 @@ GEM jquery-ui-rails (5.0.5) railties (>= 3.2.16) json (1.8.3) + json-schema (2.6.2) + addressable (~> 2.3.8) jwt (1.5.4) kaminari (0.17.0) actionpack (>= 3.0.0) @@ -796,7 +799,7 @@ PLATFORMS DEPENDENCIES RedCloth (~> 4.3.2) - ace-rails-ap (~> 4.0.2) + ace-rails-ap (~> 4.1.0) activerecord-session_store (~> 1.0.0) acts-as-taggable-on (~> 3.4) addressable (~> 2.3.8) @@ -822,6 +825,7 @@ DEPENDENCIES capybara-screenshot (~> 1.0.0) carrierwave (~> 0.10.0) charlock_holmes (~> 0.7.3) + chronic (~> 0.10.2) chronic_duration (~> 0.10.6) coffee-rails (~> 4.1.0) connection_pool (~> 2.0) @@ -832,7 +836,7 @@ DEPENDENCIES devise (~> 4.0) devise-two-factor (~> 3.0.0) diffy (~> 3.0.3) - doorkeeper (~> 4.0) + doorkeeper (~> 4.2.0) dropzonejs-rails (~> 0.7.1) email_reply_parser (~> 0.5.8) email_spec (~> 1.6.0) @@ -855,7 +859,7 @@ DEPENDENCIES github-linguist (~> 4.7.0) github-markup (~> 1.4) gitlab-flowdock-git-hook (~> 1.0.1) - gitlab_git (~> 10.4.5) + gitlab_git (~> 10.4.7) gitlab_meta (= 7.0) gitlab_omniauth-ldap (~> 1.2.1) gollum-lib (~> 4.2) @@ -863,7 +867,7 @@ DEPENDENCIES gon (~> 6.1.0) grape (~> 0.15.0) grape-entity (~> 0.4.2) - hamlit (~> 2.5) + hamlit (~> 2.6.1) health_check (~> 2.1.0) hipchat (~> 1.5.0) html-pipeline (~> 1.11.0) @@ -873,6 +877,7 @@ DEPENDENCIES jquery-rails (~> 4.1.0) jquery-turbolinks (~> 2.1.0) jquery-ui-rails (~> 5.0.0) + json-schema (~> 2.6.2) jwt kaminari (~> 0.17.0) knapsack (~> 1.11.0) diff --git a/app/assets/javascripts/abuse_reports.js.es6 b/app/assets/javascripts/abuse_reports.js.es6 new file mode 100644 index 00000000000..748084b0307 --- /dev/null +++ b/app/assets/javascripts/abuse_reports.js.es6 @@ -0,0 +1,39 @@ +window.gl = window.gl || {}; +((global) => { + const MAX_MESSAGE_LENGTH = 500; + const MESSAGE_CELL_SELECTOR = '.abuse-reports .message'; + + class AbuseReports { + constructor() { + $(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage); + $(document) + .off('click', MESSAGE_CELL_SELECTOR) + .on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation); + } + + truncateLongMessage() { + const $messageCellElement = $(this); + const reportMessage = $messageCellElement.text(); + if (reportMessage.length > MAX_MESSAGE_LENGTH) { + $messageCellElement.data('original-message', reportMessage); + $messageCellElement.data('message-truncated', 'true'); + $messageCellElement.text(global.text.truncate(reportMessage, MAX_MESSAGE_LENGTH)); + } + } + + toggleMessageTruncation() { + const $messageCellElement = $(this); + const originalMessage = $messageCellElement.data('original-message'); + if (!originalMessage) return; + if ($messageCellElement.data('message-truncated') === 'true') { + $messageCellElement.data('message-truncated', 'false'); + $messageCellElement.text(originalMessage); + } else { + $messageCellElement.data('message-truncated', 'true'); + $messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`); + } + } + } + + global.AbuseReports = AbuseReports; +})(window.gl); diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 49c2ac0dac3..84b292e59c6 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -9,10 +9,11 @@ licensePath: "/api/:version/licenses/:key", gitignorePath: "/api/:version/gitignores/:key", gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key", + issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key", + group: function(group_id, callback) { - var url; - url = Api.buildUrl(Api.groupPath); - url = url.replace(':id', group_id); + var url = Api.buildUrl(Api.groupPath) + .replace(':id', group_id); return $.ajax({ url: url, data: { @@ -24,8 +25,7 @@ }); }, groups: function(query, skip_ldap, callback) { - var url; - url = Api.buildUrl(Api.groupsPath); + var url = Api.buildUrl(Api.groupsPath); return $.ajax({ url: url, data: { @@ -39,8 +39,7 @@ }); }, namespaces: function(query, callback) { - var url; - url = Api.buildUrl(Api.namespacesPath); + var url = Api.buildUrl(Api.namespacesPath); return $.ajax({ url: url, data: { @@ -54,8 +53,7 @@ }); }, projects: function(query, order, callback) { - var url; - url = Api.buildUrl(Api.projectsPath); + var url = Api.buildUrl(Api.projectsPath); return $.ajax({ url: url, data: { @@ -70,9 +68,8 @@ }); }, newLabel: function(project_id, data, callback) { - var url; - url = Api.buildUrl(Api.labelsPath); - url = url.replace(':id', project_id); + var url = Api.buildUrl(Api.labelsPath) + .replace(':id', project_id); data.private_token = gon.api_token; return $.ajax({ url: url, @@ -86,9 +83,8 @@ }); }, groupProjects: function(group_id, query, callback) { - var url; - url = Api.buildUrl(Api.groupProjectsPath); - url = url.replace(':id', group_id); + var url = Api.buildUrl(Api.groupProjectsPath) + .replace(':id', group_id); return $.ajax({ url: url, data: { @@ -102,8 +98,8 @@ }); }, licenseText: function(key, data, callback) { - var url; - url = Api.buildUrl(Api.licensePath).replace(':key', key); + var url = Api.buildUrl(Api.licensePath) + .replace(':key', key); return $.ajax({ url: url, data: data @@ -112,19 +108,32 @@ }); }, gitignoreText: function(key, callback) { - var url; - url = Api.buildUrl(Api.gitignorePath).replace(':key', key); + var url = Api.buildUrl(Api.gitignorePath) + .replace(':key', key); return $.get(url, function(gitignore) { return callback(gitignore); }); }, gitlabCiYml: function(key, callback) { - var url; - url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key); + var url = Api.buildUrl(Api.gitlabCiYmlPath) + .replace(':key', key); return $.get(url, function(file) { return callback(file); }); }, + issueTemplate: function(namespacePath, projectPath, key, type, callback) { + var url = Api.buildUrl(Api.issuableTemplatePath) + .replace(':key', key) + .replace(':type', type) + .replace(':project_path', projectPath) + .replace(':namespace_path', namespacePath); + $.ajax({ + url: url, + dataType: 'json' + }).done(function(file) { + callback(null, file); + }).error(callback); + }, buildUrl: function(url) { if (gon.relative_url_root != null) { url = gon.relative_url_root + url; diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index f1aab067351..a122fa2d637 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -26,7 +26,7 @@ /*= require bootstrap/tooltip */ /*= require bootstrap/popover */ /*= require select2 */ -/*= require ace/ace */ +/*= require ace-rails-ap */ /*= require ace/ext-searchbox */ /*= require underscore */ /*= require dropzone */ @@ -41,6 +41,7 @@ /*= require date.format */ /*= require_directory ./behaviors */ /*= require_directory ./blob */ +/*= require_directory ./templates */ /*= require_directory ./commit */ /*= require_directory ./extensions */ /*= require_directory ./lib/utils */ @@ -223,8 +224,14 @@ return $('.navbar-toggle').toggleClass('active'); }); $body.on("click", ".js-toggle-diff-comments", function(e) { - $(this).toggleClass('active'); - $(this).closest(".diff-file").find(".notes_holder").toggle(); + var $this = $(this); + $this.toggleClass('active'); + var notesHolders = $this.closest('.diff-file').find('.notes_holder'); + if ($this.hasClass('active')) { + notesHolders.show(); + } else { + notesHolders.hide(); + } return e.preventDefault(); }); $document.off("click", '.js-confirm-danger'); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 2c5b83e4f1e..aee1c29eee3 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -1,5 +1,6 @@ (function() { this.AwardsHandler = (function() { + const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; //For separating lists produced by ruby's Array#toSentence function AwardsHandler() { this.aliases = gl.emojiAliases(); $(document).off('click', '.js-add-award').on('click', '.js-add-award', (function(_this) { @@ -130,7 +131,7 @@ counter = $emojiButton.find('.js-counter'); counter.text(parseInt(counter.text()) + 1); $emojiButton.addClass('active'); - this.addMeToUserList(votesBlock, emoji); + this.addYouToUserList(votesBlock, emoji); return this.animateEmoji($emojiButton); } } else { @@ -176,11 +177,11 @@ counterNumber = parseInt(counter.text(), 10); if (counterNumber > 1) { counter.text(counterNumber - 1); - this.removeMeFromUserList($emojiButton, emoji); + this.removeYouFromUserList($emojiButton, emoji); } else if (emoji === 'thumbsup' || emoji === 'thumbsdown') { $emojiButton.tooltip('destroy'); counter.text('0'); - this.removeMeFromUserList($emojiButton, emoji); + this.removeYouFromUserList($emojiButton, emoji); if ($emojiButton.parents('.note').length) { this.removeEmoji($emojiButton); } @@ -204,43 +205,48 @@ return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || ''; }; - AwardsHandler.prototype.removeMeFromUserList = function($emojiButton, emoji) { + AwardsHandler.prototype.toSentence = function(list) { + if(list.length <= 2){ + return list.join(' and '); + } + else{ + return list.slice(0, -1).join(', ') + ', and ' + list[list.length - 1]; + } + }; + + AwardsHandler.prototype.removeYouFromUserList = function($emojiButton, emoji) { var authors, awardBlock, newAuthors, originalTitle; awardBlock = $emojiButton; originalTitle = this.getAwardTooltip(awardBlock); - authors = originalTitle.split(', '); - authors.splice(authors.indexOf('me'), 1); - newAuthors = authors.join(', '); - awardBlock.closest('.js-emoji-btn').removeData('original-title').attr('data-original-title', newAuthors); - return this.resetTooltip(awardBlock); + authors = originalTitle.split(FROM_SENTENCE_REGEX); + authors.splice(authors.indexOf('You'), 1); + return awardBlock + .closest('.js-emoji-btn') + .removeData('title') + .removeAttr('data-title') + .removeAttr('data-original-title') + .attr('title', this.toSentence(authors)) + .tooltip('fixTitle'); }; - AwardsHandler.prototype.addMeToUserList = function(votesBlock, emoji) { + AwardsHandler.prototype.addYouToUserList = function(votesBlock, emoji) { var awardBlock, origTitle, users; awardBlock = this.findEmojiIcon(votesBlock, emoji).parent(); origTitle = this.getAwardTooltip(awardBlock); users = []; if (origTitle) { - users = origTitle.trim().split(', '); + users = origTitle.trim().split(FROM_SENTENCE_REGEX); } - users.push('me'); - awardBlock.attr('title', users.join(', ')); - return this.resetTooltip(awardBlock); - }; - - AwardsHandler.prototype.resetTooltip = function(award) { - var cb; - award.tooltip('destroy'); - cb = function() { - return award.tooltip(); - }; - return setTimeout(cb, 200); + users.unshift('You'); + return awardBlock + .attr('title', this.toSentence(users)) + .tooltip('fixTitle'); }; AwardsHandler.prototype.createEmoji_ = function(votesBlock, emoji) { var $emojiButton, buttonHtml, emojiCssClass; emojiCssClass = this.resolveNameToCssClass(emoji); - buttonHtml = ""; + buttonHtml = ""; $emojiButton = $(buttonHtml); $emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('emoji', emoji); this.animateEmoji($emojiButton); diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index 2cf0a6631b8..b0a37ef0e0a 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -9,6 +9,7 @@ } this.onClick = bind(this.onClick, this); this.dropdown = opts.dropdown, this.data = opts.data, this.pattern = opts.pattern, this.wrapper = opts.wrapper, this.editor = opts.editor, this.fileEndpoint = opts.fileEndpoint, this.$input = (ref = opts.$input) != null ? ref : $('#file_name'); + this.dropdownIcon = $('.fa-chevron-down', this.dropdown); this.buildDropdown(); this.bindEvents(); this.onFilenameUpdate(); @@ -60,11 +61,26 @@ return this.requestFile(item); }; - TemplateSelector.prototype.requestFile = function(item) {}; + TemplateSelector.prototype.requestFile = function(item) { + // This `requestFile` method is an abstract method that should + // be added by all subclasses. + }; - TemplateSelector.prototype.requestFileSuccess = function(file) { + TemplateSelector.prototype.requestFileSuccess = function(file, skipFocus) { this.editor.setValue(file.content, 1); - return this.editor.focus(); + if (!skipFocus) this.editor.focus(); + }; + + TemplateSelector.prototype.startLoadingSpinner = function() { + this.dropdownIcon + .addClass('fa-spinner fa-spin') + .removeClass('fa-chevron-down'); + }; + + TemplateSelector.prototype.stopLoadingSpinner = function() { + this.dropdownIcon + .addClass('fa-chevron-down') + .removeClass('fa-spinner fa-spin'); }; return TemplateSelector; diff --git a/app/assets/javascripts/boards/boards_bundle.js.es6 b/app/assets/javascripts/boards/boards_bundle.js.es6 new file mode 100644 index 00000000000..2c65d4427be --- /dev/null +++ b/app/assets/javascripts/boards/boards_bundle.js.es6 @@ -0,0 +1,57 @@ +//= require vue +//= require vue-resource +//= require Sortable +//= require_tree ./models +//= require_tree ./stores +//= require_tree ./services +//= require_tree ./mixins +//= require ./components/board +//= require ./components/new_list_dropdown +//= require ./vue_resource_interceptor + +$(() => { + const $boardApp = document.getElementById('board-app'), + Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + + if (gl.IssueBoardsApp) { + gl.IssueBoardsApp.$destroy(true); + } + + gl.IssueBoardsApp = new Vue({ + el: $boardApp, + components: { + 'board': gl.issueBoards.Board + }, + data: { + state: Store.state, + loading: true, + endpoint: $boardApp.dataset.endpoint, + disabled: $boardApp.dataset.disabled === 'true', + issueLinkBase: $boardApp.dataset.issueLinkBase + }, + init: Store.create.bind(Store), + created () { + gl.boardService = new BoardService(this.endpoint); + }, + ready () { + Store.disabled = this.disabled; + gl.boardService.all() + .then((resp) => { + resp.json().forEach((board) => { + const list = Store.addList(board); + + if (list.type === 'done') { + list.position = Infinity; + } else if (list.type === 'backlog') { + list.position = -1; + } + }); + + Store.addBlankState(); + this.loading = false; + }); + } + }); +}); diff --git a/app/assets/javascripts/boards/components/board.js.es6 b/app/assets/javascripts/boards/components/board.js.es6 new file mode 100644 index 00000000000..e17784e7948 --- /dev/null +++ b/app/assets/javascripts/boards/components/board.js.es6 @@ -0,0 +1,85 @@ +//= require ./board_blank_state +//= require ./board_delete +//= require ./board_list + +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.Board = Vue.extend({ + components: { + 'board-list': gl.issueBoards.BoardList, + 'board-delete': gl.issueBoards.BoardDelete, + 'board-blank-state': gl.issueBoards.BoardBlankState + }, + props: { + list: Object, + disabled: Boolean, + issueLinkBase: String + }, + data () { + return { + query: '', + filters: Store.state.filters + }; + }, + watch: { + query () { + this.list.filters = this.getFilterData(); + this.list.getIssues(true); + }, + filters: { + handler () { + this.list.page = 1; + this.list.getIssues(true); + }, + deep: true + } + }, + methods: { + getFilterData () { + const filters = this.filters; + let queryData = { search: this.query }; + + Object.keys(filters).forEach((key) => { queryData[key] = filters[key]; }); + + return queryData; + } + }, + ready () { + const options = gl.issueBoards.getBoardSortableDefaultOptions({ + disabled: this.disabled, + group: 'boards', + draggable: '.is-draggable', + handle: '.js-board-handle', + onEnd: (e) => { + document.body.classList.remove('is-dragging'); + + if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { + const order = this.sortable.toArray(), + $board = this.$parent.$refs.board[e.oldIndex + 1], + list = $board.list; + + $board.$destroy(true); + + this.$nextTick(() => { + Store.state.lists.splice(e.newIndex, 0, list); + Store.moveList(list, order); + }); + } + } + }); + + if (bp.getBreakpointSize() === 'xs') { + options.handle = '.js-board-drag-handle'; + } + + this.sortable = Sortable.create(this.$el.parentNode, options); + }, + beforeDestroy () { + Store.state.lists.$remove(this.list); + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_blank_state.js.es6 b/app/assets/javascripts/boards/components/board_blank_state.js.es6 new file mode 100644 index 00000000000..63d72d857d9 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_blank_state.js.es6 @@ -0,0 +1,49 @@ +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardBlankState = Vue.extend({ + data () { + return { + predefinedLabels: [ + new ListLabel({ title: 'Development', color: '#5CB85C' }), + new ListLabel({ title: 'Testing', color: '#F0AD4E' }), + new ListLabel({ title: 'Production', color: '#FF5F00' }), + new ListLabel({ title: 'Ready', color: '#FF0000' }) + ] + } + }, + methods: { + addDefaultLists () { + this.clearBlankState(); + + this.predefinedLabels.forEach((label, i) => { + Store.addList({ + title: label.title, + position: i, + list_type: 'label', + label: { + title: label.title, + color: label.color + } + }); + }); + + // Save the labels + gl.boardService.generateDefaultLists() + .then((resp) => { + resp.json().forEach((listObj) => { + const list = Store.findList('title', listObj.title); + + list.id = listObj.id; + list.label.id = listObj.label.id; + list.getIssues(); + }); + }); + }, + clearBlankState: Store.removeBlankState.bind(Store) + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_card.js.es6 b/app/assets/javascripts/boards/components/board_card.js.es6 new file mode 100644 index 00000000000..4a7cfeaeab2 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_card.js.es6 @@ -0,0 +1,43 @@ +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardCard = Vue.extend({ + props: { + list: Object, + issue: Object, + issueLinkBase: String, + disabled: Boolean, + index: Number + }, + methods: { + filterByLabel (label, e) { + let labelToggleText = label.title; + const labelIndex = Store.state.filters['label_name'].indexOf(label.title); + $(e.target).tooltip('hide'); + + if (labelIndex === -1) { + Store.state.filters['label_name'].push(label.title); + $('.labels-filter').prepend(``); + } else { + Store.state.filters['label_name'].splice(labelIndex, 1); + labelToggleText = Store.state.filters['label_name'][0]; + $(`.labels-filter input[name="label_name[]"][value="${label.title}"]`).remove(); + } + + const selectedLabels = Store.state.filters['label_name']; + if (selectedLabels.length === 0) { + labelToggleText = 'Label'; + } else if (selectedLabels.length > 1) { + labelToggleText = `${selectedLabels[0]} + ${selectedLabels.length - 1} more`; + } + + $('.labels-filter .dropdown-toggle-text').text(labelToggleText); + + Store.updateFiltersUrl(); + } + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_delete.js.es6 b/app/assets/javascripts/boards/components/board_delete.js.es6 new file mode 100644 index 00000000000..34653cd48ef --- /dev/null +++ b/app/assets/javascripts/boards/components/board_delete.js.es6 @@ -0,0 +1,19 @@ +(() => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardDelete = Vue.extend({ + props: { + list: Object + }, + methods: { + deleteBoard () { + $(this.$el).tooltip('hide'); + + if (confirm('Are you sure you want to delete this list?')) { + this.list.destroy(); + } + } + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_list.js.es6 b/app/assets/javascripts/boards/components/board_list.js.es6 new file mode 100644 index 00000000000..1503d14c508 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list.js.es6 @@ -0,0 +1,89 @@ +//= require ./board_card + +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardList = Vue.extend({ + components: { + 'board-card': gl.issueBoards.BoardCard + }, + props: { + disabled: Boolean, + list: Object, + issues: Array, + loading: Boolean, + issueLinkBase: String + }, + data () { + return { + scrollOffset: 250, + filters: Store.state.filters + }; + }, + watch: { + filters: { + handler () { + this.list.loadingMore = false; + this.$els.list.scrollTop = 0; + }, + deep: true + } + }, + methods: { + listHeight () { + return this.$els.list.getBoundingClientRect().height; + }, + scrollHeight () { + return this.$els.list.scrollHeight; + }, + scrollTop () { + return this.$els.list.scrollTop + this.listHeight(); + }, + loadNextPage () { + const getIssues = this.list.nextPage(); + + if (getIssues) { + this.list.loadingMore = true; + getIssues.then(() => { + this.list.loadingMore = false; + }); + } + }, + }, + ready () { + const options = gl.issueBoards.getBoardSortableDefaultOptions({ + group: 'issues', + sort: false, + disabled: this.disabled, + onStart: (e) => { + const card = this.$refs.issue[e.oldIndex]; + + Store.moving.issue = card.issue; + Store.moving.list = card.list; + }, + onAdd: (e) => { + gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue); + }, + onRemove: (e) => { + this.$refs.issue[e.oldIndex].$destroy(true); + } + }); + + if (bp.getBreakpointSize() === 'xs') { + options.handle = '.js-card-drag-handle'; + } + + this.sortable = Sortable.create(this.$els.list, options); + + // Scroll event on list to load more + this.$els.list.onscroll = () => { + if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) { + this.loadNextPage(); + } + }; + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 new file mode 100644 index 00000000000..1a4d8157970 --- /dev/null +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 @@ -0,0 +1,54 @@ +$(() => { + const Store = gl.issueBoards.BoardsStore; + + $('.js-new-board-list').each(function () { + const $this = $(this); + + new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('project-id')); + + $this.glDropdown({ + data(term, callback) { + $.get($this.attr('data-labels')) + .then((resp) => { + callback(resp); + }); + }, + renderRow (label) { + const active = Store.findList('title', label.title), + $li = $('
  • '), + $a = $('', { + class: (active ? `is-active js-board-list-${active.id}` : ''), + text: label.title, + href: '#' + }), + $labelColor = $('', { + class: 'dropdown-label-box', + style: `background-color: ${label.color}` + }); + + return $li.append($a.prepend($labelColor)); + }, + search: { + fields: ['title'] + }, + filterable: true, + selectable: true, + clicked (label, $el, e) { + e.preventDefault(); + + if (!Store.findList('title', label.title)) { + Store.new({ + title: label.title, + position: Store.state.lists.length - 2, + list_type: 'label', + label: { + id: label.id, + title: label.title, + color: label.color + } + }); + } + } + }); + }); +}); diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 new file mode 100644 index 00000000000..b7afe4897b6 --- /dev/null +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 @@ -0,0 +1,25 @@ +((w) => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { + let defaultSortOptions = { + forceFallback: true, + fallbackClass: 'is-dragging', + fallbackOnBody: true, + ghostClass: 'is-ghost', + filter: '.has-tooltip', + scrollSensitivity: 100, + scrollSpeed: 20, + onStart () { + document.body.classList.add('is-dragging'); + }, + onEnd () { + document.body.classList.remove('is-dragging'); + } + } + + Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); + return defaultSortOptions; + }; +})(window); diff --git a/app/assets/javascripts/boards/models/issue.js.es6 b/app/assets/javascripts/boards/models/issue.js.es6 new file mode 100644 index 00000000000..eb082103de9 --- /dev/null +++ b/app/assets/javascripts/boards/models/issue.js.es6 @@ -0,0 +1,44 @@ +class ListIssue { + constructor (obj) { + this.id = obj.iid; + this.title = obj.title; + this.confidential = obj.confidential; + this.labels = []; + + if (obj.assignee) { + this.assignee = new ListUser(obj.assignee); + } + + obj.labels.forEach((label) => { + this.labels.push(new ListLabel(label)); + }); + + this.priority = this.labels.reduce((max, label) => { + return (label.priority < max) ? label.priority : max; + }, Infinity); + } + + addLabel (label) { + if (!this.findLabel(label)) { + this.labels.push(new ListLabel(label)); + } + } + + findLabel (findLabel) { + return this.labels.filter( label => label.title === findLabel.title )[0]; + } + + removeLabel (removeLabel) { + if (removeLabel) { + this.labels = this.labels.filter( label => removeLabel.title !== label.title ); + } + } + + removeLabels (labels) { + labels.forEach(this.removeLabel.bind(this)); + } + + getLists () { + return gl.issueBoards.BoardsStore.state.lists.filter( list => list.findIssue(this.id) ); + } +} diff --git a/app/assets/javascripts/boards/models/label.js.es6 b/app/assets/javascripts/boards/models/label.js.es6 new file mode 100644 index 00000000000..e81e91fe972 --- /dev/null +++ b/app/assets/javascripts/boards/models/label.js.es6 @@ -0,0 +1,9 @@ +class ListLabel { + constructor (obj) { + this.id = obj.id; + this.title = obj.title; + this.color = obj.color; + this.description = obj.description; + this.priority = (obj.priority !== null) ? obj.priority : Infinity; + } +} diff --git a/app/assets/javascripts/boards/models/list.js.es6 b/app/assets/javascripts/boards/models/list.js.es6 new file mode 100644 index 00000000000..be2b8c568a8 --- /dev/null +++ b/app/assets/javascripts/boards/models/list.js.es6 @@ -0,0 +1,125 @@ +class List { + constructor (obj) { + this.id = obj.id; + this._uid = this.guid(); + this.position = obj.position; + this.title = obj.title; + this.type = obj.list_type; + this.preset = ['backlog', 'done', 'blank'].indexOf(this.type) > -1; + this.filters = gl.issueBoards.BoardsStore.state.filters; + this.page = 1; + this.loading = true; + this.loadingMore = false; + this.issues = []; + + if (obj.label) { + this.label = new ListLabel(obj.label); + } + + if (this.type !== 'blank' && this.id) { + this.getIssues(); + } + } + + guid() { + const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); + return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; + } + + save () { + return gl.boardService.createList(this.label.id) + .then((resp) => { + const data = resp.json(); + + this.id = data.id; + this.type = data.list_type; + this.position = data.position; + + return this.getIssues(); + }); + } + + destroy () { + gl.issueBoards.BoardsStore.state.lists.$remove(this); + gl.issueBoards.BoardsStore.updateNewListDropdown(this.id); + + gl.boardService.destroyList(this.id); + } + + update () { + gl.boardService.updateList(this.id, this.position); + } + + nextPage () { + if (Math.floor(this.issues.length / 20) === this.page) { + this.page++; + + return this.getIssues(false); + } + } + + canSearch () { + return this.type === 'backlog'; + } + + getIssues (emptyIssues = true) { + const filters = this.filters; + let data = { page: this.page }; + + Object.keys(filters).forEach((key) => { data[key] = filters[key]; }); + + if (this.label) { + data.label_name = data.label_name.filter( label => label !== this.label.title ); + } + + if (emptyIssues) { + this.loading = true; + } + + return gl.boardService.getIssuesForList(this.id, data) + .then((resp) => { + const data = resp.json(); + this.loading = false; + + if (emptyIssues) { + this.issues = []; + } + + this.createIssues(data); + }); + } + + createIssues (data) { + data.forEach((issueObj) => { + this.addIssue(new ListIssue(issueObj)); + }); + } + + addIssue (issue, listFrom) { + this.issues.push(issue); + + if (this.label) { + issue.addLabel(this.label); + } + + if (listFrom) { + gl.boardService.moveIssue(issue.id, listFrom.id, this.id); + } + } + + findIssue (id) { + return this.issues.filter( issue => issue.id === id )[0]; + } + + removeIssue (removeIssue) { + this.issues = this.issues.filter((issue) => { + const matchesRemove = removeIssue.id === issue.id; + + if (matchesRemove) { + issue.removeLabel(this.label); + } + + return !matchesRemove; + }); + } +} diff --git a/app/assets/javascripts/boards/models/user.js.es6 b/app/assets/javascripts/boards/models/user.js.es6 new file mode 100644 index 00000000000..904b3a68507 --- /dev/null +++ b/app/assets/javascripts/boards/models/user.js.es6 @@ -0,0 +1,8 @@ +class ListUser { + constructor (user) { + this.id = user.id; + this.name = user.name; + this.username = user.username; + this.avatar = user.avatar_url; + } +} diff --git a/app/assets/javascripts/boards/services/board_service.js.es6 b/app/assets/javascripts/boards/services/board_service.js.es6 new file mode 100644 index 00000000000..9b80fb2e99f --- /dev/null +++ b/app/assets/javascripts/boards/services/board_service.js.es6 @@ -0,0 +1,61 @@ +class BoardService { + constructor (root) { + Vue.http.options.root = root; + + this.lists = Vue.resource(`${root}/lists{/id}`, {}, { + generate: { + method: 'POST', + url: `${root}/lists/generate.json` + } + }); + this.issue = Vue.resource(`${root}/issues{/id}`, {}); + this.issues = Vue.resource(`${root}/lists{/id}/issues`, {}); + + Vue.http.interceptors.push((request, next) => { + request.headers['X-CSRF-Token'] = $.rails.csrfToken(); + next(); + }); + } + + all () { + return this.lists.get(); + } + + generateDefaultLists () { + return this.lists.generate({}); + } + + createList (label_id) { + return this.lists.save({}, { + list: { + label_id + } + }); + } + + updateList (id, position) { + return this.lists.update({ id }, { + list: { + position + } + }); + } + + destroyList (id) { + return this.lists.delete({ id }); + } + + getIssuesForList (id, filter = {}) { + let data = { id }; + Object.keys(filter).forEach((key) => { data[key] = filter[key]; }); + + return this.issues.get(data); + } + + moveIssue (id, from_list_id, to_list_id) { + return this.issue.update({ id }, { + from_list_id, + to_list_id + }); + } +}; diff --git a/app/assets/javascripts/boards/stores/boards_store.js.es6 b/app/assets/javascripts/boards/stores/boards_store.js.es6 new file mode 100644 index 00000000000..18f26a1f911 --- /dev/null +++ b/app/assets/javascripts/boards/stores/boards_store.js.es6 @@ -0,0 +1,112 @@ +(() => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardsStore = { + disabled: false, + state: {}, + moving: { + issue: {}, + list: {} + }, + create () { + this.state.lists = []; + this.state.filters = { + author_id: gl.utils.getParameterValues('author_id')[0], + assignee_id: gl.utils.getParameterValues('assignee_id')[0], + milestone_title: gl.utils.getParameterValues('milestone_title')[0], + label_name: gl.utils.getParameterValues('label_name[]') + }; + }, + addList (listObj) { + const list = new List(listObj); + this.state.lists.push(list); + + return list; + }, + new (listObj) { + const list = this.addList(listObj), + backlogList = this.findList('type', 'backlog', 'backlog'); + + list + .save() + .then(() => { + // Remove any new issues from the backlog + // as they will be visible in the new list + list.issues.forEach(backlogList.removeIssue.bind(backlogList)); + }); + this.removeBlankState(); + }, + updateNewListDropdown (listId) { + $(`.js-board-list-${listId}`).removeClass('is-active'); + }, + shouldAddBlankState () { + // Decide whether to add the blank state + return !(this.state.lists.filter( list => list.type !== 'backlog' && list.type !== 'done' )[0]); + }, + addBlankState () { + if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; + + this.addList({ + id: 'blank', + list_type: 'blank', + title: 'Welcome to your Issue Board!', + position: 0 + }); + }, + removeBlankState () { + this.removeList('blank'); + + $.cookie('issue_board_welcome_hidden', 'true', { + expires: 365 * 10 + }); + }, + welcomeIsHidden () { + return $.cookie('issue_board_welcome_hidden') === 'true'; + }, + removeList (id, type = 'blank') { + const list = this.findList('id', id, type); + + if (!list) return; + + this.state.lists = this.state.lists.filter( list => list.id !== id ); + }, + moveList (listFrom, orderLists) { + orderLists.forEach((id, i) => { + const list = this.findList('id', parseInt(id)); + + list.position = i; + }); + listFrom.update(); + }, + moveIssueToList (listFrom, listTo, issue) { + const issueTo = listTo.findIssue(issue.id), + issueLists = issue.getLists(), + listLabels = issueLists.map( listIssue => listIssue.label ); + + // Add to new lists issues if it doesn't already exist + if (!issueTo) { + listTo.addIssue(issue, listFrom); + } + + if (listTo.type === 'done' && listFrom.type !== 'backlog') { + issueLists.forEach((list) => { + list.removeIssue(issue); + }) + issue.removeLabels(listLabels); + } else { + listFrom.removeIssue(issue); + } + }, + findList (key, val, type = 'label') { + return this.state.lists.filter((list) => { + const byType = type ? list['type'] === type : true; + + return list[key] === val && byType; + })[0]; + }, + updateFiltersUrl () { + history.pushState(null, null, `?${$.param(this.state.filters)}`); + } + }; +})(); diff --git a/app/assets/javascripts/boards/test_utils/simulate_drag.js b/app/assets/javascripts/boards/test_utils/simulate_drag.js new file mode 100755 index 00000000000..75f8b730195 --- /dev/null +++ b/app/assets/javascripts/boards/test_utils/simulate_drag.js @@ -0,0 +1,119 @@ +(function () { + 'use strict'; + + function simulateEvent(el, type, options) { + var event; + if (!el) return; + var ownerDocument = el.ownerDocument; + + options = options || {}; + + if (/^mouse/.test(type)) { + event = ownerDocument.createEvent('MouseEvents'); + event.initMouseEvent(type, true, true, ownerDocument.defaultView, + options.button, options.screenX, options.screenY, options.clientX, options.clientY, + options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el); + } else { + event = ownerDocument.createEvent('CustomEvent'); + + event.initCustomEvent(type, true, true, ownerDocument.defaultView, + options.button, options.screenX, options.screenY, options.clientX, options.clientY, + options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el); + + event.dataTransfer = { + data: {}, + + setData: function (type, val) { + this.data[type] = val; + }, + + getData: function (type) { + return this.data[type]; + } + }; + } + + if (el.dispatchEvent) { + el.dispatchEvent(event); + } else if (el.fireEvent) { + el.fireEvent('on' + type, event); + } + + return event; + } + + function getTraget(target) { + var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el; + var children = el.children; + + return ( + children[target.index] || + children[target.index === 'first' ? 0 : -1] || + children[target.index === 'last' ? children.length - 1 : -1] + ); + } + + function getRect(el) { + var rect = el.getBoundingClientRect(); + var width = rect.right - rect.left; + var height = rect.bottom - rect.top; + + return { + x: rect.left, + y: rect.top, + cx: rect.left + width / 2, + cy: rect.top + height / 2, + w: width, + h: height, + hw: width / 2, + wh: height / 2 + }; + } + + function simulateDrag(options, callback) { + options.to.el = options.to.el || options.from.el; + + var fromEl = getTraget(options.from); + var toEl = getTraget(options.to); + var scrollable = options.scrollable; + + var fromRect = getRect(fromEl); + var toRect = getRect(toEl); + + var startTime = new Date().getTime(); + var duration = options.duration || 1000; + simulateEvent(fromEl, 'mousedown', {button: 0}); + options.ontap && options.ontap(); + window.SIMULATE_DRAG_ACTIVE = 1; + + var dragInterval = setInterval(function loop() { + var progress = (new Date().getTime() - startTime) / duration; + var x = (fromRect.cx + (toRect.cx - fromRect.cx) * progress) - scrollable.scrollLeft; + var y = (fromRect.cy + (toRect.cy - fromRect.cy) * progress) - scrollable.scrollTop; + var overEl = fromEl.ownerDocument.elementFromPoint(x, y); + + simulateEvent(overEl, 'mousemove', { + clientX: x, + clientY: y + }); + + if (progress >= 1) { + options.ondragend && options.ondragend(); + simulateEvent(toEl, 'mouseup'); + clearInterval(dragInterval); + window.SIMULATE_DRAG_ACTIVE = 0; + } + }, 100); + + return { + target: fromEl, + fromList: fromEl.parentNode, + toList: toEl.parentNode + }; + } + + + // Export + window.simulateEvent = simulateEvent; + window.simulateDrag = simulateDrag; +})(); diff --git a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 new file mode 100644 index 00000000000..f9f9f7999d4 --- /dev/null +++ b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 @@ -0,0 +1,10 @@ +Vue.http.interceptors.push((request, next) => { + Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; + + Vue.nextTick(() => { + setTimeout(() => { + Vue.activeResources--; + }, 500); + }); + next(); +}); diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 3d9b824d406..0d7d29bb0d0 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -6,19 +6,26 @@ Build.state = null; - function Build(page_url, build_url, build_status, state1) { - this.page_url = page_url; - this.build_url = build_url; - this.build_status = build_status; - this.state = state1; + function Build(options) { + this.page_url = options.page_url; + this.build_url = options.build_url; + this.build_status = options.build_status; + this.state = options.state1; + this.build_stage = options.build_stage; this.hideSidebar = bind(this.hideSidebar, this); this.toggleSidebar = bind(this.toggleSidebar, this); + this.updateDropdown = bind(this.updateDropdown, this); clearInterval(Build.interval); this.bp = Breakpoints.get(); - this.hideSidebar(); $('.js-build-sidebar').niceScroll(); + + this.populateJobs(this.build_stage); + this.updateStageDropdownText(this.build_stage); + this.hideSidebar(); + $(document).off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar); $(window).off('resize.build').on('resize.build', this.hideSidebar); + $(document).off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown); this.updateArtifactRemoveDate(); if ($('#build-trace').length) { this.getInitialBuildTrace(); @@ -132,6 +139,22 @@ } }; + Build.prototype.populateJobs = function(stage) { + $('.build-job').hide(); + $('.build-job[data-stage="' + stage + '"]').show(); + }; + + Build.prototype.updateStageDropdownText = function(stage) { + $('.stage-selection').text(stage); + }; + + Build.prototype.updateDropdown = function(e) { + e.preventDefault(); + var stage = e.currentTarget.text; + this.updateStageDropdownText(stage); + this.populateJobs(stage); + }; + return Build; })(); diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js index c82798cc6a5..c43af17442b 100644 --- a/app/assets/javascripts/copy_to_clipboard.js +++ b/app/assets/javascripts/copy_to_clipboard.js @@ -34,6 +34,7 @@ $(function() { var clipboard; + clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); clipboard.on('success', genericSuccess); return clipboard.on('error', genericError); diff --git a/app/assets/javascripts/create_label.js.es6 b/app/assets/javascripts/create_label.js.es6 new file mode 100644 index 00000000000..46d1c3f00c1 --- /dev/null +++ b/app/assets/javascripts/create_label.js.es6 @@ -0,0 +1,126 @@ +(function (w) { + class CreateLabelDropdown { + constructor ($el, projectId) { + this.$el = $el; + this.projectId = projectId; + this.$dropdownBack = $('.dropdown-menu-back', this.$el.closest('.dropdown')); + this.$cancelButton = $('.js-cancel-label-btn', this.$el); + this.$newLabelField = $('#new_label_name', this.$el); + this.$newColorField = $('#new_label_color', this.$el); + this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el); + this.$newLabelError = $('.js-label-error', this.$el); + this.$newLabelCreateButton = $('.js-new-label-btn', this.$el); + this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el); + + this.$newLabelError.hide(); + this.$newLabelCreateButton.disable(); + + this.cleanBinding(); + this.addBinding(); + } + + cleanBinding () { + this.$colorSuggestions.off('click'); + this.$newLabelField.off('keyup change'); + this.$newColorField.off('keyup change'); + this.$dropdownBack.off('click'); + this.$cancelButton.off('click'); + this.$newLabelCreateButton.off('click'); + } + + addBinding () { + const self = this; + + this.$colorSuggestions.on('click', function (e) { + const $this = $(this); + self.addColorValue(e, $this); + }); + + this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this)); + this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this)); + + this.$dropdownBack.on('click', this.resetForm.bind(this)); + + this.$cancelButton.on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + self.resetForm(); + self.$dropdownBack.trigger('click'); + }); + + this.$newLabelCreateButton.on('click', this.saveLabel.bind(this)); + } + + addColorValue (e, $this) { + e.preventDefault(); + e.stopPropagation(); + + this.$newColorField.val($this.data('color')).trigger('change'); + this.$colorPreview + .css('background-color', $this.data('color')) + .parent() + .addClass('is-active'); + } + + enableLabelCreateButton () { + if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') { + this.$newLabelError.hide(); + this.$newLabelCreateButton.enable(); + } else { + this.$newLabelCreateButton.disable(); + } + } + + resetForm () { + this.$newLabelField + .val('') + .trigger('change'); + + this.$newColorField + .val('') + .trigger('change'); + + this.$colorPreview + .css('background-color', '') + .parent() + .removeClass('is-active'); + } + + saveLabel (e) { + e.preventDefault(); + e.stopPropagation(); + + Api.newLabel(this.projectId, { + name: this.$newLabelField.val(), + color: this.$newColorField.val() + }, (label) => { + this.$newLabelCreateButton.enable(); + + if (label.message) { + let errors; + + if (typeof label.message === 'string') { + errors = label.message; + } else { + errors = label.message.map(function (value, key) { + return key + " " + value[0]; + }).join("
    "); + } + + this.$newLabelError + .html(errors) + .show(); + } else { + this.$dropdownBack.trigger('click'); + } + }); + } + } + + if (!w.gl) { + w.gl = {}; + } + + gl.CreateLabelDropdown = CreateLabelDropdown; +})(window); diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 new file mode 100644 index 00000000000..48bc7d77805 --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 @@ -0,0 +1,49 @@ +((w) => { + w.CommentAndResolveBtn = Vue.extend({ + props: { + discussionId: String, + textareaIsEmpty: Boolean + }, + computed: { + discussion: function () { + return CommentsStore.state[this.discussionId]; + }, + showButton: function () { + if (this.discussion) { + return this.discussion.isResolvable(); + } else { + return false; + } + }, + isDiscussionResolved: function () { + return this.discussion.isResolved(); + }, + buttonText: function () { + if (this.isDiscussionResolved) { + if (this.textareaIsEmpty) { + return "Unresolve discussion"; + } else { + return "Comment & unresolve discussion"; + } + } else { + if (this.textareaIsEmpty) { + return "Resolve discussion"; + } else { + return "Comment & resolve discussion"; + } + } + } + }, + ready: function () { + const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`); + this.textareaIsEmpty = $textarea.val() === ''; + + $textarea.on('input.comment-and-resolve-btn', () => { + this.textareaIsEmpty = $textarea.val() === ''; + }); + }, + destroyed: function () { + $(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn'); + } + }); +})(window); diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 new file mode 100644 index 00000000000..ad80d1118df --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 @@ -0,0 +1,188 @@ +(() => { + JumpToDiscussion = Vue.extend({ + mixins: [DiscussionMixins], + props: { + discussionId: String + }, + data: function () { + return { + discussions: CommentsStore.state, + }; + }, + computed: { + discussion: function () { + return this.discussions[this.discussionId]; + }, + allResolved: function () { + return this.unresolvedDiscussionCount === 0; + }, + showButton: function () { + if (this.discussionId) { + if (this.unresolvedDiscussionCount > 1) { + return true; + } else { + return this.discussionId !== this.lastResolvedId; + } + } else { + return this.unresolvedDiscussionCount >= 1; + } + }, + lastResolvedId: function () { + let lastId; + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; + + if (!discussion.isResolved()) { + lastId = discussion.id; + } + } + return lastId; + } + }, + methods: { + jumpToNextUnresolvedDiscussion: function () { + let discussionsSelector, + discussionIdsInScope, + firstUnresolvedDiscussionId, + nextUnresolvedDiscussionId, + activeTab = window.mrTabs.currentAction, + hasDiscussionsToJumpTo = true, + jumpToFirstDiscussion = !this.discussionId; + + const discussionIdsForElements = function(elements) { + return elements.map(function() { + return $(this).attr('data-discussion-id'); + }).toArray(); + }; + + const discussions = this.discussions; + + if (activeTab === 'diffs') { + discussionsSelector = '.diffs .notes[data-discussion-id]'; + discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); + + let unresolvedDiscussionCount = 0; + + for (let i = 0; i < discussionIdsInScope.length; i++) { + const discussionId = discussionIdsInScope[i]; + const discussion = discussions[discussionId]; + if (discussion && !discussion.isResolved()) { + unresolvedDiscussionCount++; + } + } + + if (this.discussionId && !this.discussion.isResolved()) { + // If this is the last unresolved discussion on the diffs tab, + // there are no discussions to jump to. + if (unresolvedDiscussionCount === 1) { + hasDiscussionsToJumpTo = false; + } + } else { + // If there are no unresolved discussions on the diffs tab at all, + // there are no discussions to jump to. + if (unresolvedDiscussionCount === 0) { + hasDiscussionsToJumpTo = false; + } + } + } else if (activeTab !== 'notes') { + // If we are on the commits or builds tabs, + // there are no discussions to jump to. + hasDiscussionsToJumpTo = false; + } + + if (!hasDiscussionsToJumpTo) { + // If there are no discussions to jump to on the current page, + // switch to the notes tab and jump to the first disucssion there. + window.mrTabs.activateTab('notes'); + activeTab = 'notes'; + jumpToFirstDiscussion = true; + } + + if (activeTab === 'notes') { + discussionsSelector = '.discussion[data-discussion-id]'; + discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); + } + + let currentDiscussionFound = false; + for (let i = 0; i < discussionIdsInScope.length; i++) { + const discussionId = discussionIdsInScope[i]; + const discussion = discussions[discussionId]; + + if (!discussion) { + // Discussions for comments on commits in this MR don't have a resolved status. + continue; + } + + if (!firstUnresolvedDiscussionId && !discussion.isResolved()) { + firstUnresolvedDiscussionId = discussionId; + + if (jumpToFirstDiscussion) { + break; + } + } + + if (!jumpToFirstDiscussion) { + if (currentDiscussionFound) { + if (!discussion.isResolved()) { + nextUnresolvedDiscussionId = discussionId; + break; + } + else { + continue; + } + } + + if (discussionId === this.discussionId) { + currentDiscussionFound = true; + } + } + } + + nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId; + + if (!nextUnresolvedDiscussionId) { + return; + } + + let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`); + + if (activeTab === 'notes') { + $target = $target.closest('.note-discussion'); + + // If the next discussion is closed, toggle it open. + if ($target.find('.js-toggle-content').is(':hidden')) { + $target.find('.js-toggle-button i').trigger('click') + } + } else if (activeTab === 'diffs') { + // Resolved discussions are hidden in the diffs tab by default. + // If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab. + // When jumping between unresolved discussions on the diffs tab, we show them. + $target.closest(".content").show(); + + $target = $target.closest("tr.notes_holder"); + $target.show(); + + // If we are on the diffs tab, we don't scroll to the discussion itself, but to + // 4 diff lines above it: the line the discussion was in response to + 3 context + let prevEl; + for (let i = 0; i < 4; i++) { + prevEl = $target.prev(); + + // If the discussion doesn't have 4 lines above it, we'll have to do with fewer. + if (!prevEl.hasClass("line_holder")) { + break; + } + + $target = prevEl; + } + } + + $.scrollTo($target, { + offset: -($('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight()) + }); + } + } + }); + + Vue.component('jump-to-discussion', JumpToDiscussion); +})(); diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 new file mode 100644 index 00000000000..be6ebc77947 --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 @@ -0,0 +1,107 @@ +((w) => { + w.ResolveBtn = Vue.extend({ + mixins: [ + ButtonMixins + ], + props: { + noteId: Number, + discussionId: String, + resolved: Boolean, + namespacePath: String, + projectPath: String, + canResolve: Boolean, + resolvedBy: String + }, + data: function () { + return { + discussions: CommentsStore.state, + loading: false + }; + }, + watch: { + 'discussions': { + handler: 'updateTooltip', + deep: true + } + }, + computed: { + discussion: function () { + return this.discussions[this.discussionId]; + }, + note: function () { + if (this.discussion) { + return this.discussion.getNote(this.noteId); + } else { + return undefined; + } + }, + buttonText: function () { + if (this.isResolved) { + return `Resolved by ${this.resolvedByName}`; + } else if (this.canResolve) { + return 'Mark as resolved'; + } else { + return 'Unable to resolve'; + } + }, + isResolved: function () { + if (this.note) { + return this.note.resolved; + } else { + return false; + } + }, + resolvedByName: function () { + return this.note.resolved_by; + }, + }, + methods: { + updateTooltip: function () { + $(this.$els.button) + .tooltip('hide') + .tooltip('fixTitle'); + }, + resolve: function () { + if (!this.canResolve) return; + + let promise; + this.loading = true; + + if (this.isResolved) { + promise = ResolveService + .unresolve(this.namespace, this.noteId); + } else { + promise = ResolveService + .resolve(this.namespace, this.noteId); + } + + promise.then((response) => { + this.loading = false; + + if (response.status === 200) { + const data = response.json(); + const resolved_by = data ? data.resolved_by : null; + + CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); + this.discussion.updateHeadline(data); + } else { + new Flash('An error occurred when trying to resolve a comment. Please try again.', 'alert'); + } + + this.$nextTick(this.updateTooltip); + }); + } + }, + compiled: function () { + $(this.$els.button).tooltip({ + container: 'body' + }); + }, + beforeDestroy: function () { + CommentsStore.delete(this.discussionId, this.noteId); + }, + created: function () { + CommentsStore.create(this.discussionId, this.noteId, this.canResolve, this.resolved, this.resolvedBy); + } + }); +})(window); diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 new file mode 100644 index 00000000000..9e383b14a3e --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 @@ -0,0 +1,18 @@ +((w) => { + w.ResolveCount = Vue.extend({ + mixins: [DiscussionMixins], + props: { + loggedOut: Boolean + }, + data: function () { + return { + discussions: CommentsStore.state + }; + }, + computed: { + allResolved: function () { + return this.resolvedDiscussionCount === this.discussionCount; + } + } + }); +})(window); diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 new file mode 100644 index 00000000000..e373b06b1eb --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 @@ -0,0 +1,60 @@ +((w) => { + w.ResolveDiscussionBtn = Vue.extend({ + mixins: [ + ButtonMixins + ], + props: { + discussionId: String, + mergeRequestId: Number, + namespacePath: String, + projectPath: String, + canResolve: Boolean, + }, + data: function() { + return { + discussions: CommentsStore.state + }; + }, + computed: { + discussion: function () { + return this.discussions[this.discussionId]; + }, + showButton: function () { + if (this.discussion) { + return this.discussion.isResolvable(); + } else { + return false; + } + }, + isDiscussionResolved: function () { + if (this.discussion) { + return this.discussion.isResolved(); + } else { + return false; + } + }, + buttonText: function () { + if (this.isDiscussionResolved) { + return "Unresolve discussion"; + } else { + return "Resolve discussion"; + } + }, + loading: function () { + if (this.discussion) { + return this.discussion.loading; + } else { + return false; + } + } + }, + methods: { + resolve: function () { + ResolveService.toggleResolveForDiscussion(this.namespace, this.mergeRequestId, this.discussionId); + } + }, + created: function () { + CommentsStore.createDiscussion(this.discussionId, this.canResolve); + } + }); +})(window); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 new file mode 100644 index 00000000000..22d9cf6c857 --- /dev/null +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 @@ -0,0 +1,35 @@ +//= require vue +//= require vue-resource +//= require_directory ./models +//= require_directory ./stores +//= require_directory ./services +//= require_directory ./mixins +//= require_directory ./components + +$(() => { + window.DiffNotesApp = new Vue({ + el: '#diff-notes-app', + components: { + 'resolve-btn': ResolveBtn, + 'resolve-discussion-btn': ResolveDiscussionBtn, + 'comment-and-resolve-btn': CommentAndResolveBtn + }, + methods: { + compileComponents: function () { + const $components = $('resolve-btn, resolve-discussion-btn, jump-to-discussion'); + if ($components.length) { + $components.each(function () { + DiffNotesApp.$compile($(this).get(0)); + }); + } + } + } + }); + + new Vue({ + el: '#resolve-count-app', + components: { + 'resolve-count': ResolveCount + } + }); +}); diff --git a/app/assets/javascripts/diff_notes/mixins/discussion.js.es6 b/app/assets/javascripts/diff_notes/mixins/discussion.js.es6 new file mode 100644 index 00000000000..a05f885201d --- /dev/null +++ b/app/assets/javascripts/diff_notes/mixins/discussion.js.es6 @@ -0,0 +1,35 @@ +((w) => { + w.DiscussionMixins = { + computed: { + discussionCount: function () { + return Object.keys(this.discussions).length; + }, + resolvedDiscussionCount: function () { + let resolvedCount = 0; + + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; + + if (discussion.isResolved()) { + resolvedCount++; + } + } + + return resolvedCount; + }, + unresolvedDiscussionCount: function () { + let unresolvedCount = 0; + + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; + + if (!discussion.isResolved()) { + unresolvedCount++; + } + } + + return unresolvedCount; + } + } + }; +})(window); diff --git a/app/assets/javascripts/diff_notes/mixins/namespace.js.es6 b/app/assets/javascripts/diff_notes/mixins/namespace.js.es6 new file mode 100644 index 00000000000..d278678085b --- /dev/null +++ b/app/assets/javascripts/diff_notes/mixins/namespace.js.es6 @@ -0,0 +1,9 @@ +((w) => { + w.ButtonMixins = { + computed: { + namespace: function () { + return `${this.namespacePath}/${this.projectPath}`; + } + } + }; +})(window); diff --git a/app/assets/javascripts/diff_notes/models/discussion.js.es6 b/app/assets/javascripts/diff_notes/models/discussion.js.es6 new file mode 100644 index 00000000000..488714e4870 --- /dev/null +++ b/app/assets/javascripts/diff_notes/models/discussion.js.es6 @@ -0,0 +1,87 @@ +class DiscussionModel { + constructor (discussionId) { + this.id = discussionId; + this.notes = {}; + this.loading = false; + this.canResolve = false; + } + + createNote (noteId, canResolve, resolved, resolved_by) { + Vue.set(this.notes, noteId, new NoteModel(this.id, noteId, canResolve, resolved, resolved_by)); + } + + deleteNote (noteId) { + Vue.delete(this.notes, noteId); + } + + getNote (noteId) { + return this.notes[noteId]; + } + + notesCount() { + return Object.keys(this.notes).length; + } + + isResolved () { + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (!note.resolved) { + return false; + } + } + return true; + } + + resolveAllNotes (resolved_by) { + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (!note.resolved) { + note.resolved = true; + note.resolved_by = resolved_by; + } + } + } + + unResolveAllNotes () { + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (note.resolved) { + note.resolved = false; + note.resolved_by = null; + } + } + } + + updateHeadline (data) { + const $discussionHeadline = $(`.discussion[data-discussion-id="${this.id}"] .js-discussion-headline`); + + if (data.discussion_headline_html) { + if ($discussionHeadline.length) { + $discussionHeadline.replaceWith(data.discussion_headline_html); + } else { + $(`.discussion[data-discussion-id="${this.id}"] .discussion-header`).append(data.discussion_headline_html); + } + } else { + $discussionHeadline.remove(); + } + } + + isResolvable () { + if (!this.canResolve) { + return false; + } + + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (note.canResolve) { + return true; + } + } + + return false; + } +} diff --git a/app/assets/javascripts/diff_notes/models/note.js.es6 b/app/assets/javascripts/diff_notes/models/note.js.es6 new file mode 100644 index 00000000000..f2d2d389c38 --- /dev/null +++ b/app/assets/javascripts/diff_notes/models/note.js.es6 @@ -0,0 +1,9 @@ +class NoteModel { + constructor (discussionId, noteId, canResolve, resolved, resolved_by) { + this.discussionId = discussionId; + this.id = noteId; + this.canResolve = canResolve; + this.resolved = resolved; + this.resolved_by = resolved_by; + } +} diff --git a/app/assets/javascripts/diff_notes/services/resolve.js.es6 b/app/assets/javascripts/diff_notes/services/resolve.js.es6 new file mode 100644 index 00000000000..de771ff814b --- /dev/null +++ b/app/assets/javascripts/diff_notes/services/resolve.js.es6 @@ -0,0 +1,88 @@ +((w) => { + class ResolveServiceClass { + constructor() { + this.noteResource = Vue.resource('notes{/noteId}/resolve'); + this.discussionResource = Vue.resource('merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve'); + } + + setCSRF() { + Vue.http.headers.common['X-CSRF-Token'] = $.rails.csrfToken(); + } + + prepareRequest(namespace) { + this.setCSRF(); + Vue.http.options.root = `/${namespace}`; + } + + resolve(namespace, noteId) { + this.prepareRequest(namespace); + + return this.noteResource.save({ noteId }, {}); + } + + unresolve(namespace, noteId) { + this.prepareRequest(namespace); + + return this.noteResource.delete({ noteId }, {}); + } + + toggleResolveForDiscussion(namespace, mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId], + isResolved = discussion.isResolved(); + let promise; + + if (isResolved) { + promise = this.unResolveAll(namespace, mergeRequestId, discussionId); + } else { + promise = this.resolveAll(namespace, mergeRequestId, discussionId); + } + + promise.then((response) => { + discussion.loading = false; + + if (response.status === 200) { + const data = response.json(); + const resolved_by = data ? data.resolved_by : null; + + if (isResolved) { + discussion.unResolveAllNotes(); + } else { + discussion.resolveAllNotes(resolved_by); + } + + discussion.updateHeadline(data); + } else { + new Flash('An error occurred when trying to resolve a discussion. Please try again.', 'alert'); + } + }) + } + + resolveAll(namespace, mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId]; + + this.prepareRequest(namespace); + + discussion.loading = true; + + return this.discussionResource.save({ + mergeRequestId, + discussionId + }, {}); + } + + unResolveAll(namespace, mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId]; + + this.prepareRequest(namespace); + + discussion.loading = true; + + return this.discussionResource.delete({ + mergeRequestId, + discussionId + }, {}); + } + } + + w.ResolveService = new ResolveServiceClass(); +})(window); diff --git a/app/assets/javascripts/diff_notes/stores/comments.js.es6 b/app/assets/javascripts/diff_notes/stores/comments.js.es6 new file mode 100644 index 00000000000..69522e1dac5 --- /dev/null +++ b/app/assets/javascripts/diff_notes/stores/comments.js.es6 @@ -0,0 +1,53 @@ +((w) => { + w.CommentsStore = { + state: {}, + get: function (discussionId, noteId) { + return this.state[discussionId].getNote(noteId); + }, + createDiscussion: function (discussionId, canResolve) { + let discussion = this.state[discussionId]; + if (!this.state[discussionId]) { + discussion = new DiscussionModel(discussionId); + Vue.set(this.state, discussionId, discussion); + } + + if (canResolve !== undefined) { + discussion.canResolve = canResolve; + } + + return discussion; + }, + create: function (discussionId, noteId, canResolve, resolved, resolved_by) { + const discussion = this.createDiscussion(discussionId); + + discussion.createNote(noteId, canResolve, resolved, resolved_by); + }, + update: function (discussionId, noteId, resolved, resolved_by) { + const discussion = this.state[discussionId]; + const note = discussion.getNote(noteId); + note.resolved = resolved; + note.resolved_by = resolved_by; + }, + delete: function (discussionId, noteId) { + const discussion = this.state[discussionId]; + discussion.deleteNote(noteId); + + if (discussion.notesCount() === 0) { + Vue.delete(this.state, discussionId); + } + }, + unresolvedDiscussionIds: function () { + let ids = []; + + for (const discussionId in this.state) { + const discussion = this.state[discussionId]; + + if (!discussion.isResolved()) { + ids.push(discussion.id); + } + } + + return ids; + } + }; +})(window); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 3946e861976..74c4ab563f9 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -55,6 +55,7 @@ shortcut_handler = new ShortcutsNavigation(); new GLForm($('.issue-form')); new IssuableForm($('.issue-form')); + new IssuableTemplateSelectors(); break; case 'projects:merge_requests:new': case 'projects:merge_requests:edit': @@ -62,6 +63,7 @@ shortcut_handler = new ShortcutsNavigation(); new GLForm($('.merge-request-form')); new IssuableForm($('.merge-request-form')); + new IssuableTemplateSelectors(); break; case 'projects:tags:new': new ZenMode(); @@ -86,6 +88,8 @@ new ZenMode(); new MergedButtons(); break; + case "projects:merge_requests:conflicts": + window.mcui = new MergeConflictResolver() case 'projects:merge_requests:index': shortcut_handler = new ShortcutsNavigation(); Issuable.init(); @@ -192,6 +196,9 @@ case 'edit': new Labels(); } + case 'abuse_reports': + new gl.AbuseReports(); + break; } break; case 'dashboard': diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 09b5eb398d4..b2e49b71fec 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -33,7 +33,7 @@ this.render = bind(this.render, this); this.VIEW_TYPE = $('input#view[type=hidden]').val(); debounce = _.debounce(this.render, DEBOUNCE_TIMEOUT_DURATION); - $(document).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy); + $(this.filesContainerElement).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy); } FilesCommentButton.prototype.render = function(e) { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js.es6 similarity index 77% rename from app/assets/javascripts/gfm_auto_complete.js rename to app/assets/javascripts/gfm_auto_complete.js.es6 index 2e5b15f4b77..3dca06d36b1 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js.es6 @@ -223,7 +223,7 @@ } } }); - return this.input.atwho({ + this.input.atwho({ at: '~', alias: 'labels', searchKey: 'search', @@ -249,6 +249,68 @@ } } }); + // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms + this.input.filter('[data-supports-slash-commands="true"]').atwho({ + at: '/', + alias: 'commands', + searchKey: 'search', + displayTpl: function(value) { + var tpl = '
  • /${name}'; + if (value.aliases.length > 0) { + tpl += ' (or /<%- aliases.join(", /") %>)'; + } + if (value.params.length > 0) { + tpl += ' <%- params.join(" ") %>'; + } + if (value.description !== '') { + tpl += '<%- description %>'; + } + tpl += '
  • '; + return _.template(tpl)(value); + }, + insertTpl: function(value) { + var tpl = "/${name} "; + var reference_prefix = null; + if (value.params.length > 0) { + reference_prefix = value.params[0][0]; + if (/^[@%~]/.test(reference_prefix)) { + tpl += '<%- reference_prefix %>'; + } + } + return _.template(tpl)({ reference_prefix: reference_prefix }); + }, + suffix: '', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(commands) { + return $.map(commands, function(c) { + var search = c.name; + if (c.aliases.length > 0) { + search = search + " " + c.aliases.join(" "); + } + return { + name: c.name, + aliases: c.aliases, + params: c.params, + description: c.description, + search: search + }; + }); + }, + matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { + var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi + var match = regexp.exec(subtext); + if (match) { + return match[1]; + } else { + return null; + } + } + } + }); + return; }, destroyAtWho: function() { return this.input.atwho('destroy'); @@ -265,6 +327,7 @@ this.input.atwho('load', 'mergerequests', data.mergerequests); this.input.atwho('load', ':', data.emojis); this.input.atwho('load', '~', data.labels); + this.input.atwho('load', '/', data.commands); return $(':focus').trigger('keyup'); } }; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 675dd5b7cea..0526430989f 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -4,7 +4,7 @@ var _this; _this = this; $('.js-label-select').each(function(i, dropdown) { - var $block, $colorPreview, $dropdown, $form, $loading, $newLabelCreateButton, $newLabelError, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, newColorField, newLabelField, projectId, resetForm, saveLabel, saveLabelData, selectedLabel, showAny, showNo; + var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, projectId, saveLabelData, selectedLabel, showAny, showNo; $dropdown = $(dropdown); projectId = $dropdown.data('project-id'); labelUrl = $dropdown.data('labels'); @@ -13,8 +13,6 @@ if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) { selectedLabel = selectedLabel.split(','); } - newLabelField = $('#new_label_name'); - newColorField = $('#new_label_color'); showNo = $dropdown.data('show-no'); showAny = $dropdown.data('show-any'); defaultLabel = $dropdown.data('default-label'); @@ -24,10 +22,6 @@ $form = $dropdown.closest('form'); $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); $value = $block.find('.value'); - $newLabelError = $('.js-label-error'); - $colorPreview = $('.js-dropdown-label-color-preview'); - $newLabelCreateButton = $('.js-new-label-btn'); - $newLabelError.hide(); $loading = $block.find('.block-loading').fadeOut(); if (issueUpdateURL != null) { issueURLSplit = issueUpdateURL.split('/'); @@ -36,60 +30,9 @@ labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <%- label.title %> <% }); %>'); labelNoneHTMLTemplate = 'None'; } - if (newLabelField.length) { - $('.suggest-colors-dropdown a').on("click", function(e) { - e.preventDefault(); - e.stopPropagation(); - newColorField.val($(this).data('color')).trigger('change'); - return $colorPreview.css('background-color', $(this).data('color')).parent().addClass('is-active'); - }); - resetForm = function() { - newLabelField.val('').trigger('change'); - newColorField.val('').trigger('change'); - return $colorPreview.css('background-color', '').parent().removeClass('is-active'); - }; - $('.dropdown-menu-back').on('click', function() { - return resetForm(); - }); - $('.js-cancel-label-btn').on('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - resetForm(); - return $('.dropdown-menu-back', $dropdown.parent()).trigger('click'); - }); - enableLabelCreateButton = function() { - if (newLabelField.val() !== '' && newColorField.val() !== '') { - $newLabelError.hide(); - return $newLabelCreateButton.enable(); - } else { - return $newLabelCreateButton.disable(); - } - }; - saveLabel = function() { - return Api.newLabel(projectId, { - name: newLabelField.val(), - color: newColorField.val() - }, function(label) { - var errors; - $newLabelCreateButton.enable(); - if (label.message != null) { - errors = _.map(label.message, function(value, key) { - return key + " " + value[0]; - }); - return $newLabelError.html(errors.join("
    ")).show(); - } else { - return $('.dropdown-menu-back', $dropdown.parent()).trigger('click'); - } - }); - }; - newLabelField.on('keyup change', enableLabelCreateButton); - newColorField.on('keyup change', enableLabelCreateButton); - $newLabelCreateButton.disable().on('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - return saveLabel(); - }); - } + + new gl.CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), projectId); + saveLabelData = function() { var data, selected; selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").map(function() { @@ -270,6 +213,9 @@ isMRIndex = page === 'projects:merge_requests:index'; $selectbox.hide(); $value.removeAttr('style'); + if (page === 'projects:boards:show') { + return; + } if ($dropdown.hasClass('js-multiselect')) { if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']"); @@ -289,7 +235,7 @@ } }, multiSelect: $dropdown.hasClass('js-multiselect'), - clicked: function(label) { + clicked: function(label, $el, e) { var isIssueIndex, isMRIndex, page; _this.enableBulkLabelDropdown(); if ($dropdown.hasClass('js-filter-bulk-update')) { @@ -298,7 +244,23 @@ page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = page === 'projects:merge_requests:index'; - if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if (page === 'projects:boards:show') { + if (label.isAny) { + gl.issueBoards.BoardsStore.state.filters['label_name'] = []; + } else if (label.title) { + gl.issueBoards.BoardsStore.state.filters['label_name'].push(label.title); + } else { + var filters = gl.issueBoards.BoardsStore.state.filters['label_name']; + filters = filters.filter(function (label) { + return label !== $el.text().trim(); + }); + gl.issueBoards.BoardsStore.state.filters['label_name'] = filters; + } + + gl.issueBoards.BoardsStore.updateFiltersUrl(); + e.preventDefault(); + return; + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { if (!$dropdown.hasClass('js-multiselect')) { selectedLabel = label.title; return Issuable.filterResults($dropdown.closest('form')); diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 130479642f3..b6636de5767 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -104,9 +104,12 @@ return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); }); }; - return gl.text.removeListeners = function(form) { + gl.text.removeListeners = function(form) { return $('.js-md', form).off(); }; + return gl.text.truncate = function(string, maxLength) { + return string.substr(0, (maxLength - 3)) + '...'; + }; })(window); }).call(this); diff --git a/app/assets/javascripts/merge_conflict_data_provider.js.es6 b/app/assets/javascripts/merge_conflict_data_provider.js.es6 new file mode 100644 index 00000000000..cd92df8ddc5 --- /dev/null +++ b/app/assets/javascripts/merge_conflict_data_provider.js.es6 @@ -0,0 +1,341 @@ +const HEAD_HEADER_TEXT = 'HEAD//our changes'; +const ORIGIN_HEADER_TEXT = 'origin//their changes'; +const HEAD_BUTTON_TITLE = 'Use ours'; +const ORIGIN_BUTTON_TITLE = 'Use theirs'; + + +class MergeConflictDataProvider { + + getInitialData() { + const diffViewType = $.cookie('diff_view'); + + return { + isLoading : true, + hasError : false, + isParallel : diffViewType === 'parallel', + diffViewType : diffViewType, + isSubmitting : false, + conflictsData : {}, + resolutionData : {} + } + } + + + decorateData(vueInstance, data) { + this.vueInstance = vueInstance; + + if (data.type === 'error') { + vueInstance.hasError = true; + data.errorMessage = data.message; + } + else { + data.shortCommitSha = data.commit_sha.slice(0, 7); + data.commitMessage = data.commit_message; + + this.setParallelLines(data); + this.setInlineLines(data); + this.updateResolutionsData(data); + } + + vueInstance.conflictsData = data; + vueInstance.isSubmitting = false; + + const conflictsText = this.getConflictsCount() > 1 ? 'conflicts' : 'conflict'; + vueInstance.conflictsData.conflictsText = conflictsText; + } + + + updateResolutionsData(data) { + const vi = this.vueInstance; + + data.files.forEach( (file) => { + file.sections.forEach( (section) => { + if (section.conflict) { + vi.$set(`resolutionData['${section.id}']`, false); + } + }); + }); + } + + + setParallelLines(data) { + data.files.forEach( (file) => { + file.filePath = this.getFilePath(file); + file.iconClass = `fa-${file.blob_icon}`; + file.blobPath = file.blob_path; + file.parallelLines = []; + const linesObj = { left: [], right: [] }; + + file.sections.forEach( (section) => { + const { conflict, lines, id } = section; + + if (conflict) { + linesObj.left.push(this.getOriginHeaderLine(id)); + linesObj.right.push(this.getHeadHeaderLine(id)); + } + + lines.forEach( (line) => { + const { type } = line; + + if (conflict) { + if (type === 'old') { + linesObj.left.push(this.getLineForParallelView(line, id, 'conflict')); + } + else if (type === 'new') { + linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true)); + } + } + else { + const lineType = type || 'context'; + + linesObj.left.push (this.getLineForParallelView(line, id, lineType)); + linesObj.right.push(this.getLineForParallelView(line, id, lineType, true)); + } + }); + + this.checkLineLengths(linesObj); + }); + + for (let i = 0, len = linesObj.left.length; i < len; i++) { + file.parallelLines.push([ + linesObj.right[i], + linesObj.left[i] + ]); + } + + }); + } + + + checkLineLengths(linesObj) { + let { left, right } = linesObj; + + if (left.length !== right.length) { + if (left.length > right.length) { + const diff = left.length - right.length; + for (let i = 0; i < diff; i++) { + right.push({ lineType: 'emptyLine', richText: '' }); + } + } + else { + const diff = right.length - left.length; + for (let i = 0; i < diff; i++) { + left.push({ lineType: 'emptyLine', richText: '' }); + } + } + } + } + + + setInlineLines(data) { + data.files.forEach( (file) => { + file.iconClass = `fa-${file.blob_icon}`; + file.blobPath = file.blob_path; + file.filePath = this.getFilePath(file); + file.inlineLines = [] + + file.sections.forEach( (section) => { + let currentLineType = 'new'; + const { conflict, lines, id } = section; + + if (conflict) { + file.inlineLines.push(this.getHeadHeaderLine(id)); + } + + lines.forEach( (line) => { + const { type } = line; + + if ((type === 'new' || type === 'old') && currentLineType !== type) { + currentLineType = type; + file.inlineLines.push({ lineType: 'emptyLine', richText: '' }); + } + + this.decorateLineForInlineView(line, id, conflict); + file.inlineLines.push(line); + }) + + if (conflict) { + file.inlineLines.push(this.getOriginHeaderLine(id)); + } + }); + }); + } + + + handleSelected(sectionId, selection) { + const vi = this.vueInstance; + + vi.resolutionData[sectionId] = selection; + vi.conflictsData.files.forEach( (file) => { + file.inlineLines.forEach( (line) => { + if (line.id === sectionId && (line.hasConflict || line.isHeader)) { + this.markLine(line, selection); + } + }); + + file.parallelLines.forEach( (lines) => { + const left = lines[0]; + const right = lines[1]; + const hasSameId = right.id === sectionId || left.id === sectionId; + const isLeftMatch = left.hasConflict || left.isHeader; + const isRightMatch = right.hasConflict || right.isHeader; + + if (hasSameId && (isLeftMatch || isRightMatch)) { + this.markLine(left, selection); + this.markLine(right, selection); + } + }) + }); + } + + + updateViewType(newType) { + const vi = this.vueInstance; + + if (newType === vi.diffView || !(newType === 'parallel' || newType === 'inline')) { + return; + } + + vi.diffView = newType; + vi.isParallel = newType === 'parallel'; + $.cookie('diff_view', newType); // TODO: Make sure that cookie path added. + $('.content-wrapper .container-fluid').toggleClass('container-limited'); + } + + + markLine(line, selection) { + if (selection === 'head' && line.isHead) { + line.isSelected = true; + line.isUnselected = false; + } + else if (selection === 'origin' && line.isOrigin) { + line.isSelected = true; + line.isUnselected = false; + } + else { + line.isSelected = false; + line.isUnselected = true; + } + } + + + getConflictsCount() { + return Object.keys(this.vueInstance.resolutionData).length; + } + + + getResolvedCount() { + let count = 0; + const data = this.vueInstance.resolutionData; + + for (const id in data) { + const resolution = data[id]; + if (resolution) { + count++; + } + } + + return count; + } + + + isReadyToCommit() { + const { conflictsData, isSubmitting } = this.vueInstance + const allResolved = this.getConflictsCount() === this.getResolvedCount(); + const hasCommitMessage = $.trim(conflictsData.commitMessage).length; + + return !isSubmitting && hasCommitMessage && allResolved; + } + + + getCommitButtonText() { + const initial = 'Commit conflict resolution'; + const inProgress = 'Committing...'; + const vue = this.vueInstance; + + return vue ? vue.isSubmitting ? inProgress : initial : initial; + } + + + decorateLineForInlineView(line, id, conflict) { + const { type } = line; + line.id = id; + line.hasConflict = conflict; + line.isHead = type === 'new'; + line.isOrigin = type === 'old'; + line.hasMatch = type === 'match'; + line.richText = line.rich_text; + line.isSelected = false; + line.isUnselected = false; + } + + getLineForParallelView(line, id, lineType, isHead) { + const { old_line, new_line, rich_text } = line; + const hasConflict = lineType === 'conflict'; + + return { + id, + lineType, + hasConflict, + isHead : hasConflict && isHead, + isOrigin : hasConflict && !isHead, + hasMatch : lineType === 'match', + lineNumber : isHead ? new_line : old_line, + section : isHead ? 'head' : 'origin', + richText : rich_text, + isSelected : false, + isUnselected : false + } + } + + + getHeadHeaderLine(id) { + return { + id : id, + richText : HEAD_HEADER_TEXT, + buttonTitle : HEAD_BUTTON_TITLE, + type : 'new', + section : 'head', + isHeader : true, + isHead : true, + isSelected : false, + isUnselected: false + } + } + + + getOriginHeaderLine(id) { + return { + id : id, + richText : ORIGIN_HEADER_TEXT, + buttonTitle : ORIGIN_BUTTON_TITLE, + type : 'old', + section : 'origin', + isHeader : true, + isOrigin : true, + isSelected : false, + isUnselected: false + } + } + + + handleFailedRequest(vueInstance, data) { + vueInstance.hasError = true; + vueInstance.conflictsData.errorMessage = 'Something went wrong!'; + } + + + getCommitData() { + return { + commit_message: this.vueInstance.conflictsData.commitMessage, + sections: this.vueInstance.resolutionData + } + } + + + getFilePath(file) { + const { old_path, new_path } = file; + return old_path === new_path ? new_path : `${old_path} → ${new_path}`; + } + +} diff --git a/app/assets/javascripts/merge_conflict_resolver.js.es6 b/app/assets/javascripts/merge_conflict_resolver.js.es6 new file mode 100644 index 00000000000..77bffbcb403 --- /dev/null +++ b/app/assets/javascripts/merge_conflict_resolver.js.es6 @@ -0,0 +1,85 @@ +//= require vue + +class MergeConflictResolver { + + constructor() { + this.dataProvider = new MergeConflictDataProvider() + this.initVue() + } + + + initVue() { + const that = this; + this.vue = new Vue({ + el : '#conflicts', + name : 'MergeConflictResolver', + data : this.dataProvider.getInitialData(), + created : this.fetchData(), + computed : this.setComputedProperties(), + methods : { + handleSelected(sectionId, selection) { + that.dataProvider.handleSelected(sectionId, selection); + }, + handleViewTypeChange(newType) { + that.dataProvider.updateViewType(newType); + }, + commit() { + that.commit(); + } + } + }) + } + + + setComputedProperties() { + const dp = this.dataProvider; + + return { + conflictsCount() { return dp.getConflictsCount() }, + resolvedCount() { return dp.getResolvedCount() }, + readyToCommit() { return dp.isReadyToCommit() }, + commitButtonText() { return dp.getCommitButtonText() } + } + } + + + fetchData() { + const dp = this.dataProvider; + + $.get($('#conflicts').data('conflictsPath')) + .done((data) => { + dp.decorateData(this.vue, data); + }) + .error((data) => { + dp.handleFailedRequest(this.vue, data); + }) + .always(() => { + this.vue.isLoading = false; + + this.vue.$nextTick(() => { + $('#conflicts .js-syntax-highlight').syntaxHighlight(); + }); + + if (this.vue.diffViewType === 'parallel') { + $('.content-wrapper .container-fluid').removeClass('container-limited'); + } + }) + } + + + commit() { + this.vue.isSubmitting = true; + + $.post($('#conflicts').data('resolveConflictsPath'), this.dataProvider.getCommitData()) + .done((data) => { + window.location.href = data.redirect_to; + }) + .error(() => { + new Flash('Something went wrong!'); + }) + .always(() => { + this.vue.isSubmitting = false; + }); + } + +} diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 47e6dd1084d..56ebf84c4f6 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -34,7 +34,7 @@ MergeRequest.prototype.initTabs = function() { if (this.opts.action !== 'new') { - return new MergeRequestTabs(this.opts); + window.mrTabs = new MergeRequestTabs(this.opts); } else { return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show'); } diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 52c2ed61012..ad08209d61e 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -9,10 +9,13 @@ MergeRequestTabs.prototype.buildsLoaded = false; + MergeRequestTabs.prototype.pipelinesLoaded = false; + MergeRequestTabs.prototype.commitsLoaded = false; function MergeRequestTabs(opts) { this.opts = opts != null ? opts : {}; + this.opts.setUrl = this.opts.setUrl !== undefined ? this.opts.setUrl : true; this.setCurrentAction = bind(this.setCurrentAction, this); this.tabShown = bind(this.tabShown, this); this.showTab = bind(this.showTab, this); @@ -50,10 +53,15 @@ } else if (action === 'builds') { this.loadBuilds($target.attr('href')); this.expandView(); + } else if (action === 'pipelines') { + this.loadPipelines($target.attr('href')); + this.expandView(); } else { this.expandView(); } - return this.setCurrentAction(action); + if (this.opts.setUrl) { + this.setCurrentAction(action); + } }; MergeRequestTabs.prototype.scrollToElement = function(container) { @@ -81,7 +89,8 @@ if (action === 'show') { action = 'notes'; } - new_state = this._location.pathname.replace(/\/(commits|diffs|builds)(\.html)?\/?$/, ''); + this.currentAction = action; + new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, ''); if (action !== 'notes') { new_state += "/" + action; } @@ -119,6 +128,11 @@ success: (function(_this) { return function(data) { $('#diffs').html(data.html); + + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } + gl.utils.localTimeAgo($('.js-timeago', 'div#diffs')); $('#diffs .js-syntax-highlight').syntaxHighlight(); $('#diffs .diff-file').singleFileDiff(); @@ -177,6 +191,21 @@ }); }; + MergeRequestTabs.prototype.loadPipelines = function(source) { + if (this.pipelinesLoaded) { + return; + } + return this._get({ + url: source + ".json", + success: function(data) { + $('#pipelines').html(data.html); + gl.utils.localTimeAgo($('.js-timeago', '#pipelines')); + this.pipelinesLoaded = true; + return this.scrollToElement("#pipelines"); + }.bind(this) + }); + }; + MergeRequestTabs.prototype.toggleLoading = function(status) { return $('.mr-loading-status .loading').toggle(status); }; diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index 362aaa906d0..bd35b6f679d 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -28,7 +28,7 @@ MergeRequestWidget.prototype.addEventListeners = function() { var allowedPages; - allowedPages = ['show', 'commits', 'builds', 'changes']; + allowedPages = ['show', 'commits', 'builds', 'pipelines', 'changes']; return $(document).on('page:change.merge_request', (function(_this) { return function() { var page; @@ -53,7 +53,7 @@ return function(data) { var callback, urlSuffix; if (data.state === "merged") { - urlSuffix = deleteSourceBranch ? '?delete_source=true' : ''; + urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : ''; return window.location.href = window.location.pathname + urlSuffix; } else if (data.merge_error) { return $('.mr-widget-body').html("

    " + data.merge_error + "

    "); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index a0b65d20c03..e897ebdf630 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -94,7 +94,7 @@ $selectbox.hide(); return $value.css('display', ''); }, - clicked: function(selected) { + clicked: function(selected, $el, e) { var data, isIssueIndex, isMRIndex, page; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; @@ -102,7 +102,11 @@ if ($dropdown.hasClass('js-filter-bulk-update')) { return; } - if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if (page === 'projects:boards:show') { + gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = selected.name; + gl.issueBoards.BoardsStore.updateFiltersUrl(); + e.preventDefault(); + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { if (selected.name != null) { selectedMilestone = selected.name; } else { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 9ece474d994..d0d5cad813a 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -68,6 +68,7 @@ $(document).on("click", ".note-edit-cancel", this.cancelEdit); $(document).on("click", ".js-comment-button", this.updateCloseButton); $(document).on("keyup input", ".js-note-text", this.updateTargetButtons); + $(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion); $(document).on("click", ".js-note-delete", this.removeNote); $(document).on("click", ".js-note-attachment-delete", this.removeAttachment); $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); @@ -100,6 +101,7 @@ $(document).off("click", ".js-note-target-close"); $(document).off("click", ".js-note-discard"); $(document).off("keydown", ".js-note-text"); + $(document).off('click', '.js-comment-resolve-button'); $('.note .js-task-list-container').taskList('disable'); return $(document).off('tasklist:changed', '.note .js-task-list-container'); }; @@ -201,7 +203,7 @@ Increase @pollingInterval up to 120 seconds on every function call, if `shouldReset` has a truthy value, 'null' or 'undefined' the variable will reset to @basePollingInterval. - + Note: this function is used to gradually increase the polling interval if there aren't new notes coming from the server */ @@ -223,7 +225,7 @@ /* Render note in main comments area. - + Note: for rendering inline notes use renderDiscussionNote */ @@ -231,7 +233,13 @@ var $notesList, votesBlock; if (!note.valid) { if (note.award) { - new Flash('You have already awarded this emoji!', 'alert'); + new Flash('You have already awarded this emoji!', 'alert', this.parentTimeline); + } + else { + if (note.errors.commands_only) { + new Flash(note.errors.commands_only, 'notice', this.parentTimeline); + this.refresh(); + } } return; } @@ -245,6 +253,7 @@ $notesList.append(note.html).syntaxHighlight(); gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); this.initTaskList(); + this.refresh(); return this.updateNotesCount(1); } }; @@ -265,7 +274,7 @@ /* Render note in discussion area. - + Note: for rendering inline notes use renderDiscussionNote */ @@ -297,6 +306,11 @@ } else { discussionContainer.append(note_html); } + + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } + gl.utils.localTimeAgo($('.js-timeago', note_html), false); return this.updateNotesCount(1); }; @@ -304,7 +318,7 @@ /* Called in response the main target form has been successfully submitted. - + Removes any errors. Resets text and preview. Resets buttons. @@ -329,7 +343,7 @@ /* Shows the main form and does some setup on it. - + Sets some hidden fields in the form. */ @@ -343,13 +357,14 @@ form.find("#note_line_code").remove(); form.find("#note_position").remove(); form.find("#note_type").remove(); + form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove(); return this.parentTimeline = form.parents('.timeline'); }; /* General note form setup. - + deactivates the submit button when text is empty hides the preview button when text is empty setup GFM auto complete @@ -366,7 +381,7 @@ /* Called in response to the new note form being submitted - + Adds new note to list. */ @@ -381,19 +396,33 @@ /* Called in response to the new note form being submitted - + Adds new note to list. */ Notes.prototype.addDiscussionNote = function(xhr, note, status) { + var $form = $(xhr.target); + + if ($form.attr('data-resolve-all') != null) { + var namespacePath = $form.attr('data-namespace-path'), + projectPath = $form.attr('data-project-path') + discussionId = $form.attr('data-discussion-id'), + mergeRequestId = $form.attr('data-noteable-iid'), + namespace = namespacePath + '/' + projectPath; + + if (ResolveService != null) { + ResolveService.toggleResolveForDiscussion(namespace, mergeRequestId, discussionId); + } + } + this.renderDiscussionNote(note); - return this.removeDiscussionNoteForm($(xhr.target)); + this.removeDiscussionNoteForm($form); }; /* Called in response to the edit note form being submitted - + Updates the current note field. */ @@ -404,13 +433,18 @@ $html.syntaxHighlight(); $html.find('.js-task-list-container').taskList('enable'); $note_li = $('.note-row-' + note.id); - return $note_li.replaceWith($html); + + $note_li.replaceWith($html); + + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } }; /* Called in response to clicking the edit note link - + Replaces the note text with the note edit form Adds a data attribute to the form with the original content of the note for cancellations */ @@ -450,7 +484,7 @@ /* Called in response to clicking the edit note link - + Hides edit form and restores the original note text to the editor textarea. */ @@ -472,7 +506,7 @@ /* Called in response to deleting a note of any kind. - + Removes the actual note from view. Removes the whole discussion if the last note is being removed. */ @@ -485,6 +519,15 @@ var note, notes; note = $(el); notes = note.closest(".notes"); + + if (typeof DiffNotesApp !== "undefined" && DiffNotesApp !== null) { + ref = DiffNotesApp.$refs[noteId]; + + if (ref) { + ref.$destroy(true); + } + } + if (notes.find(".note").length === 1) { notes.closest(".timeline-entry").remove(); notes.closest("tr").remove(); @@ -498,7 +541,7 @@ /* Called in response to clicking the delete attachment link - + Removes the attachment wrapper view, including image tag if it exists Resets the note editing form */ @@ -515,7 +558,7 @@ /* Called when clicking on the "reply" button for a diff line. - + Shows the note form below the notes. */ @@ -523,17 +566,19 @@ var form, replyLink; form = this.formClone.clone(); replyLink = $(e.target).closest(".js-discussion-reply-button"); - replyLink.hide(); - replyLink.after(form); + replyLink + .closest('.discussion-reply-holder') + .hide() + .after(form); return this.setupDiscussionNoteForm(replyLink, form); }; /* Shows the diff or discussion form and does some setup on it. - + Sets some hidden fields in the form. - + Note: dataHolder must have the "discussionId", "lineCode", "noteableType" and "noteableId" data attributes set. */ @@ -549,15 +594,29 @@ form.find("#note_noteable_type").val(dataHolder.data("noteableType")); form.find("#note_noteable_id").val(dataHolder.data("noteableId")); form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text')); + form.find('.js-note-target-close').remove(); this.setupNoteForm(form); + + if (typeof DiffNotesApp !== 'undefined') { + var $commentBtn = form.find('comment-and-resolve-btn'); + $commentBtn + .attr(':discussion-id', "'" + dataHolder.data('discussionId') + "'"); + DiffNotesApp.$compile($commentBtn.get(0)); + } + form.find(".js-note-text").focus(); - return form.removeClass('js-main-target-form').addClass("discussion-form js-discussion-note-form"); + form + .find('.js-comment-resolve-button') + .attr('data-discussion-id', dataHolder.data('discussionId')); + form + .removeClass('js-main-target-form') + .addClass("discussion-form js-discussion-note-form"); }; /* Called when clicking on the "add a comment" button on the side of a diff line. - + Inserts a temporary row for the form below the line. Sets up the form and shows it. */ @@ -570,16 +629,19 @@ nextRow = row.next(); hasNotes = nextRow.is(".notes_holder"); addForm = false; - targetContent = ".notes_content"; - rowCssToAdd = ""; + notesContentSelector = ".notes_content"; + rowCssToAdd = "
    "; if (this.isParallelView()) { lineType = $link.data("lineType"); - targetContent += "." + lineType; - rowCssToAdd = ""; + notesContentSelector += "." + lineType; + rowCssToAdd = "
    "; } + notesContentSelector += " .content"; if (hasNotes) { - notesContent = nextRow.find(targetContent); + nextRow.show(); + notesContent = nextRow.find(notesContentSelector); if (notesContent.length) { + notesContent.show(); replyButton = notesContent.find(".js-discussion-reply-button:visible"); if (replyButton.length) { e.target = replyButton[0]; @@ -593,11 +655,13 @@ } } else { row.after(rowCssToAdd); + nextRow = row.next(); + notesContent = nextRow.find(notesContentSelector); addForm = true; } if (addForm) { newForm = this.formClone.clone(); - newForm.appendTo(row.next().find(targetContent)); + newForm.appendTo(notesContent); return this.setupDiscussionNoteForm($link, newForm); } }; @@ -605,7 +669,7 @@ /* Called in response to "cancel" on a diff note form. - + Shows the reply button again. Removes the form and if necessary it's temporary row. */ @@ -616,7 +680,9 @@ glForm = form.data('gl-form'); glForm.destroy(); form.find(".js-note-text").data("autosave").reset(); - form.prev(".js-discussion-reply-button").show(); + form + .prev('.discussion-reply-holder') + .show(); if (row.is(".js-temp-notes-holder")) { return row.remove(); } else { @@ -634,7 +700,7 @@ /* Called after an attachment file has been selected. - + Updates the file name for the selected attachment. */ @@ -725,6 +791,18 @@ return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount); }; + Notes.prototype.resolveDiscussion = function () { + var $this = $(this), + discussionId = $this.attr('data-discussion-id'); + + $this + .closest('form') + .attr('data-discussion-id', discussionId) + .attr('data-resolve-all', 'true') + .attr('data-namespace-path', $this.attr('data-namespace-path')) + .attr('data-project-path', $this.attr('data-project-path')); + }; + return Notes; })(); diff --git a/app/assets/javascripts/pipeline.js.es6 b/app/assets/javascripts/pipeline.js.es6 new file mode 100644 index 00000000000..bf33eb10100 --- /dev/null +++ b/app/assets/javascripts/pipeline.js.es6 @@ -0,0 +1,15 @@ +(function() { + function toggleGraph() { + const $pipelineBtn = $(this).closest('.toggle-pipeline-btn'); + const $pipelineGraph = $(this).closest('.row-content-block').next('.pipeline-graph'); + const $btnText = $(this).find('.toggle-btn-text'); + + $($pipelineBtn).add($pipelineGraph).toggleClass('graph-collapsed'); + + const graphCollapsed = $pipelineGraph.hasClass('graph-collapsed'); + + graphCollapsed ? $btnText.text('Expand') : $btnText.text('Hide') + } + + $(document).on('click', '.toggle-pipeline-btn', toggleGraph); +})(); diff --git a/app/assets/javascripts/protected_branch_create.js.es6 b/app/assets/javascripts/protected_branch_create.js.es6 index 00e20a03b04..2efca2414dc 100644 --- a/app/assets/javascripts/protected_branch_create.js.es6 +++ b/app/assets/javascripts/protected_branch_create.js.es6 @@ -44,8 +44,8 @@ // Enable submit button const $branchInput = this.$wrap.find('input[name="protected_branch[name]"]'); - const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_level_attributes][access_level]"]'); - const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_level_attributes][access_level]"]'); + const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]'); + const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_levels_attributes][0][access_level]"]'); if ($branchInput.val() && $allowedToMergeInput.val() && $allowedToPushInput.val()){ this.$form.find('input[type="submit"]').removeAttr('disabled'); diff --git a/app/assets/javascripts/protected_branch_edit.js.es6 b/app/assets/javascripts/protected_branch_edit.js.es6 index 8d42e268ebc..a59fcbfa082 100644 --- a/app/assets/javascripts/protected_branch_edit.js.es6 +++ b/app/assets/javascripts/protected_branch_edit.js.es6 @@ -39,12 +39,14 @@ _method: 'PATCH', id: this.$wrap.data('banchId'), protected_branch: { - merge_access_level_attributes: { + merge_access_levels_attributes: [{ + id: this.$allowedToMergeDropdown.data('access-level-id'), access_level: $allowedToMergeInput.val() - }, - push_access_level_attributes: { + }], + push_access_levels_attributes: [{ + id: this.$allowedToPushDropdown.data('access-level-id'), access_level: $allowedToPushInput.val() - } + }] } }, success: () => { diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index b9ae497b0e5..156b9b8abec 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -35,10 +35,16 @@ this.isOpen = !this.isOpen; if (!this.isOpen && !this.hasError) { this.content.hide(); - return this.collapsedContent.show(); + this.collapsedContent.show(); + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } } else if (this.content) { this.collapsedContent.hide(); - return this.content.show(); + this.content.show(); + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } } else { return this.getContentHTML(); } @@ -57,7 +63,11 @@ _this.hasError = true; _this.content = $(ERROR_HTML); } - return _this.collapsedContent.after(_this.content); + _this.collapsedContent.after(_this.content); + + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } }; })(this)); }; diff --git a/app/assets/javascripts/templates/issuable_template_selector.js.es6 b/app/assets/javascripts/templates/issuable_template_selector.js.es6 new file mode 100644 index 00000000000..c32ddf80219 --- /dev/null +++ b/app/assets/javascripts/templates/issuable_template_selector.js.es6 @@ -0,0 +1,51 @@ +/*= require ../blob/template_selector */ + +((global) => { + class IssuableTemplateSelector extends TemplateSelector { + constructor(...args) { + super(...args); + this.projectPath = this.dropdown.data('project-path'); + this.namespacePath = this.dropdown.data('namespace-path'); + this.issuableType = this.wrapper.data('issuable-type'); + this.titleInput = $(`#${this.issuableType}_title`); + + let initialQuery = { + name: this.dropdown.data('selected') + }; + + if (initialQuery.name) this.requestFile(initialQuery); + + $('.reset-template', this.dropdown.parent()).on('click', () => { + if (this.currentTemplate) this.setInputValueToTemplateContent(); + }); + } + + requestFile(query) { + this.startLoadingSpinner(); + Api.issueTemplate(this.namespacePath, this.projectPath, query.name, this.issuableType, (err, currentTemplate) => { + this.currentTemplate = currentTemplate; + if (err) return; // Error handled by global AJAX error handler + this.stopLoadingSpinner(); + this.setInputValueToTemplateContent(); + }); + return; + } + + setInputValueToTemplateContent() { + // `this.requestFileSuccess` sets the value of the description input field + // to the content of the template selected. + if (this.titleInput.val() === '') { + // If the title has not yet been set, focus the title input and + // skip focusing the description input by setting `true` as the 2nd + // argument to `requestFileSuccess`. + this.requestFileSuccess(this.currentTemplate, true); + this.titleInput.focus(); + } else { + this.requestFileSuccess(this.currentTemplate); + } + return; + } + } + + global.IssuableTemplateSelector = IssuableTemplateSelector; +})(window); diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js.es6 b/app/assets/javascripts/templates/issuable_template_selectors.js.es6 new file mode 100644 index 00000000000..bd8cdde033e --- /dev/null +++ b/app/assets/javascripts/templates/issuable_template_selectors.js.es6 @@ -0,0 +1,29 @@ +((global) => { + class IssuableTemplateSelectors { + constructor(opts = {}) { + this.$dropdowns = opts.$dropdowns || $('.js-issuable-selector'); + this.editor = opts.editor || this.initEditor(); + + this.$dropdowns.each((i, dropdown) => { + let $dropdown = $(dropdown); + new IssuableTemplateSelector({ + pattern: /(\.md)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-issuable-selector-wrap'), + dropdown: $dropdown, + editor: this.editor + }); + }); + } + + initEditor() { + let editor = $('.markdown-area'); + // Proxy ace-editor's .setValue to jQuery's .val + editor.setValue = editor.val; + editor.getValue = editor.val; + return editor; + } + } + + global.IssuableTemplateSelectors = IssuableTemplateSelectors; +})(window); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 65d362e072c..bad82868ab0 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -141,7 +141,7 @@ $selectbox.hide(); return $value.css('display', ''); }, - clicked: function(user) { + clicked: function(user, $el, e) { var isIssueIndex, isMRIndex, page, selected; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; @@ -149,7 +149,12 @@ if ($dropdown.hasClass('js-filter-bulk-update')) { return; } - if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if (page === 'projects:boards:show') { + selectedId = user.id; + gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id; + gl.issueBoards.BoardsStore.updateFiltersUrl(); + e.preventDefault(); + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { selectedId = user.id; return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { diff --git a/app/assets/stylesheets/behaviors.scss b/app/assets/stylesheets/behaviors.scss index 542a53f0377..897bc49e7df 100644 --- a/app/assets/stylesheets/behaviors.scss +++ b/app/assets/stylesheets/behaviors.scss @@ -20,3 +20,8 @@ .turn-off { display: block; } } } + +// Hide element if Vue is still working on rendering it fully. +[v-cloak="true"] { + display: none !important; +} diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 473530cf094..6c3786b49bb 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -164,6 +164,10 @@ @include btn-outline($white-light, $orange-normal, $orange-normal, $orange-light, $white-light, $orange-light); } + &.btn-spam { + @include btn-outline($white-light, $red-normal, $red-normal, $red-light, $white-light, $red-light); + } + &.btn-danger, &.btn-remove, &.btn-red { @@ -200,6 +204,10 @@ position: relative; top: 2px; } + + svg, .fa { + margin-right: 3px; + } } .btn-lg { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 1f565d78387..8846e08f390 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -62,9 +62,13 @@ position: absolute; top: 50%; right: 6px; - margin-top: -4px; + margin-top: -6px; color: $dropdown-toggle-icon-color; font-size: 10px; + &.fa-spinner { + font-size: 16px; + margin-top: -8px; + } } &:hover, { @@ -412,6 +416,7 @@ font-size: 14px; a { + cursor: pointer; padding-left: 10px; } } diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 7cf4d4fba42..07c8874bf03 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -6,11 +6,11 @@ table-layout: fixed; pre { - padding: 10px; + padding: 10px 0; border: none; border-radius: 0; font-family: $monospace_font; - font-size: $code_font_size !important; + font-size: $code_font_size; line-height: $code_line_height !important; margin: 0; overflow: auto; @@ -20,13 +20,20 @@ border-left: 1px solid; code { + display: inline-block; + min-width: 100%; font-family: $monospace_font; - white-space: pre; + white-space: normal; word-wrap: normal; padding: 0; .line { - display: inline-block; + display: block; + width: 100%; + min-height: 19px; + padding-left: 10px; + padding-right: 10px; + white-space: pre; } } } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 96565da1bc9..edea4ad00eb 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -147,3 +147,8 @@ color: $gl-link-color; } } + +.atwho-view small.description { + float: right; + padding: 3px 5px; +} diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 5ec5a96a597..d2d60ed7196 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -123,4 +123,9 @@ } } } -} \ No newline at end of file +} + +@mixin dark-diff-match-line { + color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.1); +} diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 3fa4a22258d..015fe3debf9 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -222,3 +222,7 @@ header.header-pinned-nav { padding-right: $sidebar_collapsed_width; } } + +.right-sidebar { + border-left: 1px solid $border-color; +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index ca720022539..5da390118c6 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -276,3 +276,5 @@ $personal-access-tokens-disabled-label-color: #bbb; $ci-output-bg: #1d1f21; $ci-text-color: #c5c8c6; + +$issue-boards-font-size: 15px; diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss index 77a73dc379b..16ffbe57a99 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/dark.scss @@ -21,6 +21,10 @@ // Diff line .line_holder { + &.match .line_content { + @include dark-diff-match-line; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #557; @@ -36,8 +40,7 @@ } .line_content.match { - color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.1); + @include dark-diff-match-line; } } diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss index 80a509a7c1a..7de920e074b 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/monokai.scss @@ -21,6 +21,10 @@ // Diff line .line_holder { + &.match .line_content { + @include dark-diff-match-line; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #49483e; @@ -36,8 +40,7 @@ } .line_content.match { - color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.1); + @include dark-diff-match-line; } } diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss index c62bd021aef..b11499c71ee 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/solarized_dark.scss @@ -21,6 +21,10 @@ // Diff line .line_holder { + &.match .line_content { + @include dark-diff-match-line; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #174652; @@ -36,8 +40,7 @@ } .line_content.match { - color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.1); + @include dark-diff-match-line; } } diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss index 524cfaf90c3..657bb5e3cd9 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/solarized_light.scss @@ -1,4 +1,10 @@ /* https://gist.github.com/qguv/7936275 */ + +@mixin matchLine { + color: $black-transparent; + background: rgba(255, 255, 255, 0.4); +} + .code.solarized-light { // Line numbers .line-numbers, .diff-line-num { @@ -21,6 +27,10 @@ // Diff line .line_holder { + &.match .line_content { + @include matchLine; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #ddd8c5; @@ -36,8 +46,7 @@ } .line_content.match { - color: $black-transparent; - background: rgba(255, 255, 255, 0.4); + @include matchLine; } } diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss index 31a4e3deaac..36a80a916b2 100644 --- a/app/assets/stylesheets/highlight/white.scss +++ b/app/assets/stylesheets/highlight/white.scss @@ -1,4 +1,10 @@ /* https://github.com/aahan/pygments-github-style */ + +@mixin matchLine { + color: $black-transparent; + background-color: $match-line; +} + .code.white { // Line numbers .line-numbers, .diff-line-num { @@ -22,6 +28,10 @@ // Diff line .line_holder { + &.match .line_content { + @include matchLine; + } + .diff-line-num { &.old { background-color: $line-number-old; @@ -57,8 +67,7 @@ } &.match { - color: $black-transparent; - background-color: $match-line; + @include matchLine; } &.hll:not(.empty-cell) { diff --git a/app/assets/stylesheets/mailers/repository_push_email.scss b/app/assets/stylesheets/mailers/repository_push_email.scss index 33aedf1f7c1..5bfe9bcb443 100644 --- a/app/assets/stylesheets/mailers/repository_push_email.scss +++ b/app/assets/stylesheets/mailers/repository_push_email.scss @@ -45,7 +45,6 @@ .line_content { padding-left: 0.5em; padding-right: 0.5em; - white-space: pre; &.old { background-color: $line-removed; @@ -71,6 +70,10 @@ } } +pre { + margin: 0; +} + span.highlight_word { background-color: #fafe3d !important; } diff --git a/app/assets/stylesheets/pages/admin.scss b/app/assets/stylesheets/pages/admin.scss index 5607239d92d..c9cdfdcd29c 100644 --- a/app/assets/stylesheets/pages/admin.scss +++ b/app/assets/stylesheets/pages/admin.scss @@ -72,7 +72,6 @@ margin-bottom: 20px; } - // Users List .users-list { @@ -98,3 +97,44 @@ } } } + +.abuse-reports { + .table { + table-layout: fixed; + } + .subheading { + padding-bottom: $gl-padding; + } + .message { + word-wrap: break-word; + } + .btn { + white-space: normal; + padding: $gl-btn-padding; + } + th { + width: 15%; + &.wide { + width: 55%; + } + } + @media (max-width: $screen-sm-max) { + th { + width: 100%; + } + td { + width: 100%; + float: left; + } + } + + .no-reports { + .emoji-icon { + margin-left: $btn-side-margin; + margin-top: 3px; + } + span { + font-size: 19px; + } + } +} diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss new file mode 100644 index 00000000000..ad4b2d6496f --- /dev/null +++ b/app/assets/stylesheets/pages/boards.scss @@ -0,0 +1,329 @@ +[v-cloak] { + display: none; +} + +.user-can-drag { + cursor: -webkit-grab; + cursor: grab; +} + +.is-dragging { + * { + cursor: -webkit-grabbing; + cursor: grabbing; + } +} + +.dropdown-menu-issues-board-new { + width: 320px; + + .dropdown-content { + max-height: 150px; + } +} + +.issue-board-dropdown-content { + margin: 0 8px 10px; + padding-bottom: 10px; + border-bottom: 1px solid $dropdown-divider-color; + + > p { + margin: 0; + font-size: 14px; + color: #9c9c9c; + } +} + +.issue-boards-page { + .content-wrapper { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + } + + .sub-nav, + .issues-filters { + -webkit-flex: none; + flex: none; + } + + .page-with-sidebar { + display: -webkit-flex; + display: flex; + min-height: 100vh; + max-height: 100vh; + padding-bottom: 0; + } + + .issue-boards-content { + display: -webkit-flex; + display: flex; + -webkit-flex: 1; + flex: 1; + width: 100%; + + .content { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + width: 100%; + } + } +} + +.boards-app-loading { + width: 100%; + font-size: 34px; +} + +.boards-list { + display: -webkit-flex; + display: flex; + -webkit-flex: 1; + flex: 1; + -webkit-flex-basis: 0; + flex-basis: 0; + min-height: calc(100vh - 152px); + max-height: calc(100vh - 152px); + padding-top: 25px; + padding-right: ($gl-padding / 2); + padding-left: ($gl-padding / 2); + overflow-x: scroll; + + @media (min-width: $screen-sm-min) { + min-height: 475px; + max-height: none; + } +} + +.board { + display: -webkit-flex; + display: flex; + min-width: calc(100vw - 15px); + max-width: calc(100vw - 15px); + margin-bottom: 25px; + padding-right: ($gl-padding / 2); + padding-left: ($gl-padding / 2); + + @media (min-width: $screen-sm-min) { + min-width: 400px; + max-width: 400px; + } +} + +.board-inner { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + width: 100%; + font-size: $issue-boards-font-size; + background: $background-color; + border: 1px solid $border-color; + border-radius: $border-radius-default; +} + +.board-header { + border-top-left-radius: $border-radius-default; + border-top-right-radius: $border-radius-default; + + &.has-border { + border-top: 3px solid; + + .board-title { + padding-top: ($gl-padding - 3px); + } + } +} + +.board-header-loading-spinner { + margin-right: 10px; + color: $gray-darkest; +} + +.board-inner-container { + border-bottom: 1px solid $border-color; + padding: $gl-padding; +} + +.board-title { + position: relative; + margin: 0; + padding: $gl-padding; + font-size: 1em; + border-bottom: 1px solid $border-color; + + .board-mobile-handle { + position: relative; + left: 0; + top: 1px; + margin-top: 0; + margin-right: 5px; + } +} + +.board-search-container { + position: relative; + background-color: #fff; + + .form-control { + padding-right: 30px; + } +} + +.board-search-icon, +.board-search-clear-btn { + position: absolute; + right: $gl-padding + 10px; + top: 50%; + margin-top: -7px; + font-size: 14px; +} + +.board-search-icon { + color: $gl-placeholder-color; +} + +.board-search-clear-btn { + padding: 0; + line-height: 1; + background: transparent; + border: 0; + outline: 0; + + &:hover { + color: $gl-link-color; + } +} + +.board-delete { + margin-right: 10px; + padding: 0; + color: $gray-darkest; + background-color: transparent; + border: 0; + outline: 0; + + &:hover { + color: $gl-link-color; + } +} + +.board-blank-state { + height: 100%; + padding: $gl-padding; + background-color: #fff; +} + +.board-blank-state-list { + list-style: none; + + > li:not(:last-child) { + margin-bottom: 8px; + } + + .label-color { + position: relative; + top: 2px; + display: inline-block; + width: 16px; + height: 16px; + margin-right: 3px; + border-radius: $border-radius-default; + } +} + +.board-list { + -webkit-flex: 1; + flex: 1; + height: 400px; + margin-bottom: 0; + padding: 5px; + overflow-y: scroll; + overflow-x: hidden; +} + +.board-list-loading { + margin-top: 10px; + font-size: 26px; +} + +.is-ghost { + opacity: 0.3; +} + +.is-dragging { + // Important because plugin sets inline CSS + opacity: 1!important; +} + +.card { + position: relative; + width: 100%; + padding: 10px $gl-padding; + background: #fff; + border-radius: $border-radius-default; + box-shadow: 0 1px 2px rgba(186, 186, 186, 0.5); + list-style: none; + + &.user-can-drag { + padding-left: ($gl-padding * 2); + + @media (min-width: $screen-sm-min) { + padding-left: $gl-padding; + } + } + + &:not(:last-child) { + margin-bottom: 5px; + } + + a { + cursor: pointer; + } + + .label { + border: 0; + outline: 0; + } + + .confidential-icon { + margin-right: 5px; + } +} + +.board-mobile-handle { + position: absolute; + left: 10px; + top: 50%; + margin-top: (-15px / 2); + + @media (min-width: $screen-sm-min) { + display: none; + } +} + +.card-title { + margin: 0; + font-size: 1em; + + a { + color: inherit; + } +} + +.card-footer { + margin-top: 5px; + + .label { + margin-right: 4px; + font-size: (14px / $issue-boards-font-size) * 1em; + } +} + +.card-number { + margin-right: 8px; + font-weight: 500; +} diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index e26f8f7080d..81fce55853c 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -53,14 +53,6 @@ left: 70px; } } - - .nav-links { - svg { - position: relative; - top: 2px; - margin-right: 3px; - } - } } .build-header { @@ -108,24 +100,98 @@ } .right-sidebar.build-sidebar { - padding-top: $gl-padding; - padding-bottom: $gl-padding; + padding: $gl-padding 0; &.right-sidebar-collapsed { display: none; } + .blocks-container { + padding: $gl-padding; + } + .block { width: 100%; } .build-sidebar-header { - padding-top: 0; + padding: 0 $gl-padding $gl-padding; .gutter-toggle { margin-top: 0; } } + + .stage-item { + cursor: pointer; + + &:hover { + color: $gl-text-color; + } + } + + .build-dropdown { + padding: 0 $gl-padding; + + .dropdown-menu-toggle { + margin-top: 8px; + } + + .dropdown-menu { + right: $gl-padding; + left: $gl-padding; + width: auto; + } + } + + .builds-container { + margin-top: $gl-padding; + background-color: $white-light; + border-top: 1px solid $border-color; + border-bottom: 1px solid $border-color; + max-height: 300px; + overflow: scroll; + + svg { + position: relative; + top: 2px; + margin-right: 3px; + height: 13px; + } + + a { + display: block; + padding: $gl-padding 10px $gl-padding 40px; + width: 270px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + background-color: $row-hover; + color: $gl-text-color; + } + } + + .build-job { + position: relative; + + .fa { + position: absolute; + left: 15px; + top: 20px; + display: none; + } + + &.active { + font-weight: bold; + + .fa { + display: block; + } + } + } + } } .build-detail-row { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 7a50bc9c832..46c4a11aa2e 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -395,3 +395,12 @@ display: inline-block; line-height: 18px; } + +.js-issuable-selector-wrap { + .js-issuable-selector { + width: 100%; + } + @media (max-width: $screen-sm-max) { + margin-bottom: $gl-padding; + } +} diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss new file mode 100644 index 00000000000..1f499897c16 --- /dev/null +++ b/app/assets/stylesheets/pages/merge_conflicts.scss @@ -0,0 +1,238 @@ +$colors: ( + white_header_head_neutral : #e1fad7, + white_line_head_neutral : #effdec, + white_button_head_neutral : #9adb84, + + white_header_head_chosen : #baf0a8, + white_line_head_chosen : #e1fad7, + white_button_head_chosen : #52c22d, + + white_header_origin_neutral : #e0f0ff, + white_line_origin_neutral : #f2f9ff, + white_button_origin_neutral : #87c2fa, + + white_header_origin_chosen : #add8ff, + white_line_origin_chosen : #e0f0ff, + white_button_origin_chosen : #268ced, + + white_header_not_chosen : #f0f0f0, + white_line_not_chosen : #f9f9f9, + + + dark_header_head_neutral : rgba(#3f3, .2), + dark_line_head_neutral : rgba(#3f3, .1), + dark_button_head_neutral : #40874f, + + dark_header_head_chosen : rgba(#3f3, .33), + dark_line_head_chosen : rgba(#3f3, .2), + dark_button_head_chosen : #258537, + + dark_header_origin_neutral : rgba(#2878c9, .4), + dark_line_origin_neutral : rgba(#2878c9, .3), + dark_button_origin_neutral : #2a5c8c, + + dark_header_origin_chosen : rgba(#2878c9, .6), + dark_line_origin_chosen : rgba(#2878c9, .4), + dark_button_origin_chosen : #1d6cbf, + + dark_header_not_chosen : rgba(#fff, .25), + dark_line_not_chosen : rgba(#fff, .1), + + + monokai_header_head_neutral : rgba(#a6e22e, .25), + monokai_line_head_neutral : rgba(#a6e22e, .1), + monokai_button_head_neutral : #376b20, + + monokai_header_head_chosen : rgba(#a6e22e, .4), + monokai_line_head_chosen : rgba(#a6e22e, .25), + monokai_button_head_chosen : #39800d, + + monokai_header_origin_neutral : rgba(#60d9f1, .35), + monokai_line_origin_neutral : rgba(#60d9f1, .15), + monokai_button_origin_neutral : #38848c, + + monokai_header_origin_chosen : rgba(#60d9f1, .5), + monokai_line_origin_chosen : rgba(#60d9f1, .35), + monokai_button_origin_chosen : #3ea4b2, + + monokai_header_not_chosen : rgba(#76715d, .24), + monokai_line_not_chosen : rgba(#76715d, .1), + + + solarized_light_header_head_neutral : rgba(#859900, .37), + solarized_light_line_head_neutral : rgba(#859900, .2), + solarized_light_button_head_neutral : #afb262, + + solarized_light_header_head_chosen : rgba(#859900, .5), + solarized_light_line_head_chosen : rgba(#859900, .37), + solarized_light_button_head_chosen : #94993d, + + solarized_light_header_origin_neutral : rgba(#2878c9, .37), + solarized_light_line_origin_neutral : rgba(#2878c9, .15), + solarized_light_button_origin_neutral : #60a1bf, + + solarized_light_header_origin_chosen : rgba(#2878c9, .6), + solarized_light_line_origin_chosen : rgba(#2878c9, .37), + solarized_light_button_origin_chosen : #2482b2, + + solarized_light_header_not_chosen : rgba(#839496, .37), + solarized_light_line_not_chosen : rgba(#839496, .2), + + + solarized_dark_header_head_neutral : rgba(#859900, .35), + solarized_dark_line_head_neutral : rgba(#859900, .15), + solarized_dark_button_head_neutral : #376b20, + + solarized_dark_header_head_chosen : rgba(#859900, .5), + solarized_dark_line_head_chosen : rgba(#859900, .35), + solarized_dark_button_head_chosen : #39800d, + + solarized_dark_header_origin_neutral : rgba(#2878c9, .35), + solarized_dark_line_origin_neutral : rgba(#2878c9, .15), + solarized_dark_button_origin_neutral : #086799, + + solarized_dark_header_origin_chosen : rgba(#2878c9, .6), + solarized_dark_line_origin_chosen : rgba(#2878c9, .35), + solarized_dark_button_origin_chosen : #0082cc, + + solarized_dark_header_not_chosen : rgba(#839496, .25), + solarized_dark_line_not_chosen : rgba(#839496, .15) +); + + +@mixin color-scheme($color) { + .header.line_content, .diff-line-num { + &.origin { + background-color: map-get($colors, #{$color}_header_origin_neutral); + border-color: map-get($colors, #{$color}_header_origin_neutral); + + button { + background-color: map-get($colors, #{$color}_button_origin_neutral); + border-color: darken(map-get($colors, #{$color}_button_origin_neutral), 15); + } + + &.selected { + background-color: map-get($colors, #{$color}_header_origin_chosen); + border-color: map-get($colors, #{$color}_header_origin_chosen); + + button { + background-color: map-get($colors, #{$color}_button_origin_chosen); + border-color: darken(map-get($colors, #{$color}_button_origin_chosen), 15); + } + } + + &.unselected { + background-color: map-get($colors, #{$color}_header_not_chosen); + border-color: map-get($colors, #{$color}_header_not_chosen); + + button { + background-color: lighten(map-get($colors, #{$color}_button_origin_neutral), 15); + border-color: map-get($colors, #{$color}_button_origin_neutral); + } + } + } + &.head { + background-color: map-get($colors, #{$color}_header_head_neutral); + border-color: map-get($colors, #{$color}_header_head_neutral); + + button { + background-color: map-get($colors, #{$color}_button_head_neutral); + border-color: darken(map-get($colors, #{$color}_button_head_neutral), 15); + } + + &.selected { + background-color: map-get($colors, #{$color}_header_head_chosen); + border-color: map-get($colors, #{$color}_header_head_chosen); + + button { + background-color: map-get($colors, #{$color}_button_head_chosen); + border-color: darken(map-get($colors, #{$color}_button_head_chosen), 15); + } + } + + &.unselected { + background-color: map-get($colors, #{$color}_header_not_chosen); + border-color: map-get($colors, #{$color}_header_not_chosen); + + button { + background-color: lighten(map-get($colors, #{$color}_button_head_neutral), 15); + border-color: map-get($colors, #{$color}_button_head_neutral); + } + } + } + } + + .line_content { + &.origin { + background-color: map-get($colors, #{$color}_line_origin_neutral); + + &.selected { + background-color: map-get($colors, #{$color}_line_origin_chosen); + } + + &.unselected { + background-color: map-get($colors, #{$color}_line_not_chosen); + } + } + &.head { + background-color: map-get($colors, #{$color}_line_head_neutral); + + &.selected { + background-color: map-get($colors, #{$color}_line_head_chosen); + } + + &.unselected { + background-color: map-get($colors, #{$color}_line_not_chosen); + } + } + } +} + + +#conflicts { + + .white { + @include color-scheme('white') + } + + .dark { + @include color-scheme('dark') + } + + .monokai { + @include color-scheme('monokai') + } + + .solarized-light { + @include color-scheme('solarized_light') + } + + .solarized-dark { + @include color-scheme('solarized_dark') + } + + .diff-wrap-lines .line_content { + white-space: normal; + min-height: 19px; + } + + .line_content.header { + position: relative; + + button { + border-radius: 2px; + font-size: 10px; + position: absolute; + right: 10px; + padding: 0; + outline: none; + color: #fff; + width: 75px; // static width to make 2 buttons have same width + height: 19px; + } + } + + .btn-success .fa-spinner { + color: #fff; + } +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 7d8f58ca5af..abe8414e5e0 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -69,6 +69,10 @@ &.ci-success { color: $gl-success; + + a.environment { + color: inherit; + } } &.ci-success_with_warnings { @@ -126,7 +130,6 @@ &.has-conflicts .fa-exclamation-triangle { color: $gl-warning; } - } p:last-child { diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 3784010348a..bd875b9823f 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -159,6 +159,32 @@ } } +.discussion-with-resolve-btn { + display: table; + width: 100%; + border-collapse: separate; + table-layout: auto; + + .btn-group { + display: table-cell; + float: none; + width: 1%; + + &:first-child { + width: 100%; + padding-right: 5px; + } + + &:last-child { + padding-left: 5px; + } + } + + .btn { + width: 100%; + } +} + .discussion-notes-count { font-size: 16px; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index a2b5437e503..08d1692c888 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -383,3 +383,80 @@ ul.notes { color: $gl-link-color; } } + +.line-resolve-all-container { + .btn-group { + margin-top: -1px; + margin-left: -4px; + } + + .discussion-next-btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } +} + +.line-resolve-all { + display: inline-block; + padding: 5px 10px; + background-color: $background-color; + border: 1px solid $border-color; + border-radius: $border-radius-default; + + &.has-next-btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .line-resolve-btn { + vertical-align: middle; + margin-right: 5px; + } +} + +.line-resolve-text { + vertical-align: middle; +} + +.line-resolve-btn { + display: inline-block; + position: relative; + top: 2px; + padding: 0; + background-color: transparent; + border: none; + outline: 0; + + &.is-disabled { + cursor: default; + } + + &:not(.is-disabled):hover, + &:not(.is-disabled):focus, + &.is-active { + color: $gl-text-green; + + svg path { + fill: $gl-text-green; + } + } + + svg { + position: relative; + color: $notes-action-color; + + path { + fill: $notes-action-color; + } + } +} + +.discussion-next-btn { + svg { + margin: 0; + + path { + fill: $gray-darkest; + } + } +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 21919fe4d73..ce1c424624f 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -229,3 +229,196 @@ box-shadow: none; } } + +// Pipeline visualization + +.toggle-pipeline-btn { + background-color: $gray-dark; + + .caret { + border-top: none; + border-bottom: 4px solid; + } + + &.graph-collapsed { + background-color: $white-light; + + .caret { + border-bottom: none; + border-top: 4px solid; + } + } +} + +.pipeline-graph { + width: 100%; + overflow: auto; + white-space: nowrap; + max-height: 500px; + transition: max-height 0.3s, padding 0.3s; + + &.graph-collapsed { + max-height: 0; + padding: 0 16px; + } +} + +.pipeline-visualization { + position: relative; + min-width: 1220px; + + ul { + padding: 0; + } +} + +.stage-column { + display: inline-block; + vertical-align: top; + margin-right: 50px; + + li { + list-style: none; + } + + .stage-name { + margin-bottom: 15px; + font-weight: bold; + width: 150px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .build { + border: 1px solid $border-color; + position: relative; + padding: 6px 10px; + border-radius: 30px; + width: 150px; + margin-bottom: 10px; + + &.playable { + background-color: $gray-light; + } + + .build-content { + width: 130px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + a { + color: $layout-link-gray; + } + } + + svg { + position: relative; + top: 2px; + margin-right: 5px; + } + + .fa { + font-size: 13px; + } + + // Connect first build in each stage with right horizontal line + &:first-child { + &::after { + content: ''; + position: absolute; + top: 50%; + right: -54px; + border-top: 2px solid $border-color; + width: 54px; + height: 1px; + } + } + + // Connect each build (except for first) with curved lines + &:not(:first-child) { + &::after, &::before { + content: ''; + top: -47px; + position: absolute; + border-bottom: 2px solid $border-color; + width: 20px; + height: 65px; + } + + // Right connecting curves + &::after { + right: -20px; + border-right: 2px solid $border-color; + border-radius: 0 0 50px; + } + + // Left connecting curves + &::before { + left: -20px; + border-left: 2px solid $border-color; + border-radius: 0 0 0 50px; + } + } + + // Connect second build to first build with smaller curved line + &:nth-child(2) { + &::after, &::before { + height: 45px; + top: -26px; + } + } + } + + &:last-child { + .build { + // Remove right connecting horizontal line from first build in last stage + &:first-child { + &::after, &::before { + border: none; + } + } + // Remove right curved connectors from all builds in last stage + &:not(:first-child) { + &::after { + border: none; + } + } + } + } + + &:first-child { + .build { + // Remove left curved connectors from all builds in first stage + &:not(:first-child) { + &::before { + border: none; + } + } + } + } +} + +.pipeline-actions { + border-bottom: none; +} + +.toggle-pipeline-btn { + + .fa { + color: $dropdown-header-color; + } +} + +.pipelines.tab-pane { + + .content-list.pipelines { + overflow: scroll; + } + + .stage { + max-width: 60px; + width: 60px; + } +} diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 46371ec6871..6f58203f49c 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -228,3 +228,9 @@ } } } + +table.u2f-registrations { + th:not(:last-child), td:not(:last-child) { + border-right: solid 1px transparent; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index cf9aa02600d..27dc2b2a1fa 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -99,7 +99,7 @@ margin-left: auto; margin-right: auto; margin-bottom: 15px; - max-width: 480px; + max-width: 700px; > p { margin-bottom: 0; diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index cf16d070cfe..0340526a53a 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -20,10 +20,43 @@ } } -.todo { +.todos-list > .todo { + // workaround because we cannot use border-colapse + border-top: 1px solid transparent; + display: -webkit-flex; + display: flex; + -webkit-flex-direction: row; + flex-direction: row; + &:hover { + background-color: $row-hover; + border-color: $row-hover-border; cursor: pointer; } + + // overwrite border style of .content-list + &:last-child { + border-bottom: 1px solid transparent; + + &:hover { + border-color: $row-hover-border; + } + } + + .todo-actions { + display: -webkit-flex; + display: flex; + -webkit-justify-content: center; + justify-content: center; + -webkit-flex-direction: column; + flex-direction: column; + margin-left: 10px; + } + + .todo-item { + -webkit-flex: auto; + flex: auto; + } } .todo-item { @@ -43,8 +76,6 @@ } .todo-body { - margin-right: 174px; - .todo-note { word-wrap: break-word; @@ -90,6 +121,12 @@ } @media (max-width: $screen-xs-max) { + .todo { + .avatar { + display: none; + } + } + .todo-item { .todo-title { white-space: normal; @@ -98,10 +135,6 @@ margin-bottom: 10px; } - .avatar { - display: none; - } - .todo-body { margin: 0; border-left: 2px solid #ddd; diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 3a2f0185315..2abfa22712d 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -14,4 +14,14 @@ class Admin::SpamLogsController < Admin::ApplicationController head :ok end end + + def mark_as_ham + spam_log = SpamLog.find(params[:id]) + + if HamService.new(spam_log).mark_as_ham! + redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.' + else + redirect_to admin_spam_logs_path, alert: 'Error with Akismet. Please check the logs for more info.' + end + end end diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index d828d163c28..b48668eea87 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -1,5 +1,6 @@ class AutocompleteController < ApplicationController skip_before_action :authenticate_user!, only: [:users] + before_action :load_project, only: [:users] before_action :find_users, only: [:users] def users @@ -34,19 +35,13 @@ class AutocompleteController < ApplicationController def projects project = Project.find_by_id(params[:project_id]) - - projects = current_user.authorized_projects - projects = projects.search(params[:search]) if params[:search].present? - projects = projects.select do |project| - current_user.can?(:admin_issue, project) - end + projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id]) no_project = { id: 0, name_with_namespace: 'No project', } - projects.unshift(no_project) - projects.delete(project) + projects.unshift(no_project) unless params[:offset_id].present? render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace) end @@ -55,11 +50,8 @@ class AutocompleteController < ApplicationController def find_users @users = - if params[:project_id].present? - project = Project.find(params[:project_id]) - return render_404 unless can?(current_user, :read_project, project) - - project.team.users + if @project + @project.team.users elsif params[:group_id].present? group = Group.find(params[:group_id]) return render_404 unless can?(current_user, :read_group, group) @@ -71,4 +63,18 @@ class AutocompleteController < ApplicationController User.none end end + + def load_project + @project ||= begin + if params[:project_id].present? + project = Project.find(params[:project_id]) + return render_404 unless can?(current_user, :read_project, project) + project + end + end + end + + def projects_finder + MoveToProjectFinder.new(current_user) + end end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index c802922e0af..b5e79099e39 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -66,6 +66,11 @@ module IssuableCollections key = 'issuable_sort' cookies[key] = params[:sort] if params[:sort].present? + + # id_desc and id_asc are old values for these two. + cookies[key] = sort_value_recently_created if cookies[key] == 'id_desc' + cookies[key] = sort_value_oldest_created if cookies[key] == 'id_asc' + params[:sort] = cookies[key] end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 471d15af913..a69877edfd4 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -7,11 +7,16 @@ module ServiceParams :build_key, :server, :teamcity_url, :drone_url, :build_type, :description, :issues_url, :new_issue_url, :restrict_to_branch, :channel, :colorize_messages, :channels, - :push_events, :issues_events, :merge_requests_events, :tag_push_events, - :note_events, :build_events, :wiki_page_events, - :notify_only_broken_builds, :add_pusher, - :send_from_committer_email, :disable_diffs, :external_wiki_url, - :notify, :color, + # We're using `issues_events` and `merge_requests_events` + # in the view so we still need to explicitly state them + # here. `Service#event_names` would only give + # `issue_events` and `merge_request_events` (singular!) + # See app/helpers/services_helper.rb for how we + # make those event names plural as special case. + :issues_events, :merge_requests_events, + :notify_only_broken_builds, :notify_only_broken_pipelines, + :add_pusher, :send_from_committer_email, :disable_diffs, + :external_wiki_url, :notify, :color, :server_host, :server_port, :default_irc_uri, :enable_ssl_verification, :jira_issue_transition_id] @@ -19,9 +24,7 @@ module ServiceParams FILTER_BLANK_PARAMS = [:password] def service_params - dynamic_params = [] - dynamic_params.concat(@service.event_channel_names) - + dynamic_params = @service.event_channel_names + @service.event_names service_params = params.permit(:id, service: ALLOWED_PARAMS + dynamic_params) if service_params[:service].is_a?(Hash) diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb new file mode 100644 index 00000000000..29e243c66a3 --- /dev/null +++ b/app/controllers/concerns/spammable_actions.rb @@ -0,0 +1,25 @@ +module SpammableActions + extend ActiveSupport::Concern + + included do + before_action :authorize_submit_spammable!, only: :mark_as_spam + end + + def mark_as_spam + if SpamService.new(spammable).mark_as_spam! + redirect_to spammable, notice: "#{spammable.class.to_s} was submitted to Akismet successfully." + else + redirect_to spammable, alert: 'Error with Akismet. Please check the logs for more info.' + end + end + + private + + def spammable + raise NotImplementedError, "#{self.class} does not implement #{__method__}" + end + + def authorize_submit_spammable! + access_denied! unless current_user.admin? + end +end diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 1243bb96d4d..c8390af3b36 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -6,7 +6,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController end def destroy - TodoService.new.mark_todos_as_done([todo], current_user) + TodoService.new.mark_todos_as_done_by_ids([params[:id]], current_user) respond_to do |format| format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' } @@ -27,10 +27,6 @@ class Dashboard::TodosController < Dashboard::ApplicationController private - def todo - @todo ||= find_todos.find(params[:id]) - end - def find_todos @todos ||= TodosFinder.new(current_user, params).execute end diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb index 3ec173abcdb..7d0eff37635 100644 --- a/app/controllers/import/gitlab_projects_controller.rb +++ b/app/controllers/import/gitlab_projects_controller.rb @@ -1,5 +1,6 @@ class Import::GitlabProjectsController < Import::BaseController before_action :verify_gitlab_project_import_enabled + before_action :authenticate_admin! def new @namespace_id = project_params[:namespace_id] @@ -47,4 +48,8 @@ class Import::GitlabProjectsController < Import::BaseController :path, :namespace_id, :file ) end + + def authenticate_admin! + render_404 unless current_user.is_admin? + end end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index e37e9e136db..9eb75bb3891 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -43,11 +43,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController # A U2F (universal 2nd factor) device's information is stored after successful # registration, which is then used while 2FA authentication is taking place. def create_u2f - @u2f_registration = U2fRegistration.register(current_user, u2f_app_id, params[:device_response], session[:challenges]) + @u2f_registration = U2fRegistration.register(current_user, u2f_app_id, u2f_registration_params, session[:challenges]) if @u2f_registration.persisted? session.delete(:challenges) - redirect_to profile_account_path, notice: "Your U2F device was registered!" + redirect_to profile_two_factor_auth_path, notice: "Your U2F device was registered!" else @qr_code = build_qr_code setup_u2f_registration @@ -91,15 +91,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController # Actual communication is performed using a Javascript API def setup_u2f_registration @u2f_registration ||= U2fRegistration.new - @registration_key_handles = current_user.u2f_registrations.pluck(:key_handle) + @u2f_registrations = current_user.u2f_registrations u2f = U2F::U2F.new(u2f_app_id) registration_requests = u2f.registration_requests - sign_requests = u2f.authentication_requests(@registration_key_handles) + sign_requests = u2f.authentication_requests(@u2f_registrations.map(&:key_handle)) session[:challenges] = registration_requests.map(&:challenge) gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id, register_requests: registration_requests, sign_requests: sign_requests }) end + + def u2f_registration_params + params.require(:u2f_registration).permit(:device_response, :name) + end end diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb new file mode 100644 index 00000000000..c02fe85c3cc --- /dev/null +++ b/app/controllers/profiles/u2f_registrations_controller.rb @@ -0,0 +1,7 @@ +class Profiles::U2fRegistrationsController < Profiles::ApplicationController + def destroy + u2f_registration = current_user.u2f_registrations.find(params[:id]) + u2f_registration.destroy + redirect_to profile_two_factor_auth_path, notice: "Successfully deleted U2F device." + end +end diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 996909a28c6..91315a07deb 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -83,6 +83,7 @@ class Projects::ApplicationController < ApplicationController end def apply_diff_view_cookie! + @show_changes_tab = params[:view].present? cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present? end diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index d0f5071d2cc..6c25cd83a24 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -4,11 +4,24 @@ class Projects::BadgesController < Projects::ApplicationController before_action :no_cache_headers, except: [:index] def build - badge = Gitlab::Badge::Build.new(project, params[:ref]) + build_status = Gitlab::Badge::Build::Status + .new(project, params[:ref]) + render_badge build_status + end + + def coverage + coverage_report = Gitlab::Badge::Coverage::Report + .new(project, params[:ref], params[:job]) + + render_badge coverage_report + end + + private + + def render_badge(badge) respond_to do |format| format.html { render_404 } - format.svg do render 'badge', locals: { badge: badge.template } end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 19d051720e9..cdf9a04bacf 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -17,6 +17,7 @@ class Projects::BlobController < Projects::ApplicationController before_action :require_branch_head, only: [:edit, :update] before_action :editor_variables, except: [:show, :preview, :diff] before_action :validate_diff_params, only: :diff + before_action :set_last_commit_sha, only: [:edit, :update] def new commit unless @repository.empty? @@ -33,7 +34,6 @@ class Projects::BlobController < Projects::ApplicationController end def edit - @last_commit = Gitlab::Git::Commit.last_for_path(@repository, @ref, @path).sha blob.load_all_data!(@repository) end @@ -55,6 +55,10 @@ class Projects::BlobController < Projects::ApplicationController create_commit(Files::UpdateService, success_path: after_edit_path, failure_view: :edit, failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) + + rescue Files::UpdateService::FileChangedError + @conflict = true + render :edit end def preview @@ -152,7 +156,8 @@ class Projects::BlobController < Projects::ApplicationController file_path: @file_path, commit_message: params[:commit_message], file_content: params[:content], - file_content_encoding: params[:encoding] + file_content_encoding: params[:encoding], + last_commit_sha: params[:last_commit_sha] } end @@ -161,4 +166,9 @@ class Projects::BlobController < Projects::ApplicationController render nothing: true end end + + def set_last_commit_sha + @last_commit_sha = Gitlab::Git::Commit. + last_for_path(@repository, @ref, @path).sha + end end diff --git a/app/controllers/projects/board_lists_controller.rb b/app/controllers/projects/board_lists_controller.rb new file mode 100644 index 00000000000..3cfb08d5822 --- /dev/null +++ b/app/controllers/projects/board_lists_controller.rb @@ -0,0 +1,65 @@ +class Projects::BoardListsController < Projects::ApplicationController + respond_to :json + + before_action :authorize_admin_list! + + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + + def create + list = Boards::Lists::CreateService.new(project, current_user, list_params).execute + + if list.valid? + render json: list.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } }) + else + render json: list.errors, status: :unprocessable_entity + end + end + + def update + service = Boards::Lists::MoveService.new(project, current_user, move_params) + + if service.execute + head :ok + else + head :unprocessable_entity + end + end + + def destroy + service = Boards::Lists::DestroyService.new(project, current_user, params) + + if service.execute + head :ok + else + head :unprocessable_entity + end + end + + def generate + service = Boards::Lists::GenerateService.new(project, current_user) + + if service.execute + render json: project.board.lists.label.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } }) + else + head :unprocessable_entity + end + end + + private + + def authorize_admin_list! + return render_403 unless can?(current_user, :admin_list, project) + end + + def list_params + params.require(:list).permit(:label_id) + end + + def move_params + params.require(:list).permit(:position).merge(id: params[:id]) + end + + def record_not_found(exception) + render json: { error: exception.message }, status: :not_found + end +end diff --git a/app/controllers/projects/boards/application_controller.rb b/app/controllers/projects/boards/application_controller.rb new file mode 100644 index 00000000000..dad38fff6b9 --- /dev/null +++ b/app/controllers/projects/boards/application_controller.rb @@ -0,0 +1,15 @@ +module Projects + module Boards + class ApplicationController < Projects::ApplicationController + respond_to :json + + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + + private + + def record_not_found(exception) + render json: { error: exception.message }, status: :not_found + end + end + end +end diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb new file mode 100644 index 00000000000..2d894b3dd4a --- /dev/null +++ b/app/controllers/projects/boards/issues_controller.rb @@ -0,0 +1,56 @@ +module Projects + module Boards + class IssuesController < Boards::ApplicationController + before_action :authorize_read_issue!, only: [:index] + before_action :authorize_update_issue!, only: [:update] + + def index + issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute + issues = issues.page(params[:page]) + + render json: issues.as_json( + only: [:iid, :title, :confidential], + include: { + assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, + labels: { only: [:id, :title, :description, :color, :priority] } + }) + end + + def update + service = ::Boards::Issues::MoveService.new(project, current_user, move_params) + + if service.execute(issue) + head :ok + else + head :unprocessable_entity + end + end + + private + + def issue + @issue ||= + IssuesFinder.new(current_user, project_id: project.id, state: 'all') + .execute + .where(iid: params[:id]) + .first! + end + + def authorize_read_issue! + return render_403 unless can?(current_user, :read_issue, project) + end + + def authorize_update_issue! + return render_403 unless can?(current_user, :update_issue, issue) + end + + def filter_params + params.merge(id: params[:list_id]) + end + + def move_params + params.permit(:id, :from_list_id, :to_list_id) + end + end + end +end diff --git a/app/controllers/projects/boards/lists_controller.rb b/app/controllers/projects/boards/lists_controller.rb new file mode 100644 index 00000000000..b995f586737 --- /dev/null +++ b/app/controllers/projects/boards/lists_controller.rb @@ -0,0 +1,81 @@ +module Projects + module Boards + class ListsController < Boards::ApplicationController + before_action :authorize_admin_list!, only: [:create, :update, :destroy, :generate] + before_action :authorize_read_list!, only: [:index] + + def index + render json: serialize_as_json(project.board.lists) + end + + def create + list = ::Boards::Lists::CreateService.new(project, current_user, list_params).execute + + if list.valid? + render json: serialize_as_json(list) + else + render json: list.errors, status: :unprocessable_entity + end + end + + def update + list = project.board.lists.movable.find(params[:id]) + service = ::Boards::Lists::MoveService.new(project, current_user, move_params) + + if service.execute(list) + head :ok + else + head :unprocessable_entity + end + end + + def destroy + list = project.board.lists.destroyable.find(params[:id]) + service = ::Boards::Lists::DestroyService.new(project, current_user, params) + + if service.execute(list) + head :ok + else + head :unprocessable_entity + end + end + + def generate + service = ::Boards::Lists::GenerateService.new(project, current_user) + + if service.execute + render json: serialize_as_json(project.board.lists.movable) + else + head :unprocessable_entity + end + end + + private + + def authorize_admin_list! + return render_403 unless can?(current_user, :admin_list, project) + end + + def authorize_read_list! + return render_403 unless can?(current_user, :read_list, project) + end + + def list_params + params.require(:list).permit(:label_id) + end + + def move_params + params.require(:list).permit(:position) + end + + def serialize_as_json(resource) + resource.as_json( + only: [:id, :list_type, :position], + methods: [:title], + include: { + label: { only: [:id, :title, :description, :color, :priority] } + }) + end + end + end +end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb new file mode 100644 index 00000000000..33206717089 --- /dev/null +++ b/app/controllers/projects/boards_controller.rb @@ -0,0 +1,15 @@ +class Projects::BoardsController < Projects::ApplicationController + respond_to :html + + before_action :authorize_read_board!, only: [:show] + + def show + ::Boards::CreateService.new(project, current_user).execute + end + + private + + def authorize_read_board! + return access_denied! unless can?(current_user, :read_board, project) + end +end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index f44e9bb3fd7..02fb3f56890 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController end def commit - @commit ||= @project.commit(params[:id]) + @noteable = @commit ||= @project.commit(params[:id]) end def pipelines diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb new file mode 100644 index 00000000000..b2e8733ccb7 --- /dev/null +++ b/app/controllers/projects/discussions_controller.rb @@ -0,0 +1,43 @@ +class Projects::DiscussionsController < Projects::ApplicationController + before_action :module_enabled + before_action :merge_request + before_action :discussion + before_action :authorize_resolve_discussion! + + def resolve + discussion.resolve!(current_user) + + MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) + + render json: { + resolved_by: discussion.resolved_by.try(:name), + discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion) + } + end + + def unresolve + discussion.unresolve! + + render json: { + discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion) + } + end + + private + + def merge_request + @merge_request ||= @project.merge_requests.find_by!(iid: params[:merge_request_id]) + end + + def discussion + @discussion ||= @merge_request.find_diff_discussion(params[:id]) || render_404 + end + + def authorize_resolve_discussion! + access_denied! unless discussion.can_resolve?(current_user) + end + + def module_enabled + render_404 unless @project.merge_requests_enabled + end +end diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index 7c21bd181dc..a5b4031c30f 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -27,6 +27,9 @@ class Projects::GitHttpClientController < Projects::ApplicationController @ci = true elsif auth_result.type == :oauth && !download_request? # Not allowed + elsif auth_result.type == :missing_personal_token + render_missing_personal_token + return # Render above denied access, nothing left to do else @user = auth_result.user end @@ -91,6 +94,13 @@ class Projects::GitHttpClientController < Projects::ApplicationController [nil, nil] end + def render_missing_personal_token + render plain: "HTTP Basic: Access denied\n" \ + "You have 2FA enabled, please use a personal access token for Git over HTTP.\n" \ + "You can generate one at #{profile_personal_access_tokens_url}", + status: 401 + end + def repository _, suffix = project_id_with_suffix if suffix == '.wiki.git' diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index a60027ff477..b5624046387 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -56,6 +56,7 @@ class Projects::HooksController < Projects::ApplicationController def hook_params params.require(:hook).permit( :build_events, + :pipeline_events, :enable_ssl_verification, :issues_events, :merge_requests_events, diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 660e0eba06f..639cf4c0ef2 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -4,6 +4,7 @@ class Projects::IssuesController < Projects::ApplicationController include IssuableActions include ToggleAwardEmoji include IssuableCollections + include SpammableActions before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :module_enabled @@ -176,15 +177,12 @@ class Projects::IssuesController < Projects::ApplicationController protected def issue - @issue ||= begin - @project.issues.find_by!(iid: params[:id]) - rescue ActiveRecord::RecordNotFound - redirect_old - end + @noteable = @issue ||= @project.issues.find_by(iid: params[:id]) || redirect_old end alias_method :subscribable_resource, :issue alias_method :issuable, :issue alias_method :awardable, :issue + alias_method :spammable, :issue def authorize_read_issue! return render_404 unless can?(current_user, :read_issue, @issue) @@ -224,7 +222,6 @@ class Projects::IssuesController < Projects::ApplicationController if issue redirect_to issue_path(issue) - return else raise ActiveRecord::RecordNotFound.new end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a4b1262896c..438dd928853 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -9,15 +9,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController 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, :remove_wip + :edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check, + :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts ] - before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds] - before_action :define_show_vars, only: [:show, :diffs, :commits, :builds] + before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines] + before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :builds, :pipelines] before_action :define_widget_vars, only: [:merge, :cancel_merge_when_build_succeeds, :merge_check] before_action :define_commit_vars, only: [:diffs] before_action :define_diff_comment_vars, only: [:diffs] - before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds] + before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :pipelines] # Allow read any merge_request before_action :authorize_read_merge_request! @@ -28,6 +28,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController # Allow modify merge_request before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort] + before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :resolve_conflicts] + def index terms = params['issue_search'] @merge_requests = merge_requests_collection @@ -135,6 +137,47 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end + def conflicts + respond_to do |format| + format.html { define_discussion_vars } + + format.json do + if @merge_request.conflicts_can_be_resolved_in_ui? + render json: @merge_request.conflicts + elsif @merge_request.can_be_merged? + render json: { + message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.', + type: 'error' + } + else + render json: { + message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.', + type: 'error' + } + end + end + end + end + + def resolve_conflicts + return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui? + + if @merge_request.can_be_merged? + render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' } + return + end + + begin + MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request) + + flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.' + + render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) } + rescue Gitlab::Conflict::File::MissingResolution => e + render status: :bad_request, json: { message: e.message } + end + end + def builds respond_to do |format| format.html do @@ -146,7 +189,22 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end + def pipelines + @pipelines = @merge_request.all_pipelines + + respond_to do |format| + format.html do + define_discussion_vars + + render 'show' + end + format.json { render json: { html: view_to_html_string('projects/merge_requests/show/_pipelines') } } + end + end + def new + apply_diff_view_cookie! + build_merge_request @noteable = @merge_request @@ -163,7 +221,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController @base_commit = @merge_request.diff_base_commit @diffs = @merge_request.diffs(diff_options) if @merge_request.compare @diff_notes_disabled = true - @pipeline = @merge_request.pipeline @statuses = @pipeline.statuses.relevant if @pipeline @@ -329,7 +386,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def merge_request - @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) + @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) end alias_method :subscribable_resource, :merge_request alias_method :issuable, :merge_request @@ -343,6 +400,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController return render_404 unless can?(current_user, :admin_merge_request, @merge_request) end + def authorize_can_resolve_conflicts! + return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user) + end + def module_enabled return render_404 unless @project.merge_requests_enabled end @@ -379,12 +440,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController # :show, :diff, :commits, :builds. but not when request the data through AJAX def define_discussion_vars # Build a note object for comment form - @note = @project.notes.new(noteable: @noteable) + @note = @project.notes.new(noteable: @merge_request) - @discussions = @noteable.mr_and_commit_notes. - inc_author_project_award_emoji. - fresh. - discussions + @discussions = @merge_request.discussions preload_noteable_for_regular_notes(@discussions.flat_map(&:notes)) @@ -417,8 +475,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController noteable_id: @merge_request.id } - @use_legacy_diff_notes = !@merge_request.support_new_diff_notes? - @grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions + @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? + @grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions Banzai::NoteRenderer.render( @grouped_diff_discussions.values.flat_map(&:notes), diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 766b7e9cf22..0948ad21649 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -5,6 +5,7 @@ class Projects::NotesController < Projects::ApplicationController before_action :authorize_read_note! before_action :authorize_create_note!, only: [:create] before_action :authorize_admin_note!, only: [:update, :destroy] + before_action :authorize_resolve_note!, only: [:resolve, :unresolve] before_action :find_current_user_notes, only: [:index] def index @@ -66,6 +67,33 @@ class Projects::NotesController < Projects::ApplicationController end end + def resolve + return render_404 unless note.resolvable? + + note.resolve!(current_user) + + MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(note.noteable) + + discussion = note.discussion + + render json: { + resolved_by: note.resolved_by.try(:name), + discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) + } + end + + def unresolve + return render_404 unless note.resolvable? + + note.unresolve! + + discussion = note.discussion + + render json: { + discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) + } + end + private def note @@ -125,7 +153,7 @@ class Projects::NotesController < Projects::ApplicationController id: note.id, name: note.name } - elsif note.valid? + elsif note.persisted? Banzai::NoteRenderer.render([note], @project, current_user) attrs = { @@ -138,7 +166,7 @@ class Projects::NotesController < Projects::ApplicationController } if note.diff_note? - discussion = Discussion.new([note]) + discussion = note.to_discussion attrs.merge!( diff_discussion_html: diff_discussion_html(discussion), @@ -175,6 +203,10 @@ class Projects::NotesController < Projects::ApplicationController return access_denied! unless can?(current_user, :admin_note, note) end + def authorize_resolve_note! + return access_denied! unless can?(current_user, :resolve_note, note) + end + def note_params params.require(:note).permit( :note, :noteable, :noteable_id, :noteable_type, :project_id, diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index 75dd3648e45..9136633b87a 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -3,7 +3,13 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController def show @ref = params[:ref] || @project.default_branch || 'master' - @build_badge = Gitlab::Badge::Build.new(@project, @ref).metadata + + @badges = [Gitlab::Badge::Build::Status, + Gitlab::Badge::Coverage::Report] + + @badges.map! do |badge| + badge.new(@project, @ref).metadata + end end def update diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index d28ec6e2eac..9a438d5512c 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -9,16 +9,16 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController def index @protected_branch = @project.protected_branches.new - load_protected_branches_gon_variables + load_gon_index end def create - @protected_branch = ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute + @protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute if @protected_branch.persisted? redirect_to namespace_project_protected_branches_path(@project.namespace, @project) else load_protected_branches - load_protected_branches_gon_variables + load_gon_index render :index end end @@ -28,7 +28,7 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController end def update - @protected_branch = ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch) + @protected_branch = ::ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch) if @protected_branch.valid? respond_to do |format| @@ -58,17 +58,23 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController def protected_branch_params params.require(:protected_branch).permit(:name, - merge_access_level_attributes: [:access_level], - push_access_level_attributes: [:access_level]) + merge_access_levels_attributes: [:access_level, :id], + push_access_levels_attributes: [:access_level, :id]) end def load_protected_branches @protected_branches = @project.protected_branches.order(:name).page(params[:page]) end - def load_protected_branches_gon_variables - gon.push({ open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } }, - push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } }, - merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } } }) + def access_levels_options + { + push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } }, + merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } } + } + end + + def load_gon_index + params = { open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } } + gon.push(params.merge(access_levels_options)) end end diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb new file mode 100644 index 00000000000..694b468c8d3 --- /dev/null +++ b/app/controllers/projects/templates_controller.rb @@ -0,0 +1,19 @@ +class Projects::TemplatesController < Projects::ApplicationController + before_action :authenticate_user!, :get_template_class + + def show + template = @template_type.find(params[:key], project) + + respond_to do |format| + format.json { render json: template.to_json } + end + end + + private + + def get_template_class + template_types = { issue: Gitlab::Template::IssueTemplate, merge_request: Gitlab::Template::MergeRequestTemplate }.with_indifferent_access + @template_type = template_types[params[:template_type]] + render json: [], status: 404 unless @template_type + end +end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 47efbd4a939..fc52cd2f367 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -134,10 +134,22 @@ class ProjectsController < Projects::ApplicationController end def autocomplete_sources - note_type = params['type'] - note_id = params['type_id'] + noteable = + case params[:type] + when 'Issue' + IssuesFinder.new(current_user, project_id: @project.id, state: 'all'). + execute.find_by(iid: params[:type_id]) + when 'MergeRequest' + MergeRequestsFinder.new(current_user, project_id: @project.id, state: 'all'). + execute.find_by(iid: params[:type_id]) + when 'Commit' + @project.commit(params[:type_id]) + else + nil + end + autocomplete = ::Projects::AutocompleteService.new(@project, current_user) - participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id) + participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable) @suggestions = { emojis: Gitlab::AwardEmoji.urls, @@ -145,7 +157,8 @@ class ProjectsController < Projects::ApplicationController milestones: autocomplete.milestones, mergerequests: autocomplete.merge_requests, labels: autocomplete.labels, - members: participants + members: participants, + commands: autocomplete.commands(noteable, params[:type]) } respond_to do |format| diff --git a/app/finders/move_to_project_finder.rb b/app/finders/move_to_project_finder.rb new file mode 100644 index 00000000000..3334b8556df --- /dev/null +++ b/app/finders/move_to_project_finder.rb @@ -0,0 +1,14 @@ +class MoveToProjectFinder + def initialize(user) + @user = user + end + + def execute(from_project, search: nil, offset_id: nil) + projects = @user.projects_where_can_admin_issues + projects = projects.search(search) if search.present? + projects = projects.excluding_project(from_project) + + # to ask for Project#name_with_namespace + projects.includes(namespace: :owner) + end +end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 4fe0070552e..37bad596a16 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -17,7 +17,7 @@ class TodosFinder attr_accessor :current_user, :params - def initialize(current_user, params) + def initialize(current_user, params = {}) @current_user = current_user @params = params end diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index e12a1052988..de13e7a1fc2 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -32,6 +32,8 @@ module AppearancesHelper end def custom_icon(icon_name, size: 16) + # We can't simply do the below, because there are some .erb SVGs. + # File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe render "shared/icons/#{icon_name}.svg", size: size end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c3613bc67dd..f3733b01721 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -320,4 +320,8 @@ module ApplicationHelper capture(&block) end end + + def page_class + "issue-boards-page" if current_controller?(:boards) + end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 48c27828219..9ea03720c1e 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -11,17 +11,14 @@ module BlobHelper def edit_blob_link(project = @project, ref = @ref, path = @path, options = {}) return unless current_user - blob = project.repository.blob_at(ref, path) rescue nil + blob = options.delete(:blob) + blob ||= project.repository.blob_at(ref, path) rescue nil return unless blob - from_mr = options[:from_merge_request_id] - link_opts = {} - link_opts[:from_merge_request_id] = from_mr if from_mr - edit_path = namespace_project_edit_blob_path(project.namespace, project, tree_join(ref, path), - link_opts) + options[:link_opts]) if !on_top_of_branch?(project, ref) button_tag "Edit", class: "btn disabled has-tooltip btn-file-option", title: "You can only edit files when you are on a branch", data: { container: 'body' } @@ -182,17 +179,42 @@ module BlobHelper } end + def selected_template(issuable) + templates = issuable_templates(issuable) + params[:issuable_template] if templates.include?(params[:issuable_template]) + end + + def can_add_template?(issuable) + names = issuable_templates(issuable) + names.empty? && can?(current_user, :push_code, @project) && !@project.private? + end + + def merge_request_template_names + @merge_request_templates ||= Gitlab::Template::MergeRequestTemplate.dropdown_names(ref_project) + end + + def issue_template_names + @issue_templates ||= Gitlab::Template::IssueTemplate.dropdown_names(ref_project) + end + + def issuable_templates(issuable) + @issuable_templates ||= + if issuable.is_a?(Issue) + issue_template_names + elsif issuable.is_a?(MergeRequest) + merge_request_template_names + end + end + + def ref_project + @ref_project ||= @target_project || @project + end + def gitignore_names - @gitignore_names ||= - Gitlab::Template::Gitignore.categories.keys.map do |k| - [k, Gitlab::Template::Gitignore.by_category(k).map { |t| { name: t.name } }] - end.to_h + @gitignore_names ||= Gitlab::Template::GitignoreTemplate.dropdown_names end def gitlab_ci_ymls - @gitlab_ci_ymls ||= - Gitlab::Template::GitlabCiYml.categories.keys.map do |k| - [k, Gitlab::Template::GitlabCiYml.by_category(k).map { |t| { name: t.name } }] - end.to_h + @gitlab_ci_ymls ||= Gitlab::Template::GitlabCiYmlTemplate.dropdown_names end end diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index ea2f5f9281a..94df7d131ca 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -38,6 +38,10 @@ module CiStatusHelper 'icon_status_pending' when 'running' 'icon_status_running' + when 'play' + return icon('play fw') + when 'created' + 'icon_status_pending' else 'icon_status_cancel' end @@ -48,13 +52,13 @@ module CiStatusHelper def render_commit_status(commit, tooltip_placement: 'auto left') project = commit.project path = builds_namespace_project_commit_path(project.namespace, project, commit) - render_status_with_link('commit', commit.status, path, tooltip_placement) + render_status_with_link('commit', commit.status, path, tooltip_placement: tooltip_placement) end def render_pipeline_status(pipeline, tooltip_placement: 'auto left') project = pipeline.project path = namespace_project_pipeline_path(project.namespace, project, pipeline) - render_status_with_link('pipeline', pipeline.status, path, tooltip_placement) + render_status_with_link('pipeline', pipeline.status, path, tooltip_placement: tooltip_placement) end def no_runners_for_project?(project) @@ -62,13 +66,17 @@ module CiStatusHelper Ci::Runner.shared.blank? end - private + def render_status_with_link(type, status, path = nil, tooltip_placement: 'auto left', cssclass: '') + klass = "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}" + title = "#{type.titleize}: #{ci_label_for_status(status)}" + data = { toggle: 'tooltip', placement: tooltip_placement } - def render_status_with_link(type, status, path, tooltip_placement, cssclass: '') - link_to ci_icon_for_status(status), - path, - class: "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}", - title: "#{type.titleize}: #{ci_label_for_status(status)}", - data: { toggle: 'tooltip', placement: tooltip_placement } + if path + link_to ci_icon_for_status(status), path, + class: klass, title: title, data: data + else + content_tag :span, ci_icon_for_status(status), + class: klass, title: title, data: data + end end end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 7a02d0b10d9..33dcee49aee 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -98,28 +98,31 @@ module CommitsHelper end def link_to_browse_code(project, commit) - if current_controller?(:projects, :commits) - if @repo.blob_at(commit.id, @path) - return link_to( - "Browse File", - namespace_project_blob_path(project.namespace, project, - tree_join(commit.id, @path)), - class: "btn btn-default" - ) - elsif @path.present? - return link_to( - "Browse Directory", - namespace_project_tree_path(project.namespace, project, - tree_join(commit.id, @path)), - class: "btn btn-default" - ) - end + if @path.blank? + return link_to( + "Browse Files", + namespace_project_tree_path(project.namespace, project, commit), + class: "btn btn-default" + ) + end + + return unless current_controller?(:projects, :commits) + + if @repo.blob_at(commit.id, @path) + return link_to( + "Browse File", + namespace_project_blob_path(project.namespace, project, + tree_join(commit.id, @path)), + class: "btn btn-default" + ) + elsif @path.present? + return link_to( + "Browse Directory", + namespace_project_tree_path(project.namespace, project, + tree_join(commit.id, @path)), + class: "btn btn-default" + ) end - link_to( - "Browse Files", - namespace_project_tree_path(project.namespace, project, commit), - class: "btn btn-default" - ) end def revert_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 2e82b44437b..8b212b0327a 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -114,9 +114,17 @@ module IssuesHelper end def award_user_list(awards, current_user) - awards.map do |award| - award.user == current_user ? 'me' : award.user.name - end.join(', ') + names = awards.map do |award| + award.user == current_user ? 'You' : award.user.name + end + + # Take first 9 OR current user + first 9 + current_user_name = names.delete('You') + names = names.first(9).insert(0, current_user_name).compact + + names << "#{awards.size - names.size} more." if awards.size > names.size + + names.to_sentence end def award_active_class(awards, current_user) diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 3ff8be5e284..6c1cc6ef072 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -24,6 +24,7 @@ module NavHelper current_path?('merge_requests#diffs') || current_path?('merge_requests#commits') || current_path?('merge_requests#builds') || + current_path?('merge_requests#conflicts') || current_path?('issues#show') if cookies[:collapsed_gutter] == 'true' "page-gutter right-sidebar-collapsed" diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 26bde2230a9..da230f76bae 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -49,7 +49,7 @@ module NotesHelper } if use_legacy_diff_note - discussion_id = LegacyDiffNote.build_discussion_id( + discussion_id = LegacyDiffNote.discussion_id( @comments_target[:noteable_type], @comments_target[:noteable_id] || @comments_target[:commit_id], line_code @@ -60,7 +60,7 @@ module NotesHelper discussion_id: discussion_id ) else - discussion_id = DiffNote.build_discussion_id( + discussion_id = DiffNote.discussion_id( @comments_target[:noteable_type], @comments_target[:noteable_id] || @comments_target[:commit_id], position @@ -81,10 +81,8 @@ module NotesHelper data = discussion.reply_attributes.merge(line_type: line_type) - content_tag(:div, class: "discussion-reply-holder") do - button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', - data: data, title: 'Add a reply' - end + button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', + data: data, title: 'Add a reply' end def preload_max_access_for_authors(notes, project) diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index e1c0b497550..8b138a8e69f 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -20,13 +20,19 @@ module SortingHelper end def projects_sort_options_hash - { + options = { sort_value_name => sort_title_name, sort_value_recently_updated => sort_title_recently_updated, sort_value_oldest_updated => sort_title_oldest_updated, sort_value_recently_created => sort_title_recently_created, sort_value_oldest_created => sort_title_oldest_created, } + + if current_controller?('admin/projects') + options.merge!(sort_value_largest_repo => sort_title_largest_repo) + end + + options end def sort_title_priority diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 6f54c42146c..d64e48f774b 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -6,6 +6,11 @@ module Emails mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id)) end + def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id) + setup_issue_mail(issue_id, recipient_id) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) + end + def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id) setup_issue_mail(issue_id, recipient_id) diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 9dd11d20ea6..ec27ac517db 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -6,6 +6,11 @@ module Emails mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id)) end + def new_mention_in_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, merge_request_thread_options(updated_by_user_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) @@ -42,6 +47,13 @@ module Emails mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end + def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id) + setup_merge_request_mail(merge_request_id, recipient_id) + + @resolved_by = User.find(resolved_by_user_id) + mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id)) + end + private def setup_merge_request_mail(merge_request_id, recipient_id) diff --git a/app/models/ability.rb b/app/models/ability.rb index d9113ffd99a..07f703f205d 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -90,6 +90,8 @@ class Ability if project && project.public? rules = [ :read_project, + :read_board, + :read_list, :read_wiki, :read_label, :read_milestone, @@ -228,6 +230,8 @@ class Ability :read_project, :read_wiki, :read_issue, + :read_board, + :read_list, :read_label, :read_milestone, :read_project_snippet, @@ -249,6 +253,7 @@ class Ability :update_issue, :admin_issue, :admin_label, + :admin_list, :read_commit_status, :read_build, :read_container_image, @@ -271,6 +276,7 @@ class Ability :create_merge_request, :create_wiki, :push_code, + :resolve_note, :create_container_image, :update_container_image, :create_environment, @@ -452,7 +458,8 @@ class Ability rules += [ :read_note, :update_note, - :admin_note + :admin_note, + :resolve_note ] end @@ -460,6 +467,10 @@ class Ability rules += project_abilities(user, note.project) end + if note.for_merge_request? && note.noteable.author == user + rules << :resolve_note + end + rules end diff --git a/app/models/blob.rb b/app/models/blob.rb index 0df2805e448..12cc5aaafba 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -3,6 +3,9 @@ class Blob < SimpleDelegator CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour + # The maximum size of an SVG that can be displayed. + MAXIMUM_SVG_SIZE = 2.megabytes + # Wrap a Gitlab::Git::Blob object, or return nil when given nil # # This method prevents the decorated object from evaluating to "truthy" when @@ -31,6 +34,10 @@ class Blob < SimpleDelegator text? && language && language.name == 'SVG' end + def size_within_svg_limits? + size <= MAXIMUM_SVG_SIZE + end + def video? UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) end diff --git a/app/models/board.rb b/app/models/board.rb new file mode 100644 index 00000000000..3240c4bede3 --- /dev/null +++ b/app/models/board.rb @@ -0,0 +1,7 @@ +class Board < ActiveRecord::Base + belongs_to :project + + has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all + + validates :project, presence: true +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 3d6c6ea3209..ed056a07a49 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -97,7 +97,7 @@ module Ci end def playable? - project.builds_enabled? && commands.present? && manual? + project.builds_enabled? && commands.present? && manual? && skipped? end def play(current_user = nil) @@ -344,7 +344,7 @@ module Ci def execute_hooks return unless project - build_data = Gitlab::BuildDataBuilder.build(self) + build_data = Gitlab::DataBuilder::Build.build(self) project.execute_hooks(build_data.dup, :build_hooks) project.execute_services(build_data.dup, :build_hooks) project.running_or_pending_build_count(force: true) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 8cfba92ae9b..c360a6ff729 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -19,6 +19,8 @@ module Ci after_save :keep_around_commits + delegate :stages, to: :statuses + state_machine :status, initial: :created do event :enqueue do transition created: :pending @@ -56,6 +58,10 @@ module Ci before_transition do |pipeline| pipeline.update_duration end + + after_transition do |pipeline, transition| + pipeline.execute_hooks unless transition.loopback? + end end # ref can't be HEAD or SHA, can only be branch/tag name @@ -72,6 +78,10 @@ module Ci CommitStatus.where(pipeline: pluck(:id)).stages end + def stages_with_latest_statuses + statuses.latest.order(:stage_idx).group_by(&:stage) + end + def project_id project.id end @@ -243,8 +253,18 @@ module Ci self.duration = statuses.latest.duration end + def execute_hooks + data = pipeline_data + project.execute_hooks(data, :pipeline_hooks) + project.execute_services(data, :pipeline_hooks) + end + private + def pipeline_data + Gitlab::DataBuilder::Pipeline.build(self) + end + def latest_builds_status return 'failed' unless yaml_errors.blank? diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb new file mode 100644 index 00000000000..5a7b36070e7 --- /dev/null +++ b/app/models/concerns/protected_branch_access.rb @@ -0,0 +1,7 @@ +module ProtectedBranchAccess + extend ActiveSupport::Concern + + def humanize + self.class.human_access_levels[self.access_level] + end +end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 3b8e6df2da9..ce54fe5d3bf 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -1,9 +1,32 @@ module Spammable extend ActiveSupport::Concern + module ClassMethods + def attr_spammable(attr, options = {}) + spammable_attrs << [attr.to_s, options] + end + end + included do + has_one :user_agent_detail, as: :subject, dependent: :destroy + attr_accessor :spam + after_validation :check_for_spam, on: :create + + cattr_accessor :spammable_attrs, instance_accessor: false do + [] + end + + delegate :ip_address, :user_agent, to: :user_agent_detail, allow_nil: true + end + + def submittable_as_spam? + if user_agent_detail + user_agent_detail.submittable? + else + false + end end def spam? @@ -13,4 +36,33 @@ module Spammable def check_for_spam self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam? end + + def spam_title + attr = self.class.spammable_attrs.find do |_, options| + options.fetch(:spam_title, false) + end + + public_send(attr.first) if attr && respond_to?(attr.first.to_sym) + end + + def spam_description + attr = self.class.spammable_attrs.find do |_, options| + options.fetch(:spam_description, false) + end + + public_send(attr.first) if attr && respond_to?(attr.first.to_sym) + end + + def spammable_text + result = self.class.spammable_attrs.map do |attr| + public_send(attr.first) + end + + result.reject(&:blank?).join("\n") + end + + # Override in Spammable if further checks are necessary + def check_for_spam? + true + end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 1a7cd60817e..1e338889714 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -36,4 +36,10 @@ class Deployment < ActiveRecord::Base def manual_actions deployable.try(:other_actions) end + + def includes_commit?(commit) + return false unless commit + + project.repository.is_ancestor?(commit.id, sha) + end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 364e1a8341b..52215f6e2ae 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -9,11 +9,13 @@ class DiffNote < Note validates :diff_line, presence: true validates :line_code, presence: true, line_code: true validates :noteable_type, inclusion: { in: ['Commit', 'MergeRequest'] } + validates :resolved_by, presence: true, if: :resolved? validate :positions_complete validate :verify_supported + after_initialize :ensure_original_discussion_id before_validation :set_original_position, :update_position, on: :create - before_validation :set_line_code + before_validation :set_line_code, :set_original_discussion_id after_save :keep_around_commits class << self @@ -30,14 +32,6 @@ class DiffNote < Note { position: position.to_json } end - def discussion_id - @discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position) - end - - def original_discussion_id - @original_discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position) - end - def position=(new_position) if new_position.is_a?(String) new_position = JSON.parse(new_position) rescue nil @@ -72,10 +66,48 @@ class DiffNote < Note self.position.diff_refs == diff_refs end + def resolvable? + !system? && for_merge_request? + end + + def resolved? + return false unless resolvable? + + self.resolved_at.present? + end + + def resolve!(current_user) + return unless resolvable? + return if resolved? + + self.resolved_at = Time.now + self.resolved_by = current_user + save! + end + + def unresolve! + return unless resolvable? + return unless resolved? + + self.resolved_at = nil + self.resolved_by = nil + save! + end + + def discussion + return unless resolvable? + + self.noteable.find_diff_discussion(self.discussion_id) + end + + def to_discussion + Discussion.new([self]) + end + private def supported? - !self.for_merge_request? || self.noteable.support_new_diff_notes? + for_commit? || self.noteable.has_complete_diff_refs? end def noteable_diff_refs @@ -90,6 +122,26 @@ class DiffNote < Note self.line_code = self.position.line_code(self.project.repository) end + def ensure_original_discussion_id + return unless self.persisted? + return if self.original_discussion_id + + set_original_discussion_id + update_column(:original_discussion_id, self.original_discussion_id) + end + + def set_original_discussion_id + self.original_discussion_id = Digest::SHA1.hexdigest(build_original_discussion_id) + end + + def build_discussion_id + self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position) + end + + def build_original_discussion_id + self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position) + end + def update_position return unless supported? return if for_commit? diff --git a/app/models/discussion.rb b/app/models/discussion.rb index e2218a5f02b..3fddc084af2 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -1,7 +1,7 @@ class Discussion NUMBER_OF_TRUNCATED_DIFF_LINES = 16 - attr_reader :first_note, :notes + attr_reader :first_note, :last_note, :notes delegate :created_at, :project, @@ -18,6 +18,12 @@ class Discussion to: :first_note + delegate :resolved_at, + :resolved_by, + + to: :last_resolved_note, + allow_nil: true + delegate :blob, :highlighted_diff_lines, to: :diff_file, allow_nil: true def self.for_notes(notes) @@ -30,13 +36,30 @@ class Discussion def initialize(notes) @first_note = notes.first + @last_note = notes.last @notes = notes end + def last_resolved_note + return unless resolved? + + @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last + end + + def last_updated_at + last_note.created_at + end + + def last_updated_by + last_note.author + end + def id first_note.discussion_id end + alias_method :to_param, :id + def diff_discussion? first_note.diff_note? end @@ -45,6 +68,50 @@ class Discussion notes.any?(&:legacy_diff_note?) end + def resolvable? + return @resolvable if defined?(@resolvable) + + @resolvable = diff_discussion? && notes.any?(&:resolvable?) + end + + def resolved? + return @resolved if defined?(@resolved) + + @resolved = resolvable? && notes.none?(&:to_be_resolved?) + end + + def resolved_notes + notes.select(&:resolved?) + end + + def to_be_resolved? + resolvable? && !resolved? + end + + def can_resolve?(current_user) + return false unless current_user + return false unless resolvable? + + current_user == self.noteable.author || + current_user.can?(:resolve_note, self.project) + end + + def resolve!(current_user) + return unless resolvable? + + notes.each do |note| + note.resolve!(current_user) if note.resolvable? + end + end + + def unresolve! + return unless resolvable? + + notes.each do |note| + note.unresolve! if note.resolvable? + end + end + def for_target?(target) self.noteable == target && !diff_discussion? end @@ -55,8 +122,20 @@ class Discussion @active = first_note.active? end + def collapsed? + return false unless diff_discussion? + + if resolvable? + # New diff discussions only disappear once they are marked resolved + resolved? + else + # Old diff discussions disappear once they become outdated + !active? + end + end + def expanded? - !diff_discussion? || active? + !collapsed? end def reply_attributes diff --git a/app/models/environment.rb b/app/models/environment.rb index baed106e8c8..75e6f869786 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -25,4 +25,10 @@ class Environment < ActiveRecord::Base def nullify_external_url self.external_url = nil if self.external_url.blank? end + + def includes_commit?(commit) + return false unless last_deployment + + last_deployment.includes_commit?(commit) + end end diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index ba42a8eeb70..836a75b0608 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -5,5 +5,6 @@ class ProjectHook < WebHook scope :note_hooks, -> { where(note_events: true) } scope :merge_request_hooks, -> { where(merge_requests_events: true) } scope :build_hooks, -> { where(build_events: true) } + scope :pipeline_hooks, -> { where(pipeline_events: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true) } end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 8b87b6c3d64..f365dee3141 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -8,6 +8,7 @@ class WebHook < ActiveRecord::Base default_value_for :merge_requests_events, false default_value_for :tag_push_events, false default_value_for :build_events, false + default_value_for :pipeline_events, false default_value_for :enable_ssl_verification, true scope :push_hooks, -> { where(push_events: true) } diff --git a/app/models/issue.rb b/app/models/issue.rb index d62ffb21467..788611305fe 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -36,6 +36,9 @@ class Issue < ActiveRecord::Base scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') } scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') } + attr_spammable :title, spam_title: true + attr_spammable :description, spam_description: true + state_machine :state, initial: :opened do event :close do transition [:reopened, :opened] => :closed @@ -262,4 +265,9 @@ class Issue < ActiveRecord::Base def overdue? due_date.try(:past?) || false end + + # Only issues on public projects should be checked for spam + def check_for_spam? + project.public? + end end diff --git a/app/models/label.rb b/app/models/label.rb index 35e678001dc..a23140b7d64 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -13,6 +13,8 @@ class Label < ActiveRecord::Base default_value_for :color, DEFAULT_COLOR belongs_to :project + + has_many :lists, dependent: :destroy has_many :label_links, dependent: :destroy has_many :issues, through: :label_links, source: :target, source_type: 'Issue' has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest' diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index 6ed66001513..8e26cbe9835 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -8,8 +8,8 @@ class LegacyDiffNote < Note before_create :set_diff class << self - def build_discussion_id(noteable_type, noteable_id, line_code, active = true) - [super(noteable_type, noteable_id), line_code, active].join("-") + def build_discussion_id(noteable_type, noteable_id, line_code) + [super(noteable_type, noteable_id), line_code].join("-") end end @@ -21,10 +21,6 @@ class LegacyDiffNote < Note { line_code: line_code } end - def discussion_id - @discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code) - end - def project_repository if RequestStore.active? RequestStore.fetch("project:#{project_id}:repository") { self.project.repository } @@ -119,4 +115,8 @@ class LegacyDiffNote < Note diffs = noteable.raw_diffs(Commit.max_diff_options) diffs.find { |d| d.new_path == self.diff.new_path } end + + def build_discussion_id + self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code) + end end diff --git a/app/models/list.rb b/app/models/list.rb new file mode 100644 index 00000000000..eb87decdbc8 --- /dev/null +++ b/app/models/list.rb @@ -0,0 +1,34 @@ +class List < ActiveRecord::Base + belongs_to :board + belongs_to :label + + enum list_type: { backlog: 0, label: 1, done: 2 } + + validates :board, :list_type, presence: true + validates :label, :position, presence: true, if: :label? + validates :label_id, uniqueness: { scope: :board_id }, if: :label? + validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label? + + before_destroy :can_be_destroyed + + scope :destroyable, -> { where(list_type: list_types[:label]) } + scope :movable, -> { where(list_type: list_types[:label]) } + + def destroyable? + label? + end + + def movable? + label? + end + + def title + label? ? label.name : list_type.humanize + end + + private + + def can_be_destroyed + destroyable? + end +end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 007c2aa74e1..14b785e6bd4 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -438,6 +438,32 @@ class MergeRequest < ActiveRecord::Base ) end + def discussions + @discussions ||= self.mr_and_commit_notes. + inc_relations_for_view. + fresh. + discussions + end + + def diff_discussions + @diff_discussions ||= self.notes.diff_notes.discussions + end + + def find_diff_discussion(discussion_id) + notes = self.notes.diff_notes.where(discussion_id: discussion_id).fresh.to_a + return if notes.empty? + + Discussion.new(notes) + end + + def discussions_resolvable? + diff_discussions.any?(&:resolvable?) + end + + def discussions_resolved? + discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?) + end + def hook_attrs attrs = { source: source_project.try(:hook_attrs), @@ -611,6 +637,14 @@ class MergeRequest < ActiveRecord::Base !pipeline || pipeline.success? end + def environments + return unless diff_head_commit + + target_project.environments.select do |environment| + environment.includes_commit?(diff_head_commit) + end + end + def state_human_name if merged? "Merged" @@ -686,10 +720,21 @@ class MergeRequest < ActiveRecord::Base diverged_commits_count > 0 end + def commits_sha + commits.map(&:sha) + end + def pipeline @pipeline ||= source_project.pipeline(diff_head_sha, source_branch) if diff_head_sha && source_project end + def all_pipelines + @all_pipelines ||= + if diff_head_sha && source_project + source_project.pipelines.order(id: :desc).where(sha: commits_sha, ref: source_branch) + end + end + def merge_commit @merge_commit ||= project.commit(merge_commit_sha) if merge_commit_sha end @@ -702,12 +747,12 @@ class MergeRequest < ActiveRecord::Base merge_commit end - def support_new_diff_notes? + def has_complete_diff_refs? diff_refs && diff_refs.complete? end def update_diff_notes_positions(old_diff_refs:, new_diff_refs:) - return unless support_new_diff_notes? + return unless has_complete_diff_refs? return if new_diff_refs == old_diff_refs active_diff_notes = self.notes.diff_notes.select do |note| @@ -735,4 +780,26 @@ class MergeRequest < ActiveRecord::Base def keep_around_commit project.repository.keep_around(self.merge_commit_sha) end + + def conflicts + @conflicts ||= Gitlab::Conflict::FileCollection.new(self) + end + + def conflicts_can_be_resolved_by?(user) + access = ::Gitlab::UserAccess.new(user, project: source_project) + access.can_push_to_branch?(source_branch) + end + + def conflicts_can_be_resolved_in_ui? + return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui) + + return @conflicts_can_be_resolved_in_ui = false unless cannot_be_merged? + return @conflicts_can_be_resolved_in_ui = false unless has_complete_diff_refs? + + begin + @conflicts_can_be_resolved_in_ui = conflicts.files.each(&:lines) + rescue Gitlab::Conflict::Parser::ParserError, Gitlab::Conflict::FileCollection::ConflictSideMissing + @conflicts_can_be_resolved_in_ui = false + end + end end diff --git a/app/models/note.rb b/app/models/note.rb index ddcd7f9d034..3bbf5db0b70 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -25,6 +25,9 @@ class Note < ActiveRecord::Base belongs_to :author, class_name: "User" belongs_to :updated_by, class_name: "User" + # Only used by DiffNote, but defined here so that it can be used in `Note.includes` + belongs_to :resolved_by, class_name: "User" + has_many :todos, dependent: :destroy has_many :events, as: :target, dependent: :destroy @@ -59,7 +62,7 @@ class Note < ActiveRecord::Base scope :fresh, ->{ order(created_at: :asc, id: :asc) } scope :inc_author_project, ->{ includes(:project, :author) } scope :inc_author, ->{ includes(:author) } - scope :inc_author_project_award_emoji, ->{ includes(:project, :author, :award_emoji) } + scope :inc_relations_for_view, ->{ includes(:project, :author, :updated_by, :resolved_by, :award_emoji) } scope :diff_notes, ->{ where(type: ['LegacyDiffNote', 'DiffNote']) } scope :non_diff_notes, ->{ where(type: ['Note', nil]) } @@ -70,7 +73,9 @@ class Note < ActiveRecord::Base project: [:project_members, { group: [:group_members] }]) end + after_initialize :ensure_discussion_id before_validation :nullify_blank_type, :nullify_blank_line_code + before_validation :set_discussion_id after_save :keep_around_commit class << self @@ -82,13 +87,18 @@ class Note < ActiveRecord::Base [:discussion, noteable_type.try(:underscore), noteable_id].join("-") end + def discussion_id(*args) + Digest::SHA1.hexdigest(build_discussion_id(*args)) + end + def discussions Discussion.for_notes(all) end def grouped_diff_discussions - notes = diff_notes.fresh.select(&:active?) - Discussion.for_diff_notes(notes).map { |d| [d.line_code, d] }.to_h + active_notes = diff_notes.fresh.select(&:active?) + Discussion.for_diff_notes(active_notes). + map { |d| [d.line_code, d] }.to_h end # Searches for notes matching the given query. @@ -129,13 +139,16 @@ class Note < ActiveRecord::Base true end - def discussion_id - @discussion_id ||= - if for_merge_request? - [:discussion, :note, id].join("-") - else - self.class.build_discussion_id(noteable_type, noteable_id || commit_id) - end + def resolvable? + false + end + + def resolved? + false + end + + def to_be_resolved? + resolvable? && !resolved? end def max_attachment_size @@ -243,4 +256,26 @@ class Note < ActiveRecord::Base def nullify_blank_line_code self.line_code = nil if self.line_code.blank? end + + def ensure_discussion_id + return unless self.persisted? + return if self.discussion_id + + set_discussion_id + update_column(:discussion_id, self.discussion_id) + end + + def set_discussion_id + self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id) + end + + def build_discussion_id + if for_merge_request? + # Notes on merge requests are always in a discussion of their own, + # so we generate a unique discussion ID. + [:discussion, :note, SecureRandom.hex].join("-") + else + self.class.build_discussion_id(noteable_type, noteable_id || commit_id) + end + end end diff --git a/app/models/project.rb b/app/models/project.rb index e0b28160937..043da030468 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -62,6 +62,8 @@ class Project < ActiveRecord::Base belongs_to :group, -> { where(type: Group) }, foreign_key: 'namespace_id' belongs_to :namespace + has_one :board, dependent: :destroy + has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event', foreign_key: 'project_id' # Project services @@ -197,6 +199,8 @@ class Project < ActiveRecord::Base scope :active, -> { joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') } scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) } + scope :excluding_project, ->(project) { where.not(id: project) } + state_machine :import_status, initial: :none do event :import_start do transition [:none, :finished] => :started diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb index 5e166471077..fa66e5864b8 100644 --- a/app/models/project_services/builds_email_service.rb +++ b/app/models/project_services/builds_email_service.rb @@ -51,8 +51,7 @@ class BuildsEmailService < Service end def test_data(project = nil, user = nil) - build = project.builds.last - Gitlab::BuildDataBuilder.build(build) + Gitlab::DataBuilder::Build.build(project.builds.last) end def fields diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index a255710f577..46f70da2452 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -56,6 +56,10 @@ class ProjectWiki end end + def repository_exists? + !!repository.exists? + end + def empty? pages.empty? end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 226b3f54342..6240912a6e1 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -5,11 +5,14 @@ class ProtectedBranch < ActiveRecord::Base validates :name, presence: true validates :project, presence: true - has_one :merge_access_level, dependent: :destroy - has_one :push_access_level, dependent: :destroy + has_many :merge_access_levels, dependent: :destroy + has_many :push_access_levels, dependent: :destroy - accepts_nested_attributes_for :push_access_level - accepts_nested_attributes_for :merge_access_level + validates_length_of :merge_access_levels, is: 1, message: "are restricted to a single instance per protected branch." + validates_length_of :push_access_levels, is: 1, message: "are restricted to a single instance per protected branch." + + accepts_nested_attributes_for :push_access_levels + accepts_nested_attributes_for :merge_access_levels def commit project.commit(self.name) diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb index b1112ee737d..806b3ccd275 100644 --- a/app/models/protected_branch/merge_access_level.rb +++ b/app/models/protected_branch/merge_access_level.rb @@ -1,4 +1,6 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base + include ProtectedBranchAccess + belongs_to :protected_branch delegate :project, to: :protected_branch @@ -17,8 +19,4 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base project.team.max_member_access(user.id) >= access_level end - - def humanize - self.class.human_access_levels[self.access_level] - end end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index 6a5e49cf453..92e9c51d883 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -1,4 +1,6 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base + include ProtectedBranchAccess + belongs_to :protected_branch delegate :project, to: :protected_branch @@ -20,8 +22,4 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base project.team.max_member_access(user.id) >= access_level end - - def humanize - self.class.human_access_levels[self.access_level] - end end diff --git a/app/models/repository.rb b/app/models/repository.rb index e56bac509a4..2494c266cd2 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -391,6 +391,8 @@ class Repository expire_exists_cache expire_root_ref_cache expire_emptiness_caches + + repository_event(:create_repository) end # Runs code just before a repository is deleted. @@ -407,6 +409,8 @@ class Repository expire_root_ref_cache expire_emptiness_caches expire_exists_cache + + repository_event(:remove_repository) end # Runs code just before the HEAD of a repository is changed. @@ -414,6 +418,8 @@ class Repository # Cached divergent commit counts are based on repository head expire_branch_cache expire_root_ref_cache + + repository_event(:change_default_branch) end # Runs code before pushing (= creating or removing) a tag. @@ -421,12 +427,16 @@ class Repository expire_cache expire_tags_cache expire_tag_count_cache + + repository_event(:push_tag) end # Runs code before removing a tag. def before_remove_tag expire_tags_cache expire_tag_count_cache + + repository_event(:remove_tag) end def before_import @@ -443,6 +453,8 @@ class Repository # Runs code after a new commit has been pushed. def after_push_commit(branch_name, revision) expire_cache(branch_name, revision) + + repository_event(:push_commit, branch: branch_name) end # Runs code after a new branch has been created. @@ -450,11 +462,15 @@ class Repository expire_branches_cache expire_has_visible_content_cache expire_branch_count_cache + + repository_event(:push_branch) end # Runs code before removing an existing branch. def before_remove_branch expire_branches_cache + + repository_event(:remove_branch) end # Runs code after an existing branch has been removed. @@ -869,6 +885,14 @@ class Repository end end + def resolve_conflicts(user, branch, params) + commit_with_hooks(user, branch) do + committer = user_to_committer(user) + + Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer)) + end + end + def check_revert_content(commit, base_branch) source_sha = find_branch(base_branch).target.sha args = [commit.id, source_sha] @@ -1059,4 +1083,8 @@ class Repository def keep_around_ref_name(sha) "refs/keep-around/#{sha}" end + + def repository_event(event, tags = {}) + Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags)) + end end diff --git a/app/models/service.rb b/app/models/service.rb index 40cd9b861f0..09b4717a523 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -36,6 +36,7 @@ class Service < ActiveRecord::Base scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) } scope :note_hooks, -> { where(note_events: true, active: true) } scope :build_hooks, -> { where(build_events: true, active: true) } + scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } scope :external_issue_trackers, -> { issue_trackers.active.without_defaults } @@ -79,13 +80,17 @@ class Service < ActiveRecord::Base end def test_data(project, user) - Gitlab::PushDataBuilder.build_sample(project, user) + Gitlab::DataBuilder::Push.build_sample(project, user) end def event_channel_names [] end + def event_names + supported_events.map { |event| "#{event}_events" } + end + def event_field(event) nil end diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb index 12df68ef83b..3b8b9833565 100644 --- a/app/models/spam_log.rb +++ b/app/models/spam_log.rb @@ -7,4 +7,8 @@ class SpamLog < ActiveRecord::Base user.block user.destroy end + + def text + [title, description].join("\n") + end end diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb index 00b19686d48..808acec098f 100644 --- a/app/models/u2f_registration.rb +++ b/app/models/u2f_registration.rb @@ -3,18 +3,19 @@ class U2fRegistration < ActiveRecord::Base belongs_to :user - def self.register(user, app_id, json_response, challenges) + def self.register(user, app_id, params, challenges) u2f = U2F::U2F.new(app_id) registration = self.new begin - response = U2F::RegisterResponse.load_from_json(json_response) + response = U2F::RegisterResponse.load_from_json(params[:device_response]) registration_data = u2f.register!(challenges, response) registration.update(certificate: registration_data.certificate, key_handle: registration_data.key_handle, public_key: registration_data.public_key, counter: registration_data.counter, - user: user) + user: user, + name: params[:name]) rescue JSON::ParserError, NoMethodError, ArgumentError registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.') rescue U2F::Error => e diff --git a/app/models/user.rb b/app/models/user.rb index 87a2d999843..48e83ab7e56 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -429,6 +429,13 @@ class User < ActiveRecord::Base owned_groups.select(:id), namespace.id).joins(:namespace) end + # Returns projects which user can admin issues on (for example to move an issue to that project). + # + # This logic is duplicated from `Ability#project_abilities` into a SQL form. + def projects_where_can_admin_issues + authorized_projects(Gitlab::Access::REPORTER).non_archived.where.not(issues_enabled: false) + end + def is_admin? admin end diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb new file mode 100644 index 00000000000..0949c6ef083 --- /dev/null +++ b/app/models/user_agent_detail.rb @@ -0,0 +1,9 @@ +class UserAgentDetail < ActiveRecord::Base + belongs_to :subject, polymorphic: true + + validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true + + def submittable? + !submitted? + end +end diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb new file mode 100644 index 00000000000..5c60addbe7c --- /dev/null +++ b/app/services/akismet_service.rb @@ -0,0 +1,79 @@ +class AkismetService + attr_accessor :owner, :text, :options + + def initialize(owner, text, options = {}) + @owner = owner + @text = text + @options = options + end + + def is_spam? + return false unless akismet_enabled? + + params = { + type: 'comment', + text: text, + created_at: DateTime.now, + author: owner.name, + author_email: owner.email, + referrer: options[:referrer], + } + + begin + is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params) + is_spam || is_blatant + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") + false + end + end + + def submit_ham + return false unless akismet_enabled? + + params = { + type: 'comment', + text: text, + author: owner.name, + author_email: owner.email + } + + begin + akismet_client.submit_ham(options[:ip_address], options[:user_agent], params) + true + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") + false + end + end + + def submit_spam + return false unless akismet_enabled? + + params = { + type: 'comment', + text: text, + author: owner.name, + author_email: owner.email + } + + begin + akismet_client.submit_spam(options[:ip_address], options[:user_agent], params) + true + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") + false + end + end + + private + + def akismet_client + @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key, + Gitlab.config.gitlab.url) + end + + def akismet_enabled? + current_application_settings.akismet_enabled + end +end diff --git a/app/services/boards/base_service.rb b/app/services/boards/base_service.rb new file mode 100644 index 00000000000..b2069ca825a --- /dev/null +++ b/app/services/boards/base_service.rb @@ -0,0 +1,5 @@ +module Boards + class BaseService < ::BaseService + delegate :board, to: :project + end +end diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb new file mode 100644 index 00000000000..072a0749285 --- /dev/null +++ b/app/services/boards/create_service.rb @@ -0,0 +1,16 @@ +module Boards + class CreateService < Boards::BaseService + def execute + create_board! unless project.board.present? + project.board + end + + private + + def create_board! + project.create_board + project.board.lists.create(list_type: :backlog) + project.board.lists.create(list_type: :done) + end + end +end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb new file mode 100644 index 00000000000..435a8c6e681 --- /dev/null +++ b/app/services/boards/issues/list_service.rb @@ -0,0 +1,63 @@ +module Boards + module Issues + class ListService < Boards::BaseService + def execute + issues = IssuesFinder.new(current_user, filter_params).execute + issues = without_board_labels(issues) unless list.movable? + issues = with_list_label(issues) if list.movable? + issues + end + + private + + def list + @list ||= board.lists.find(params[:id]) + end + + def filter_params + set_default_scope + set_default_sort + set_project + set_state + + params + end + + def set_default_scope + params[:scope] = 'all' + end + + def set_default_sort + params[:sort] = 'priority' + end + + def set_project + params[:project_id] = project.id + end + + def set_state + params[:state] = list.done? ? 'closed' : 'opened' + end + + def board_label_ids + @board_label_ids ||= board.lists.movable.pluck(:label_id) + end + + def without_board_labels(issues) + return issues unless board_label_ids.any? + + issues.where.not( + LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id") + .where(label_id: board_label_ids).limit(1).arel.exists + ) + end + + def with_list_label(issues) + issues.where( + LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id") + .where("label_links.label_id = ?", list.label_id).limit(1).arel.exists + ) + end + end + end +end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb new file mode 100644 index 00000000000..84dc3f70e76 --- /dev/null +++ b/app/services/boards/issues/move_service.rb @@ -0,0 +1,59 @@ +module Boards + module Issues + class MoveService < Boards::BaseService + def execute(issue) + return false unless can?(current_user, :update_issue, issue) + return false unless valid_move? + + update_service.execute(issue) + end + + private + + def valid_move? + moving_from_list.present? && moving_to_list.present? && + moving_from_list != moving_to_list + end + + def moving_from_list + @moving_from_list ||= board.lists.find_by(id: params[:from_list_id]) + end + + def moving_to_list + @moving_to_list ||= board.lists.find_by(id: params[:to_list_id]) + end + + def update_service + ::Issues::UpdateService.new(project, current_user, issue_params) + end + + def issue_params + { + add_label_ids: add_label_ids, + remove_label_ids: remove_label_ids, + state_event: issue_state + } + end + + def issue_state + return 'reopen' if moving_from_list.done? + return 'close' if moving_to_list.done? + end + + def add_label_ids + [moving_to_list.label_id].compact + end + + def remove_label_ids + label_ids = + if moving_to_list.movable? + moving_from_list.label_id + else + board.lists.movable.pluck(:label_id) + end + + Array(label_ids).compact + end + end + end +end diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb new file mode 100644 index 00000000000..5cb408b9d20 --- /dev/null +++ b/app/services/boards/lists/create_service.rb @@ -0,0 +1,22 @@ +module Boards + module Lists + class CreateService < Boards::BaseService + def execute + List.transaction do + create_list_at(next_position) + end + end + + private + + def next_position + max_position = board.lists.movable.maximum(:position) + max_position.nil? ? 0 : max_position.succ + end + + def create_list_at(position) + board.lists.create(params.merge(list_type: :label, position: position)) + end + end + end +end diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb new file mode 100644 index 00000000000..25da3bfb56d --- /dev/null +++ b/app/services/boards/lists/destroy_service.rb @@ -0,0 +1,25 @@ +module Boards + module Lists + class DestroyService < Boards::BaseService + def execute(list) + return false unless list.destroyable? + + list.with_lock do + decrement_higher_lists(list) + remove_list(list) + end + end + + private + + def decrement_higher_lists(list) + board.lists.movable.where('position > ?', list.position) + .update_all('position = position - 1') + end + + def remove_list(list) + list.destroy + end + end + end +end diff --git a/app/services/boards/lists/generate_service.rb b/app/services/boards/lists/generate_service.rb new file mode 100644 index 00000000000..1c48b9786e4 --- /dev/null +++ b/app/services/boards/lists/generate_service.rb @@ -0,0 +1,36 @@ +module Boards + module Lists + class GenerateService < Boards::BaseService + def execute + return false unless board.lists.movable.empty? + + List.transaction do + label_params.each { |params| create_list(params) } + end + + true + end + + private + + def create_list(params) + label = find_or_create_label(params) + Lists::CreateService.new(project, current_user, label_id: label.id).execute + end + + def find_or_create_label(params) + project.labels.create_with(color: params[:color]) + .find_or_create_by(name: params[:name]) + end + + def label_params + [ + { name: 'Development', color: '#5CB85C' }, + { name: 'Testing', color: '#F0AD4E' }, + { name: 'Production', color: '#FF5F00' }, + { name: 'Ready', color: '#FF0000' } + ] + end + end + end +end diff --git a/app/services/boards/lists/move_service.rb b/app/services/boards/lists/move_service.rb new file mode 100644 index 00000000000..020ff69f4a7 --- /dev/null +++ b/app/services/boards/lists/move_service.rb @@ -0,0 +1,51 @@ +module Boards + module Lists + class MoveService < Boards::BaseService + def execute(list) + @old_position = list.position + @new_position = params[:position] + + return false unless list.movable? + return false unless valid_move? + + list.with_lock do + reorder_intermediate_lists + update_list_position(list) + end + end + + private + + attr_reader :old_position, :new_position + + def valid_move? + new_position.present? && new_position != old_position && + new_position >= 0 && new_position < board.lists.movable.size + end + + def reorder_intermediate_lists + if old_position < new_position + decrement_intermediate_lists + else + increment_intermediate_lists + end + end + + def decrement_intermediate_lists + board.lists.movable.where('position > ?', old_position) + .where('position <= ?', new_position) + .update_all('position = position - 1') + end + + def increment_intermediate_lists + board.lists.movable.where('position >= ?', new_position) + .where('position < ?', old_position) + .update_all('position = position + 1') + end + + def update_list_position(list) + list.update_attribute(:position, new_position) + end + end + end +end diff --git a/app/services/create_spam_log_service.rb b/app/services/create_spam_log_service.rb deleted file mode 100644 index 59a66fde47a..00000000000 --- a/app/services/create_spam_log_service.rb +++ /dev/null @@ -1,13 +0,0 @@ -class CreateSpamLogService < BaseService - def initialize(project, user, params) - super(project, user, params) - end - - def execute - spam_params = params.merge({ user_id: @current_user.id, - project_id: @project.id } ) - spam_log = SpamLog.new(spam_params) - spam_log.save - spam_log - end -end diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb index 87f066edb6f..918eddaa53a 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -39,7 +39,12 @@ class DeleteBranchService < BaseService end def build_push_data(branch) - Gitlab::PushDataBuilder - .build(project, current_user, branch.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", []) + Gitlab::DataBuilder::Push.build( + project, + current_user, + branch.target.sha, + Gitlab::Git::BLANK_SHA, + "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", + []) end end diff --git a/app/services/delete_tag_service.rb b/app/services/delete_tag_service.rb index 32e0eed6b63..d0cb151a010 100644 --- a/app/services/delete_tag_service.rb +++ b/app/services/delete_tag_service.rb @@ -33,7 +33,12 @@ class DeleteTagService < BaseService end def build_push_data(tag) - Gitlab::PushDataBuilder - .build(project, current_user, tag.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", []) + Gitlab::DataBuilder::Push.build( + project, + current_user, + tag.target.sha, + Gitlab::Git::BLANK_SHA, + "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", + []) end end diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb index c4a206f785e..ea94818713b 100644 --- a/app/services/files/base_service.rb +++ b/app/services/files/base_service.rb @@ -15,6 +15,7 @@ module Files else params[:file_content] end + @last_commit_sha = params[:last_commit_sha] # Validate parameters validate diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb index 8d2b5083179..4fc3b640799 100644 --- a/app/services/files/update_service.rb +++ b/app/services/files/update_service.rb @@ -2,11 +2,34 @@ require_relative "base_service" module Files class UpdateService < Files::BaseService + class FileChangedError < StandardError; end + def commit repository.update_file(current_user, @file_path, @file_content, branch: @target_branch, previous_path: @previous_path, message: @commit_message) end + + private + + def validate + super + + if file_has_changed? + raise FileChangedError.new("You are attempting to update a file that has changed since you started editing it.") + end + end + + def file_has_changed? + return false unless @last_commit_sha && last_commit + + @last_commit_sha != last_commit.sha + end + + def last_commit + @last_commit ||= Gitlab::Git::Commit. + last_for_path(@source_project.repository, @source_branch, @file_path) + end end end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 6f521462cf3..78feb37aa2a 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -91,12 +91,12 @@ class GitPushService < BaseService params = { name: @project.default_branch, - push_access_level_attributes: { + push_access_levels_attributes: [{ access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - }, - merge_access_level_attributes: { + }], + merge_access_levels_attributes: [{ access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - } + }] } ProtectedBranches::CreateService.new(@project, current_user, params).execute @@ -138,13 +138,23 @@ class GitPushService < BaseService end def build_push_data - @push_data ||= Gitlab::PushDataBuilder. - build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], push_commits) + @push_data ||= Gitlab::DataBuilder::Push.build( + @project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + push_commits) end def build_push_data_system_hook - @push_data_system ||= Gitlab::PushDataBuilder. - build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], []) + @push_data_system ||= Gitlab::DataBuilder::Push.build( + @project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + []) end def push_to_existing_branch? diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index d2b52f16fa8..e6002b03b93 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -34,12 +34,24 @@ class GitTagPushService < BaseService end end - Gitlab::PushDataBuilder. - build(project, current_user, params[:oldrev], params[:newrev], params[:ref], commits, message) + Gitlab::DataBuilder::Push.build( + project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + commits, + message) end def build_system_push_data - Gitlab::PushDataBuilder. - build(project, current_user, params[:oldrev], params[:newrev], params[:ref], [], '') + Gitlab::DataBuilder::Push.build( + project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + [], + '') end end diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb new file mode 100644 index 00000000000..b0e1799b489 --- /dev/null +++ b/app/services/ham_service.rb @@ -0,0 +1,26 @@ +class HamService + attr_accessor :spam_log + + def initialize(spam_log) + @spam_log = spam_log + end + + def mark_as_ham! + if akismet.submit_ham + spam_log.update_attribute(:submitted_as_ham, true) + else + false + end + end + + private + + def akismet + @akismet ||= AkismetService.new( + spam_log.user, + spam_log.text, + ip_address: spam_log.source_ip, + user_agent: spam_log.user_agent + ) + end +end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 2d96efe1042..e06c37c323e 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -69,14 +69,9 @@ class IssuableBaseService < BaseService end def filter_labels - if params[:add_label_ids].present? || params[:remove_label_ids].present? - params.delete(:label_ids) - - filter_labels_in_param(:add_label_ids) - filter_labels_in_param(:remove_label_ids) - else - filter_labels_in_param(:label_ids) - end + filter_labels_in_param(:add_label_ids) + filter_labels_in_param(:remove_label_ids) + filter_labels_in_param(:label_ids) end def filter_labels_in_param(key) @@ -85,30 +80,90 @@ class IssuableBaseService < BaseService params[key] = project.labels.where(id: params[key]).pluck(:id) end + def process_label_ids(attributes, existing_label_ids: nil) + label_ids = attributes.delete(:label_ids) + add_label_ids = attributes.delete(:add_label_ids) + remove_label_ids = attributes.delete(:remove_label_ids) + + new_label_ids = existing_label_ids || label_ids || [] + + if add_label_ids.blank? && remove_label_ids.blank? + new_label_ids = label_ids if label_ids + else + new_label_ids |= add_label_ids if add_label_ids + new_label_ids -= remove_label_ids if remove_label_ids + end + + new_label_ids + end + + def merge_slash_commands_into_params!(issuable) + description, command_params = + SlashCommands::InterpretService.new(project, current_user). + execute(params[:description], issuable) + + params[:description] = description + + params.merge!(command_params) + end + + def create_issuable(issuable, attributes, label_ids:) + issuable.with_transaction_returning_status do + if issuable.save + issuable.update_attributes(label_ids: label_ids) + end + end + end + + def create(issuable) + merge_slash_commands_into_params!(issuable) + filter_params + + params.delete(:state_event) + params[:author] ||= current_user + label_ids = process_label_ids(params) + + issuable.assign_attributes(params) + + before_create(issuable) + + if params.present? && create_issuable(issuable, params, label_ids: label_ids) + after_create(issuable) + issuable.create_cross_references!(current_user) + execute_hooks(issuable) + end + + issuable + end + + def before_create(issuable) + # To be overridden by subclasses + end + + def after_create(issuable) + # To be overridden by subclasses + end + def update_issuable(issuable, attributes) issuable.with_transaction_returning_status do - add_label_ids = attributes.delete(:add_label_ids) - remove_label_ids = attributes.delete(:remove_label_ids) - - issuable.label_ids |= add_label_ids if add_label_ids - issuable.label_ids -= remove_label_ids if remove_label_ids - - issuable.assign_attributes(attributes.merge(updated_by: current_user)) - - issuable.save + issuable.update(attributes.merge(updated_by: current_user)) end end def update(issuable) change_state(issuable) change_subscription(issuable) + change_todo(issuable) filter_params old_labels = issuable.labels.to_a + old_mentioned_users = issuable.mentioned_users.to_a + + params[:label_ids] = process_label_ids(params, existing_label_ids: issuable.label_ids) if params.present? && update_issuable(issuable, params) issuable.reset_events_cache handle_common_system_notes(issuable, old_labels: old_labels) - handle_changes(issuable, old_labels: old_labels) + handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') end @@ -134,6 +189,16 @@ class IssuableBaseService < BaseService end end + def change_todo(issuable) + case params.delete(:todo_event) + when 'add' + todo_service.mark_todo(issuable, current_user) + when 'done' + todo = TodosFinder.new(current_user).execute.find_by(target: issuable) + todo_service.mark_todos_as_done([todo], current_user) if todo + end + end + def has_changes?(issuable, old_labels: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 859c934ea3b..45cca216ccc 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -1,6 +1,8 @@ module Issues class CloseService < Issues::BaseService def execute(issue, commit: nil, notifications: true, system_note: true) + return issue unless can?(current_user, :update_issue, issue) + if project.jira_tracker? && project.jira_service.active project.jira_service.execute(commit, issue) todo_service.close_issue(issue, current_user) diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 5e2de2ccf64..ea1690f3e38 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -1,31 +1,33 @@ module Issues class CreateService < Issues::BaseService def execute - filter_params - label_params = params.delete(:label_ids) - request = params.delete(:request) - api = params.delete(:api) - issue = project.issues.new(params) - issue.author = params[:author] || current_user + @request = params.delete(:request) + @api = params.delete(:api) - issue.spam = spam_check_service.execute(request, api) + @issue = project.issues.new - if issue.save - issue.update_attributes(label_ids: label_params) - notification_service.new_issue(issue, current_user) - todo_service.new_issue(issue, current_user) - event_service.open_issue(issue, current_user) - issue.create_cross_references!(current_user) - execute_hooks(issue, 'open') - end + create(@issue) + end - issue + def before_create(issuable) + issuable.spam = spam_service.check(@api) + end + + def after_create(issuable) + event_service.open_issue(issuable, current_user) + notification_service.new_issue(issuable, current_user) + todo_service.new_issue(issuable, current_user) + user_agent_detail_service.create end private - def spam_check_service - SpamCheckService.new(project, current_user, params) + def spam_service + SpamService.new(@issue, @request) + end + + def user_agent_detail_service + UserAgentDetailService.new(@issue, @request) end end end diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index e48ca359f4f..40fbe354492 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -1,6 +1,8 @@ module Issues class ReopenService < Issues::BaseService def execute(issue) + return issue unless can?(current_user, :update_issue, issue) + if issue.reopen event_service.reopen_issue(issue, current_user) create_note(issue) diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index c7d406cc331..a2111b3806b 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -4,7 +4,7 @@ module Issues update(issue) end - def handle_changes(issue, old_labels: []) + def handle_changes(issue, old_labels: [], old_mentioned_users: []) if has_changes?(issue, old_labels: old_labels) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -32,6 +32,11 @@ module Issues if added_labels.present? notification_service.relabeled_issue(issue, added_labels, current_user) end + + added_mentions = issue.mentioned_users - old_mentioned_users + if added_mentions.present? + notification_service.new_mentions_in_issue(issue, added_mentions, current_user) + end end def reopen_service diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index 27ee81fe3e7..f2053bda83a 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -1,6 +1,8 @@ module MergeRequests class CloseService < MergeRequests::BaseService def execute(merge_request, commit = nil) + return merge_request unless can?(current_user, :update_merge_request, merge_request) + # If we close MergeRequest we want to ignore validation # so we can close broken one (Ex. fork project removed) merge_request.allow_broken = true diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 96a25330af1..73247e62421 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -7,26 +7,19 @@ module MergeRequests source_project = @project @project = Project.find(params[:target_project_id]) if params[:target_project_id] - filter_params - label_params = params.delete(:label_ids) - force_remove_source_branch = params.delete(:force_remove_source_branch) + params[:target_project_id] ||= source_project.id - merge_request = MergeRequest.new(params) + merge_request = MergeRequest.new merge_request.source_project = source_project - merge_request.target_project ||= source_project - merge_request.author = current_user - merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch + merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch) - if merge_request.save - merge_request.update_attributes(label_ids: label_params) - event_service.open_mr(merge_request, current_user) - notification_service.new_merge_request(merge_request, current_user) - todo_service.new_merge_request(merge_request, current_user) - merge_request.create_cross_references!(current_user) - execute_hooks(merge_request) - end + create(merge_request) + end - merge_request + def after_create(issuable) + event_service.open_mr(issuable, current_user) + notification_service.new_merge_request(issuable, current_user) + todo_service.new_merge_request(issuable, current_user) end end end diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index eb88ae9d11c..fadcce5d9b6 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -1,6 +1,8 @@ module MergeRequests class ReopenService < MergeRequests::BaseService def execute(merge_request) + return merge_request unless can?(current_user, :update_merge_request, merge_request) + if merge_request.reopen event_service.reopen_mr(merge_request, current_user) create_note(merge_request) diff --git a/app/services/merge_requests/resolve_service.rb b/app/services/merge_requests/resolve_service.rb new file mode 100644 index 00000000000..adc71b0c2bc --- /dev/null +++ b/app/services/merge_requests/resolve_service.rb @@ -0,0 +1,31 @@ +module MergeRequests + class ResolveService < MergeRequests::BaseService + attr_accessor :conflicts, :rugged, :merge_index + + def execute(merge_request) + @conflicts = merge_request.conflicts + @rugged = project.repository.rugged + @merge_index = conflicts.merge_index + + conflicts.files.each do |file| + write_resolved_file_to_index(file, params[:sections]) + end + + commit_params = { + message: params[:commit_message] || conflicts.default_commit_message, + parents: [conflicts.our_commit, conflicts.their_commit].map(&:oid), + tree: merge_index.write_tree(rugged) + } + + project.repository.resolve_conflicts(current_user, merge_request.source_branch, commit_params) + end + + def write_resolved_file_to_index(file, resolutions) + new_file = file.resolve_lines(resolutions).map(&:text).join("\n") + our_path = file.our_path + + merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode) + merge_index.conflict_remove(our_path) + end + end +end diff --git a/app/services/merge_requests/resolved_discussion_notification_service.rb b/app/services/merge_requests/resolved_discussion_notification_service.rb new file mode 100644 index 00000000000..3a09350c847 --- /dev/null +++ b/app/services/merge_requests/resolved_discussion_notification_service.rb @@ -0,0 +1,10 @@ +module MergeRequests + class ResolvedDiscussionNotificationService < MergeRequests::BaseService + def execute(merge_request) + return unless merge_request.discussions_resolved? + + SystemNoteService.resolve_all_discussions(merge_request, project, current_user) + notification_service.resolve_all_discussions(merge_request, current_user) + end + end +end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 026a37997d4..30c5f24988c 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -16,7 +16,7 @@ module MergeRequests update(merge_request) end - def handle_changes(merge_request, old_labels: []) + def handle_changes(merge_request, old_labels: [], old_mentioned_users: []) if has_changes?(merge_request, old_labels: old_labels) todo_service.mark_pending_todos_as_done(merge_request, current_user) end @@ -55,6 +55,15 @@ module MergeRequests current_user ) end + + added_mentions = merge_request.mentioned_users - old_mentioned_users + if added_mentions.present? + notification_service.new_mentions_in_merge_request( + merge_request, + added_mentions, + current_user + ) + end end def reopen_service diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 18971bd0be3..a36008c3ef5 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -11,10 +11,33 @@ module Notes return noteable.create_award_emoji(note.award_emoji_name, current_user) end - if note.save + # We execute commands (extracted from `params[:note]`) on the noteable + # **before** we save the note because if the note consists of commands + # only, there is no need be create a note! + slash_commands_service = SlashCommandsService.new(project, current_user) + + if slash_commands_service.supported?(note) + content, command_params = slash_commands_service.extract_commands(note) + + only_commands = content.empty? + + note.note = content + end + + if !only_commands && note.save # Finish the harder work in the background NewNoteWorker.perform_in(2.seconds, note.id, params) - TodoService.new.new_note(note, current_user) + todo_service.new_note(note, current_user) + end + + if command_params && command_params.any? + slash_commands_service.execute(command_params, note) + + # We must add the error after we call #save because errors are reset + # when #save is called + if only_commands + note.errors.add(:commands_only, 'Your commands have been executed!') + end end note diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 534c48aefff..e4cd3fc7833 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -16,7 +16,7 @@ module Notes end def hook_data - Gitlab::NoteDataBuilder.build(@note, @note.author) + Gitlab::DataBuilder::Note.build(@note, @note.author) end def execute_note_hooks diff --git a/app/services/notes/slash_commands_service.rb b/app/services/notes/slash_commands_service.rb new file mode 100644 index 00000000000..4a9a8a64653 --- /dev/null +++ b/app/services/notes/slash_commands_service.rb @@ -0,0 +1,33 @@ +module Notes + class SlashCommandsService < BaseService + UPDATE_SERVICES = { + 'Issue' => Issues::UpdateService, + 'MergeRequest' => MergeRequests::UpdateService + } + + def supported?(note) + noteable_update_service(note) && + can?(current_user, :"update_#{note.noteable_type.underscore}", note.noteable) + end + + def extract_commands(note) + return [note.note, {}] unless supported?(note) + + SlashCommands::InterpretService.new(project, current_user). + execute(note.note, note.noteable) + end + + def execute(command_params, note) + return if command_params.empty? + return unless supported?(note) + + noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable) + end + + private + + def noteable_update_service(note) + UPDATE_SERVICES[note.noteable_type] + end + end +end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index ab6e51209ee..66a838b3d13 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -35,6 +35,20 @@ class NotificationService new_resource_email(issue, issue.project, :new_issue_email) end + # When issue text is updated, we should send an email to: + # + # * newly mentioned project team members with notification level higher than Participating + # + def new_mentions_in_issue(issue, new_mentioned_users, current_user) + new_mentions_in_resource_email( + issue, + issue.project, + new_mentioned_users, + current_user, + :new_mention_in_issue_email + ) + end + # When we close an issue we should send an email to: # # * issue author if their notification level is not Disabled @@ -75,6 +89,20 @@ class NotificationService new_resource_email(merge_request, merge_request.target_project, :new_merge_request_email) end + # When merge request text is updated, we should send an email to: + # + # * newly mentioned project team members with notification level higher than Participating + # + def new_mentions_in_merge_request(merge_request, new_mentioned_users, current_user) + new_mentions_in_resource_email( + merge_request, + merge_request.target_project, + new_mentioned_users, + current_user, + :new_mention_in_merge_request_email + ) + end + # When we reassign a merge_request we should send an email to: # # * merge_request old assignee if their notification level is not Disabled @@ -120,6 +148,14 @@ class NotificationService ) end + def resolve_all_discussions(merge_request, current_user) + recipients = build_recipients(merge_request, merge_request.target_project, current_user, action: "resolve_all_discussions") + + recipients.each do |recipient| + mailer.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id).deliver_later + end + end + # Notify new user with email after creation def new_user(user, token = nil) # Don't email omniauth created users @@ -177,7 +213,7 @@ class NotificationService # build notify method like 'note_commit_email' notify_method = "note_#{note.noteable_type.underscore}_email".to_sym - + recipients.each do |recipient| mailer.send(notify_method, recipient.id, note.id).deliver_later end @@ -471,6 +507,15 @@ class NotificationService end end + def new_mentions_in_resource_email(target, project, new_mentioned_users, current_user, method) + recipients = build_recipients(target, project, current_user, action: "new") + recipients = recipients & new_mentioned_users + + recipients.each do |recipient| + mailer.send(method, recipient.id, target.id, current_user.id).deliver_later + end + end + def close_resource_email(target, project, current_user, method) action = method == :merged_merge_request_email ? "merge" : "close" recipients = build_recipients(target, project, current_user, action: action) diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 23b6668e0d1..f578f8dbea2 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -1,7 +1,7 @@ module Projects class AutocompleteService < BaseService def issues - @project.issues.visible_to_user(current_user).opened.select([:iid, :title]) + IssuesFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title]) end def milestones @@ -9,11 +9,34 @@ module Projects end def merge_requests - @project.merge_requests.opened.select([:iid, :title]) + MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title]) end def labels @project.labels.select([:title, :color]) end + + def commands(noteable, type) + noteable ||= + case type + when 'Issue' + @project.issues.build + when 'MergeRequest' + @project.merge_requests.build + end + + return [] unless noteable && noteable.is_a?(Issuable) + + opts = { + project: project, + issuable: noteable, + current_user: current_user + } + SlashCommands::InterpretService.command_definitions.map do |definition| + next unless definition.available?(opts) + + definition.to_h(opts) + end.compact + end end end diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 02c4eee3d02..d38328403c1 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -1,40 +1,28 @@ module Projects class ParticipantsService < BaseService - def execute(noteable_type, noteable_id) - @noteable_type = noteable_type - @noteable_id = noteable_id + attr_reader :noteable + + def execute(noteable) + @noteable = noteable + project_members = sorted(project.team.members) - participants = target_owner + participants_in_target + all_members + groups + project_members + participants = noteable_owner + participants_in_noteable + all_members + groups + project_members participants.uniq end - def target - @target ||= - case @noteable_type - when "Issue" - project.issues.find_by_iid(@noteable_id) - when "MergeRequest" - project.merge_requests.find_by_iid(@noteable_id) - when "Commit" - project.commit(@noteable_id) - else - nil - end - end - - def target_owner - return [] unless target && target.author.present? + def noteable_owner + return [] unless noteable && noteable.author.present? [{ - name: target.author.name, - username: target.author.username + name: noteable.author.name, + username: noteable.author.username }] end - def participants_in_target - return [] unless target + def participants_in_noteable + return [] unless noteable - users = target.participants(current_user) + users = noteable.participants(current_user) sorted(users) end diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb index 6150a2a83c9..a84e335340d 100644 --- a/app/services/protected_branches/create_service.rb +++ b/app/services/protected_branches/create_service.rb @@ -5,23 +5,7 @@ module ProtectedBranches def execute raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) - protected_branch = project.protected_branches.new(params) - - ProtectedBranch.transaction do - protected_branch.save! - - if protected_branch.push_access_level.blank? - protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER) - end - - if protected_branch.merge_access_level.blank? - protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER) - end - end - - protected_branch - rescue ActiveRecord::RecordInvalid - protected_branch + project.protected_branches.create(params) end end end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb new file mode 100644 index 00000000000..9ac1124abc1 --- /dev/null +++ b/app/services/slash_commands/interpret_service.rb @@ -0,0 +1,236 @@ +module SlashCommands + class InterpretService < BaseService + include Gitlab::SlashCommands::Dsl + + attr_reader :issuable + + # Takes a text and interprets the commands that are extracted from it. + # Returns the content without commands, and hash of changes to be applied to a record. + def execute(content, issuable) + @issuable = issuable + @updates = {} + + opts = { + issuable: issuable, + current_user: current_user, + project: project + } + + content, commands = extractor.extract_commands(content, opts) + + commands.each do |name, arg| + definition = self.class.command_definitions_by_name[name.to_sym] + next unless definition + + definition.execute(self, opts, arg) + end + + [content, @updates] + end + + private + + def extractor + Gitlab::SlashCommands::Extractor.new(self.class.command_definitions) + end + + desc do + "Close this #{issuable.to_ability_name.humanize(capitalize: false)}" + end + condition do + issuable.persisted? && + issuable.open? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :close do + @updates[:state_event] = 'close' + end + + desc do + "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}" + end + condition do + issuable.persisted? && + issuable.closed? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :reopen do + @updates[:state_event] = 'reopen' + end + + desc 'Change title' + params '' + condition do + issuable.persisted? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :title do |title_param| + @updates[:title] = title_param + end + + desc 'Assign' + params '@user' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :assign do |assignee_param| + user = extract_references(assignee_param, :user).first + user ||= User.find_by(username: assignee_param) + + @updates[:assignee_id] = user.id if user + end + + desc 'Remove assignee' + condition do + issuable.persisted? && + issuable.assignee_id? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :unassign do + @updates[:assignee_id] = nil + end + + desc 'Set milestone' + params '%"milestone"' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) && + project.milestones.active.any? + end + command :milestone do |milestone_param| + milestone = extract_references(milestone_param, :milestone).first + milestone ||= project.milestones.find_by(title: milestone_param.strip) + + @updates[:milestone_id] = milestone.id if milestone + end + + desc 'Remove milestone' + condition do + issuable.persisted? && + issuable.milestone_id? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :remove_milestone do + @updates[:milestone_id] = nil + end + + desc 'Add label(s)' + params '~label1 ~"label 2"' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) && + project.labels.any? + end + command :label do |labels_param| + label_ids = find_label_ids(labels_param) + + @updates[:add_label_ids] = label_ids unless label_ids.empty? + end + + desc 'Remove all or specific label(s)' + params '~label1 ~"label 2"' + condition do + issuable.persisted? && + issuable.labels.any? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :unlabel do |labels_param = nil| + if labels_param.present? + label_ids = find_label_ids(labels_param) + + @updates[:remove_label_ids] = label_ids unless label_ids.empty? + else + @updates[:label_ids] = [] + end + end + + desc 'Replace all label(s)' + params '~label1 ~"label 2"' + condition do + issuable.persisted? && + issuable.labels.any? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :relabel do |labels_param| + label_ids = find_label_ids(labels_param) + + @updates[:label_ids] = label_ids unless label_ids.empty? + end + + desc 'Add a todo' + condition do + issuable.persisted? && + !TodoService.new.todo_exist?(issuable, current_user) + end + command :todo do + @updates[:todo_event] = 'add' + end + + desc 'Mark todo as done' + condition do + issuable.persisted? && + TodoService.new.todo_exist?(issuable, current_user) + end + command :done do + @updates[:todo_event] = 'done' + end + + desc 'Subscribe' + condition do + issuable.persisted? && + !issuable.subscribed?(current_user) + end + command :subscribe do + @updates[:subscription_event] = 'subscribe' + end + + desc 'Unsubscribe' + condition do + issuable.persisted? && + issuable.subscribed?(current_user) + end + command :unsubscribe do + @updates[:subscription_event] = 'unsubscribe' + end + + desc 'Set due date' + params '' + condition do + issuable.respond_to?(:due_date) && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :due do |due_date_param| + due_date = Chronic.parse(due_date_param).try(:to_date) + + @updates[:due_date] = due_date if due_date + end + + desc 'Remove due date' + condition do + issuable.persisted? && + issuable.respond_to?(:due_date) && + issuable.due_date? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :remove_due_date do + @updates[:due_date] = nil + end + + # This is a dummy command, so that it appears in the autocomplete commands + desc 'CC' + params '@user' + command :cc + + def find_label_ids(labels_param) + label_ids_by_reference = extract_references(labels_param, :label).map(&:id) + labels_ids_by_name = @project.labels.where(name: labels_param.split).select(:id) + + label_ids_by_reference | labels_ids_by_name + end + + def extract_references(arg, type) + ext = Gitlab::ReferenceExtractor.new(project, current_user) + ext.analyze(arg, author: current_user) + + ext.references(type) + end + end +end diff --git a/app/services/spam_check_service.rb b/app/services/spam_check_service.rb deleted file mode 100644 index 7c3e692bde9..00000000000 --- a/app/services/spam_check_service.rb +++ /dev/null @@ -1,38 +0,0 @@ -class SpamCheckService < BaseService - include Gitlab::AkismetHelper - - attr_accessor :request, :api - - def execute(request, api) - @request, @api = request, api - return false unless request || check_for_spam?(project) - return false unless is_spam?(request.env, current_user, text) - - create_spam_log - - true - end - - private - - def text - [params[:title], params[:description]].reject(&:blank?).join("\n") - end - - def spam_log_attrs - { - user_id: current_user.id, - project_id: project.id, - title: params[:title], - description: params[:description], - source_ip: client_ip(request.env), - user_agent: user_agent(request.env), - noteable_type: 'Issue', - via_api: api - } - end - - def create_spam_log - CreateSpamLogService.new(project, current_user, spam_log_attrs).execute - end -end diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb new file mode 100644 index 00000000000..48903291799 --- /dev/null +++ b/app/services/spam_service.rb @@ -0,0 +1,78 @@ +class SpamService + attr_accessor :spammable, :request, :options + + def initialize(spammable, request = nil) + @spammable = spammable + @request = request + @options = {} + + if @request + @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s + @options[:user_agent] = @request.env['HTTP_USER_AGENT'] + @options[:referrer] = @request.env['HTTP_REFERRER'] + else + @options[:ip_address] = @spammable.ip_address + @options[:user_agent] = @spammable.user_agent + end + end + + def check(api = false) + return false unless request && check_for_spam? + + return false unless akismet.is_spam? + + create_spam_log(api) + true + end + + def mark_as_spam! + return false unless spammable.submittable_as_spam? + + if akismet.submit_spam + spammable.user_agent_detail.update_attribute(:submitted, true) + else + false + end + end + + private + + def akismet + @akismet ||= AkismetService.new( + spammable_owner, + spammable.spammable_text, + options + ) + end + + def spammable_owner + @user ||= User.find(spammable_owner_id) + end + + def spammable_owner_id + @owner_id ||= + if spammable.respond_to?(:author_id) + spammable.author_id + elsif spammable.respond_to?(:creator_id) + spammable.creator_id + end + end + + def check_for_spam? + spammable.check_for_spam? + end + + def create_spam_log(api) + SpamLog.create( + { + user_id: spammable_owner_id, + title: spammable.spam_title, + description: spammable.spam_description, + source_ip: options[:ip_address], + user_agent: options[:user_agent], + noteable_type: spammable.class.to_s, + via_api: api + } + ) + end +end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index e13dc9265b8..546a8f11330 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -158,6 +158,12 @@ module SystemNoteService create_note(noteable: noteable, project: project, author: author, note: body) end + def self.resolve_all_discussions(merge_request, project, author) + body = "Resolved all discussions" + + create_note(noteable: merge_request, project: project, author: author, note: body) + end + # Called when the title of a Noteable is changed # # noteable - Noteable object that responds to `title` diff --git a/app/services/test_hook_service.rb b/app/services/test_hook_service.rb index e85e58751e7..280c81f7d2d 100644 --- a/app/services/test_hook_service.rb +++ b/app/services/test_hook_service.rb @@ -1,6 +1,6 @@ class TestHookService def execute(hook, current_user) - data = Gitlab::PushDataBuilder.build_sample(hook.project, current_user) + data = Gitlab::DataBuilder::Push.build_sample(hook.project, current_user) hook.execute(data, 'push_hooks') end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index eb833dd82ac..e0ccb654590 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -142,7 +142,11 @@ class TodoService # When user marks some todos as done def mark_todos_as_done(todos, current_user) - todos = current_user.todos.where(id: todos.map(&:id)) unless todos.respond_to?(:update_all) + mark_todos_as_done_by_ids(todos.select(&:id), current_user) + end + + def mark_todos_as_done_by_ids(ids, current_user) + todos = current_user.todos.where(id: ids) marked_todos = todos.update_all(state: :done) current_user.update_todos_count_cache @@ -155,6 +159,10 @@ class TodoService create_todos(current_user, attributes) end + def todo_exist?(issuable, current_user) + TodosFinder.new(current_user).execute.exists?(target: issuable) + end + private def create_todos(users, attributes) diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb new file mode 100644 index 00000000000..a1ee3df5fe1 --- /dev/null +++ b/app/services/user_agent_detail_service.rb @@ -0,0 +1,13 @@ +class UserAgentDetailService + attr_accessor :spammable, :request + + def initialize(spammable, request) + @spammable, @request = spammable, request + end + + def create + return unless request + + spammable.create_user_agent_detail(user_agent: request.env['HTTP_USER_AGENT'], ip_address: request.env['action_dispatch.remote_ip'].to_s) + end +end diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml index dd2e7ebd030..56bf6194914 100644 --- a/app/views/admin/abuse_reports/_abuse_report.html.haml +++ b/app/views/admin/abuse_reports/_abuse_report.html.haml @@ -1,6 +1,8 @@ - reporter = abuse_report.reporter - user = abuse_report.user %tr + %th.visible-xs-block.visible-sm-block + %strong User %td - if user = link_to user.name, user @@ -9,6 +11,7 @@ - else (removed) %td + %strong.subheading.visible-xs-block.visible-sm-block Reported by - if reporter = link_to reporter.name, reporter - else @@ -16,16 +19,16 @@ .light.small = time_ago_with_tooltip(abuse_report.created_at) %td - = markdown(abuse_report.message.squish!, pipeline: :single_line, author: reporter) + %strong.subheading.visible-xs-block.visible-sm-block Message + .message + = markdown(abuse_report.message.squish!, pipeline: :single_line, author: reporter) %td - if user = link_to 'Remove user & report', admin_abuse_report_path(abuse_report, remove_user: true), - data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-xs btn-remove js-remove-tr" - - %td + data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-sm btn-block btn-remove js-remove-tr" - if user && !user.blocked? - = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs" + = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-sm btn-block" - else - .btn.btn-xs.disabled + .btn.btn-sm.disabled.btn-block Already Blocked - = link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-xs btn-close js-remove-tr" + = link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr" diff --git a/app/views/admin/abuse_reports/index.html.haml b/app/views/admin/abuse_reports/index.html.haml index bc4a9cedb2c..7bbc75db9ff 100644 --- a/app/views/admin/abuse_reports/index.html.haml +++ b/app/views/admin/abuse_reports/index.html.haml @@ -1,17 +1,20 @@ -- page_title "Abuse Reports" +- page_title 'Abuse Reports' %h3.page-title Abuse Reports %hr -- if @abuse_reports.present? - .table-holder - %table.table - %thead - %tr - %th User - %th Reported by - %th Message - %th Primary action - %th - = render @abuse_reports - = paginate @abuse_reports -- else - %h4 There are no abuse reports +.abuse-reports + - if @abuse_reports.present? + .table-holder + %table.table + %thead.hidden-sm.hidden-xs + %tr + %th User + %th Reported by + %th.wide Message + %th Action + = render @abuse_reports + - else + .no-reports + %span.pull-left + There are no abuse reports! + .pull-left + = emoji_icon 'tada' diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index 8aea67f4497..4ce4eab8753 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -24,6 +24,11 @@ = link_to 'Remove user', admin_spam_log_path(spam_log, remove_user: true), data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-xs btn-remove" %td + - if spam_log.submitted_as_ham? + .btn.btn-xs.disabled + Submitted as ham + - else + = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'btn btn-xs btn-warning' - if user && !user.blocked? = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs" - else diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 98f302d2f93..b40395c74de 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -1,6 +1,7 @@ %li{class: "todo todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data:{url: todo_target_path(todo)} } + = author_avatar(todo, size: 40) + .todo-item.todo-block - = image_tag avatar_icon(todo.author_email, 40), class: 'avatar s40', alt:'' .todo-title.title - unless todo.build_failed? = todo_target_state_pill(todo) @@ -19,13 +20,13 @@ · #{time_ago_with_tooltip(todo.created_at)} - - if todo.pending? - .todo-actions.pull-right - = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do - Done - = icon('spinner spin') - .todo-body .todo-note .md = event_note(todo.body, project: todo.project) + + - if todo.pending? + .todo-actions + = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do + Done + = icon('spinner spin') diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml index fa1ad9efa73..1411daeb4a6 100644 --- a/app/views/discussions/_diff_discussion.html.haml +++ b/app/views/discussions/_diff_discussion.html.haml @@ -1,6 +1,6 @@ -%tr.notes_holder +- expanded = local_assigns.fetch(:expanded, true) +%tr.notes_holder{class: ('hide' unless expanded)} %td.notes_line{ colspan: 2 } %td.notes_content - %ul.notes{ data: { discussion_id: discussion.id } } - = render partial: "projects/notes/note", collection: discussion.notes, as: :note - = link_to_reply_discussion(discussion) + .content + = render "discussions/notes", discussion: discussion diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 02b159ffd45..b2e55f7647a 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -7,8 +7,11 @@ .diff-content.code.js-syntax-highlight %table - - discussion.truncated_diff_lines.each do |line| - = render "projects/diffs/line", line: line, diff_file: diff_file, plain: true - - - if discussion.for_line?(line) - = render "discussions/diff_discussion", discussion: discussion + - discussions = { discussion.line_code => discussion } + = render partial: "projects/diffs/line", + collection: discussion.truncated_diff_lines, + as: :line, + locals: { diff_file: diff_file, + discussions: discussions, + discussion_expanded: true, + plain: true } diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 49702e048aa..077e8e64e5f 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -5,8 +5,17 @@ = link_to user_path(discussion.author) do = image_tag avatar_icon(discussion.author), class: "avatar s40" .timeline-content - .discussion.js-toggle-container{ class: discussion.id } + .discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } } .discussion-header + .discussion-actions + = link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do + - if expanded + = icon("chevron-up") + - else + = icon("chevron-down") + + Toggle discussion + = link_to_member(@project, discussion.author, avatar: false) .inline.discussion-headline-light @@ -29,17 +38,11 @@ = time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago") - .discussion-actions - = link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do - - if expanded - = icon("chevron-up") - - else - = icon("chevron-down") - - Toggle discussion + = render "discussions/headline", discussion: discussion .discussion-body.js-toggle-content{ class: ("hide" unless expanded) } - if discussion.diff_discussion? && discussion.diff_file = render "discussions/diff_with_notes", discussion: discussion - else - = render "discussions/notes", discussion: discussion + .panel.panel-default + = render "discussions/notes", discussion: discussion diff --git a/app/views/discussions/_headline.html.haml b/app/views/discussions/_headline.html.haml new file mode 100644 index 00000000000..c1dabeed387 --- /dev/null +++ b/app/views/discussions/_headline.html.haml @@ -0,0 +1,14 @@ +- if discussion.resolved? + .discussion-headline-light.js-discussion-headline + Resolved + - if discussion.resolved_by + by + = link_to_member(@project, discussion.resolved_by, avatar: false) + = time_ago_with_tooltip(discussion.resolved_at, placement: "bottom") +- elsif discussion.last_updated_at != discussion.created_at + .discussion-headline-light.js-discussion-headline + Last updated + - if discussion.last_updated_by + by + = link_to_member(@project, discussion.last_updated_by, avatar: false) + = time_ago_with_tooltip(discussion.last_updated_at, placement: "bottom") diff --git a/app/views/discussions/_jump_to_next.html.haml b/app/views/discussions/_jump_to_next.html.haml new file mode 100644 index 00000000000..69bd416c4de --- /dev/null +++ b/app/views/discussions/_jump_to_next.html.haml @@ -0,0 +1,9 @@ +- discussion = local_assigns.fetch(:discussion, nil) +- if current_user + %jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" } + .btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" } + %button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion", + title: "Jump to next unresolved discussion", + "aria-label" => "Jump to next unresolved discussion", + data: { container: "body" } } + = custom_icon("next_discussion") diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index a2642b839f6..fbe470bed2c 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,5 +1,15 @@ -.panel.panel-default - .notes{ data: { discussion_id: discussion.id } } - %ul.notes.timeline - = render partial: "projects/notes/note", collection: discussion.notes, as: :note - = link_to_reply_discussion(discussion) +%ul.notes{ data: { discussion_id: discussion.id } } + = render partial: "projects/notes/note", collection: discussion.notes, as: :note + +- if current_user + .discussion-reply-holder + - if discussion.diff_discussion? + - line_type = local_assigns.fetch(:line_type, nil) + + .btn-group-justified.discussion-with-resolve-btn{ role: "group" } + .btn-group{ role: "group" } + = link_to_reply_discussion(discussion, line_type) + = render "discussions/resolve_all", discussion: discussion + = render "discussions/jump_to_next", discussion: discussion + - else + = link_to_reply_discussion(discussion) diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml index a798c438ea0..f1072ce0feb 100644 --- a/app/views/discussions/_parallel_diff_discussion.html.haml +++ b/app/views/discussions/_parallel_diff_discussion.html.haml @@ -1,22 +1,21 @@ -%tr.notes_holder +- expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?) +%tr.notes_holder{class: ('hide' unless expanded)} - if discussion_left %td.notes_line.old %td.notes_content.parallel.old - %ul.notes{ data: { discussion_id: discussion_left.id } } - = render partial: "projects/notes/note", collection: discussion_left.notes, as: :note - - = link_to_reply_discussion(discussion_left, 'old') + .content{class: ('hide' unless discussion_left.expanded?)} + = render "discussions/notes", discussion: discussion_left, line_type: 'old' - else %td.notes_line.old= "" - %td.notes_content.parallel.old= "" + %td.notes_content.parallel.old + .content - if discussion_right %td.notes_line.new %td.notes_content.parallel.new - %ul.notes{ data: { discussion_id: discussion_right.id } } - = render partial: "projects/notes/note", collection: discussion_right.notes, as: :note - - = link_to_reply_discussion(discussion_right, 'new') + .content{class: ('hide' unless discussion_right.expanded?)} + = render "discussions/notes", discussion: discussion_right, line_type: 'new' - else %td.notes_line.new= "" - %td.notes_content.parallel.new= "" + %td.notes_content.parallel.new + .content diff --git a/app/views/discussions/_resolve_all.html.haml b/app/views/discussions/_resolve_all.html.haml new file mode 100644 index 00000000000..7a8767ddba0 --- /dev/null +++ b/app/views/discussions/_resolve_all.html.haml @@ -0,0 +1,11 @@ +- if discussion.for_merge_request? + %resolve-discussion-btn{ ":namespace-path" => "'#{discussion.project.namespace.path}'", + ":project-path" => "'#{discussion.project.path}'", + ":discussion-id" => "'#{discussion.id}'", + ":merge-request-id" => discussion.noteable.iid, + ":can-resolve" => discussion.can_resolve?(current_user), + "inline-template" => true } + .btn-group{ role: "group", "v-if" => "showButton" } + %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading" } + = icon("spinner spin", "v-show" => "loading") + {{ buttonText }} diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml index 351100f3523..67ff4b272b9 100644 --- a/app/views/layouts/_init_auto_complete.html.haml +++ b/app/views/layouts/_init_auto_complete.html.haml @@ -1,7 +1,7 @@ - project = @target_project || @project -- noteable_class = @noteable.class if @noteable.present? +- noteable_type = @noteable.class if @noteable.present? :javascript - GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_class, type_id: params[:id])}" + GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_type, type_id: params[:id])}" GitLab.GfmAutoComplete.cachedData = undefined; GitLab.GfmAutoComplete.setup(); diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index a1a71c2fb33..bf50633af24 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -23,7 +23,6 @@ = render "layouts/broadcast" = render "layouts/flash" = yield :flash_message - %div{ class: (container_class unless @no_container) } + %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } .content - .clearfix - = yield + = yield diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 33cedaaf2ee..15a94ac23c5 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,5 +1,5 @@ !!! 5 -%html{ lang: "en"} +%html{ lang: "en", class: "#{page_class}" } = render "layouts/head" %body{class: "#{user_application_theme}", data: {page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}"}} = Gon::Base.render_data diff --git a/app/views/notify/new_mention_in_issue_email.html.haml b/app/views/notify/new_mention_in_issue_email.html.haml new file mode 100644 index 00000000000..4f3d36bd9ca --- /dev/null +++ b/app/views/notify/new_mention_in_issue_email.html.haml @@ -0,0 +1,12 @@ +%p + You have been mentioned in an issue. + +- if current_application_settings.email_author_in_body + %div + #{link_to @issue.author_name, user_url(@issue.author)} wrote: +-if @issue.description + = markdown(@issue.description, pipeline: :email, author: @issue.author) + +- if @issue.assignee_id.present? + %p + Assignee: #{@issue.assignee_name} diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb new file mode 100644 index 00000000000..457e94b4800 --- /dev/null +++ b/app/views/notify/new_mention_in_issue_email.text.erb @@ -0,0 +1,7 @@ +You have been mentioned in an issue. + +Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> +Author: <%= @issue.author_name %> +Assignee: <%= @issue.assignee_name %> + +<%= @issue.description %> diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml new file mode 100644 index 00000000000..32aedb9e6b9 --- /dev/null +++ b/app/views/notify/new_mention_in_merge_request_email.html.haml @@ -0,0 +1,15 @@ +%p + You have been mentioned in Merge Request #{@merge_request.to_reference} + +- if current_application_settings.email_author_in_body + %div + #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote: +%p.details + != merge_path_description(@merge_request, '→') + +- if @merge_request.assignee_id.present? + %p + Assignee: #{@merge_request.author_name} → #{@merge_request.assignee_name} + +-if @merge_request.description + = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb new file mode 100644 index 00000000000..5bf0282e097 --- /dev/null +++ b/app/views/notify/new_mention_in_merge_request_email.text.erb @@ -0,0 +1,9 @@ +You have been mentioned in Merge Request <%= @merge_request.to_reference %> + +<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %> + +<%= merge_path_description(@merge_request, 'to') %> +Author: <%= @merge_request.author_name %> +Assignee: <%= @merge_request.assignee_name %> + +<%= @merge_request.description %> diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index c161ecc3463..c0c07d65daa 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -75,8 +75,7 @@ - blob = diff_file.blob - if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob) %table.code.white - - diff_file.highlighted_diff_lines.each do |line| - = render "projects/diffs/line", line: line, diff_file: diff_file, plain: true + = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true } - else No preview for this file type %br diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml new file mode 100644 index 00000000000..522421b7cc3 --- /dev/null +++ b/app/views/notify/resolved_all_discussions_email.html.haml @@ -0,0 +1,2 @@ +%p + All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{@resolved_by.name} diff --git a/app/views/notify/resolved_all_discussions_email.text.erb b/app/views/notify/resolved_all_discussions_email.text.erb new file mode 100644 index 00000000000..b0d380af8fc --- /dev/null +++ b/app/views/notify/resolved_all_discussions_email.text.erb @@ -0,0 +1,3 @@ +All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %> + +<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %> diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 71ac367830d..05a2ea67aa2 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -7,6 +7,10 @@ = page_title %p You can generate a personal access token for each application you use that needs access to the GitLab API. + %p + You can also use personal access tokens to authenticate against Git over HTTP. + They are the only accepted password when you have Two-Factor Authentication (2FA) enabled. + .col-lg-9 - if flash[:personal_access_token] diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 366f1fed35b..03ac739ade5 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -60,13 +60,38 @@ two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser. .col-lg-9 - %p - - if @registration_key_handles.present? - = icon "check inverse", base: "circle", class: "text-success", text: "You have #{pluralize(@registration_key_handles.size, 'U2F device')} registered with GitLab." - if @u2f_registration.errors.present? = form_errors(@u2f_registration) = render "u2f/register" + %hr + + %h5 U2F Devices (#{@u2f_registrations.length}) + + - if @u2f_registrations.present? + .table-responsive + %table.table.table-bordered.u2f-registrations + %colgroup + %col{ width: "50%" } + %col{ width: "30%" } + %col{ width: "20%" } + %thead + %tr + %th Name + %th Registered On + %th + %tbody + - @u2f_registrations.each do |registration| + %tr + %td= registration.name.presence || "" + %td= registration.created_at.to_date.to_s(:medium) + %td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." } + + - else + .settings-message.text-center + You don't have any U2F devices registered yet. + + - if two_factor_skippable? :javascript var button = "Configure it later"; diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index 413477a2d3a..3978fa60d66 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -1,7 +1,8 @@ +- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) .zen-backdrop - classes << ' js-gfm-input js-autosize markdown-area' - if defined?(f) && f - = f.text_area attr, class: classes, placeholder: placeholder + = f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands } - else = text_area_tag attr, nil, class: classes, placeholder: placeholder %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index ff379bafb26..0237e152b54 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -24,7 +24,7 @@ .encoding-selector = select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2' - .file-content.code + .file-editor.code %pre.js-edit-mode-pane#editor #{params[:content] || local_assigns[:blob_data]} - if local_assigns[:path] .js-edit-mode-pane#preview.hide diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml index 18caddabd39..4c356d1f07f 100644 --- a/app/views/projects/blob/_image.html.haml +++ b/app/views/projects/blob/_image.html.haml @@ -1,9 +1,15 @@ .file-content.image_file - if blob.svg? - - # We need to scrub SVG but we cannot do so in the RawController: it would - - # be wrong/strange if RawController modified the data. - - blob.load_all_data!(@repository) - - blob = sanitize_svg(blob) - %img{src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}"} + - if blob.size_within_svg_limits? + - # We need to scrub SVG but we cannot do so in the RawController: it would + - # be wrong/strange if RawController modified the data. + - blob.load_all_data!(@repository) + - blob = sanitize_svg(blob) + %img{src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}"} + - else + .nothing-here-block + The SVG could not be displayed as it is too large, you can + #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank')} + instead. - else %img{src: namespace_project_raw_path(@project.namespace, @project, tree_join(@commit.id, blob.path))} diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index b1c9895f43e..7b0621f9401 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -1,5 +1,11 @@ - page_title "Edit", @blob.path, @ref +- if @conflict + .alert.alert-danger + Someone edited the file the same time you did. Please check out + = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@target_branch, @file_path)), target: "_blank" + and make sure your changes will not unintentionally remove theirs. + .file-editor %ul.nav-links.no-bottom.js-edit-mode %li.active @@ -13,8 +19,7 @@ = form_tag(namespace_project_update_blob_path(@project.namespace, @project, @id), method: :put, class: 'form-horizontal js-quick-submit js-requires-input js-edit-blob-form') do = render 'projects/blob/editor', ref: @ref, path: @path, blob_data: @blob.data = render 'shared/new_commit_form', placeholder: "Update #{@blob.name}" - - = hidden_field_tag 'last_commit', @last_commit + = hidden_field_tag 'last_commit_sha', @last_commit_sha = hidden_field_tag 'content', '', id: "file-content" = hidden_field_tag 'from_merge_request_id', params[:from_merge_request_id] = render 'projects/commit_button', ref: @ref, cancel_path: namespace_project_blob_path(@project.namespace, @project, @id) diff --git a/app/views/projects/boards/components/_blank_state.html.haml b/app/views/projects/boards/components/_blank_state.html.haml new file mode 100644 index 00000000000..97eb952eff1 --- /dev/null +++ b/app/views/projects/boards/components/_blank_state.html.haml @@ -0,0 +1,15 @@ +%board-blank-state{ "inline-template" => true, + "v-if" => "list.id == 'blank'" } + .board-blank-state + %p + Add the following default lists to your Issue Board with one click: + %ul.board-blank-state-list + %li{ "v-for" => "label in predefinedLabels" } + %span.label-color{ ":style" => "{ backgroundColor: label.color } " } + {{ label.title }} + %p + Starting out with the default set of lists will get you right on the way to making the most of your board. + %button.btn.btn-create.btn-inverted.btn-block{ type: "button", "@click.stop" => "addDefaultLists" } + Add default lists + %button.btn.btn-default.btn-block{ type: "button", "@click.stop" => "clearBlankState" } + Nevermind, I'll use my own diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml new file mode 100644 index 00000000000..f8ebf397ee2 --- /dev/null +++ b/app/views/projects/boards/components/_board.html.haml @@ -0,0 +1,44 @@ +%board{ "inline-template" => true, + "v-cloak" => true, + "v-for" => "list in state.lists | orderBy 'position'", + "v-ref:board" => true, + ":list" => "list", + ":disabled" => "disabled", + ":issue-link-base" => "issueLinkBase", + "track-by" => "_uid" } + .board{ ":class" => "{ 'is-draggable': !list.preset }", + ":data-id" => "list.id" } + .board-inner + %header.board-header{ ":class" => "{ 'has-border': list.label }", ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" } + %h3.board-title.js-board-handle{ ":class" => "{ 'user-can-drag': (!disabled && !list.preset) }" } + = icon("align-justify", class: "board-mobile-handle js-board-drag-handle", "v-if" => "(!disabled && !list.preset)") + {{ list.title }} + %span.pull-right{ "v-if" => "list.type !== 'blank'" } + {{ list.issues.length }} + - if can?(current_user, :admin_list, @project) + %board-delete{ "inline-template" => true, + ":list" => "list", + "v-if" => "!list.preset && list.id" } + %button.board-delete.has-tooltip.pull-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } + = icon("trash") + = icon("spinner spin", class: "board-header-loading-spinner pull-right", "v-show" => "list.loadingMore") + .board-inner-container.board-search-container{ "v-if" => "list.canSearch()" } + %input.form-control{ type: "text", placeholder: "Search issues", "v-model" => "query", "debounce" => "250" } + = icon("search", class: "board-search-icon", "v-show" => "!query") + %button.board-search-clear-btn{ type: "button", role: "button", "aria-label" => "Clear search", "@click" => "query = ''", "v-show" => "query" } + = icon("times", class: "board-search-clear") + %board-list{ "inline-template" => true, + "v-if" => "list.type !== 'blank'", + ":list" => "list", + ":issues" => "list.issues", + ":loading" => "list.loading", + ":disabled" => "disabled", + ":issue-link-base" => "issueLinkBase" } + .board-list-loading.text-center{ "v-if" => "loading" } + = icon("spinner spin") + %ul.board-list{ "v-el:list" => true, + "v-show" => "!loading", + ":data-board" => "list.id" } + = render "projects/boards/components/card" + - if can?(current_user, :admin_list, @project) + = render "projects/boards/components/blank_state" diff --git a/app/views/projects/boards/components/_card.html.haml b/app/views/projects/boards/components/_card.html.haml new file mode 100644 index 00000000000..b20c23f6b8e --- /dev/null +++ b/app/views/projects/boards/components/_card.html.haml @@ -0,0 +1,34 @@ +%board-card{ "inline-template" => true, + "v-for" => "issue in issues | orderBy 'priority'", + "v-ref:issue" => true, + ":index" => "$index", + ":list" => "list", + ":issue" => "issue", + ":issue-link-base" => "issueLinkBase", + ":disabled" => "disabled", + "track-by" => "id" } + %li.card{ ":class" => "{ 'user-can-drag': !disabled }", + ":index" => "index" } + = icon("align-justify", class: "board-mobile-handle js-card-drag-handle", "v-if" => "!disabled") + %h4.card-title + = icon("eye-slash", class: "confidential-icon", "v-if" => "issue.confidential") + %a{ ":href" => "issueLinkBase + '/' + issue.id", + ":title" => "issue.title" } + {{ issue.title }} + .card-footer + %span.card-number + = precede '#' do + {{ issue.id }} + %button.label.color-label.has-tooltip{ "v-for" => "label in issue.labels", + type: "button", + "v-if" => "(!list.label || label.id !== list.label.id)", + "@click" => "filterByLabel(label, $event)", + ":style" => "{ backgroundColor: label.color, color: label.textColor }", + ":title" => "label.description", + data: { container: 'body' } } + {{ label.title }} + %a.has-tooltip{ ":href" => "'/u/' + issue.assignee.username", + ":title" => "'Assigned to ' + issue.assignee.name", + "v-if" => "issue.assignee", + data: { container: 'body' } } + %img.avatar.avatar-inline.s20{ ":src" => "issue.assignee.avatar", width: 20, height: 20 } diff --git a/app/views/projects/boards/show.html.haml b/app/views/projects/boards/show.html.haml new file mode 100644 index 00000000000..edbbd3f3d2a --- /dev/null +++ b/app/views/projects/boards/show.html.haml @@ -0,0 +1,19 @@ +- @no_container = true +- @content_class = "issue-boards-content" +- page_title "Boards" + +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('boards/boards_bundle.js') + = page_specific_javascript_tag('boards/test_utils/simulate_drag.js') if Rails.env.test? + += render "projects/issues/head" + += render 'shared/issuable/filter', type: :boards + +.boards-list#board-app{ "v-cloak" => true, + "data-endpoint" => "#{namespace_project_board_path(@project.namespace, @project)}", + "data-disabled" => "#{!can?(current_user, :admin_list, @project)}", + "data-issue-link-base" => "#{namespace_project_issues_path(@project.namespace, @project)}" } + .boards-app-loading.text-center{ "v-if" => "loading" } + = icon("spinner spin") + = render "projects/boards/components/board" diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index a8bc53c2849..5b0b58e087b 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -11,98 +11,133 @@ %p.build-detail-row #{@build.coverage}% - - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) - .block{ class: ("block-first" if !@build.coverage) } + - builds = @build.pipeline.builds.latest.to_a + - statuses = ["failed", "pending", "running", "canceled", "success", "skipped"] + - if builds.size > 1 + .dropdown.build-dropdown + .build-light-text Stage + %button.dropdown-menu-toggle{type: 'button', 'data-toggle' => 'dropdown'} + %span.stage-selection More + = icon('caret-down') + %ul.dropdown-menu + - builds.map(&:stage).uniq.each do |stage| + %li + %a.stage-item= stage + + .builds-container + - statuses.each do |build_status| + - builds.select{|build| build.status == build_status}.each do |build| + .build-job{class: ('active' if build == @build), data: {stage: build.stage}} + = link_to namespace_project_build_path(@project.namespace, @project, build) do + = icon('check') + = ci_icon_for_status(build.status) + %span + - if build.name + = build.name + - else + = build.id + + - if @build.retried? + %li.active + %a + Build ##{@build.id} + · + %i.fa.fa-warning + This build was retried. + + .blocks-container + - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) + .block{ class: ("block-first" if !@build.coverage) } + .title + Build artifacts + - if @build.artifacts_expired? + %p.build-detail-row + The artifacts were removed + #{time_ago_with_tooltip(@build.artifacts_expire_at)} + - elsif @build.artifacts_expire_at + %p.build-detail-row + The artifacts will be removed in + %span.js-artifacts-remove= @build.artifacts_expire_at + + - if @build.artifacts? + .btn-group.btn-group-justified{ role: :group } + - if @build.artifacts_expire_at + = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do + Keep + + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + Download + + - if @build.artifacts_metadata? + = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + Browse + + .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } .title - Build artifacts - - if @build.artifacts_expired? + Build details + - if can?(current_user, :update_build, @build) && @build.retryable? + = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right', method: :post + - if @build.merge_request %p.build-detail-row - The artifacts were removed - #{time_ago_with_tooltip(@build.artifacts_expire_at)} - - elsif @build.artifacts_expire_at + %span.build-light-text Merge Request: + = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request) + - if @build.duration %p.build-detail-row - The artifacts will be removed in - %span.js-artifacts-remove= @build.artifacts_expire_at - - - if @build.artifacts? - .btn-group.btn-group-justified{ role: :group } - - if @build.artifacts_expire_at - = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do - Keep - - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do - Download - - - if @build.artifacts_metadata? - = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do - Browse - - .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } - .title - Build details - - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right', method: :post - - if @build.merge_request + %span.build-light-text Duration: + = time_interval_in_words(@build.duration) + - if @build.finished_at + %p.build-detail-row + %span.build-light-text Finished: + #{time_ago_with_tooltip(@build.finished_at)} + - if @build.erased_at + %p.build-detail-row + %span.build-light-text Erased: + #{time_ago_with_tooltip(@build.erased_at)} %p.build-detail-row - %span.build-light-text Merge Request: - = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request) - - if @build.duration - %p.build-detail-row - %span.build-light-text Duration: - = time_interval_in_words(@build.duration) - - if @build.finished_at - %p.build-detail-row - %span.build-light-text Finished: - #{time_ago_with_tooltip(@build.finished_at)} - - if @build.erased_at - %p.build-detail-row - %span.build-light-text Erased: - #{time_ago_with_tooltip(@build.erased_at)} - %p.build-detail-row - %span.build-light-text Runner: - - if @build.runner && current_user && current_user.admin - = link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id) - - elsif @build.runner - \##{@build.runner.id} - .btn-group.btn-group-justified{ role: :group } - - if @build.has_trace? - = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' - - if @build.active? - = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post - - if can?(current_user, :update_build, @project) && @build.erasable? - = link_to erase_namespace_project_build_path(@project.namespace, @project, @build), - class: "btn btn-sm btn-default", method: :post, - data: { confirm: "Are you sure you want to erase this build?" } do - Erase + %span.build-light-text Runner: + - if @build.runner && current_user && current_user.admin + = link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id) + - elsif @build.runner + \##{@build.runner.id} + .btn-group.btn-group-justified{ role: :group } + - if @build.has_trace? + = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' + - if @build.active? + = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post + - if can?(current_user, :update_build, @project) && @build.erasable? + = link_to erase_namespace_project_build_path(@project.namespace, @project, @build), + class: "btn btn-sm btn-default", method: :post, + data: { confirm: "Are you sure you want to erase this build?" } do + Erase - - if @build.trigger_request - .build-widget - %h4.title - Trigger + - if @build.trigger_request + .build-widget + %h4.title + Trigger - %p - %span.build-light-text Token: - #{@build.trigger_request.trigger.short_token} - - - if @build.trigger_request.variables %p - %span.build-light-text Variables: + %span.build-light-text Token: + #{@build.trigger_request.trigger.short_token} + + - if @build.trigger_request.variables + %p + %span.build-light-text Variables: - - @build.trigger_request.variables.each do |key, value| - %code - #{key}=#{value} + - @build.trigger_request.variables.each do |key, value| + %code + #{key}=#{value} - .block - .title - Commit title - %p.build-light-text.append-bottom-0 - #{@build.pipeline.git_commit_title} - - - if @build.tags.any? .block .title - Tags - - @build.tag_list.each do |tag| - %span.label.label-primary - = tag + Commit title + %p.build-light-text.append-bottom-0 + #{@build.pipeline.git_commit_title} + + - if @build.tags.any? + .block + .title + Tags + - @build.tag_list.each do |tag| + %span.label.label-primary + = tag diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index 4421f3b9562..e4d41288aa6 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -5,26 +5,6 @@ .build-page = render "header" - - builds = @build.pipeline.builds.latest.to_a - - if builds.size > 1 - %ul.nav-links.no-top.no-bottom - - builds.each do |build| - %li{class: ('active' if build == @build) } - = link_to namespace_project_build_path(@project.namespace, @project, build) do - = ci_icon_for_status(build.status) - %span - - if build.name - = build.name - - else - = build.id - - - if @build.retried? - %li.active - %a - Build ##{@build.id} - · - %i.fa.fa-warning - This build was retried. - if @build.stuck? - unless @build.any_runners_online? .bs-callout.bs-callout-warning @@ -67,4 +47,10 @@ = render "sidebar" :javascript - new Build("#{namespace_project_build_url(@project.namespace, @project, @build)}", "#{namespace_project_build_url(@project.namespace, @project, @build, :json)}", "#{@build.status}", "#{trace_with_state[:state]}") + new Build({ + page_url: "#{namespace_project_build_url(@project.namespace, @project, @build)}", + build_url: "#{namespace_project_build_url(@project.namespace, @project, @build, :json)}", + build_status: "#{@build.status}", + build_stage: "#{@build.stage}", + state1: "#{trace_with_state[:state]}" + }) diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index 71cf5582a4c..311583037e5 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -1,10 +1,10 @@ - if current_user = link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star has-tooltip', method: :post, remote: true, title: current_user.starred?(@project) ? 'Unstar project' : 'Star project' } do - if current_user.starred?(@project) - = icon('star fw') + = icon('star') %span.starred Unstar - else - = icon('star-o fw') + = icon('star-o') %span Star %div.count-with-arrow %span.arrow @@ -13,7 +13,7 @@ - else = link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: 'You must sign in to star a project' do - = icon('star fw') + = icon('star') Star %div.count-with-arrow %span.arrow diff --git a/app/views/projects/ci/builds/_build_pipeline.html.haml b/app/views/projects/ci/builds/_build_pipeline.html.haml new file mode 100644 index 00000000000..04cbd0c3591 --- /dev/null +++ b/app/views/projects/ci/builds/_build_pipeline.html.haml @@ -0,0 +1,14 @@ +- is_playable = subject.playable? && can?(current_user, :update_build, @project) +%li.build{class: ("playable" if is_playable)} + .build-content + - if is_playable + = link_to play_namespace_project_build_path(subject.project.namespace, subject.project, subject, return_to: request.original_url), method: :post, title: 'Play' do + = render_status_with_link('build', 'play') + = subject.name + - elsif can?(current_user, :read_build, @project) + = link_to namespace_project_build_path(subject.project.namespace, subject.project, subject) do + = render_status_with_link('build', subject.status) + = subject.name + - else + = render_status_with_link('build', subject.status) + = ci_icon_for_status(subject.status) diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml index 78709a92aed..be387201f8d 100644 --- a/app/views/projects/ci/pipelines/_pipeline.html.haml +++ b/app/views/projects/ci/pipelines/_pipeline.html.haml @@ -2,19 +2,21 @@ %tr.commit %td.commit-link = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do - = ci_status_with_icon(status) - - + - if defined?(status_icon_only) && status_icon_only + = ci_icon_for_status(status) + - else + = ci_status_with_icon(status) %td .branch-commit = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do %span ##{pipeline.id} - if pipeline.ref - .icon-container - = pipeline.tag? ? icon('tag') : icon('code-fork') - = link_to pipeline.ref, namespace_project_commits_path(@project.namespace, @project, pipeline.ref), class: "monospace branch-name" - .icon-container - = custom_icon("icon_commit") + - unless defined?(hide_branch) && hide_branch + .icon-container + = pipeline.tag? ? icon('tag') : icon('code-fork') + = link_to pipeline.ref, namespace_project_commits_path(@project.namespace, @project, pipeline.ref), class: "monospace branch-name" + .icon-container + = custom_icon("icon_commit") = link_to pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, pipeline.sha), class: "commit-id monospace" - if pipeline.latest? %span.label.label-success.has-tooltip{ title: 'Latest build for this branch' } latest @@ -53,7 +55,7 @@ - if pipeline.finished_at %p.finished-at = icon("calendar") - #{time_ago_with_tooltip(pipeline.finished_at, short_format: true, skip_js: true)} + #{time_ago_with_tooltip(pipeline.finished_at, short_format: false, skip_js: true)} %td.pipeline-actions .controls.hidden-xs.pull-right diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml index 640abdb993f..20a85148ab5 100644 --- a/app/views/projects/commit/_pipeline.html.haml +++ b/app/views/projects/commit/_pipeline.html.haml @@ -1,5 +1,9 @@ -.row-content-block.build-content.middle-block +.row-content-block.build-content.middle-block.pipeline-actions .pull-right + .btn.btn-grouped.btn-white.toggle-pipeline-btn + %span.toggle-btn-text Hide + %span pipeline graph + %span.caret - if can?(current_user, :update_pipeline, pipeline.project) - if pipeline.builds.latest.failed.any?(&:retryable?) = link_to "Retry failed", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-grouped btn-primary', method: :post @@ -23,6 +27,22 @@ in = time_interval_in_words pipeline.duration +.row-content-block.build-content.middle-block.pipeline-graph + .pipeline-visualization + %ul.stage-column-list + - stages = pipeline.stages_with_latest_statuses + - stages.each do |stage, statuses| + %li.stage-column + .stage-name + %a{name: stage} + - if stage + = stage.titleize + .builds-container + %ul + - statuses.each do |status| + = render "projects/#{status.to_partial_path}_pipeline", subject: status + + - if pipeline.yaml_errors.present? .bs-callout.bs-callout-danger %h4 Found errors in your .gitlab-ci.yml: diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml new file mode 100644 index 00000000000..29f4ef8f49e --- /dev/null +++ b/app/views/projects/commit/_pipelines_list.haml @@ -0,0 +1,17 @@ +%ul.content-list.pipelines + - if pipelines.blank? + %li + .nothing-here-block No pipelines to show + - else + .table-holder + %table.table.builds + %tbody + %th Status + %th Commit + - pipelines.stages.each do |stage| + %th.stage + %span.has-tooltip{ title: "#{stage.titleize}" } + = stage.titleize + %th + %th + = render pipelines, commit_sha: true, stage: true, allow_retry: true, stages: pipelines.stages, status_icon_only: true, hide_branch: true diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 8fbd89100ca..ad2eb3e504f 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -10,10 +10,9 @@ \ - if editable_diff?(diff_file) - = edit_blob_link(@merge_request.source_project, - @merge_request.source_branch, diff_file.new_path, - from_merge_request_id: @merge_request.id, - skip_visible_check: true) + - link_opts = @merge_request.id ? { from_merge_request_id: @merge_request.id } : {} + = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path, + blob: blob, link_opts: link_opts) = view_file_btn(diff_commit.id, diff_file.new_path, project) diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index 2d6a370b848..7042e9f1fc9 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -1,6 +1,7 @@ +- email = local_assigns.fetch(:email, false) - plain = local_assigns.fetch(:plain, false) - type = line.type -- line_code = diff_file.line_code(line) unless plain +- line_code = diff_file.line_code(line) %tr.line_holder{ plain ? { class: type} : { class: type, id: line_code } } - case type - when 'match' @@ -22,4 +23,15 @@ = link_text - else %a{href: "##{line_code}", data: { linenumber: link_text }} - %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }= diff_line_content(line.text, type) + %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }< + - if email + %pre= diff_line_content(line.text, type) + - else + = diff_line_content(line.text, type) + +- discussions = local_assigns.fetch(:discussions, nil) +- if discussions && !line.meta? + - discussion = discussions[line_code] + - if discussion + - discussion_expanded = local_assigns.fetch(:discussion_expanded, discussion.expanded?) + = render "discussions/diff_discussion", discussion: discussion, expanded: discussion_expanded diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index ab5463ba89d..f1d2d4bf268 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -5,15 +5,12 @@ %table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' } - last_line = 0 - - diff_file.highlighted_diff_lines.each do |line| - - last_line = line.new_pos - = render "projects/diffs/line", line: line, diff_file: diff_file - - - unless @diff_notes_disabled - - line_code = diff_file.line_code(line) - - discussion = @grouped_diff_discussions[line_code] if line_code - - if discussion - = render "discussions/diff_discussion", discussion: discussion + - discussions = @grouped_diff_discussions unless @diff_notes_disabled + = render partial: "projects/diffs/line", + collection: diff_file.highlighted_diff_lines, + as: :line, + locals: { diff_file: diff_file, discussions: discussions } + - last_line = diff_file.highlighted_diff_lines.last.new_pos - if !diff_file.new_file && last_line > 0 = diff_match_line last_line, last_line, bottom: true diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml new file mode 100644 index 00000000000..584c0fa18ae --- /dev/null +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml @@ -0,0 +1,9 @@ +%li.build + .build-content + - if subject.target_url + - link_to subject.target_url do + = render_status_with_link('commit status', subject.status) + = subject.name + - else + = render_status_with_link('commit status', subject.status) + = subject.name diff --git a/app/views/projects/hooks/_project_hook.html.haml b/app/views/projects/hooks/_project_hook.html.haml index 8151187d499..3fcf1692e09 100644 --- a/app/views/projects/hooks/_project_hook.html.haml +++ b/app/views/projects/hooks/_project_hook.html.haml @@ -3,7 +3,7 @@ .col-md-8.col-lg-7 %strong.light-header= hook.url %div - - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events wiki_page_events).each do |trigger| + - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events pipeline_events wiki_page_events).each do |trigger| - if hook.send(trigger) %span.label.label-gray.deploy-project-label= trigger.titleize .col-md-4.col-lg-5.text-right-lg.prepend-top-5 diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml index 60b45115b73..b6cb559afcb 100644 --- a/app/views/projects/issues/_head.html.haml +++ b/app/views/projects/issues/_head.html.haml @@ -6,6 +6,11 @@ %span Issues + = nav_link(controller: :boards) do + = link_to namespace_project_board_path(@project.namespace, @project), title: 'Board' do + %span + Board + - if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests) = nav_link(controller: :merge_requests) do = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index e5cce16a171..9f1a046ea74 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -37,14 +37,19 @@ = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %li = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) + - if @issue.submittable_as_spam? && current_user.admin? + %li + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' + - if can?(current_user, :create_issue, @project) = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do New issue - if can?(current_user, :update_issue, @issue) = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - = link_to edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' do - Edit + - if @issue.submittable_as_spam? && current_user.admin? + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' .issue-details.issuable-details diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index 53dd300c35c..d070979bcfe 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -4,5 +4,8 @@ = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"} - if @merge_request.closed? = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} + %comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" } + %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { namespace_path: "#{@merge_request.project.namespace.path}", project_path: "#{@merge_request.project.path}" } } + {{ buttonText }} #notes= render "projects/notes/notes_with_form" diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml index 598bd743676..00bd4e143df 100644 --- a/app/views/projects/merge_requests/_new_submit.html.haml +++ b/app/views/projects/merge_requests/_new_submit.html.haml @@ -20,7 +20,7 @@ .mr-compare.merge-request %ul.merge-request-tabs.nav-links.no-top.no-bottom %li.commits-tab - = link_to url_for(params), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do + = link_to url_for(params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do Commits %span.badge= @commits.size - if @pipeline @@ -52,11 +52,8 @@ $('#merge_request_assignee_id').val("#{current_user.id}").trigger("change"); e.preventDefault(); }); - :javascript - var merge_request - merge_request = new MergeRequest({ - action: 'new', - diffs_loaded: true, - commits_loaded: true + var merge_request = new MergeRequest({ + action: "#{(@show_changes_tab ? 'diffs' : 'new')}", + setUrl: false }); diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 269198adf91..f8025fc1dbe 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -1,6 +1,8 @@ - page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" - page_description @merge_request.description - page_card_attributes @merge_request.card_attributes +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('diff_notes/diff_notes_bundle.js') - if diff_view == :parallel - fluid_layout true @@ -45,24 +47,38 @@ - if @commits_count.nonzero? %ul.merge-request-tabs.nav-links.no-top.no-bottom %li.notes-tab - = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#notes', action: 'notes', toggle: 'tab'} do + = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do Discussion %span.badge= @merge_request.mr_and_commit_notes.user.count %li.commits-tab - = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do + = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do Commits %span.badge= @commits_count - if @pipeline + %li.pipelines-tab + = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do + Pipelines + %span.badge= @merge_request.all_pipelines.size %li.builds-tab - = link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: '#builds', action: 'builds', toggle: 'tab'} do + = link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#builds', action: 'builds', toggle: 'tab' } do Builds %span.badge= @statuses.size %li.diffs-tab - = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do + = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do Changes %span.badge= @merge_request.diff_size + %li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true } + %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } + .line-resolve-all{ "v-show" => "discussionCount > 0", + ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } + %span.line-resolve-btn.is-disabled{ type: "button", + ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } + = render "shared/icons/icon_status_success.svg" + %span.line-resolve-text + {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ discussionCount | pluralize 'discussion' }} resolved + = render "discussions/jump_to_next" - .tab-content + .tab-content#diff-notes-app #notes.notes.tab-pane.voting_notes .content-block.content-block-small.oneline-block = render 'award_emoji/awards_block', awardable: @merge_request, inline: true @@ -76,6 +92,8 @@ - # This tab is always loaded via AJAX #builds.builds.tab-pane - # This tab is always loaded via AJAX + #pipelines.pipelines.tab-pane + - # This tab is always loaded via AJAX #diffs.diffs.tab-pane - # This tab is always loaded via AJAX diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml new file mode 100644 index 00000000000..a524936f73c --- /dev/null +++ b/app/views/projects/merge_requests/conflicts.html.haml @@ -0,0 +1,29 @@ +- class_bindings = "{ | + 'head': line.isHead, | + 'origin': line.isOrigin, | + 'match': line.hasMatch, | + 'selected': line.isSelected, | + 'unselected': line.isUnselected }" + +- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" += render "projects/merge_requests/show/mr_title" + +.merge-request-details.issuable-details + = render "projects/merge_requests/show/mr_box" + += render 'shared/issuable/sidebar', issuable: @merge_request + +#conflicts{"v-cloak" => "true", data: { conflicts_path: conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request, format: :json), + resolve_conflicts_path: resolve_conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request) } } + .loading{"v-if" => "isLoading"} + %i.fa.fa-spinner.fa-spin + + .nothing-here-block{"v-if" => "hasError"} + {{conflictsData.errorMessage}} + + = render partial: "projects/merge_requests/conflicts/commit_stats" + + .files-wrapper{"v-if" => "!isLoading && !hasError"} + = render partial: "projects/merge_requests/conflicts/parallel_view", locals: { class_bindings: class_bindings } + = render partial: "projects/merge_requests/conflicts/inline_view", locals: { class_bindings: class_bindings } + = render partial: "projects/merge_requests/conflicts/submit_form" diff --git a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml new file mode 100644 index 00000000000..457c467fba9 --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml @@ -0,0 +1,20 @@ +.content-block.oneline-block.files-changed{"v-if" => "!isLoading && !hasError"} + .inline-parallel-buttons + .btn-group + %a.btn{ | + ":class" => "{'active': !isParallel}", | + "@click" => "handleViewTypeChange('inline')"} + Inline + %a.btn{ | + ":class" => "{'active': isParallel}", | + "@click" => "handleViewTypeChange('parallel')"} + Side-by-side + + .js-toggle-container + .commit-stat-summary + Showing + %strong.cred {{conflictsCount}} {{conflictsData.conflictsText}} + between + %strong {{conflictsData.source_branch}} + and + %strong {{conflictsData.target_branch}} diff --git a/app/views/projects/merge_requests/conflicts/_inline_view.html.haml b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml new file mode 100644 index 00000000000..19c7da4b5e3 --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml @@ -0,0 +1,28 @@ +.files{"v-show" => "!isParallel"} + .diff-file.file-holder.conflict.inline-view{"v-for" => "file in conflictsData.files"} + .file-title + %i.fa.fa-fw{":class" => "file.iconClass"} + %strong {{file.filePath}} + .file-actions + %a.btn.view-file.btn-file-option{":href" => "file.blobPath"} + View file @{{conflictsData.shortCommitSha}} + + .diff-content.diff-wrap-lines + .diff-wrap-lines.code.file-content.js-syntax-highlight + %table + %tr.line_holder.diff-inline{"v-for" => "line in file.inlineLines"} + %template{"v-if" => "!line.isHeader"} + %td.diff-line-num.new_line{":class" => class_bindings} + %a {{line.new_line}} + %td.diff-line-num.old_line{":class" => class_bindings} + %a {{line.old_line}} + %td.line_content{":class" => class_bindings} + {{{line.richText}}} + + %template{"v-if" => "line.isHeader"} + %td.diff-line-num.header{":class" => class_bindings} + %td.diff-line-num.header{":class" => class_bindings} + %td.line_content.header{":class" => class_bindings} + %strong {{{line.richText}}} + %button.btn{"@click" => "handleSelected(line.id, line.section)"} + {{line.buttonTitle}} diff --git a/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml new file mode 100644 index 00000000000..2e6f67c2eaf --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml @@ -0,0 +1,27 @@ +.files{"v-show" => "isParallel"} + .diff-file.file-holder.conflict.parallel-view{"v-for" => "file in conflictsData.files"} + .file-title + %i.fa.fa-fw{":class" => "file.iconClass"} + %strong {{file.filePath}} + .file-actions + %a.btn.view-file.btn-file-option{":href" => "file.blobPath"} + View file @{{conflictsData.shortCommitSha}} + + .diff-content.diff-wrap-lines + .diff-wrap-lines.code.file-content.js-syntax-highlight + %table + %tr.line_holder.parallel{"v-for" => "section in file.parallelLines"} + %template{"v-for" => "line in section"} + + %template{"v-if" => "line.isHeader"} + %td.diff-line-num.header{":class" => class_bindings} + %td.line_content.header{":class" => class_bindings} + %strong {{line.richText}} + %button.btn{"@click" => "handleSelected(line.id, line.section)"} + {{line.buttonTitle}} + + %template{"v-if" => "!line.isHeader"} + %td.diff-line-num.old_line{":class" => class_bindings} + {{line.lineNumber}} + %td.line_content.parallel{":class" => class_bindings} + {{{line.richText}}} diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml new file mode 100644 index 00000000000..78bd4133ea2 --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml @@ -0,0 +1,15 @@ +.content-block.oneline-block.files-changed + %strong.resolved-count {{resolvedCount}} + of + %strong.total-count {{conflictsCount}} + conflicts have been resolved + + .commit-message-container.form-group + .max-width-marker + %textarea.form-control.js-commit-message{"v-model" => "conflictsData.commitMessage"} + {{{conflictsData.commitMessage}}} + + %button{type: "button", class: "btn btn-success js-submit-button", ":disabled" => "!readyToCommit", "@click" => "commit()"} + %span {{commitButtonText}} + + = link_to "Cancel", namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request), class: "btn btn-cancel" diff --git a/app/views/projects/merge_requests/show/_builds.html.haml b/app/views/projects/merge_requests/show/_builds.html.haml index 81de60f116c..808ef7fed27 100644 --- a/app/views/projects/merge_requests/show/_builds.html.haml +++ b/app/views/projects/merge_requests/show/_builds.html.haml @@ -1,2 +1 @@ = render "projects/commit/pipeline", pipeline: @pipeline, link_to_commit: true - diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/show/_pipelines.html.haml new file mode 100644 index 00000000000..afe3f3430c6 --- /dev/null +++ b/app/views/projects/merge_requests/show/_pipelines.html.haml @@ -0,0 +1 @@ += render "projects/commit/pipelines_list", pipelines: @pipelines, link_to_commit: true diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml index 6ef640bb654..494695a03a5 100644 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ b/app/views/projects/merge_requests/widget/_heading.html.haml @@ -42,3 +42,16 @@ .ci_widget.ci-error{style: "display:none"} = icon("times-circle") Could not connect to the CI server. Please check your settings and try again. + +- @merge_request.environments.each do |environment| + .mr-widget-heading + .ci_widget.ci-success + = ci_icon_for_status("success") + %span.hidden-sm + Deployed to + = succeed '.' do + = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment), class: 'environment' + - external_url = environment.external_url + - if external_url + = link_to external_url, target: '_blank' do + = icon('external-link', text: "View on #{external_url.gsub(/\A.*?:\/\//, '')}", right: true) diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml index 19b5d0ff066..7794d6d7df2 100644 --- a/app/views/projects/merge_requests/widget/_merged.html.haml +++ b/app/views/projects/merge_requests/widget/_merged.html.haml @@ -6,7 +6,7 @@ - if @merge_request.merge_event by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)} #{time_ago_with_tooltip(@merge_request.merge_event.created_at)} - - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true') + - if !@merge_request.source_branch_exists? || params[:deleted_source_branch] %p The changes were merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index dc18f715f25..6f5ee5f16c5 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -1,6 +1,12 @@ .mr-state-widget = render 'projects/merge_requests/widget/heading' .mr-widget-body + -# After conflicts are resolved, the user is redirected back to the MR page. + -# There is a short window before background workers run and GitLab processes + -# the new push and commits, during which it will think the conflicts still exist. + -# We send this param to get the widget to treat the MR as having no more conflicts. + - resolved_conflicts = params[:resolved_conflicts] + - if @project.archived? = render 'projects/merge_requests/widget/open/archived' - elsif @merge_request.commits.blank? @@ -9,7 +15,7 @@ = render 'projects/merge_requests/widget/open/missing_branch' - elsif @merge_request.unchecked? = render 'projects/merge_requests/widget/open/check' - - elsif @merge_request.cannot_be_merged? + - elsif @merge_request.cannot_be_merged? && !resolved_conflicts = render 'projects/merge_requests/widget/open/conflicts' - elsif @merge_request.work_in_progress? = render 'projects/merge_requests/widget/open/wip' @@ -19,7 +25,7 @@ = render 'projects/merge_requests/widget/open/not_allowed' - elsif !@merge_request.mergeable_ci_state? && @pipeline && @pipeline.failed? = render 'projects/merge_requests/widget/open/build_failed' - - elsif @merge_request.can_be_merged? + - elsif @merge_request.can_be_merged? || resolved_conflicts = render 'projects/merge_requests/widget/open/accept' - if mr_closes_issues.present? diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml index d9efe81701f..ea618263a4a 100644 --- a/app/views/projects/merge_requests/widget/_show.html.haml +++ b/app/views/projects/merge_requests/widget/_show.html.haml @@ -23,7 +23,8 @@ preparing: "{{status}} build", normal: "Build {{status}}" }, - builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}" + builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", + pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}" }; if (typeof merge_request_widget !== 'undefined') { diff --git a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml index f000cc38a65..af3096f04d9 100644 --- a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml +++ b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml @@ -3,7 +3,18 @@ This merge request contains merge conflicts %p - Please resolve these conflicts or + Please + - if @merge_request.conflicts_can_be_resolved_by?(current_user) + - if @merge_request.conflicts_can_be_resolved_in_ui? + = link_to "resolve these conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + - else + %span.has-tooltip{title: "These conflicts cannot be resolved through GitLab"} + resolve these conflicts locally + - else + resolve these conflicts + + or + - if @merge_request.can_be_merged_via_command_line_by?(current_user) #{link_to "merge this request manually", "#modal_merge_info", class: "how_to_merge_link vlink", "data-toggle" => "modal"}. - else diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index adcc984f506..ea4898f2107 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -77,7 +77,7 @@ = link_to "#", class: 'btn js-toggle-button import_git' do = icon('git', text: 'Repo by URL') %div{ class: 'import_gitlab_project' } - - if gitlab_project_import_enabled? + - if gitlab_project_import_enabled? && current_user.is_admin? = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do = icon('gitlab', text: 'GitLab export') diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml index 7c61ba750fe..402f5b52f5e 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/projects/notes/_form.html.haml @@ -1,4 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form" }, authenticity_token: true do |f| += form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| = hidden_field_tag :view, diff_view = hidden_field_tag :line_type = note_target_fields(@note) @@ -10,8 +10,12 @@ = f.hidden_field :position = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do - = render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here..." - = render 'projects/notes/hints' + = render 'projects/zen', f: f, + attr: :note, + classes: 'note-textarea js-note-text', + placeholder: "Write a comment or drag your files here...", + supports_slash_commands: true + = render 'projects/notes/hints', supports_slash_commands: true .error-alert .note-form-actions.clearfix diff --git a/app/views/projects/notes/_hints.html.haml b/app/views/projects/notes/_hints.html.haml index 25466e7562e..cf6e14648cc 100644 --- a/app/views/projects/notes/_hints.html.haml +++ b/app/views/projects/notes/_hints.html.haml @@ -1,8 +1,15 @@ +- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) .comment-toolbar.clearfix .toolbar-text Styling with = link_to 'Markdown', help_page_path('markdown/markdown'), target: '_blank', tabindex: -1 - is supported + - if supports_slash_commands + and + = link_to 'slash commands', help_page_path('workflow/slash_commands'), target: '_blank', tabindex: -1 + are + - else + is + supported %button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' } = icon('file-image-o', class: 'toolbar-button-icon') - Attach a file \ No newline at end of file + Attach a file diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index 71da8ac9d7c..d2ac1ce2b9a 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -1,5 +1,6 @@ - return unless note.author - return if note.cross_reference_not_visible_for?(current_user) +- can_resolve = can?(current_user, :resolve_note, note) - note_editable = note_editable?(note) %li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable} } @@ -16,19 +17,48 @@ commented %a{ href: "##{dom_id(note)}" } = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') - .note-actions - - access = note_max_access_for_user(note) - - if access and not note.system - %span.note-role.hidden-xs= access - - if current_user and not note.system - = link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do - = icon('spinner spin') - = icon('smile-o') - - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do - = icon('pencil') - = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do - = icon('trash-o') + - unless note.system? + .note-actions + - access = note_max_access_for_user(note) + - if access + %span.note-role.hidden-xs= access + + - if note.resolvable? + %resolve-btn{ ":namespace-path" => "'#{note.project.namespace.path}'", + ":project-path" => "'#{note.project.path}'", + ":discussion-id" => "'#{note.discussion_id}'", + ":note-id" => note.id, + ":resolved" => note.resolved?, + ":can-resolve" => can_resolve, + ":resolved-by" => "'#{note.resolved_by.try(:name)}'", + "v-show" => "#{can_resolve || note.resolved?}", + "inline-template" => true, + "v-ref:note_#{note.id}" => true } + + .note-action-button + = icon("spin spinner", "v-show" => "loading") + %button.line-resolve-btn{ type: "button", + class: ("is-disabled" unless can_resolve), + ":class" => "{ 'is-active': isResolved }", + ":aria-label" => "buttonText", + "@click" => "resolve", + ":title" => "buttonText", + "v-show" => "!loading", + "v-el:button" => true } + + = render "shared/icons/icon_status_success.svg" + + - if current_user + - if note.emoji_awardable? + = link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do + = icon('spinner spin') + = icon('smile-o') + + - if note_editable + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = icon('pencil') + = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do + = icon('trash-o') .note-body{class: note_editable ? 'js-task-list-container' : ''} .note-text.md = preserve do diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/pipelines_settings/_badge.html.haml new file mode 100644 index 00000000000..7b7fa56d993 --- /dev/null +++ b/app/views/projects/pipelines_settings/_badge.html.haml @@ -0,0 +1,27 @@ +.row{ class: badge.title.gsub(' ', '-') } + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = badge.title.capitalize + .col-lg-9 + .prepend-top-10 + .panel.panel-default + .panel-heading + %b + = badge.title.capitalize + · + = badge.to_html + .pull-right + = render 'shared/ref_switcher', destination: 'badges', align_right: true + .panel-body + .row + .col-md-2.text-center + Markdown + .col-md-10.code.js-syntax-highlight + = highlight('.md', badge.to_markdown) + .row + %hr + .row + .col-md-2.text-center + HTML + .col-md-10.code.js-syntax-highlight + = highlight('.html', badge.to_html) diff --git a/app/views/projects/pipelines_settings/show.html.haml b/app/views/projects/pipelines_settings/show.html.haml index 228bad36ebd..8c7222bfe3d 100644 --- a/app/views/projects/pipelines_settings/show.html.haml +++ b/app/views/projects/pipelines_settings/show.html.haml @@ -77,27 +77,4 @@ %hr .row.prepend-top-default - .col-lg-3.profile-settings-sidebar - %h4.prepend-top-0 - Builds Badge - .col-lg-9 - .prepend-top-10 - .panel.panel-default - .panel-heading - %b Builds badge · - = @build_badge.to_html - .pull-right - = render 'shared/ref_switcher', destination: 'badges', align_right: true - .panel-body - .row - .col-md-2.text-center - Markdown - .col-md-10.code.js-syntax-highlight - = highlight('.md', @build_badge.to_markdown) - .row - %hr - .row - .col-md-2.text-center - HTML - .col-md-10.code.js-syntax-highlight - = highlight('.html', @build_badge.to_html) + = render partial: 'badge', collection: @badges diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index 85d0c494ba8..d4c6fa24768 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -5,6 +5,7 @@ Protect a branch .panel-body .form-horizontal + = form_errors(@protected_branch) .form-group = f.label :name, class: 'col-md-2 text-right' do Branch: @@ -18,19 +19,19 @@ %code production/* are supported .form-group - %label.col-md-2.text-right{ for: 'merge_access_level_attributes' } + %label.col-md-2.text-right{ for: 'merge_access_levels_attributes' } Allowed to merge: .col-md-10 = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-merge wide', - data: { field_name: 'protected_branch[merge_access_level_attributes][access_level]', input_id: 'merge_access_level_attributes' }}) + data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }}) .form-group - %label.col-md-2.text-right{ for: 'push_access_level_attributes' } + %label.col-md-2.text-right{ for: 'push_access_levels_attributes' } Allowed to push: .col-md-10 = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-push wide', - data: { field_name: 'protected_branch[push_access_level_attributes][access_level]', input_id: 'push_access_level_attributes' }}) + data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }}) .panel-footer = f.submit 'Protect', class: 'btn-create btn', disabled: true diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml index e2e01ee78f8..0628134b1bb 100644 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_protected_branch.html.haml @@ -13,16 +13,9 @@ = time_ago_with_tooltip(commit.committed_date) - else (branch was removed from repository) - %td - = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_level.access_level - = dropdown_tag( (protected_branch.merge_access_level.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', - data: { field_name: "allowed_to_merge_#{protected_branch.id}" }}) - %td - = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_level.access_level - = dropdown_tag( (protected_branch.push_access_level.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', - data: { field_name: "allowed_to_push_#{protected_branch.id}" }}) + + = render partial: 'update_protected_branch', locals: { protected_branch: protected_branch } + - if can_admin_project %td = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml new file mode 100644 index 00000000000..d6044aacaec --- /dev/null +++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml @@ -0,0 +1,10 @@ +%td + = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level + = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', + data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) +%td + = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level + = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', + data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) diff --git a/app/views/shared/icons/_icon_fork.svg b/app/views/shared/icons/_icon_fork.svg index a21f8f3a951..fc970e4ce50 100644 --- a/app/views/shared/icons/_icon_fork.svg +++ b/app/views/shared/icons/_icon_fork.svg @@ -1,3 +1,3 @@ - + diff --git a/app/views/shared/icons/_next_discussion.svg b/app/views/shared/icons/_next_discussion.svg new file mode 100644 index 00000000000..43559a60cb0 --- /dev/null +++ b/app/views/shared/icons/_next_discussion.svg @@ -0,0 +1 @@ + diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 0b7fa8c7d06..4f8ea7e7cef 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -27,7 +27,17 @@ = render "shared/issuable/label_dropdown" .pull-right - = render 'shared/sort_dropdown' + - if controller.controller_name == 'boards' && can?(current_user, :admin_list, @project) + .dropdown + %button.btn.btn-create.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, project_id: @project.try(:id) } } + Create new list + .dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable + = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Create a new list" } + - if can?(current_user, :admin_label, @project) + = render partial: "shared/issuable/label_page_create" + = dropdown_loading + - else + = render 'shared/sort_dropdown' - if controller.controller_name == 'issues' .issues_bulk_update.hide @@ -45,7 +55,7 @@ .filter-item.inline = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, show_footer: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } + = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } .filter-item.inline = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do %ul diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index c30bdb0ae91..d717c3d92ee 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -2,7 +2,22 @@ .form-group = f.label :title, class: 'control-label' - .col-sm-10 + + - issuable_template_names = issuable_templates(issuable) + + - if issuable_template_names.any? + .col-sm-3.col-lg-2 + .js-issuable-selector-wrap{ data: { issuable_type: issuable.class.to_s.underscore.downcase } } + - title = selected_template(issuable) || "Choose a template" + + = dropdown_tag(title, options: { toggle_class: 'js-issuable-selector', + title: title, filter: true, placeholder: 'Filter', footer_content: true, + data: { data: issuable_template_names, field_name: 'issuable_template', selected: selected_template(issuable), project_path: @project.path, namespace_path: @project.namespace.path } } ) do + %ul.dropdown-footer-list + %li + %a.reset-template + Reset template + %div{ class: issuable_template_names.any? ? 'col-sm-7 col-lg-8' : 'col-sm-10' } = f.text_field :title, maxlength: 255, autofocus: true, autocomplete: 'off', class: 'form-control pad', required: true @@ -23,6 +38,13 @@ to prevent a %strong Work In Progress merge request from being merged before it's ready. + + - if can_add_template?(issuable) + %p.help-block + Add + = link_to "description templates", help_page_path('user/project/description_templates') + to help your contributors communicate effectively! + .form-group.detail-page-description = f.label :description, 'Description', class: 'control-label' .col-sm-10 @@ -30,8 +52,9 @@ = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', - placeholder: "Write a comment or drag your files here..." - = render 'projects/notes/hints' + placeholder: "Write a comment or drag your files here...", + supports_slash_commands: !issuable.persisted? + = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted? .clearfix .error-alert diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml index 4e280c371ac..a76b7baf918 100644 --- a/app/views/shared/issuable/_label_page_default.html.haml +++ b/app/views/shared/issuable/_label_page_default.html.haml @@ -2,8 +2,13 @@ - show_create = local_assigns.fetch(:show_create, true) - show_footer = local_assigns.fetch(:show_footer, true) - filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search labels') +- show_boards_content = local_assigns.fetch(:show_boards_content, false) .dropdown-page-one = dropdown_title(title) + - if show_boards_content + .issue-board-dropdown-content + %p + Each label that exists in your issue tracker can have its own dedicated list. Select a label below to add a list to your Board and it will automatically be populated with issues that have that label. To create a list for a label that doesn't exist yet, simply create the label below. = dropdown_filter(filter_placeholder) = dropdown_content - if @project && show_footer @@ -12,7 +17,7 @@ - if can?(current_user, :admin_label, @project) %li %a.dropdown-toggle-page{href: "#"} - Create new + Create new label %li = link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do - if show_create && @project && can?(current_user, :admin_label, @project) diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 281ec728e41..66c309644a7 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -20,11 +20,11 @@ - if forks %span = icon('code-fork') - = project.forks_count + = number_with_delimiter(project.forks_count) - if stars %span = icon('star') - = project.star_count + = number_with_delimiter(project.star_count) %span.visibility-icon.has-tooltip{data: { container: 'body', placement: 'left' }, title: visibility_icon_description(project)} = visibility_level_icon(project.visibility_level, fw: true) diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 470dac6d75b..d2ec6c3ddef 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -29,49 +29,56 @@ = f.label :push_events, class: 'list-label' do %strong Push events %p.light - This url will be triggered by a push to the repository + This URL will be triggered by a push to the repository %li = f.check_box :tag_push_events, class: 'pull-left' .prepend-left-20 = f.label :tag_push_events, class: 'list-label' do %strong Tag push events %p.light - This url will be triggered when a new tag is pushed to the repository + This URL will be triggered when a new tag is pushed to the repository %li = f.check_box :note_events, class: 'pull-left' .prepend-left-20 = f.label :note_events, class: 'list-label' do %strong Comments %p.light - This url will be triggered when someone adds a comment + This URL will be triggered when someone adds a comment %li = f.check_box :issues_events, class: 'pull-left' .prepend-left-20 = f.label :issues_events, class: 'list-label' do %strong Issues events %p.light - This url will be triggered when an issue is created/updated/merged + This URL will be triggered when an issue is created/updated/merged %li = f.check_box :merge_requests_events, class: 'pull-left' .prepend-left-20 = f.label :merge_requests_events, class: 'list-label' do %strong Merge Request events %p.light - This url will be triggered when a merge request is created/updated/merged + This URL will be triggered when a merge request is created/updated/merged %li = f.check_box :build_events, class: 'pull-left' .prepend-left-20 = f.label :build_events, class: 'list-label' do %strong Build events %p.light - This url will be triggered when the build status changes + This URL will be triggered when the build status changes + %li + = f.check_box :pipeline_events, class: 'pull-left' + .prepend-left-20 + = f.label :pipeline_events, class: 'list-label' do + %strong Pipeline events + %p.light + This URL will be triggered when the pipeline status changes %li = f.check_box :wiki_page_events, class: 'pull-left' .prepend-left-20 = f.label :wiki_page_events, class: 'list-label' do %strong Wiki Page events %p.light - This url will be triggered when a wiki page is created/updated + This URL will be triggered when a wiki page is created/updated .form-group = f.label :enable_ssl_verification, "SSL verification", class: 'label-light checkbox' .checkbox diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml index cbb8dfb7829..8f7b42eb351 100644 --- a/app/views/u2f/_register.html.haml +++ b/app/views/u2f/_register.html.haml @@ -28,10 +28,15 @@ %script#js-register-u2f-registered{ type: "text/template" } %div.row.append-bottom-10 - %p Your device was successfully set up! Click this button to register with the GitLab server. - = form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do - = hidden_field_tag :device_response, nil, class: 'form-control', required: true, id: "js-device-response" - = submit_tag "Register U2F Device", class: "btn btn-success" + .col-md-12 + %p Your device was successfully set up! Give it a name and register it with the GitLab server. + = form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do + .row.append-bottom-10 + .col-md-3 + = text_field_tag 'u2f_registration[name]', nil, class: 'form-control', placeholder: "Pick a name" + .col-md-3 + = hidden_field_tag 'u2f_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response" + = submit_tag "Register U2F Device", class: "btn btn-success" :javascript var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f); diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index d69d6037053..61ed1c38ac4 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -5,6 +5,10 @@ class RepositoryForkWorker sidekiq_options queue: :gitlab_shell def perform(project_id, forked_from_repository_storage_path, source_path, target_path) + Gitlab::Metrics.add_event(:fork_repository, + source_path: source_path, + target_path: target_path) + project = Project.find_by_id(project_id) unless project.present? diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 7d819fe78f8..e6701078f71 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -10,6 +10,10 @@ class RepositoryImportWorker @project = Project.find(project_id) @current_user = @project.creator + Gitlab::Metrics.add_event(:import_repository, + import_url: @project.import_url, + path: @project.path_with_namespace) + result = Projects::ImportService.new(project, current_user).execute if result[:status] == :error diff --git a/config/application.rb b/config/application.rb index 4a9ed41cbf8..6b80f8ddafa 100644 --- a/config/application.rb +++ b/config/application.rb @@ -85,6 +85,9 @@ module Gitlab config.assets.precompile << "users/users_bundle.js" config.assets.precompile << "network/network_bundle.js" config.assets.precompile << "profile/profile_bundle.js" + config.assets.precompile << "diff_notes/diff_notes_bundle.js" + config.assets.precompile << "boards/boards_bundle.js" + config.assets.precompile << "boards/test_utils/simulate_drag.js" config.assets.precompile << "lib/utils/*.js" config.assets.precompile << "lib/*.js" config.assets.precompile << "u2f.js" diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 618dba74151..fc4b0a72add 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -12,7 +12,8 @@ Doorkeeper.configure do end resource_owner_from_credentials do |routes| - Gitlab::Auth.find_with_user_password(params[:username], params[:password]) + user = Gitlab::Auth.find_with_user_password(params[:username], params[:password]) + user unless user.try(:two_factor_enabled?) end # If you want to restrict access to the web interface for adding oauth authorized applications, you need to declare the block below. diff --git a/config/routes.rb b/config/routes.rb index 9a98fab15a3..66f77aee06a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -252,7 +252,11 @@ Rails.application.routes.draw do resource :impersonation, only: :destroy resources :abuse_reports, only: [:index, :destroy] - resources :spam_logs, only: [:index, :destroy] + resources :spam_logs, only: [:index, :destroy] do + member do + post :mark_as_ham + end + end resources :applications @@ -371,6 +375,8 @@ Rails.application.routes.draw do patch :skip end end + + resources :u2f_registrations, only: [:destroy] end end @@ -524,6 +530,11 @@ Rails.application.routes.draw do put '/update/*id', to: 'blob#update', constraints: { id: /.+/ }, as: 'update_blob' post '/preview/*id', to: 'blob#preview', constraints: { id: /.+/ }, as: 'preview_blob' + # + # Templates + # + get '/templates/:template_type/:key' => 'templates#show', as: :template + scope do get( '/blob/*id/diff', @@ -718,7 +729,9 @@ Rails.application.routes.draw do member do get :commits get :diffs + get :conflicts get :builds + get :pipelines get :merge_check post :merge post :cancel_merge_when_build_succeeds @@ -727,6 +740,7 @@ Rails.application.routes.draw do post :toggle_award_emoji post :remove_wip get :diff_for_path + post :resolve_conflicts end collection do @@ -735,6 +749,13 @@ Rails.application.routes.draw do get :update_branches get :diff_for_path end + + resources :discussions, only: [], constraints: { id: /\h{40}/ } do + member do + post :resolve + delete :resolve, action: :unresolve + end + end end resources :branches, only: [:index, :new, :create, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex } @@ -813,6 +834,7 @@ Rails.application.routes.draw do member do post :toggle_subscription post :toggle_award_emoji + post :mark_as_spam get :referenced_merge_requests get :related_branches get :can_create_branch @@ -843,6 +865,22 @@ Rails.application.routes.draw do member do post :toggle_award_emoji delete :delete_attachment + post :resolve + delete :resolve, action: :unresolve + end + end + + resource :board, only: [:show] do + scope module: :boards do + resources :issues, only: [:update] + + resources :lists, only: [:index, :create, :update, :destroy] do + collection do + post :generate + end + + resources :issues, only: [:index] + end end end @@ -869,7 +907,10 @@ Rails.application.routes.draw do resources :badges, only: [:index] do collection do scope '*ref', constraints: { ref: Gitlab::Regex.git_reference_regex } do - get :build, constraints: { format: /svg/ } + constraints format: /svg/ do + get :build + get :coverage + end end end end diff --git a/db/fixtures/development/14_builds.rb b/db/fixtures/development/14_builds.rb index e65abe4ef77..069d9dd6226 100644 --- a/db/fixtures/development/14_builds.rb +++ b/db/fixtures/development/14_builds.rb @@ -1,5 +1,20 @@ class Gitlab::Seeder::Builds - STAGES = %w[build notify_build test notify_test deploy notify_deploy] + STAGES = %w[build test deploy notify] + BUILDS = [ + { name: 'build:linux', stage: 'build', status: :success }, + { name: 'build:osx', stage: 'build', status: :success }, + { name: 'rspec:linux', stage: 'test', status: :success }, + { name: 'rspec:windows', stage: 'test', status: :success }, + { name: 'rspec:windows', stage: 'test', status: :success }, + { name: 'rspec:osx', stage: 'test', status_event: :success }, + { name: 'spinach:linux', stage: 'test', status: :pending }, + { name: 'spinach:osx', stage: 'test', status: :canceled }, + { name: 'cucumber:linux', stage: 'test', status: :running }, + { name: 'cucumber:osx', stage: 'test', status: :failed }, + { name: 'staging', stage: 'deploy', environment: 'staging', status: :success }, + { name: 'production', stage: 'deploy', environment: 'production', when: 'manual', status: :skipped }, + { name: 'slack', stage: 'notify', when: 'manual', status: :created }, + ] def initialize(project) @project = project @@ -8,62 +23,65 @@ class Gitlab::Seeder::Builds def seed! pipelines.each do |pipeline| begin - build_create!(pipeline, name: 'build:linux', stage: 'build', status_event: :success) - build_create!(pipeline, name: 'build:osx', stage: 'build', status_event: :success) - - build_create!(pipeline, name: 'slack post build', stage: 'notify_build', status_event: :success) - - build_create!(pipeline, name: 'rspec:linux', stage: 'test', status_event: :success) - build_create!(pipeline, name: 'rspec:windows', stage: 'test', status_event: :success) - build_create!(pipeline, name: 'rspec:windows', stage: 'test', status_event: :success) - build_create!(pipeline, name: 'rspec:osx', stage: 'test', status_event: :success) - build_create!(pipeline, name: 'spinach:linux', stage: 'test', status: :pending) - build_create!(pipeline, name: 'spinach:osx', stage: 'test', status_event: :cancel) - build_create!(pipeline, name: 'cucumber:linux', stage: 'test', status_event: :run) - build_create!(pipeline, name: 'cucumber:osx', stage: 'test', status_event: :drop) - - build_create!(pipeline, name: 'slack post test', stage: 'notify_test', status_event: :success) - - build_create!(pipeline, name: 'staging', stage: 'deploy', environment: 'staging', status_event: :success) - build_create!(pipeline, name: 'production', stage: 'deploy', environment: 'production', when: 'manual', status: :success) - - commit_status_create!(pipeline, name: 'jenkins', status: :success) - + BUILDS.each { |opts| build_create!(pipeline, opts) } + commit_status_create!(pipeline, name: 'jenkins', stage: 'test', status: :success) print '.' rescue ActiveRecord::RecordInvalid print 'F' + ensure + pipeline.build_updated end end end def pipelines - commits = @project.repository.commits('master', limit: 5) - commits_sha = commits.map { |commit| commit.raw.id } - commits_sha.map do |sha| - @project.ensure_pipeline(sha, 'master') - end + master_pipelines + merge_request_pipelines + end + + def master_pipelines + create_pipelines_for(@project, 'master') rescue [] end + def merge_request_pipelines + @project.merge_requests.last(5).map do |merge_request| + create_pipelines(merge_request.source_project, merge_request.source_branch, merge_request.commits.last(5)) + end.flatten + rescue + [] + end + + def create_pipelines_for(project, ref) + commits = project.repository.commits(ref, limit: 5) + create_pipelines(project, ref, commits) + end + + def create_pipelines(project, ref, commits) + commits.map do |commit| + project.pipelines.create(sha: commit.id, ref: ref) + end + end + def build_create!(pipeline, opts = {}) attributes = build_attributes_for(pipeline, opts) - build = Ci::Build.create!(attributes) - if opts[:name].start_with?('build') - artifacts_cache_file(artifacts_archive_path) do |file| - build.artifacts_file = file + Ci::Build.create!(attributes) do |build| + if opts[:name].start_with?('build') + artifacts_cache_file(artifacts_archive_path) do |file| + build.artifacts_file = file + end + + artifacts_cache_file(artifacts_metadata_path) do |file| + build.artifacts_metadata = file + end end - artifacts_cache_file(artifacts_metadata_path) do |file| - build.artifacts_metadata = file + if %w(running success failed).include?(build.status) + # We need to set build trace after saving a build (id required) + build.trace = FFaker::Lorem.paragraphs(6).join("\n\n") end end - - if %w(running success failed).include?(build.status) - # We need to set build trace after saving a build (id required) - build.trace = FFaker::Lorem.paragraphs(6).join("\n\n") - end end def commit_status_create!(pipeline, opts = {}) diff --git a/db/migrate/20160724205507_add_resolved_to_notes.rb b/db/migrate/20160724205507_add_resolved_to_notes.rb new file mode 100644 index 00000000000..b8ebcdbd156 --- /dev/null +++ b/db/migrate/20160724205507_add_resolved_to_notes.rb @@ -0,0 +1,10 @@ +class AddResolvedToNotes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :notes, :resolved_at, :datetime + add_column :notes, :resolved_by_id, :integer + end +end diff --git a/db/migrate/20160727163552_create_user_agent_details.rb b/db/migrate/20160727163552_create_user_agent_details.rb new file mode 100644 index 00000000000..ed4ccfedc0a --- /dev/null +++ b/db/migrate/20160727163552_create_user_agent_details.rb @@ -0,0 +1,18 @@ +class CreateUserAgentDetails < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + create_table :user_agent_details do |t| + t.string :user_agent, null: false + t.string :ip_address, null: false + t.integer :subject_id, null: false + t.string :subject_type, null: false + t.boolean :submitted, default: false, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160727191041_create_boards.rb b/db/migrate/20160727191041_create_boards.rb new file mode 100644 index 00000000000..56afbd4e030 --- /dev/null +++ b/db/migrate/20160727191041_create_boards.rb @@ -0,0 +1,13 @@ +class CreateBoards < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :boards do |t| + t.references :project, index: true, foreign_key: true, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160727193336_create_lists.rb b/db/migrate/20160727193336_create_lists.rb new file mode 100644 index 00000000000..61d501215f2 --- /dev/null +++ b/db/migrate/20160727193336_create_lists.rb @@ -0,0 +1,16 @@ +class CreateLists < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :lists do |t| + t.references :board, index: true, foreign_key: true, null: false + t.references :label, index: true, foreign_key: true + t.integer :list_type, null: false, default: 1 + t.integer :position + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb b/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb new file mode 100644 index 00000000000..b800e6d7283 --- /dev/null +++ b/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb @@ -0,0 +1,16 @@ +class AddPipelineEventsToWebHooks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:web_hooks, :pipeline_events, :boolean, + default: false, allow_null: false) + end + + def down + remove_column(:web_hooks, :pipeline_events) + end +end diff --git a/db/migrate/20160728103734_add_pipeline_events_to_services.rb b/db/migrate/20160728103734_add_pipeline_events_to_services.rb new file mode 100644 index 00000000000..bcd24fe1566 --- /dev/null +++ b/db/migrate/20160728103734_add_pipeline_events_to_services.rb @@ -0,0 +1,16 @@ +class AddPipelineEventsToServices < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:services, :pipeline_events, :boolean, + default: false, allow_null: false) + end + + def down + remove_column(:services, :pipeline_events) + end +end diff --git a/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb b/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb new file mode 100644 index 00000000000..e28ab31d629 --- /dev/null +++ b/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveProjectIdFromSpamLogs < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = true + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + DOWNTIME_REASON = 'Removing a column that contains data that is not used anywhere.' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + remove_column :spam_logs, :project_id, :integer + end +end diff --git a/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb b/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb new file mode 100644 index 00000000000..296f1dfac7b --- /dev/null +++ b/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb @@ -0,0 +1,20 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddSubmittedAsHamToSpamLogs < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + disable_ddl_transaction! + + def change + add_column_with_default :spam_logs, :submitted_as_ham, :boolean, default: false + end +end diff --git a/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb b/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb new file mode 100644 index 00000000000..baf2e70b127 --- /dev/null +++ b/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb @@ -0,0 +1,15 @@ +class AddUniqueIndexToListsLabelId < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :lists, [:board_id, :label_id], unique: true + end + + def down + remove_index :lists, column: [:board_id, :label_id] if index_exists?(:lists, [:board_id, :label_id], unique: true) + end +end diff --git a/db/migrate/20160816161312_add_column_name_to_u2f_registrations.rb b/db/migrate/20160816161312_add_column_name_to_u2f_registrations.rb new file mode 100644 index 00000000000..7152bd04331 --- /dev/null +++ b/db/migrate/20160816161312_add_column_name_to_u2f_registrations.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddColumnNameToU2fRegistrations < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + add_column :u2f_registrations, :name, :string + end +end diff --git a/db/migrate/20160817154936_add_discussion_ids_to_notes.rb b/db/migrate/20160817154936_add_discussion_ids_to_notes.rb new file mode 100644 index 00000000000..61facce665a --- /dev/null +++ b/db/migrate/20160817154936_add_discussion_ids_to_notes.rb @@ -0,0 +1,13 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddDiscussionIdsToNotes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :notes, :discussion_id, :string + add_column :notes, :original_discussion_id, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 0b61e19d998..c74d4688a7d 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: 20160810142633) do +ActiveRecord::Schema.define(version: 20160817154936) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -117,6 +117,14 @@ ActiveRecord::Schema.define(version: 20160810142633) do add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree add_index "award_emoji", ["user_id"], name: "index_award_emoji_on_user_id", using: :btree + create_table "boards", force: :cascade do |t| + t.integer "project_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree + create_table "broadcast_messages", force: :cascade do |t| t.text "message", null: false t.datetime "starts_at" @@ -534,6 +542,19 @@ ActiveRecord::Schema.define(version: 20160810142633) do add_index "lfs_objects_projects", ["project_id"], name: "index_lfs_objects_projects_on_project_id", using: :btree + create_table "lists", force: :cascade do |t| + t.integer "board_id", null: false + t.integer "label_id" + t.integer "list_type", default: 1, null: false + t.integer "position" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "lists", ["board_id", "label_id"], name: "index_lists_on_board_id_and_label_id", unique: true, using: :btree + add_index "lists", ["board_id"], name: "index_lists_on_board_id", using: :btree + add_index "lists", ["label_id"], name: "index_lists_on_label_id", using: :btree + create_table "members", force: :cascade do |t| t.integer "access_level", null: false t.integer "source_id", null: false @@ -590,13 +611,13 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.datetime "locked_at" t.integer "updated_by_id" t.string "merge_error" - t.text "merge_params" t.boolean "merge_when_build_succeeds", default: false, null: false t.integer "merge_user_id" t.string "merge_commit_sha" t.datetime "deleted_at" t.integer "lock_version", default: 0, null: false t.string "in_progress_merge_commit_sha" + t.text "merge_params" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree @@ -665,12 +686,16 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.string "line_code" t.string "commit_id" t.integer "noteable_id" - t.boolean "system", default: false, null: false + t.boolean "system", default: false, null: false t.text "st_diff" t.integer "updated_by_id" t.string "type" t.text "position" t.text "original_position" + t.datetime "resolved_at" + t.integer "resolved_by_id" + t.string "discussion_id" + t.string "original_discussion_id" end add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree @@ -899,6 +924,7 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.string "category", default: "common", null: false t.boolean "default", default: false t.boolean "wiki_page_events", default: true + t.boolean "pipeline_events", default: false, null: false end add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree @@ -928,12 +954,12 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.string "source_ip" t.string "user_agent" t.boolean "via_api" - t.integer "project_id" t.string "noteable_type" t.string "title" t.text "description" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "submitted_as_ham", default: false, null: false end create_table "subscriptions", force: :cascade do |t| @@ -996,11 +1022,22 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.integer "user_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "name" end add_index "u2f_registrations", ["key_handle"], name: "index_u2f_registrations_on_key_handle", using: :btree add_index "u2f_registrations", ["user_id"], name: "index_u2f_registrations_on_user_id", using: :btree + create_table "user_agent_details", force: :cascade do |t| + t.string "user_agent", null: false + t.string "ip_address", null: false + t.integer "subject_id", null: false + t.string "subject_type", null: false + t.boolean "submitted", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "users", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false @@ -1102,10 +1139,14 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.boolean "build_events", default: false, null: false t.boolean "wiki_page_events", default: false, null: false t.string "token" + t.boolean "pipeline_events", default: false, null: false end add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree + add_foreign_key "boards", "projects" + add_foreign_key "lists", "boards" + add_foreign_key "lists", "labels" add_foreign_key "personal_access_tokens", "users" add_foreign_key "protected_branch_merge_access_levels", "protected_branches" add_foreign_key "protected_branch_push_access_levels", "protected_branches" diff --git a/doc/api/README.md b/doc/api/README.md index f3117815c7c..3e79cce0120 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -26,6 +26,7 @@ following locations: - [Open source license templates](licenses.md) - [Namespaces](namespaces.md) - [Notes](notes.md) (comments) +- [Pipelines](pipelines.md) - [Projects](projects.md) including setting Webhooks - [Project Access Requests](access_requests.md) - [Project Members](members.md) diff --git a/doc/api/builds.md b/doc/api/builds.md index 8864df03c98..dce666445d0 100644 --- a/doc/api/builds.md +++ b/doc/api/builds.md @@ -532,3 +532,49 @@ Example response: "user": null } ``` + +## Play a build + +Triggers a manual action to start a build. + +``` +POST /projects/:id/builds/:build_id/play +``` + +| Attribute | Type | Required | Description | +|------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `build_id` | integer | yes | The ID of a build | + +``` +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/play" +``` + +Example of response + +```json +{ + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2016-01-11T10:13:33.506Z", + "artifacts_file": null, + "finished_at": null, + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": null, + "status": "started", + "tag": false, + "user": null +} +``` diff --git a/doc/api/deployments.md b/doc/api/deployments.md new file mode 100644 index 00000000000..417962de82d --- /dev/null +++ b/doc/api/deployments.md @@ -0,0 +1,218 @@ +# Deployments API + +## List project deployments + +Get a list of deployments in a project. + +``` +GET /projects/:id/deployments +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/deployments" +``` + +Example of response + +```json +[ + { + "created_at": "2016-08-11T07:36:40.222Z", + "deployable": { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2016-08-11T09:36:01.000+02:00", + "id": "99d03678b90d914dbb1b109132516d71a4a03ea8", + "message": "Merge branch 'new-title' into 'master'\r\n\r\nUpdate README\r\n\r\n\r\n\r\nSee merge request !1", + "short_id": "99d03678", + "title": "Merge branch 'new-title' into 'master'\r" + }, + "coverage": null, + "created_at": "2016-08-11T07:36:27.357Z", + "finished_at": "2016-08-11T07:36:39.851Z", + "id": 657, + "name": "deploy", + "ref": "master", + "runner": null, + "stage": "deploy", + "started_at": null, + "status": "success", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2016-08-11T07:09:20.351Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "location": null, + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://localhost:3000/u/root", + "website_url": "" + } + }, + "environment": { + "external_url": "https://about.gitlab.com", + "id": 9, + "name": "production" + }, + "id": 41, + "iid": 1, + "ref": "master", + "sha": "99d03678b90d914dbb1b109132516d71a4a03ea8", + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "id": 1, + "name": "Administrator", + "state": "active", + "username": "root", + "web_url": "http://localhost:3000/u/root" + } + }, + { + "created_at": "2016-08-11T11:32:35.444Z", + "deployable": { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2016-08-11T13:28:26.000+02:00", + "id": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "message": "Merge branch 'rename-readme' into 'master'\r\n\r\nRename README\r\n\r\n\r\n\r\nSee merge request !2", + "short_id": "a91957a8", + "title": "Merge branch 'rename-readme' into 'master'\r" + }, + "coverage": null, + "created_at": "2016-08-11T11:32:24.456Z", + "finished_at": "2016-08-11T11:32:35.145Z", + "id": 664, + "name": "deploy", + "ref": "master", + "runner": null, + "stage": "deploy", + "started_at": null, + "status": "success", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2016-08-11T07:09:20.351Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "location": null, + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://localhost:3000/u/root", + "website_url": "" + } + }, + "environment": { + "external_url": "https://about.gitlab.com", + "id": 9, + "name": "production" + }, + "id": 42, + "iid": 2, + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "id": 1, + "name": "Administrator", + "state": "active", + "username": "root", + "web_url": "http://localhost:3000/u/root" + } + } +] +``` + +## Get a specific deployment + +``` +GET /projects/:id/deployments/:deployment_id +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `deployment_id` | integer | yes | The ID of the deployment | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/deployments/1" +``` + +Example of response + +```json +{ + "id": 42, + "iid": 2, + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "created_at": "2016-08-11T11:32:35.444Z", + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "environment": { + "id": 9, + "name": "production", + "external_url": "https://about.gitlab.com" + }, + "deployable": { + "id": 664, + "status": "success", + "stage": "deploy", + "name": "deploy", + "ref": "master", + "tag": false, + "coverage": null, + "created_at": "2016-08-11T11:32:24.456Z", + "started_at": null, + "finished_at": "2016-08-11T11:32:35.145Z", + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root", + "created_at": "2016-08-11T07:09:20.351Z", + "is_admin": true, + "bio": null, + "location": null, + "skype": "", + "linkedin": "", + "twitter": "", + "website_url": "" + }, + "commit": { + "id": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "short_id": "a91957a8", + "title": "Merge branch 'rename-readme' into 'master'\r", + "author_name": "Administrator", + "author_email": "admin@example.com", + "created_at": "2016-08-11T13:28:26.000+02:00", + "message": "Merge branch 'rename-readme' into 'master'\r\n\r\nRename README\r\n\r\n\r\n\r\nSee merge request !2" + }, + "runner": null + } +} +``` diff --git a/doc/api/oauth2.md b/doc/api/oauth2.md index 16ef79617c0..0b0fc39ec7e 100644 --- a/doc/api/oauth2.md +++ b/doc/api/oauth2.md @@ -90,7 +90,7 @@ curl --header "Authorization: Bearer OAUTH-TOKEN" https://localhost:3000/api/v3/ ## Deprecation Notice -1. Starting in GitLab 9.0, the Resource Owner Password Credentials will be *disabled* for users with two-factor authentication turned on. +1. Starting in GitLab 8.11, the Resource Owner Password Credentials has been *disabled* for users with two-factor authentication turned on. 2. These users can access the API using [personal access tokens] instead. --- diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md new file mode 100644 index 00000000000..847408a7f61 --- /dev/null +++ b/doc/api/pipelines.md @@ -0,0 +1,207 @@ +# Pipelines API + +## List project pipelines + +> [Introduced][ce-5837] in GitLab 8.11 + +``` +GET /projects/:id/pipelines +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | + +``` +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipelines" +``` + +Example of response + +```json +[ + { + "id": 47, + "status": "pending", + "ref": "new-pipeline", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "tag": false, + "yaml_errors": null, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "created_at": "2016-08-16T10:23:19.007Z", + "updated_at": "2016-08-16T10:23:19.216Z", + "started_at": null, + "finished_at": null, + "committed_at": null, + "duration": null + }, + { + "id": 48, + "status": "pending", + "ref": "new-pipeline", + "sha": "eb94b618fb5865b26e80fdd8ae531b7a63ad851a", + "before_sha": "eb94b618fb5865b26e80fdd8ae531b7a63ad851a", + "tag": false, + "yaml_errors": null, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "created_at": "2016-08-16T10:23:21.184Z", + "updated_at": "2016-08-16T10:23:21.314Z", + "started_at": null, + "finished_at": null, + "committed_at": null, + "duration": null + } +] +``` + +## Get a single pipeline + +> [Introduced][ce-5837] in GitLab 8.11 + +``` +GET /projects/:id/pipelines/:pipeline_id +``` + +| Attribute | Type | Required | Description | +|------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `pipeline_id` | integer | yes | The ID of a pipeline | + +``` +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipeline/46" +``` + +Example of response + +```json +{ + "id": 46, + "status": "success", + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "tag": false, + "yaml_errors": null, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "created_at": "2016-08-11T11:28:34.085Z", + "updated_at": "2016-08-11T11:32:35.169Z", + "started_at": null, + "finished_at": "2016-08-11T11:32:35.145Z", + "committed_at": null, + "duration": null +} +``` + +## Retry failed builds in a pipeline + +> [Introduced][ce-5837] in GitLab 8.11 + +``` +POST /projects/:id/pipelines/:pipeline_id/retry +``` + +| Attribute | Type | Required | Description | +|------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `pipeline_id` | integer | yes | The ID of a pipeline | + +``` +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipelines/46/retry" +``` + +Response: + +```json +{ + "id": 46, + "status": "pending", + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "tag": false, + "yaml_errors": null, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "created_at": "2016-08-11T11:28:34.085Z", + "updated_at": "2016-08-11T11:32:35.169Z", + "started_at": null, + "finished_at": "2016-08-11T11:32:35.145Z", + "committed_at": null, + "duration": null +} +``` + +## Cancel a pipelines builds + +> [Introduced][ce-5837] in GitLab 8.11 + +``` +POST /projects/:id/pipelines/:pipeline_id/cancel +``` + +| Attribute | Type | Required | Description | +|------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `pipeline_id` | integer | yes | The ID of a pipeline | + +``` +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipelines/46/cancel" +``` + +Response: + +```json +{ + "id": 46, + "status": "canceled", + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "tag": false, + "yaml_errors": null, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "created_at": "2016-08-11T11:28:34.085Z", + "updated_at": "2016-08-11T11:32:35.169Z", + "started_at": null, + "finished_at": "2016-08-11T11:32:35.145Z", + "committed_at": null, + "duration": null +} +``` + +[ce-5837]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5837 diff --git a/doc/api/session.md b/doc/api/session.md index 9076c48b899..f776424023e 100644 --- a/doc/api/session.md +++ b/doc/api/session.md @@ -2,7 +2,7 @@ ## Deprecation Notice -1. Starting in GitLab 9.0, this feature will be *disabled* for users with two-factor authentication turned on. +1. Starting in GitLab 8.11, this feature has been *disabled* for users with two-factor authentication turned on. 2. These users can access the API using [personal access tokens] instead. --- diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md index 48a9f994759..d90d7aca4fd 100644 --- a/doc/ci/pipelines.md +++ b/doc/ci/pipelines.md @@ -32,6 +32,41 @@ project. Clicking on a pipeline will show the builds that were run for that pipeline. +## Badges + +There are build status and test coverage report badges available. + +Go to pipeline settings to see available badges and code you can use to embed +badges in the `README.md` or your website. + +### Build status badge + +You can access a build status badge image using following link: + +``` +http://example.gitlab.com/namespace/project/badges/branch/build.svg +``` + +### Test coverage report badge + +GitLab makes it possible to define the regular expression for coverage report, +that each build log will be matched against. This means that each build in the +pipeline can have the test coverage percentage value defined. + +You can access test coverage badge using following link: + +``` +http://example.gitlab.com/namespace/project/badges/branch/coverage.svg +``` + +If you would like to get the coverage report from the specific job, you can add +a `job=coverage_job_name` parameter to the URL. For example, it is possible to +use following Markdown code to embed the est coverage report into `README.md`: + +```markdown +![coverage](http://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage) +``` + [builds]: #builds [jobs]: yaml/README.md#jobs [stages]: yaml/README.md#stages diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 6a3c416d995..c835ebc2d44 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -218,21 +218,13 @@ project's settings. For more information read the [Builds emails service documentation](../../project_services/builds_emails.md). -## Builds badge - -You can access a builds badge image using following link: - -``` -http://example.gitlab.com/namespace/project/badges/branch/build.svg -``` - -Awesome! You started using CI in GitLab! - ## Examples Visit the [examples README][examples] to see a list of examples using GitLab CI with various languages. +Awesome! You started using CI in GitLab! + [runner-install]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/tree/master#install-gitlab-runner [blog-ci]: https://about.gitlab.com/2015/05/06/why-were-replacing-gitlab-ci-jobs-with-gitlab-ci-dot-yml/ [examples]: ../examples/README.md diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 01d71088543..e7850aa2c9d 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -353,7 +353,7 @@ job_name: | except | no | Defines a list of git refs for which build is not created | | tags | no | Defines a list of tags which are used to select Runner | | allow_failure | no | Allow build to fail. Failed build doesn't contribute to commit status | -| when | no | Define when to run build. Can be `on_success`, `on_failure` or `always` | +| when | no | Define when to run build. Can be `on_success`, `on_failure`, `always` or `manual` | | dependencies | no | Define other builds that a build depends on so that you can pass artifacts between them| | artifacts | no | Define list of build artifacts | | cache | no | Define list of files that should be cached between subsequent runs | diff --git a/doc/development/README.md b/doc/development/README.md index bf67b5d8dff..57f37da6f80 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -30,7 +30,11 @@ - [Rake tasks](rake_tasks.md) for development - [Shell commands](shell_commands.md) in the GitLab codebase - [Sidekiq debugging](sidekiq_debugging.md) + +## Databases + - [What requires downtime?](what_requires_downtime.md) +- [Adding database indexes](adding_database_indexes.md) ## Compliance diff --git a/doc/development/adding_database_indexes.md b/doc/development/adding_database_indexes.md new file mode 100644 index 00000000000..ea6f14da3b9 --- /dev/null +++ b/doc/development/adding_database_indexes.md @@ -0,0 +1,123 @@ +# Adding Database Indexes + +Indexes can be used to speed up database queries, but when should you add a new +index? Traditionally the answer to this question has been to add an index for +every column used for filtering or joining data. For example, consider the +following query: + +```sql +SELECT * +FROM projects +WHERE user_id = 2; +``` + +Here we are filtering by the `user_id` column and as such a developer may decide +to index this column. + +While in certain cases indexing columns using the above approach may make sense +it can actually have a negative impact. Whenever you write data to a table any +existing indexes need to be updated. The more indexes there are the slower this +can potentially become. Indexes can also take up quite some disk space depending +on the amount of data indexed and the index type. For example, PostgreSQL offers +"GIN" indexes which can be used to index certain data types that can not be +indexed by regular btree indexes. These indexes however generally take up more +data and are slower to update compared to btree indexes. + +Because of all this one should not blindly add a new index for every column used +to filter data by. Instead one should ask themselves the following questions: + +1. Can I write my query in such a way that it re-uses as many existing indexes + as possible? +2. Is the data going to be large enough that using an index will actually be + faster than just iterating over the rows in the table? +3. Is the overhead of maintaining the index worth the reduction in query + timings? + +We'll explore every question in detail below. + +## Re-using Queries + +The first step is to make sure your query re-uses as many existing indexes as +possible. For example, consider the following query: + +```sql +SELECT * +FROM todos +WHERE user_id = 123 +AND state = 'open'; +``` + +Now imagine we already have an index on the `user_id` column but not on the +`state` column. One may think this query will perform badly due to `state` being +unindexed. In reality the query may perform just fine given the index on +`user_id` can filter out enough rows. + +The best way to determine if indexes are re-used is to run your query using +`EXPLAIN ANALYZE`. Depending on any extra tables that may be joined and +other columns being used for filtering you may find an extra index is not going +to make much (if any) difference. On the other hand you may determine that the +index _may_ make a difference. + +In short: + +1. Try to write your query in such a way that it re-uses as many existing + indexes as possible. +2. Run the query using `EXPLAIN ANALYZE` and study the output to find the most + ideal query. + +## Data Size + +A database may decide not to use an index despite it existing in case a regular +sequence scan (= simply iterating over all existing rows) is faster. This is +especially the case for small tables. + +If a table is expected to grow in size and you expect your query has to filter +out a lot of rows you may want to consider adding an index. If the table size is +very small (e.g. only a handful of rows) or any existing indexes filter out +enough rows you may _not_ want to add a new index. + +## Maintenance Overhead + +Indexes have to be updated on every table write. In case of PostgreSQL _all_ +existing indexes will be updated whenever data is written to a table. As a +result of this having many indexes on the same table will slow down writes. + +Because of this one should ask themselves: is the reduction in query performance +worth the overhead of maintaining an extra index? + +If adding an index reduces SELECT timings by 5 milliseconds but increases +INSERT/UPDATE/DELETE timings by 10 milliseconds then the index may not be worth +it. On the other hand, if SELECT timings are reduced but INSERT/UPDATE/DELETE +timings are not affected you may want to add the index after all. + +## Finding Unused Indexes + +To see which indexes are unused you can run the following query: + +```sql +SELECT relname as table_name, indexrelname as index_name, idx_scan, idx_tup_read, idx_tup_fetch, pg_size_pretty(pg_relation_size(indexrelname::regclass)) +FROM pg_stat_all_indexes +WHERE schemaname = 'public' +AND idx_scan = 0 +AND idx_tup_read = 0 +AND idx_tup_fetch = 0 +ORDER BY pg_relation_size(indexrelname::regclass) desc; +``` + +This query outputs a list containing all indexes that are never used and sorts +them by indexes sizes in descending order. This query can be useful to +determine if any previously indexes are useful after all. More information on +the meaning of the various columns can be found at +. + +Because the output of this query relies on the actual usage of your database it +may be affected by factors such as (but not limited to): + +* Certain queries never being executed, thus not being able to use certain + indexes. +* Certain tables having little data, resulting in PostgreSQL using sequence + scans instead of index scans. + +In other words, this data is only reliable for a frequently used database with +plenty of data and with as many GitLab features enabled (and being used) as +possible. diff --git a/doc/development/ui_guide.md b/doc/development/ui_guide.md index 3a8c823e026..2d1d504202c 100644 --- a/doc/development/ui_guide.md +++ b/doc/development/ui_guide.md @@ -15,11 +15,14 @@ repository and maintained by GitLab UX designers. ## Navigation GitLab's layout contains 2 sections: the left sidebar and the content. The left sidebar contains a static navigation menu. -This menu will be visible regardless of what page you visit. The left sidebar also contains the GitLab logo -and the current user's profile picture. The content section contains a header and the content itself. -The header describes the current GitLab page and what navigation is -available to user in this area. Depending on the area (project, group, profile setting) the header name and navigation may change. For example when user visits one of the -project pages the header will contain a project name and navigation for that project. When the user visits a group page it will contain a group name and navigation related to this group. +This menu will be visible regardless of what page you visit. +The content section contains a header and the content itself. The header describes the current GitLab page and what navigation is +available to the user in this area. Depending on the area (project, group, profile setting) the header name and navigation may change. For example, when the user visits one of the +project pages the header will contain the project's name and navigation for that project. When the user visits a group page it will contain the group's name and navigation related to this group. + +You can see a visual representation of the navigation in GitLab in the GitLab Product Map, which is located in the [Design Repository](gitlab-map-graffle) +along with [PDF](gitlab-map-pdf) and [PNG](gitlab-map-png) exports. + ### Adding new tab to header navigation @@ -99,3 +102,6 @@ Do not use both green and blue button in one form. display counts in the UI. [number_with_delimiter]: http://api.rubyonrails.org/classes/ActionView/Helpers/NumberHelper.html#method-i-number_with_delimiter +[gitlab-map-graffle]: https://gitlab.com/gitlab-org/gitlab-design/blob/master/production/resources/gitlab-map.graffle +[gitlab-map-pdf]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.pdf +[gitlab-map-png]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.png \ No newline at end of file diff --git a/doc/integration/akismet.md b/doc/integration/akismet.md index c222d21612f..a6436b5f926 100644 --- a/doc/integration/akismet.md +++ b/doc/integration/akismet.md @@ -22,14 +22,37 @@ To use Akismet: 2. Sign-in or create a new account. -3. Click on "Show" to reveal the API key. +3. Click on **Show** to reveal the API key. 4. Go to Applications Settings on Admin Area (`admin/application_settings`) -5. Check the `Enable Akismet` checkbox +5. Check the **Enable Akismet** checkbox 6. Fill in the API key from step 3. 7. Save the configuration. ![Screenshot of Akismet settings](img/akismet_settings.png) + + +## Training + +> *Note:* Training the Akismet filter is only available in 8.11 and above. + +As a way to better recognize between spam and ham, you can train the Akismet +filter whenever there is a false positive or false negative. + +When an entry is recognized as spam, it is rejected and added to the Spam Logs. +From here you can review if they are really spam. If one of them is not really +spam, you can use the **Submit as ham** button to tell Akismet that it falsely +recognized an entry as spam. + +![Screenshot of Spam Logs](img/spam_log.png) + +If an entry that is actually spam was not recognized as such, you will be able +to also submit this to Akismet. The **Submit as spam** button will only appear +to admin users. + +![Screenshot of Issue](img/submit_issue.png) + +Training Akismet will help it to recognize spam more accurately in the future. diff --git a/doc/integration/img/spam_log.png b/doc/integration/img/spam_log.png new file mode 100644 index 00000000000..8d574448690 Binary files /dev/null and b/doc/integration/img/spam_log.png differ diff --git a/doc/integration/img/submit_issue.png b/doc/integration/img/submit_issue.png new file mode 100644 index 00000000000..5c7896a7eec Binary files /dev/null and b/doc/integration/img/submit_issue.png differ diff --git a/doc/legal/corporate_contributor_license_agreement.md b/doc/legal/corporate_contributor_license_agreement.md index edd6c59138f..7f08188bd65 100644 --- a/doc/legal/corporate_contributor_license_agreement.md +++ b/doc/legal/corporate_contributor_license_agreement.md @@ -16,7 +16,7 @@ Subject to the terms and conditions of this Agreement, You hereby grant to GitLa Subject to the terms and conditions of this Agreement, You hereby grant to GitLab B.V. and to recipients of software distributed by GitLab B.V. a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. -4. You represent that You are legally entitled to grant the above license. You represent further that each employee of the Corporation is authorized to submit Contributions on behalf of the Corporation, but excluding employees that are designated in writing by You as "Not authorized to submit Contributions on behalf of [name of corporation here]." +4. You represent that You are legally entitled to grant the above license. You represent further that each of Your employees is authorized to submit Contributions on Your behalf, but excluding employees that are designated in writing by You as "Not authorized to submit Contributions on behalf of [name of Your corporation here]." Such designations of exclusion for unauthorized employees are to be submitted via email to legal@gitlab.com. 5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). @@ -24,6 +24,6 @@ Subject to the terms and conditions of this Agreement, You hereby grant to GitLa 7. Should You wish to submit work that is not Your original creation, You may submit it to GitLab B.V. separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". -8. It is your responsibility to notify GitLab B.V. when any change is required to the designation of employees not authorized to submit Contributions on behalf of the Corporation, or to the Corporation's Point of Contact with GitLab B.V.. +8. It is Your responsibility to notify GitLab.com when any change is required to the list of designated employees excluded from submitting Contributions on Your behalf per Section 4. Such notification should be sent via email to legal@gitlab.com. This text is licensed under the [Creative Commons Attribution 3.0 License](https://creativecommons.org/licenses/by/3.0/) and the original source is the Google Open Source Programs Office. diff --git a/doc/monitoring/performance/influxdb_schema.md b/doc/monitoring/performance/influxdb_schema.md index 41861860b6d..eff0e29f58d 100644 --- a/doc/monitoring/performance/influxdb_schema.md +++ b/doc/monitoring/performance/influxdb_schema.md @@ -9,6 +9,7 @@ The following measurements are currently stored in InfluxDB: - `PROCESS_object_counts` - `PROCESS_transactions` - `PROCESS_views` +- `events` Here, `PROCESS` is replaced with either `rails` or `sidekiq` depending on the process type. In all series, any form of duration is stored in milliseconds. @@ -78,6 +79,14 @@ following value fields are available: The `action` tag contains the action name of the transaction that rendered the view. +## events + +This measurement is used to store generic events such as the number of Git +pushes, Emails sent, etc. Each point in this measurement has a single value +field called `count`. The value of this field is simply set to `1`. Each point +also has at least one tag: `event`. This tag's value is set to the event name. +Depending on the event type additional tags may be available as well. + --- Read more on: diff --git a/doc/user/project/description_templates.md b/doc/user/project/description_templates.md new file mode 100644 index 00000000000..ea7496af089 --- /dev/null +++ b/doc/user/project/description_templates.md @@ -0,0 +1,42 @@ +# Description templates + +>[Introduced][ce-4981] in GitLab 8.11. + +Description templates allow you to define context-specific templates for issue +and merge request description fields for your project. + +## Overview + +By using the description templates, users that create a new issue or merge +request can select a description template to help them communicate with other +contributors effectively. + +Every GitLab project can define its own set of description templates as they +are added to the root directory of a GitLab project's repository. + +Description templates must be written in [Markdown](../markdown.md) and stored +in your project's repository under a directory named `.gitlab`. Only the +templates of the default branch will be taken into account. + +## Creating issue templates + +Create a new Markdown (`.md`) file inside the `.gitlab/issue_templates/` +directory in your repository. Commit and push to your default branch. + +## Creating merge request templates + +Similarly to issue templates, create a new Markdown (`.md`) file inside the +`.gitlab/merge_request_templates/` directory in your repository. Commit and +push to your default branch. + +## Using the templates + +Let's take for example that you've created the file `.gitlab/issue_templates/Bug.md`. +This will enable the `Bug` dropdown option when creating or editing issues. When +`Bug` is selected, the content from the `Bug.md` template file will be copied +to the issue description field. The 'Reset template' button will discard any +changes you made after picking the template and return it to its initial status. + +![Description templates](img/description_templates.png) + +[ce-4981]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4981 diff --git a/doc/user/project/img/description_templates.png b/doc/user/project/img/description_templates.png new file mode 100644 index 00000000000..c41cc77a94c Binary files /dev/null and b/doc/user/project/img/description_templates.png differ diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md index 0f7e9eede19..cf1d9cbe69c 100644 --- a/doc/user/project/labels.md +++ b/doc/user/project/labels.md @@ -1,8 +1,8 @@ # Labels Labels provide an easy way to categorize the issues or merge requests based on -descriptive titles like `bug`, `documentation` or any other text you feel like -it. They can have different colors, a description, and are visible throughout +descriptive titles like `bug`, `documentation` or any other text you feel like. +They can have different colors, a description, and are visible throughout the issue tracker or inside each issue individually. With labels, you can navigate the issue tracker and filter any bloated diff --git a/doc/user/project/merge_requests/img/conflict_section.png b/doc/user/project/merge_requests/img/conflict_section.png new file mode 100644 index 00000000000..842e50b14b2 Binary files /dev/null and b/doc/user/project/merge_requests/img/conflict_section.png differ diff --git a/doc/user/project/merge_requests/img/discussion_view.png b/doc/user/project/merge_requests/img/discussion_view.png new file mode 100644 index 00000000000..83bb60acce2 Binary files /dev/null and b/doc/user/project/merge_requests/img/discussion_view.png differ diff --git a/doc/user/project/merge_requests/img/discussions_resolved.png b/doc/user/project/merge_requests/img/discussions_resolved.png new file mode 100644 index 00000000000..85428129ac8 Binary files /dev/null and b/doc/user/project/merge_requests/img/discussions_resolved.png differ diff --git a/doc/user/project/merge_requests/img/merge_request_widget.png b/doc/user/project/merge_requests/img/merge_request_widget.png new file mode 100644 index 00000000000..ffb96b17b07 Binary files /dev/null and b/doc/user/project/merge_requests/img/merge_request_widget.png differ diff --git a/doc/user/project/merge_requests/img/resolve_comment_button.png b/doc/user/project/merge_requests/img/resolve_comment_button.png new file mode 100644 index 00000000000..2c4ab2f5d53 Binary files /dev/null and b/doc/user/project/merge_requests/img/resolve_comment_button.png differ diff --git a/doc/user/project/merge_requests/img/resolve_discussion_button.png b/doc/user/project/merge_requests/img/resolve_discussion_button.png new file mode 100644 index 00000000000..73f265bb101 Binary files /dev/null and b/doc/user/project/merge_requests/img/resolve_discussion_button.png differ diff --git a/doc/user/project/merge_requests/merge_request_discussion_resolution.md b/doc/user/project/merge_requests/merge_request_discussion_resolution.md new file mode 100644 index 00000000000..2559f5f5250 --- /dev/null +++ b/doc/user/project/merge_requests/merge_request_discussion_resolution.md @@ -0,0 +1,40 @@ +# Merge Request discussion resolution + +> [Introduced][ce-5022] in GitLab 8.11. + +Discussion resolution helps keep track of progress during code review. +Resolving comments prevents you from forgetting to address feedback and lets you +hide discussions that are no longer relevant. + +!["A discussion between two people on a piece of code"][discussion-view] + +Comments and discussions can be resolved by anyone with at least Developer +access to the project, as well as by the author of the merge request. + +## Marking a comment or discussion as resolved + +You can mark a discussion as resolved by clicking the "Resolve discussion" +button at the bottom of the discussion. + +!["Resolve discussion" button][resolve-discussion-button] + +Alternatively, you can mark each comment as resolved individually. + +!["Resolve comment" button][resolve-comment-button] + +## Jumping between unresolved discussions + +When a merge request has a large number of comments it can be difficult to track +what remains unresolved. You can jump between unresolved discussions with the +Jump button next to the Reply field on a discussion. + +You can also jump to the first unresolved discussion from the button next to the +resolved discussions tracker. + +!["3/4 discussions resolved"][discussions-resolved] + +[ce-5022]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5022 +[resolve-discussion-button]: img/resolve_discussion_button.png +[resolve-comment-button]: img/resolve_comment_button.png +[discussion-view]: img/discussion_view.png +[discussions-resolved]: img/discussions_resolved.png diff --git a/doc/user/project/merge_requests/resolve_conflicts.md b/doc/user/project/merge_requests/resolve_conflicts.md new file mode 100644 index 00000000000..44b76ffc8e6 --- /dev/null +++ b/doc/user/project/merge_requests/resolve_conflicts.md @@ -0,0 +1,41 @@ +# Merge conflict resolution + +> [Introduced][ce-5479] in GitLab 8.11. + +When a merge request has conflicts, GitLab may provide the option to resolve +those conflicts in the GitLab UI. (See +[conflicts available for resolution](#conflicts-available-for-resolution) for +more information on when this is available.) If this is an option, you will see +a **resolve these conflicts** link in the merge request widget: + +![Merge request widget](img/merge_request_widget.png) + +Clicking this will show a list of files with conflicts, with conflict sections +highlighted: + +![Conflict section](img/conflict_section.png) + +Once all conflicts have been marked as using 'ours' or 'theirs', the conflict +can be resolved. This will perform a merge of the target branch of the merge +request into the source branch, resolving the conflicts using the options +chosen. If the source branch is `feature` and the target branch is `master`, +this is similar to performing `git checkout feature; git merge master` locally. + +## Conflicts available for resolution + +GitLab allows resolving conflicts in a file where all of the below are true: + +- The file is text, not binary +- The file does not already contain conflict markers +- The file, with conflict markers added, is not over 200 KB in size +- The file exists under the same path in both branches + +If any file with conflicts in that merge request does not meet all of these +criteria, the conflicts for that merge request cannot be resolved in the UI. + +Additionally, GitLab does not detect conflicts in renames away from a path. For +example, this will not create a conflict: on branch `a`, doing `git mv file1 +file2`; on branch `b`, doing `git mv file1 file3`. Instead, both files will be +present in the branch after the merge request is merged. + +[ce-5479]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5479 diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index 2513def49a4..08ff89ce6ae 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -7,8 +7,7 @@ > than that of the exporter. > - For existing installations, the project import option has to be enabled in > application settings (`/admin/application_settings`) under 'Import sources'. -> Ask your administrator if you don't see the **GitLab export** button when -> creating a new project. +> You will have to be an administrator to enable and use the import functionality. > - You can find some useful raketasks if you are an administrator in the > [import_export](../../../administration/raketasks/project_import_export.md) > raketask. diff --git a/doc/web_hooks/web_hooks.md b/doc/web_hooks/web_hooks.md index d4b28d875cd..33c1a79d59c 100644 --- a/doc/web_hooks/web_hooks.md +++ b/doc/web_hooks/web_hooks.md @@ -754,6 +754,174 @@ X-Gitlab-Event: Wiki Page Hook } ``` +## Pipeline events + +Triggered on status change of Pipeline. + +**Request Header**: + +``` +X-Gitlab-Event: Pipeline Hook +``` + +**Request Body**: + +```json +{ + "object_kind": "pipeline", + "object_attributes":{ + "id": 31, + "ref": "master", + "tag": false, + "sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "before_sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "status": "success", + "stages":[ + "build", + "test", + "deploy" + ], + "created_at": "2016-08-12 15:23:28 UTC", + "finished_at": "2016-08-12 15:26:29 UTC", + "duration": 63 + }, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "project":{ + "name": "Gitlab Test", + "description": "Atque in sunt eos similique dolores voluptatem.", + "web_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test", + "avatar_url": null, + "git_ssh_url": "git@192.168.64.1:gitlab-org/gitlab-test.git", + "git_http_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test.git", + "namespace": "Gitlab Org", + "visibility_level": 20, + "path_with_namespace": "gitlab-org/gitlab-test", + "default_branch": "master" + }, + "commit":{ + "id": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "message": "test\n", + "timestamp": "2016-08-12T17:23:21+02:00", + "url": "http://example.com/gitlab-org/gitlab-test/commit/bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "author":{ + "name": "User", + "email": "user@gitlab.com" + } + }, + "builds":[ + { + "id": 380, + "stage": "deploy", + "name": "production", + "status": "skipped", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": null, + "finished_at": null, + "when": "manual", + "manual": true, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 377, + "stage": "test", + "name": "test-image", + "status": "success", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": "2016-08-12 15:26:12 UTC", + "finished_at": null, + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 378, + "stage": "test", + "name": "test-build", + "status": "success", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": "2016-08-12 15:26:12 UTC", + "finished_at": "2016-08-12 15:26:29 UTC", + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 376, + "stage": "build", + "name": "build-image", + "status": "success", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": "2016-08-12 15:24:56 UTC", + "finished_at": "2016-08-12 15:25:26 UTC", + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 379, + "stage": "deploy", + "name": "staging", + "status": "created", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": null, + "finished_at": null, + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + } + ] +} +``` + #### Example webhook receiver If you want to see GitLab's webhooks in action for testing purposes you can use diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 49dec613716..1653d95e722 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -2,10 +2,12 @@ - [Authorization for merge requests](authorization_for_merge_requests.md) - [Change your time zone](timezone.md) +- [Description templates](../user/project/description_templates.md) - [Feature branch workflow](workflow.md) - [GitLab Flow](gitlab_flow.md) - [Groups](groups.md) - [Keyboard shortcuts](shortcuts.md) +- [Slash commands](slash_commands.md) - [File finder](file_finder.md) - [Labels](../user/project/labels.md) - [Notification emails](notifications.md) diff --git a/doc/workflow/notifications.md b/doc/workflow/notifications.md index b4a9c2f3d3e..1b49a5c385f 100644 --- a/doc/workflow/notifications.md +++ b/doc/workflow/notifications.md @@ -67,7 +67,7 @@ In all of the below cases, the notification will be sent to: - Participants: - the author and assignee of the issue/merge request - authors of comments on the issue/merge request - - anyone mentioned by `@username` in the issue/merge request description + - anyone mentioned by `@username` in the issue/merge request title or description - anyone mentioned by `@username` in any of the comments on the issue/merge request ...with notification level "Participating" or higher @@ -89,6 +89,11 @@ In all of the below cases, the notification will be sent to: | Merge merge request | | | New comment | The above, plus anyone mentioned by `@username` in the comment, with notification level "Mention" or higher | + +In addition, if the title or description of an Issue or Merge Request is +changed, notifications will be sent to any **new** mentions by `@username` as +if they had been mentioned in the original text. + You won't receive notifications for Issues, Merge Requests or Milestones created by yourself. You will only receive automatic notifications when somebody else comments or adds changes to the ones that you've created or diff --git a/doc/workflow/slash_commands.md b/doc/workflow/slash_commands.md new file mode 100644 index 00000000000..91d69d4e77e --- /dev/null +++ b/doc/workflow/slash_commands.md @@ -0,0 +1,30 @@ +# GitLab slash commands + +Slash commands are textual shortcuts for common actions on issues or merge +requests that are usually done by clicking buttons or dropdowns in GitLab's UI. +You can enter these commands while creating a new issue or merge request, and +in comments. Each command should be on a separate line in order to be properly +detected and executed. The commands are removed from the issue, merge request or +comment body before it is saved and will not be visible to anyone else. + +Below is a list of all of the available commands and descriptions about what they +do. + +| Command | Action | +|:---------------------------|:-------------| +| `/close` | Close the issue or merge request | +| `/reopen` | Reopen the issue or merge request | +| `/title ` | Change title | +| `/assign @username` | Assign | +| `/unassign` | Remove assignee | +| `/milestone %milestone` | Set milestone | +| `/remove_milestone` | Remove milestone | +| `/label ~foo ~"bar baz"` | Add label(s) | +| `/unlabel ~foo ~"bar baz"` | Remove all or specific label(s) | +| `/relabel ~foo ~"bar baz"` | Replace all label(s) | +| `/todo` | Add a todo | +| `/done` | Mark todo as done | +| `/subscribe` | Subscribe | +| `/unsubscribe` | Unsubscribe | +| `/due ` | Set due date | +| `/remove_due_date` | Remove due date | diff --git a/features/dashboard/new_project.feature b/features/dashboard/new_project.feature index 8ddafb6a7ac..046e2815d4e 100644 --- a/features/dashboard/new_project.feature +++ b/features/dashboard/new_project.feature @@ -9,7 +9,7 @@ Background: @javascript Scenario: I should see New Projects page Then I see "New Project" page - Then I see all possible import optios + Then I see all possible import options @javascript Scenario: I should see instructions on how to import from Git URL diff --git a/features/steps/dashboard/new_project.rb b/features/steps/dashboard/new_project.rb index dcfa88f69fc..f0d8d498e46 100644 --- a/features/steps/dashboard/new_project.rb +++ b/features/steps/dashboard/new_project.rb @@ -14,14 +14,13 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps expect(page).to have_content('Project name') end - step 'I see all possible import optios' do + step 'I see all possible import options' do expect(page).to have_link('GitHub') expect(page).to have_link('Bitbucket') expect(page).to have_link('GitLab.com') expect(page).to have_link('Gitorious.org') expect(page).to have_link('Google Code') expect(page).to have_link('Repo by URL') - expect(page).to have_link('GitLab export') end step 'I click on "Import project from GitHub"' do diff --git a/features/steps/project/issues/award_emoji.rb b/features/steps/project/issues/award_emoji.rb index 1498f899cf5..cbe5738e7e4 100644 --- a/features/steps/project/issues/award_emoji.rb +++ b/features/steps/project/issues/award_emoji.rb @@ -48,7 +48,7 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps page.within '.awards' do expect(page).to have_selector '.js-emoji-btn' expect(page.find('.js-emoji-btn.active .js-counter')).to have_content '1' - expect(page).to have_css(".js-emoji-btn.active[data-original-title='me']") + expect(page).to have_css(".js-emoji-btn.active[data-original-title='You']") end end diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index daee90b3767..e21f76d00d9 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -45,6 +45,8 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps step 'I click link "All"' do click_link "All" + # Waits for load + expect(find('.issues-state-filters > .active')).to have_content 'All' end step 'I click link "Release 0.4"' do @@ -297,7 +299,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I fill in issue search with \'Rock and roll\'' do - filter_issue 'Description for issue' + filter_issue 'Rock and roll' end step 'I should see \'Bugfix1\' in issues' do @@ -354,8 +356,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end def filter_issue(text) - sleep 1 fill_in 'issue_search', with: text - sleep 1 end end diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 0c63ffc2e3a..56b28949585 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -22,6 +22,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I click link "All"' do click_link "All" + # Waits for load + expect(find('.issues-state-filters > .active')).to have_content 'All' end step 'I click link "Merged"' do @@ -489,7 +491,6 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I fill in merge request search with "Fe"' do - sleep 1 fill_in 'issue_search', with: "Fe" end diff --git a/lib/api/api.rb b/lib/api/api.rb index aa34110c34b..382d29f8dc4 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -43,6 +43,7 @@ module API mount ::API::CommitStatuses mount ::API::Commits mount ::API::DeployKeys + mount ::API::Deployments mount ::API::Environments mount ::API::Files mount ::API::Groups @@ -56,6 +57,7 @@ module API mount ::API::Milestones mount ::API::Namespaces mount ::API::Notes + mount ::API::Pipelines mount ::API::ProjectHooks mount ::API::ProjectSnippets mount ::API::Projects diff --git a/lib/api/branches.rb b/lib/api/branches.rb index a77afe634f6..b615703df93 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -61,22 +61,27 @@ module API name: @branch.name } - unless developers_can_merge.nil? - protected_branch_params.merge!({ - merge_access_level_attributes: { - access_level: developers_can_merge ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - } - }) + # If `developers_can_merge` is switched off, _all_ `DEVELOPER` + # merge_access_levels need to be deleted. + if developers_can_merge == false + protected_branch.merge_access_levels.where(access_level: Gitlab::Access::DEVELOPER).destroy_all end - unless developers_can_push.nil? - protected_branch_params.merge!({ - push_access_level_attributes: { - access_level: developers_can_push ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - } - }) + # If `developers_can_push` is switched off, _all_ `DEVELOPER` + # push_access_levels need to be deleted. + if developers_can_push == false + protected_branch.push_access_levels.where(access_level: Gitlab::Access::DEVELOPER).destroy_all end + protected_branch_params.merge!( + merge_access_levels_attributes: [{ + access_level: developers_can_merge ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER + }], + push_access_levels_attributes: [{ + access_level: developers_can_push ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER + }] + ) + if protected_branch service = ProtectedBranches::UpdateService.new(user_project, current_user, protected_branch_params) service.execute(protected_branch) diff --git a/lib/api/builds.rb b/lib/api/builds.rb index be5a3484ec8..52bdbcae5a8 100644 --- a/lib/api/builds.rb +++ b/lib/api/builds.rb @@ -189,6 +189,27 @@ module API present build, with: Entities::Build, user_can_download_artifacts: can?(current_user, :read_build, user_project) end + + desc 'Trigger a manual build' do + success Entities::Build + detail 'This feature was added in GitLab 8.11' + end + params do + requires :build_id, type: Integer, desc: 'The ID of a Build' + end + post ":id/builds/:build_id/play" do + authorize_read_builds! + + build = get_build!(params[:build_id]) + + bad_request!("Unplayable Build") unless build.playable? + + build.play(current_user) + + status 200 + present build, with: Entities::Build, + user_can_download_artifacts: can?(current_user, :read_build, user_project) + end end helpers do diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb new file mode 100644 index 00000000000..f782bcaf7e9 --- /dev/null +++ b/lib/api/deployments.rb @@ -0,0 +1,40 @@ +module API + # Deployments RESTfull API endpoints + class Deployments < Grape::API + before { authenticate! } + + params do + requires :id, type: String, desc: 'The project ID' + end + resource :projects do + desc 'Get all deployments of the project' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Deployment + end + params do + optional :page, type: Integer, desc: 'Page number of the current request' + optional :per_page, type: Integer, desc: 'Number of items per page' + end + get ':id/deployments' do + authorize! :read_deployment, user_project + + present paginate(user_project.deployments), with: Entities::Deployment + end + + desc 'Gets a specific deployment' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Deployment + end + params do + requires :deployment_id, type: Integer, desc: 'The deployment ID' + end + get ':id/deployments/:deployment_id' do + authorize! :read_deployment, user_project + + deployment = user_project.deployments.find(params[:deployment_id]) + + present deployment, with: Entities::Deployment + end + end + end +end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index b305fce9fcf..fcb0b12c191 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -48,7 +48,8 @@ module API class ProjectHook < Hook expose :project_id, :push_events - expose :issues_events, :merge_requests_events, :tag_push_events, :note_events, :build_events + expose :issues_events, :merge_requests_events, :tag_push_events + expose :note_events, :build_events, :pipeline_events expose :enable_ssl_verification end @@ -129,12 +130,14 @@ module API expose :developers_can_push do |repo_branch, options| project = options[:project] - project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.push_access_level.access_level == Gitlab::Access::DEVELOPER } + access_levels = project.protected_branches.matching(repo_branch.name).map(&:push_access_levels).flatten + access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER } end expose :developers_can_merge do |repo_branch, options| project = options[:project] - project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.merge_access_level.access_level == Gitlab::Access::DEVELOPER } + access_levels = project.protected_branches.matching(repo_branch.name).map(&:merge_access_levels).flatten + access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER } end end @@ -357,7 +360,8 @@ module API class ProjectService < Grape::Entity expose :id, :title, :created_at, :updated_at, :active - expose :push_events, :issues_events, :merge_requests_events, :tag_push_events, :note_events, :build_events + expose :push_events, :issues_events, :merge_requests_events + expose :tag_push_events, :note_events, :build_events, :pipeline_events # Expose serialized properties expose :properties do |service, options| field_names = service.fields. @@ -511,8 +515,28 @@ module API expose :key, :value end + class Pipeline < Grape::Entity + expose :id, :status, :ref, :sha, :before_sha, :tag, :yaml_errors + + expose :user, with: Entities::UserBasic + expose :created_at, :updated_at, :started_at, :finished_at, :committed_at + expose :duration + end + class Environment < Grape::Entity expose :id, :name, :external_url + expose :project, using: Entities::Project + end + + class EnvironmentBasic < Grape::Entity + expose :id, :name, :external_url + end + + class Deployment < Grape::Entity + expose :id, :iid, :ref, :sha, :created_at + expose :user, using: Entities::UserBasic + expose :environment, using: Entities::EnvironmentBasic + expose :deployable, using: Entities::Build end class RepoLicense < Grape::Entity diff --git a/lib/api/issues.rb b/lib/api/issues.rb index c4d3134da6c..077258faee1 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -3,8 +3,6 @@ module API class Issues < Grape::API before { authenticate! } - helpers ::Gitlab::AkismetHelper - helpers do def filter_issues_state(issues, state) case state diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb new file mode 100644 index 00000000000..2aae75c471d --- /dev/null +++ b/lib/api/pipelines.rb @@ -0,0 +1,74 @@ +module API + class Pipelines < Grape::API + before { authenticate! } + + params do + requires :id, type: String, desc: 'The project ID' + end + resource :projects do + desc 'Get all Pipelines of the project' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Pipeline + end + params do + optional :page, type: Integer, desc: 'Page number of the current request' + optional :per_page, type: Integer, desc: 'Number of items per page' + end + get ':id/pipelines' do + authorize! :read_pipeline, user_project + + present paginate(user_project.pipelines), with: Entities::Pipeline + end + + desc 'Gets a specific pipeline for the project' do + detail 'This feature was introduced in GitLab 8.11' + success Entities::Pipeline + end + params do + requires :pipeline_id, type: Integer, desc: 'The pipeline ID' + end + get ':id/pipelines/:pipeline_id' do + authorize! :read_pipeline, user_project + + present pipeline, with: Entities::Pipeline + end + + desc 'Retry failed builds in the pipeline' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Pipeline + end + params do + requires :pipeline_id, type: Integer, desc: 'The pipeline ID' + end + post ':id/pipelines/:pipeline_id/retry' do + authorize! :update_pipeline, user_project + + pipeline.retry_failed(current_user) + + present pipeline, with: Entities::Pipeline + end + + desc 'Cancel all builds in the pipeline' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Pipeline + end + params do + requires :pipeline_id, type: Integer, desc: 'The pipeline ID' + end + post ':id/pipelines/:pipeline_id/cancel' do + authorize! :update_pipeline, user_project + + pipeline.cancel_running + + status 200 + present pipeline.reload, with: Entities::Pipeline + end + end + + helpers do + def pipeline + @pipeline ||= user_project.pipelines.find(params[:pipeline_id]) + end + end + end +end diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb index 6bb70bc8bc3..3f63cd678e8 100644 --- a/lib/api/project_hooks.rb +++ b/lib/api/project_hooks.rb @@ -45,6 +45,7 @@ module API :tag_push_events, :note_events, :build_events, + :pipeline_events, :enable_ssl_verification ] @hook = user_project.hooks.new(attrs) @@ -78,6 +79,7 @@ module API :tag_push_events, :note_events, :build_events, + :pipeline_events, :enable_ssl_verification ] diff --git a/lib/api/session.rb b/lib/api/session.rb index 56c202f1294..55ec66a6d67 100644 --- a/lib/api/session.rb +++ b/lib/api/session.rb @@ -14,6 +14,7 @@ module API user = Gitlab::Auth.find_with_user_password(params[:email] || params[:login], params[:password]) return unauthorized! unless user + return render_api_error!('401 Unauthorized. You have 2FA enabled. Please use a personal access token to access the API', 401) if user.two_factor_enabled? present user, with: Entities::UserLogin end end diff --git a/lib/api/templates.rb b/lib/api/templates.rb index 18408797756..b9e718147e1 100644 --- a/lib/api/templates.rb +++ b/lib/api/templates.rb @@ -1,21 +1,28 @@ module API class Templates < Grape::API - TEMPLATE_TYPES = { - gitignores: Gitlab::Template::Gitignore, - gitlab_ci_ymls: Gitlab::Template::GitlabCiYml + GLOBAL_TEMPLATE_TYPES = { + gitignores: Gitlab::Template::GitignoreTemplate, + gitlab_ci_ymls: Gitlab::Template::GitlabCiYmlTemplate }.freeze - TEMPLATE_TYPES.each do |template, klass| + helpers do + def render_response(template_type, template) + not_found!(template_type.to_s.singularize) unless template + present template, with: Entities::Template + end + end + + GLOBAL_TEMPLATE_TYPES.each do |template_type, klass| # Get the list of the available template # # Example Request: # GET /gitignores # GET /gitlab_ci_ymls - get template.to_s do + get template_type.to_s do present klass.all, with: Entities::TemplatesList end - # Get the text for a specific template + # Get the text for a specific template present in local filesystem # # Parameters: # name (required) - The name of a template @@ -23,13 +30,10 @@ module API # Example Request: # GET /gitignores/Elixir # GET /gitlab_ci_ymls/Ruby - get "#{template}/:name" do + get "#{template_type}/:name" do required_attributes! [:name] - new_template = klass.find(params[:name]) - not_found!(template.to_s.singularize) unless new_template - - present new_template, with: Entities::Template + render_response(template_type, new_template) end end end diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb index 260ac81f5fa..9f3b582a263 100644 --- a/lib/ci/api/builds.rb +++ b/lib/ci/api/builds.rb @@ -20,8 +20,13 @@ module Ci build = Ci::RegisterBuildService.new.execute(current_runner) if build + Gitlab::Metrics.add_event(:build_found, + project: build.project.path_with_namespace) + present build, with: Entities::BuildDetails else + Gitlab::Metrics.add_event(:build_not_found) + not_found! end end @@ -42,6 +47,9 @@ module Ci build.update_attributes(trace: params[:trace]) if params[:trace] + Gitlab::Metrics.add_event(:update_build, + project: build.project.path_with_namespace) + case params[:state].to_s when 'success' build.success diff --git a/lib/gitlab/akismet_helper.rb b/lib/gitlab/akismet_helper.rb deleted file mode 100644 index 207736b59db..00000000000 --- a/lib/gitlab/akismet_helper.rb +++ /dev/null @@ -1,47 +0,0 @@ -module Gitlab - module AkismetHelper - def akismet_enabled? - current_application_settings.akismet_enabled - end - - def akismet_client - @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key, - Gitlab.config.gitlab.url) - end - - def client_ip(env) - env['action_dispatch.remote_ip'].to_s - end - - def user_agent(env) - env['HTTP_USER_AGENT'] - end - - def check_for_spam?(project) - akismet_enabled? && project.public? - end - - def is_spam?(environment, user, text) - client = akismet_client - ip_address = client_ip(environment) - user_agent = user_agent(environment) - - params = { - type: 'comment', - text: text, - created_at: DateTime.now, - author: user.name, - author_email: user.email, - referrer: environment['HTTP_REFERER'], - } - - begin - is_spam, is_blatant = client.check(ip_address, user_agent, params) - is_spam || is_blatant - rescue => e - Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") - false - end - end - end -end diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index db1704af75e..91f0270818a 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -10,13 +10,12 @@ module Gitlab if valid_ci_request?(login, password, project) result.type = :ci - elsif result.user = find_with_user_password(login, password) - result.type = :gitlab_or_ldap - elsif result.user = oauth_access_token_check(login, password) - result.type = :oauth + else + result = populate_result(login, password) end - rate_limit!(ip, success: !!result.user || (result.type == :ci), login: login) + success = result.user.present? || [:ci, :missing_personal_token].include?(result.type) + rate_limit!(ip, success: success, login: login) result end @@ -76,10 +75,43 @@ module Gitlab end end + def populate_result(login, password) + result = + user_with_password_for_git(login, password) || + oauth_access_token_check(login, password) || + personal_access_token_check(login, password) + + if result + result.type = nil unless result.user + + if result.user && result.user.two_factor_enabled? && result.type == :gitlab_or_ldap + result.type = :missing_personal_token + end + end + + result || Result.new + end + + def user_with_password_for_git(login, password) + user = find_with_user_password(login, password) + Result.new(user, :gitlab_or_ldap) if user + end + def oauth_access_token_check(login, password) if login == "oauth2" && password.present? token = Doorkeeper::AccessToken.by_token(password) - token && token.accessible? && User.find_by(id: token.resource_owner_id) + if token && token.accessible? + user = User.find_by(id: token.resource_owner_id) + Result.new(user, :oauth) + end + end + end + + def personal_access_token_check(login, password) + if login && password + user = User.find_by_personal_access_token(password) + validation = User.by_login(login) + Result.new(user, :personal_token) if user == validation end end end diff --git a/lib/gitlab/badge/base.rb b/lib/gitlab/badge/base.rb new file mode 100644 index 00000000000..909fa24fa90 --- /dev/null +++ b/lib/gitlab/badge/base.rb @@ -0,0 +1,21 @@ +module Gitlab + module Badge + class Base + def entity + raise NotImplementedError + end + + def status + raise NotImplementedError + end + + def metadata + raise NotImplementedError + end + + def template + raise NotImplementedError + end + end + end +end diff --git a/lib/gitlab/badge/build.rb b/lib/gitlab/badge/build.rb deleted file mode 100644 index 1de721a2269..00000000000 --- a/lib/gitlab/badge/build.rb +++ /dev/null @@ -1,30 +0,0 @@ -module Gitlab - module Badge - ## - # Build badge - # - class Build - delegate :key_text, :value_text, to: :template - - def initialize(project, ref) - @project = project - @ref = ref - @sha = @project.commit(@ref).try(:sha) - end - - def status - @project.pipelines - .where(sha: @sha, ref: @ref) - .status || 'unknown' - end - - def metadata - @metadata ||= Build::Metadata.new(@project, @ref) - end - - def template - @template ||= Build::Template.new(status) - end - end - end -end diff --git a/lib/gitlab/badge/build/metadata.rb b/lib/gitlab/badge/build/metadata.rb index 553ef8d7b16..f87a7b7942e 100644 --- a/lib/gitlab/badge/build/metadata.rb +++ b/lib/gitlab/badge/build/metadata.rb @@ -1,25 +1,17 @@ module Gitlab module Badge - class Build + module Build ## # Class that describes build badge metadata # - class Metadata - include Gitlab::Application.routes.url_helpers - include ActionView::Helpers::AssetTagHelper - include ActionView::Helpers::UrlHelper - - def initialize(project, ref) - @project = project - @ref = ref + class Metadata < Badge::Metadata + def initialize(badge) + @project = badge.project + @ref = badge.ref end - def to_html - link_to(image_tag(image_url, alt: 'build status'), link_url) - end - - def to_markdown - "[![build status](#{image_url})](#{link_url})" + def title + 'build status' end def image_url diff --git a/lib/gitlab/badge/build/status.rb b/lib/gitlab/badge/build/status.rb new file mode 100644 index 00000000000..50aa45e5406 --- /dev/null +++ b/lib/gitlab/badge/build/status.rb @@ -0,0 +1,37 @@ +module Gitlab + module Badge + module Build + ## + # Build status badge + # + class Status < Badge::Base + attr_reader :project, :ref + + def initialize(project, ref) + @project = project + @ref = ref + + @sha = @project.commit(@ref).try(:sha) + end + + def entity + 'build' + end + + def status + @project.pipelines + .where(sha: @sha, ref: @ref) + .status || 'unknown' + end + + def metadata + @metadata ||= Build::Metadata.new(self) + end + + def template + @template ||= Build::Template.new(self) + end + end + end + end +end diff --git a/lib/gitlab/badge/build/template.rb b/lib/gitlab/badge/build/template.rb index deba3b669b3..2b95ddfcb53 100644 --- a/lib/gitlab/badge/build/template.rb +++ b/lib/gitlab/badge/build/template.rb @@ -1,12 +1,12 @@ module Gitlab module Badge - class Build + module Build ## # Class that represents a build badge template. # # Template object will be passed to badge.svg.erb template. # - class Template + class Template < Badge::Template STATUS_COLOR = { success: '#4c1', failed: '#e05d44', @@ -17,16 +17,17 @@ module Gitlab unknown: '#9f9f9f' } - def initialize(status) - @status = status + def initialize(badge) + @entity = badge.entity + @status = badge.status end def key_text - 'build' + @entity.to_s end def value_text - @status + @status.to_s end def key_width @@ -37,25 +38,8 @@ module Gitlab 54 end - def key_color - '#555' - end - def value_color - STATUS_COLOR[@status.to_sym] || - STATUS_COLOR[:unknown] - end - - def key_text_anchor - key_width / 2 - end - - def value_text_anchor - key_width + (value_width / 2) - end - - def width - key_width + value_width + STATUS_COLOR[@status.to_sym] || STATUS_COLOR[:unknown] end end end diff --git a/lib/gitlab/badge/coverage/metadata.rb b/lib/gitlab/badge/coverage/metadata.rb new file mode 100644 index 00000000000..53588185622 --- /dev/null +++ b/lib/gitlab/badge/coverage/metadata.rb @@ -0,0 +1,30 @@ +module Gitlab + module Badge + module Coverage + ## + # Class that describes coverage badge metadata + # + class Metadata < Badge::Metadata + def initialize(badge) + @project = badge.project + @ref = badge.ref + @job = badge.job + end + + def title + 'coverage report' + end + + def image_url + coverage_namespace_project_badges_url(@project.namespace, + @project, @ref, + format: :svg) + end + + def link_url + namespace_project_commits_url(@project.namespace, @project, id: @ref) + end + end + end + end +end diff --git a/lib/gitlab/badge/coverage/report.rb b/lib/gitlab/badge/coverage/report.rb new file mode 100644 index 00000000000..3d56ea3e47a --- /dev/null +++ b/lib/gitlab/badge/coverage/report.rb @@ -0,0 +1,56 @@ +module Gitlab + module Badge + module Coverage + ## + # Test coverage report badge + # + class Report < Badge::Base + attr_reader :project, :ref, :job + + def initialize(project, ref, job = nil) + @project = project + @ref = ref + @job = job + + @pipeline = @project.pipelines + .where(ref: @ref) + .where(sha: @project.commit(@ref).try(:sha)) + .first + end + + def entity + 'coverage' + end + + def status + @coverage ||= raw_coverage + return unless @coverage + + @coverage.to_i + end + + def metadata + @metadata ||= Coverage::Metadata.new(self) + end + + def template + @template ||= Coverage::Template.new(self) + end + + private + + def raw_coverage + return unless @pipeline + + if @job.blank? + @pipeline.coverage + else + @pipeline.builds + .find_by(name: @job) + .try(:coverage) + end + end + end + end + end +end diff --git a/lib/gitlab/badge/coverage/template.rb b/lib/gitlab/badge/coverage/template.rb new file mode 100644 index 00000000000..06e0d084e9f --- /dev/null +++ b/lib/gitlab/badge/coverage/template.rb @@ -0,0 +1,52 @@ +module Gitlab + module Badge + module Coverage + ## + # Class that represents a coverage badge template. + # + # Template object will be passed to badge.svg.erb template. + # + class Template < Badge::Template + STATUS_COLOR = { + good: '#4c1', + acceptable: '#a3c51c', + medium: '#dfb317', + low: '#e05d44', + unknown: '#9f9f9f' + } + + def initialize(badge) + @entity = badge.entity + @status = badge.status + end + + def key_text + @entity.to_s + end + + def value_text + @status ? "#{@status}%" : 'unknown' + end + + def key_width + 62 + end + + def value_width + @status ? 36 : 58 + end + + def value_color + case @status + when 95..100 then STATUS_COLOR[:good] + when 90..95 then STATUS_COLOR[:acceptable] + when 75..90 then STATUS_COLOR[:medium] + when 0..75 then STATUS_COLOR[:low] + else + STATUS_COLOR[:unknown] + end + end + end + end + end +end diff --git a/lib/gitlab/badge/metadata.rb b/lib/gitlab/badge/metadata.rb new file mode 100644 index 00000000000..548f85b78bb --- /dev/null +++ b/lib/gitlab/badge/metadata.rb @@ -0,0 +1,36 @@ +module Gitlab + module Badge + ## + # Abstract class for badge metadata + # + class Metadata + include Gitlab::Application.routes.url_helpers + include ActionView::Helpers::AssetTagHelper + include ActionView::Helpers::UrlHelper + + def initialize(badge) + @badge = badge + end + + def to_html + link_to(image_tag(image_url, alt: title), link_url) + end + + def to_markdown + "[![#{title}](#{image_url})](#{link_url})" + end + + def title + raise NotImplementedError + end + + def image_url + raise NotImplementedError + end + + def link_url + raise NotImplementedError + end + end + end +end diff --git a/lib/gitlab/badge/template.rb b/lib/gitlab/badge/template.rb new file mode 100644 index 00000000000..bfeb0052642 --- /dev/null +++ b/lib/gitlab/badge/template.rb @@ -0,0 +1,49 @@ +module Gitlab + module Badge + ## + # Abstract template class for badges + # + class Template + def initialize(badge) + @entity = badge.entity + @status = badge.status + end + + def key_text + raise NotImplementedError + end + + def value_text + raise NotImplementedError + end + + def key_width + raise NotImplementedError + end + + def value_width + raise NotImplementedError + end + + def value_color + raise NotImplementedError + end + + def key_color + '#555' + end + + def key_text_anchor + key_width / 2 + end + + def value_text_anchor + key_width + (value_width / 2) + end + + def width + key_width + value_width + end + end + end +end diff --git a/lib/gitlab/conflict/file.rb b/lib/gitlab/conflict/file.rb new file mode 100644 index 00000000000..0a1fd27ced5 --- /dev/null +++ b/lib/gitlab/conflict/file.rb @@ -0,0 +1,186 @@ +module Gitlab + module Conflict + class File + include Gitlab::Routing.url_helpers + include IconsHelper + + class MissingResolution < StandardError + end + + CONTEXT_LINES = 3 + + attr_reader :merge_file_result, :their_path, :our_path, :our_mode, :merge_request, :repository + + def initialize(merge_file_result, conflict, merge_request:) + @merge_file_result = merge_file_result + @their_path = conflict[:theirs][:path] + @our_path = conflict[:ours][:path] + @our_mode = conflict[:ours][:mode] + @merge_request = merge_request + @repository = merge_request.project.repository + @match_line_headers = {} + end + + # Array of Gitlab::Diff::Line objects + def lines + @lines ||= Gitlab::Conflict::Parser.new.parse(merge_file_result[:data], + our_path: our_path, + their_path: their_path, + parent_file: self) + end + + def resolve_lines(resolution) + section_id = nil + + lines.map do |line| + unless line.type + section_id = nil + next line + end + + section_id ||= line_code(line) + + case resolution[section_id] + when 'head' + next unless line.type == 'new' + when 'origin' + next unless line.type == 'old' + else + raise MissingResolution, "Missing resolution for section ID: #{section_id}" + end + + line + end.compact + end + + def highlight_lines! + their_file = lines.reject { |line| line.type == 'new' }.map(&:text).join("\n") + our_file = lines.reject { |line| line.type == 'old' }.map(&:text).join("\n") + + their_highlight = Gitlab::Highlight.highlight(their_path, their_file, repository: repository).lines + our_highlight = Gitlab::Highlight.highlight(our_path, our_file, repository: repository).lines + + lines.each do |line| + if line.type == 'old' + line.rich_text = their_highlight[line.old_line - 1].try(:html_safe) + else + line.rich_text = our_highlight[line.new_line - 1].try(:html_safe) + end + end + end + + def sections + return @sections if @sections + + chunked_lines = lines.chunk { |line| line.type.nil? }.to_a + match_line = nil + + sections_count = chunked_lines.size + + @sections = chunked_lines.flat_map.with_index do |(no_conflict, lines), i| + section = nil + + # We need to reduce context sections to CONTEXT_LINES. Conflict sections are + # always shown in full. + if no_conflict + conflict_before = i > 0 + conflict_after = (sections_count - i) > 1 + + if conflict_before && conflict_after + # Create a gap in a long context section. + if lines.length > CONTEXT_LINES * 2 + head_lines = lines.first(CONTEXT_LINES) + tail_lines = lines.last(CONTEXT_LINES) + + # Ensure any existing match line has text for all lines up to the last + # line of its context. + update_match_line_text(match_line, head_lines.last) + + # Insert a new match line after the created gap. + match_line = create_match_line(tail_lines.first) + + section = [ + { conflict: false, lines: head_lines }, + { conflict: false, lines: tail_lines.unshift(match_line) } + ] + end + elsif conflict_after + tail_lines = lines.last(CONTEXT_LINES) + + # Create a gap and insert a match line at the start. + if lines.length > tail_lines.length + match_line = create_match_line(tail_lines.first) + + tail_lines.unshift(match_line) + end + + lines = tail_lines + elsif conflict_before + # We're at the end of the file (no conflicts after), so just remove extra + # trailing lines. + lines = lines.first(CONTEXT_LINES) + end + end + + # We want to update the match line's text every time unless we've already + # created a gap and its corresponding match line. + update_match_line_text(match_line, lines.last) unless section + + section ||= { conflict: !no_conflict, lines: lines } + section[:id] = line_code(lines.first) unless no_conflict + section + end + end + + def line_code(line) + Gitlab::Diff::LineCode.generate(our_path, line.new_pos, line.old_pos) + end + + def create_match_line(line) + Gitlab::Diff::Line.new('', 'match', line.index, line.old_pos, line.new_pos) + end + + # Any line beginning with a letter, an underscore, or a dollar can be used in a + # match line header. Only context sections can contain match lines, as match lines + # have to exist in both versions of the file. + def find_match_line_header(index) + return @match_line_headers[index] if @match_line_headers.key?(index) + + @match_line_headers[index] = begin + if index >= 0 + line = lines[index] + + if line.type.nil? && line.text.match(/\A[A-Za-z$_]/) + " #{line.text}" + else + find_match_line_header(index - 1) + end + end + end + end + + # Set the match line's text for the current line. A match line takes its start + # position and context header (where present) from itself, and its end position from + # the line passed in. + def update_match_line_text(match_line, line) + return unless match_line + + header = find_match_line_header(match_line.index - 1) + + match_line.text = "@@ -#{match_line.old_pos},#{line.old_pos} +#{match_line.new_pos},#{line.new_pos} @@#{header}" + end + + def as_json(opts = nil) + { + old_path: their_path, + new_path: our_path, + blob_icon: file_type_icon_class('file', our_mode, our_path), + blob_path: namespace_project_blob_path(merge_request.project.namespace, + merge_request.project, + ::File.join(merge_request.diff_refs.head_sha, our_path)), + sections: sections + } + end + end + end +end diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb new file mode 100644 index 00000000000..bbd0427a2c8 --- /dev/null +++ b/lib/gitlab/conflict/file_collection.rb @@ -0,0 +1,57 @@ +module Gitlab + module Conflict + class FileCollection + class ConflictSideMissing < StandardError + end + + attr_reader :merge_request, :our_commit, :their_commit + + def initialize(merge_request) + @merge_request = merge_request + @our_commit = merge_request.source_branch_head.raw.raw_commit + @their_commit = merge_request.target_branch_head.raw.raw_commit + end + + def repository + merge_request.project.repository + end + + def merge_index + @merge_index ||= repository.rugged.merge_commits(our_commit, their_commit) + end + + def files + @files ||= merge_index.conflicts.map do |conflict| + raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours] + + Gitlab::Conflict::File.new(merge_index.merge_file(conflict[:ours][:path]), + conflict, + merge_request: merge_request) + end + end + + def as_json(opts = nil) + { + target_branch: merge_request.target_branch, + source_branch: merge_request.source_branch, + commit_sha: merge_request.diff_head_sha, + commit_message: default_commit_message, + files: files + } + end + + def default_commit_message + conflict_filenames = merge_index.conflicts.map do |conflict| + "# #{conflict[:ours][:path]}" + end + + < 102400 + + line_obj_index = 0 + line_old = 1 + line_new = 1 + type = nil + lines = [] + conflict_start = "<<<<<<< #{our_path}" + conflict_middle = '=======' + conflict_end = ">>>>>>> #{their_path}" + + text.each_line.map do |line| + full_line = line.delete("\n") + + if full_line == conflict_start + raise UnexpectedDelimiter unless type.nil? + + type = 'new' + elsif full_line == conflict_middle + raise UnexpectedDelimiter unless type == 'new' + + type = 'old' + elsif full_line == conflict_end + raise UnexpectedDelimiter unless type == 'old' + + type = nil + elsif line[0] == '\\' + type = 'nonewline' + lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file) + else + lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file) + line_old += 1 if type != 'new' + line_new += 1 if type != 'old' + + line_obj_index += 1 + end + end + + raise MissingEndDelimiter unless type.nil? + + lines + end + end + end +end diff --git a/lib/gitlab/build_data_builder.rb b/lib/gitlab/data_builder/build.rb similarity index 96% rename from lib/gitlab/build_data_builder.rb rename to lib/gitlab/data_builder/build.rb index 9f45aefda0f..6548e6475c6 100644 --- a/lib/gitlab/build_data_builder.rb +++ b/lib/gitlab/data_builder/build.rb @@ -1,6 +1,8 @@ module Gitlab - class BuildDataBuilder - class << self + module DataBuilder + module Build + extend self + def build(build) project = build.project commit = build.pipeline diff --git a/lib/gitlab/note_data_builder.rb b/lib/gitlab/data_builder/note.rb similarity index 97% rename from lib/gitlab/note_data_builder.rb rename to lib/gitlab/data_builder/note.rb index 8bdc89a7751..50fea1232af 100644 --- a/lib/gitlab/note_data_builder.rb +++ b/lib/gitlab/data_builder/note.rb @@ -1,6 +1,8 @@ module Gitlab - class NoteDataBuilder - class << self + module DataBuilder + module Note + extend self + # Produce a hash of post-receive data # # For all notes: diff --git a/lib/gitlab/data_builder/pipeline.rb b/lib/gitlab/data_builder/pipeline.rb new file mode 100644 index 00000000000..06a783ebc1c --- /dev/null +++ b/lib/gitlab/data_builder/pipeline.rb @@ -0,0 +1,62 @@ +module Gitlab + module DataBuilder + module Pipeline + extend self + + def build(pipeline) + { + object_kind: 'pipeline', + object_attributes: hook_attrs(pipeline), + user: pipeline.user.try(:hook_attrs), + project: pipeline.project.hook_attrs(backward: false), + commit: pipeline.commit.try(:hook_attrs), + builds: pipeline.builds.map(&method(:build_hook_attrs)) + } + end + + def hook_attrs(pipeline) + { + id: pipeline.id, + ref: pipeline.ref, + tag: pipeline.tag, + sha: pipeline.sha, + before_sha: pipeline.before_sha, + status: pipeline.status, + stages: pipeline.stages, + created_at: pipeline.created_at, + finished_at: pipeline.finished_at, + duration: pipeline.duration + } + end + + def build_hook_attrs(build) + { + id: build.id, + stage: build.stage, + name: build.name, + status: build.status, + created_at: build.created_at, + started_at: build.started_at, + finished_at: build.finished_at, + when: build.when, + manual: build.manual?, + user: build.user.try(:hook_attrs), + runner: build.runner && runner_hook_attrs(build.runner), + artifacts_file: { + filename: build.artifacts_file.filename, + size: build.artifacts_size + } + } + end + + def runner_hook_attrs(runner) + { + id: runner.id, + description: runner.description, + active: runner.active?, + is_shared: runner.is_shared? + } + end + end + end +end diff --git a/lib/gitlab/push_data_builder.rb b/lib/gitlab/data_builder/push.rb similarity index 98% rename from lib/gitlab/push_data_builder.rb rename to lib/gitlab/data_builder/push.rb index c8f12577112..4f81863da35 100644 --- a/lib/gitlab/push_data_builder.rb +++ b/lib/gitlab/data_builder/push.rb @@ -1,6 +1,8 @@ module Gitlab - class PushDataBuilder - class << self + module DataBuilder + module Push + extend self + # Produce a hash of post-receive data # # data = { diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb index cf097e0d0de..80a146b4a5a 100644 --- a/lib/gitlab/diff/line.rb +++ b/lib/gitlab/diff/line.rb @@ -2,11 +2,13 @@ module Gitlab module Diff class Line attr_reader :type, :index, :old_pos, :new_pos + attr_writer :rich_text attr_accessor :text - def initialize(text, type, index, old_pos, new_pos) + def initialize(text, type, index, old_pos, new_pos, parent_file: nil) @text, @type, @index = text, type, index @old_pos, @new_pos = old_pos, new_pos + @parent_file = parent_file end def self.init_from_hash(hash) @@ -43,9 +45,25 @@ module Gitlab type == 'old' end + def rich_text + @parent_file.highlight_lines! if @parent_file && !@rich_text + + @rich_text + end + def meta? type == 'match' || type == 'nonewline' end + + def as_json(opts = nil) + { + type: type, + old_line: old_line, + new_line: new_line, + text: text, + rich_text: rich_text || text + } + end end end end diff --git a/lib/gitlab/downtime_check/message.rb b/lib/gitlab/downtime_check/message.rb index 4446e921e0d..40a4815a9a0 100644 --- a/lib/gitlab/downtime_check/message.rb +++ b/lib/gitlab/downtime_check/message.rb @@ -1,10 +1,10 @@ module Gitlab class DowntimeCheck class Message - attr_reader :path, :offline, :reason + attr_reader :path, :offline - OFFLINE = "\e[32moffline\e[0m" - ONLINE = "\e[31monline\e[0m" + OFFLINE = "\e[31moffline\e[0m" + ONLINE = "\e[32monline\e[0m" # path - The file path of the migration. # offline - When set to `true` the migration will require downtime. @@ -19,10 +19,21 @@ module Gitlab label = offline ? OFFLINE : ONLINE message = "[#{label}]: #{path}" - message += ": #{reason}" if reason + + if reason? + message += ":\n\n#{reason}\n\n" + end message end + + def reason? + @reason.present? + end + + def reason + @reason.strip.lines.map(&:strip).join("\n") + end end end end diff --git a/lib/gitlab/email/handler/base_handler.rb b/lib/gitlab/email/handler/base_handler.rb index b7ed11cb638..7cccf465334 100644 --- a/lib/gitlab/email/handler/base_handler.rb +++ b/lib/gitlab/email/handler/base_handler.rb @@ -45,6 +45,7 @@ module Gitlab def verify_record!(record:, invalid_exception:, record_name:) return if record.persisted? + return if record.errors.key?(:commands_only) error_title = "The #{record_name} could not be created for the following reasons:" diff --git a/lib/gitlab/import_export/json_hash_builder.rb b/lib/gitlab/import_export/json_hash_builder.rb index 008300bde45..0cc10f40087 100644 --- a/lib/gitlab/import_export/json_hash_builder.rb +++ b/lib/gitlab/import_export/json_hash_builder.rb @@ -57,19 +57,16 @@ module Gitlab # +value+ existing model to be included in the hash # +json_config_hash+ the original hash containing the root model def create_model_value(current_key, value, json_config_hash) - parsed_hash = { include: value } - parse_hash(value, parsed_hash) - - json_config_hash[current_key] = parsed_hash + json_config_hash[current_key] = parse_hash(value) || { include: value } end # Calls attributes finder to parse the hash and add any attributes to it # # +value+ existing model to be included in the hash # +parsed_hash+ the original hash - def parse_hash(value, parsed_hash) + def parse_hash(value) @attributes_finder.parse(value) do |hash| - parsed_hash = { include: hash_or_merge(value, hash) } + { include: hash_or_merge(value, hash) } end end diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb index 41fcd971c22..3d1ba33ec68 100644 --- a/lib/gitlab/metrics.rb +++ b/lib/gitlab/metrics.rb @@ -124,6 +124,15 @@ module Gitlab trans.action = action if trans end + # Tracks an event. + # + # See `Gitlab::Metrics::Transaction#add_event` for more details. + def self.add_event(*args) + trans = current_transaction + + trans.add_event(*args) if trans + end + # Returns the prefix to use for the name of a series. def self.series_prefix @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_' diff --git a/lib/gitlab/metrics/metric.rb b/lib/gitlab/metrics/metric.rb index f23d67e1e38..bd0afe53c51 100644 --- a/lib/gitlab/metrics/metric.rb +++ b/lib/gitlab/metrics/metric.rb @@ -4,15 +4,20 @@ module Gitlab class Metric JITTER_RANGE = 0.000001..0.001 - attr_reader :series, :values, :tags + attr_reader :series, :values, :tags, :type # series - The name of the series (as a String) to store the metric in. # values - A Hash containing the values to store. # tags - A Hash containing extra tags to add to the metrics. - def initialize(series, values, tags = {}) + def initialize(series, values, tags = {}, type = :metric) @values = values @series = series @tags = tags + @type = type + end + + def event? + type == :event end # Returns a Hash in a format that can be directly written to InfluxDB. diff --git a/lib/gitlab/metrics/rack_middleware.rb b/lib/gitlab/metrics/rack_middleware.rb index e61670f491c..b4493bf44d2 100644 --- a/lib/gitlab/metrics/rack_middleware.rb +++ b/lib/gitlab/metrics/rack_middleware.rb @@ -17,6 +17,10 @@ module Gitlab begin retval = trans.run { @app.call(env) } + rescue Exception => error # rubocop: disable Lint/RescueException + trans.add_event(:rails_exception) + + raise error # Even in the event of an error we want to submit any metrics we # might've gathered up to this point. ensure diff --git a/lib/gitlab/metrics/sidekiq_middleware.rb b/lib/gitlab/metrics/sidekiq_middleware.rb index a1240fd33ee..f9dd8e41912 100644 --- a/lib/gitlab/metrics/sidekiq_middleware.rb +++ b/lib/gitlab/metrics/sidekiq_middleware.rb @@ -11,6 +11,10 @@ module Gitlab # Old gitlad-shell messages don't provide enqueued_at/created_at attributes trans.set(:sidekiq_queue_duration, Time.now.to_f - (message['enqueued_at'] || message['created_at'] || 0)) trans.run { yield } + rescue Exception => error # rubocop: disable Lint/RescueException + trans.add_event(:sidekiq_exception) + + raise error ensure trans.finish end diff --git a/lib/gitlab/metrics/transaction.rb b/lib/gitlab/metrics/transaction.rb index 968f3218950..7bc16181be6 100644 --- a/lib/gitlab/metrics/transaction.rb +++ b/lib/gitlab/metrics/transaction.rb @@ -4,7 +4,10 @@ module Gitlab class Transaction THREAD_KEY = :_gitlab_metrics_transaction - attr_reader :tags, :values, :methods + # The series to store events (e.g. Git pushes) in. + EVENT_SERIES = 'events' + + attr_reader :tags, :values, :method, :metrics attr_accessor :action @@ -55,6 +58,20 @@ module Gitlab @metrics << Metric.new("#{Metrics.series_prefix}#{series}", values, tags) end + # Tracks a business level event + # + # Business level events including events such as Git pushes, Emails being + # sent, etc. + # + # event_name - The name of the event (e.g. "git_push"). + # tags - A set of tags to attach to the event. + def add_event(event_name, tags = {}) + @metrics << Metric.new(EVENT_SERIES, + { count: 1 }, + { event: event_name }.merge(tags), + :event) + end + # Returns a MethodCall object for the given name. def method_call_for(name) unless method = @methods[name] @@ -101,7 +118,7 @@ module Gitlab submit_hashes = submit.map do |metric| hash = metric.to_hash - hash[:tags][:action] ||= @action if @action + hash[:tags][:action] ||= @action if @action && !metric.event? hash end diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb new file mode 100644 index 00000000000..60d35be2599 --- /dev/null +++ b/lib/gitlab/slash_commands/command_definition.rb @@ -0,0 +1,57 @@ +module Gitlab + module SlashCommands + class CommandDefinition + attr_accessor :name, :aliases, :description, :params, :condition_block, :action_block + + def initialize(name, attributes = {}) + @name = name + + @aliases = attributes[:aliases] || [] + @description = attributes[:description] || '' + @params = attributes[:params] || [] + @condition_block = attributes[:condition_block] + @action_block = attributes[:action_block] + end + + def all_names + [name, *aliases] + end + + def noop? + action_block.nil? + end + + def available?(opts) + return true unless condition_block + + context = OpenStruct.new(opts) + context.instance_exec(&condition_block) + end + + def execute(context, opts, arg) + return if noop? || !available?(opts) + + if arg.present? + context.instance_exec(arg, &action_block) + elsif action_block.arity == 0 + context.instance_exec(&action_block) + end + end + + def to_h(opts) + desc = description + if desc.respond_to?(:call) + context = OpenStruct.new(opts) + desc = context.instance_exec(&desc) rescue '' + end + + { + name: name, + aliases: aliases, + description: desc, + params: params + } + end + end + end +end diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb new file mode 100644 index 00000000000..50b0937d267 --- /dev/null +++ b/lib/gitlab/slash_commands/dsl.rb @@ -0,0 +1,98 @@ +module Gitlab + module SlashCommands + module Dsl + extend ActiveSupport::Concern + + included do + cattr_accessor :command_definitions, instance_accessor: false do + [] + end + + cattr_accessor :command_definitions_by_name, instance_accessor: false do + {} + end + end + + class_methods do + # Allows to give a description to the next slash command. + # This description is shown in the autocomplete menu. + # It accepts a block that will be evaluated with the context given to + # `CommandDefintion#to_h`. + # + # Example: + # + # desc do + # "This is a dynamic description for #{noteable.to_ability_name}" + # end + # command :command_key do |arguments| + # # Awesome code block + # end + def desc(text = '', &block) + @description = block_given? ? block : text + end + + # Allows to define params for the next slash command. + # These params are shown in the autocomplete menu. + # + # Example: + # + # params "~label ~label2" + # command :command_key do |arguments| + # # Awesome code block + # end + def params(*params) + @params = params + end + + # Allows to define conditions that must be met in order for the command + # to be returned by `.command_names` & `.command_definitions`. + # It accepts a block that will be evaluated with the context given to + # `CommandDefintion#to_h`. + # + # Example: + # + # condition do + # project.public? + # end + # command :command_key do |arguments| + # # Awesome code block + # end + def condition(&block) + @condition_block = block + end + + # Registers a new command which is recognizeable from body of email or + # comment. + # It accepts aliases and takes a block. + # + # Example: + # + # command :my_command, :alias_for_my_command do |arguments| + # # Awesome code block + # end + def command(*command_names, &block) + name, *aliases = command_names + + definition = CommandDefinition.new( + name, + aliases: aliases, + description: @description, + params: @params, + condition_block: @condition_block, + action_block: block + ) + + self.command_definitions << definition + + definition.all_names.each do |name| + self.command_definitions_by_name[name] = definition + end + + @description = nil + @params = nil + @condition_block = nil + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb new file mode 100644 index 00000000000..a672e5e4855 --- /dev/null +++ b/lib/gitlab/slash_commands/extractor.rb @@ -0,0 +1,122 @@ +module Gitlab + module SlashCommands + # This class takes an array of commands that should be extracted from a + # given text. + # + # ``` + # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels]) + # ``` + class Extractor + attr_reader :command_definitions + + def initialize(command_definitions) + @command_definitions = command_definitions + end + + # Extracts commands from content and return an array of commands. + # The array looks like the following: + # [ + # ['command1'], + # ['command3', 'arg1 arg2'], + # ] + # The command and the arguments are stripped. + # The original command text is removed from the given `content`. + # + # Usage: + # ``` + # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels]) + # msg = %(hello\n/labels ~foo ~"bar baz"\nworld) + # commands = extractor.extract_commands(msg) #=> [['labels', '~foo ~"bar baz"']] + # msg #=> "hello\nworld" + # ``` + def extract_commands(content, opts = {}) + return [content, []] unless content + + content = content.dup + + commands = [] + + content.delete!("\r") + content.gsub!(commands_regex(opts)) do + if $~[:cmd] + commands << [$~[:cmd], $~[:arg]].reject(&:blank?) + '' + else + $~[0] + end + end + + [content.strip, commands] + end + + private + + # Builds a regular expression to match known commands. + # First match group captures the command name and + # second match group captures its arguments. + # + # It looks something like: + # + # /^\/(?close|reopen|...)(?:( |$))(?[^\/\n]*)(?:\n|$)/ + def commands_regex(opts) + names = command_names(opts).map(&:to_s) + + @commands_regex ||= %r{ + (? + # Code blocks: + # ``` + # Anything, including `/cmd arg` which are ignored by this filter + # ``` + + ^``` + .+? + \n```$ + ) + | + (? + # HTML block: + # + # Anything, including `/cmd arg` which are ignored by this filter + # + + ^<[^>]+?>\n + .+? + \n<\/[^>]+?>$ + ) + | + (? + # Quote block: + # >>> + # Anything, including `/cmd arg` which are ignored by this filter + # >>> + + ^>>> + .+? + \n>>>$ + ) + | + (?: + # Command not in a blockquote, blockcode, or HTML tag: + # /close + + ^\/ + (?#{Regexp.union(names)}) + (?: + [ ] + (?[^\/\n]*) + )? + (?:\n|$) + ) + }mx + end + + def command_names(opts) + command_definitions.flat_map do |command| + next if command.noop? + + command.all_names + end.compact + end + end + end +end diff --git a/lib/gitlab/template/base_template.rb b/lib/gitlab/template/base_template.rb index 760ff3e614a..7ebec8e2cff 100644 --- a/lib/gitlab/template/base_template.rb +++ b/lib/gitlab/template/base_template.rb @@ -1,8 +1,9 @@ module Gitlab module Template class BaseTemplate - def initialize(path) + def initialize(path, project = nil) @path = path + @finder = self.class.finder(project) end def name @@ -10,23 +11,32 @@ module Gitlab end def content - File.read(@path) + @finder.read(@path) + end + + def to_json + { name: name, content: content } end class << self - def all - self.categories.keys.flat_map { |cat| by_category(cat) } + def all(project = nil) + if categories.any? + categories.keys.flat_map { |cat| by_category(cat, project) } + else + by_category("", project) + end end - def find(key) - file_name = "#{key}#{self.extension}" - - directory = select_directory(file_name) - directory ? new(File.join(category_directory(directory), file_name)) : nil + def find(key, project = nil) + path = self.finder(project).find(key) + path.present? ? new(path, project) : nil end + # Set categories as sub directories + # Example: { "category_name_1" => "directory_path_1", "category_name_2" => "directory_name_2" } + # Default is no category with all files in base dir of each class def categories - raise NotImplementedError + {} end def extension @@ -37,30 +47,41 @@ module Gitlab raise NotImplementedError end - def by_category(category) - templates_for_directory(category_directory(category)) + # Defines which strategy will be used to get templates files + # RepoTemplateFinder - Finds templates on project repository, templates are filtered perproject + # GlobalTemplateFinder - Finds templates on gitlab installation source, templates can be used in all projects + def finder(project = nil) + raise NotImplementedError + end + + def by_category(category, project = nil) + directory = category_directory(category) + files = finder(project).list_files_for(directory) + + files.map { |f| new(f, project) } end def category_directory(category) + return base_dir unless category.present? + File.join(base_dir, categories[category]) end - private + # If template is organized by category it returns { category_name: [{ name: template_name }, { name: template2_name }] } + # If no category is present returns [{ name: template_name }, { name: template2_name}] + def dropdown_names(project = nil) + return [] if project && !project.repository.exists? - def select_directory(file_name) - categories.keys.find do |category| - File.exist?(File.join(category_directory(category), file_name)) + if categories.any? + categories.keys.map do |category| + files = self.by_category(category, project) + [category, files.map { |t| { name: t.name } }] + end.to_h + else + files = self.all(project) + files.map { |t| { name: t.name } } end end - - def templates_for_directory(dir) - dir << '/' unless dir.end_with?('/') - Dir.glob(File.join(dir, "*#{self.extension}")).select { |f| f =~ filter_regex }.map { |f| new(f) } - end - - def filter_regex - @filter_reges ||= /#{Regexp.escape(extension)}\z/ - end end end end diff --git a/lib/gitlab/template/finders/base_template_finder.rb b/lib/gitlab/template/finders/base_template_finder.rb new file mode 100644 index 00000000000..473b05257c6 --- /dev/null +++ b/lib/gitlab/template/finders/base_template_finder.rb @@ -0,0 +1,35 @@ +module Gitlab + module Template + module Finders + class BaseTemplateFinder + def initialize(base_dir) + @base_dir = base_dir + end + + def list_files_for + raise NotImplementedError + end + + def read + raise NotImplementedError + end + + def find + raise NotImplementedError + end + + def category_directory(category) + return @base_dir unless category.present? + + @base_dir + @categories[category] + end + + class << self + def filter_regex(extension) + /#{Regexp.escape(extension)}\z/ + end + end + end + end + end +end diff --git a/lib/gitlab/template/finders/global_template_finder.rb b/lib/gitlab/template/finders/global_template_finder.rb new file mode 100644 index 00000000000..831da45191f --- /dev/null +++ b/lib/gitlab/template/finders/global_template_finder.rb @@ -0,0 +1,38 @@ +# Searches and reads file present on Gitlab installation directory +module Gitlab + module Template + module Finders + class GlobalTemplateFinder < BaseTemplateFinder + def initialize(base_dir, extension, categories = {}) + @categories = categories + @extension = extension + super(base_dir) + end + + def read(path) + File.read(path) + end + + def find(key) + file_name = "#{key}#{@extension}" + + directory = select_directory(file_name) + directory ? File.join(category_directory(directory), file_name) : nil + end + + def list_files_for(dir) + dir << '/' unless dir.end_with?('/') + Dir.glob(File.join(dir, "*#{@extension}")).select { |f| f =~ self.class.filter_regex(@extension) } + end + + private + + def select_directory(file_name) + @categories.keys.find do |category| + File.exist?(File.join(category_directory(category), file_name)) + end + end + end + end + end +end diff --git a/lib/gitlab/template/finders/repo_template_finder.rb b/lib/gitlab/template/finders/repo_template_finder.rb new file mode 100644 index 00000000000..22c39436cb2 --- /dev/null +++ b/lib/gitlab/template/finders/repo_template_finder.rb @@ -0,0 +1,59 @@ +# Searches and reads files present on each Gitlab project repository +module Gitlab + module Template + module Finders + class RepoTemplateFinder < BaseTemplateFinder + # Raised when file is not found + class FileNotFoundError < StandardError; end + + def initialize(project, base_dir, extension, categories = {}) + @categories = categories + @extension = extension + @repository = project.repository + @commit = @repository.head_commit if @repository.exists? + + super(base_dir) + end + + def read(path) + blob = @repository.blob_at(@commit.id, path) if @commit + raise FileNotFoundError if blob.nil? + blob.data + end + + def find(key) + file_name = "#{key}#{@extension}" + directory = select_directory(file_name) + raise FileNotFoundError if directory.nil? + + category_directory(directory) + file_name + end + + def list_files_for(dir) + return [] unless @commit + + dir << '/' unless dir.end_with?('/') + + entries = @repository.tree(:head, dir).entries + + names = entries.map(&:name) + names.select { |f| f =~ self.class.filter_regex(@extension) } + end + + private + + def select_directory(file_name) + return [] unless @commit + + # Insert root as directory + directories = ["", @categories.keys] + + directories.find do |category| + path = category_directory(category) + file_name + @repository.blob_at(@commit.id, path) + end + end + end + end + end +end diff --git a/lib/gitlab/template/gitignore.rb b/lib/gitlab/template/gitignore_template.rb similarity index 63% rename from lib/gitlab/template/gitignore.rb rename to lib/gitlab/template/gitignore_template.rb index 964fbfd4de3..8d2a9d2305c 100644 --- a/lib/gitlab/template/gitignore.rb +++ b/lib/gitlab/template/gitignore_template.rb @@ -1,6 +1,6 @@ module Gitlab module Template - class Gitignore < BaseTemplate + class GitignoreTemplate < BaseTemplate class << self def extension '.gitignore' @@ -16,6 +16,10 @@ module Gitlab def base_dir Rails.root.join('vendor/gitignore') end + + def finder(project = nil) + Gitlab::Template::Finders::GlobalTemplateFinder.new(self.base_dir, self.extension, self.categories) + end end end end diff --git a/lib/gitlab/template/gitlab_ci_yml.rb b/lib/gitlab/template/gitlab_ci_yml_template.rb similarity index 72% rename from lib/gitlab/template/gitlab_ci_yml.rb rename to lib/gitlab/template/gitlab_ci_yml_template.rb index 7f480fe33c0..8d1a1ed54c9 100644 --- a/lib/gitlab/template/gitlab_ci_yml.rb +++ b/lib/gitlab/template/gitlab_ci_yml_template.rb @@ -1,6 +1,6 @@ module Gitlab module Template - class GitlabCiYml < BaseTemplate + class GitlabCiYmlTemplate < BaseTemplate def content explanation = "# This file is a template, and might need editing before it works on your project." [explanation, super].join("\n") @@ -21,6 +21,10 @@ module Gitlab def base_dir Rails.root.join('vendor/gitlab-ci-yml') end + + def finder(project = nil) + Gitlab::Template::Finders::GlobalTemplateFinder.new(self.base_dir, self.extension, self.categories) + end end end end diff --git a/lib/gitlab/template/issue_template.rb b/lib/gitlab/template/issue_template.rb new file mode 100644 index 00000000000..c6fa8d3eafc --- /dev/null +++ b/lib/gitlab/template/issue_template.rb @@ -0,0 +1,19 @@ +module Gitlab + module Template + class IssueTemplate < BaseTemplate + class << self + def extension + '.md' + end + + def base_dir + '.gitlab/issue_templates/' + end + + def finder(project) + Gitlab::Template::Finders::RepoTemplateFinder.new(project, self.base_dir, self.extension, self.categories) + end + end + end + end +end diff --git a/lib/gitlab/template/merge_request_template.rb b/lib/gitlab/template/merge_request_template.rb new file mode 100644 index 00000000000..f826c02f3b5 --- /dev/null +++ b/lib/gitlab/template/merge_request_template.rb @@ -0,0 +1,19 @@ +module Gitlab + module Template + class MergeRequestTemplate < BaseTemplate + class << self + def extension + '.md' + end + + def base_dir + '.gitlab/merge_request_templates/' + end + + def finder(project) + Gitlab::Template::Finders::RepoTemplateFinder.new(project, self.base_dir, self.extension, self.categories) + end + end + end + end +end diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb index c55a7fc4d3d..9858d2e7d83 100644 --- a/lib/gitlab/user_access.rb +++ b/lib/gitlab/user_access.rb @@ -32,7 +32,7 @@ module Gitlab if project.protected_branch?(ref) return true if project.empty_repo? && project.user_can_push_to_empty_repo?(user) - access_levels = project.protected_branches.matching(ref).map(&:push_access_level) + access_levels = project.protected_branches.matching(ref).map(&:push_access_levels).flatten access_levels.any? { |access_level| access_level.check_access(user) } else user.can?(:push_code, project) @@ -43,7 +43,7 @@ module Gitlab return false unless user if project.protected_branch?(ref) - access_levels = project.protected_branches.matching(ref).map(&:merge_access_level) + access_levels = project.protected_branches.matching(ref).map(&:merge_access_levels).flatten access_levels.any? { |access_level| access_level.check_access(user) } else user.can?(:push_code, project) diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb index 0239aea47fb..602de72d23f 100644 --- a/spec/controllers/admin/groups_controller_spec.rb +++ b/spec/controllers/admin/groups_controller_spec.rb @@ -7,12 +7,13 @@ describe Admin::GroupsController do before do sign_in(admin) - Sidekiq::Testing.fake! end describe 'DELETE #destroy' do it 'schedules a group destroy' do - expect { delete :destroy, id: project.group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + Sidekiq::Testing.fake! do + expect { delete :destroy, id: project.group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + end end it 'redirects to the admin group path' do diff --git a/spec/controllers/admin/spam_logs_controller_spec.rb b/spec/controllers/admin/spam_logs_controller_spec.rb index 520a4f6f9c5..585ca31389d 100644 --- a/spec/controllers/admin/spam_logs_controller_spec.rb +++ b/spec/controllers/admin/spam_logs_controller_spec.rb @@ -34,4 +34,16 @@ describe Admin::SpamLogsController do expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) end end + + describe '#mark_as_ham' do + before do + allow_any_instance_of(AkismetService).to receive(:submit_ham).and_return(true) + end + it 'submits the log as ham' do + post :mark_as_ham, id: first_spam.id + + expect(response).to have_http_status(302) + expect(SpamLog.find(first_spam.id).submitted_as_ham).to be_truthy + end + end end diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb index ed0b7f9e240..44128a43362 100644 --- a/spec/controllers/autocomplete_controller_spec.rb +++ b/spec/controllers/autocomplete_controller_spec.rb @@ -2,178 +2,262 @@ require 'spec_helper' describe AutocompleteController do let!(:project) { create(:project) } - let!(:user) { create(:user) } - let!(:user2) { create(:user) } - let!(:non_member) { create(:user) } + let!(:user) { create(:user) } - context 'project members' do - before do - sign_in(user) - project.team << [user, :master] + context 'users and members' do + let!(:user2) { create(:user) } + let!(:non_member) { create(:user) } + + context 'project members' do + before do + sign_in(user) + project.team << [user, :master] + end + + describe 'GET #users with project ID' do + before do + get(:users, project_id: project.id) + end + + let(:body) { JSON.parse(response.body) } + + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq 1 } + it { expect(body.map { |u| u["username"] }).to include(user.username) } + end + + describe 'GET #users with unknown project' do + before do + get(:users, project_id: 'unknown') + end + + it { expect(response).to have_http_status(404) } + end end - describe 'GET #users with project ID' do + context 'group members' do + let(:group) { create(:group) } + before do - get(:users, project_id: project.id) + sign_in(user) + group.add_owner(user) + end + + let(:body) { JSON.parse(response.body) } + + describe 'GET #users with group ID' do + before do + get(:users, group_id: group.id) + end + + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq 1 } + it { expect(body.first["username"]).to eq user.username } + end + + describe 'GET #users with unknown group ID' do + before do + get(:users, group_id: 'unknown') + end + + it { expect(response).to have_http_status(404) } + end + end + + context 'non-member login for public project' do + let!(:project) { create(:project, :public) } + + before do + sign_in(non_member) + project.team << [user, :master] + end + + let(:body) { JSON.parse(response.body) } + + describe 'GET #users with project ID' do + before do + get(:users, project_id: project.id, current_user: true) + end + + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq 2 } + it { expect(body.map { |u| u['username'] }).to match_array([user.username, non_member.username]) } + end + end + + context 'all users' do + before do + sign_in(user) + get(:users) end let(:body) { JSON.parse(response.body) } it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 1 } - it { expect(body.map { |u| u["username"] }).to include(user.username) } + it { expect(body.size).to eq User.count } end - describe 'GET #users with unknown project' do - before do - get(:users, project_id: 'unknown') + context 'unauthenticated user' do + let(:public_project) { create(:project, :public) } + let(:body) { JSON.parse(response.body) } + + describe 'GET #users with public project' do + before do + public_project.team << [user, :guest] + get(:users, project_id: public_project.id) + end + + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq 1 } end - it { expect(response).to have_http_status(404) } + describe 'GET #users with project' do + before do + get(:users, project_id: project.id) + end + + it { expect(response).to have_http_status(404) } + end + + describe 'GET #users with unknown project' do + before do + get(:users, project_id: 'unknown') + end + + it { expect(response).to have_http_status(404) } + end + + describe 'GET #users with inaccessible group' do + before do + project.team << [user, :guest] + get(:users, group_id: user.namespace.id) + end + + it { expect(response).to have_http_status(404) } + end + + describe 'GET #users with no project' do + before do + get(:users) + end + + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq 0 } + end + end + + context 'author of issuable included' do + before do + sign_in(user) + end + + let(:body) { JSON.parse(response.body) } + + it 'includes the author' do + get(:users, author_id: non_member.id) + + expect(body.first["username"]).to eq non_member.username + end + + it 'rejects non existent user ids' do + get(:users, author_id: 99999) + + expect(body.collect { |u| u['id'] }).not_to include(99999) + end + end + + context 'skip_users parameter included' do + before { sign_in(user) } + + it 'skips the user IDs passed' do + get(:users, skip_users: [user, user2].map(&:id)) + + other_user_ids = [non_member, project.owner, project.creator].map(&:id) + response_user_ids = JSON.parse(response.body).map { |user| user['id'] } + + expect(response_user_ids).to contain_exactly(*other_user_ids) + end end end - context 'group members' do - let(:group) { create(:group) } + context 'projects' do + let(:authorized_project) { create(:project) } + let(:authorized_search_project) { create(:project, name: 'rugged') } before do sign_in(user) - group.add_owner(user) - end - - let(:body) { JSON.parse(response.body) } - - describe 'GET #users with group ID' do - before do - get(:users, group_id: group.id) - end - - it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 1 } - it { expect(body.first["username"]).to eq user.username } - end - - describe 'GET #users with unknown group ID' do - before do - get(:users, group_id: 'unknown') - end - - it { expect(response).to have_http_status(404) } - end - end - - context 'non-member login for public project' do - let!(:project) { create(:project, :public) } - - before do - sign_in(non_member) project.team << [user, :master] end - let(:body) { JSON.parse(response.body) } - - describe 'GET #users with project ID' do + context 'authorized projects' do before do - get(:users, project_id: project.id, current_user: true) + authorized_project.team << [user, :master] end - it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 2 } - it { expect(body.map { |u| u['username'] }).to match_array([user.username, non_member.username]) } - end - end + describe 'GET #projects with project ID' do + before do + get(:projects, project_id: project.id) + end - context 'all users' do - before do - sign_in(user) - get(:users) + let(:body) { JSON.parse(response.body) } + + it do + expect(body).to be_kind_of(Array) + expect(body.size).to eq 2 + + expect(body.first['id']).to eq 0 + expect(body.first['name_with_namespace']).to eq 'No project' + + expect(body.last['id']).to eq authorized_project.id + expect(body.last['name_with_namespace']).to eq authorized_project.name_with_namespace + end + end end - let(:body) { JSON.parse(response.body) } - - it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq User.count } - end - - context 'unauthenticated user' do - let(:public_project) { create(:project, :public) } - let(:body) { JSON.parse(response.body) } - - describe 'GET #users with public project' do + context 'authorized projects and search' do before do - public_project.team << [user, :guest] - get(:users, project_id: public_project.id) + authorized_project.team << [user, :master] + authorized_search_project.team << [user, :master] end - it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 1 } + describe 'GET #projects with project ID and search' do + before do + get(:projects, project_id: project.id, search: 'rugged') + end + + let(:body) { JSON.parse(response.body) } + + it do + expect(body).to be_kind_of(Array) + expect(body.size).to eq 2 + + expect(body.last['id']).to eq authorized_search_project.id + expect(body.last['name_with_namespace']).to eq authorized_search_project.name_with_namespace + end + end end - describe 'GET #users with project' do - before do - get(:users, project_id: project.id) + context 'authorized projects without admin_issue ability' do + before(:each) do + authorized_project.team << [user, :guest] + + expect(user.can?(:admin_issue, authorized_project)).to eq(false) end - it { expect(response).to have_http_status(404) } - end + describe 'GET #projects with project ID' do + before do + get(:projects, project_id: project.id) + end - describe 'GET #users with unknown project' do - before do - get(:users, project_id: 'unknown') + let(:body) { JSON.parse(response.body) } + + it do + expect(body).to be_kind_of(Array) + expect(body.size).to eq 1 # 'No project' + + expect(body.first['id']).to eq 0 + end end - - it { expect(response).to have_http_status(404) } - end - - describe 'GET #users with inaccessible group' do - before do - project.team << [user, :guest] - get(:users, group_id: user.namespace.id) - end - - it { expect(response).to have_http_status(404) } - end - - describe 'GET #users with no project' do - before do - get(:users) - end - - it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 0 } - end - end - - context 'author of issuable included' do - before do - sign_in(user) - end - - let(:body) { JSON.parse(response.body) } - - it 'includes the author' do - get(:users, author_id: non_member.id) - - expect(body.first["username"]).to eq non_member.username - end - - it 'rejects non existent user ids' do - get(:users, author_id: 99999) - - expect(body.collect { |u| u['id'] }).not_to include(99999) - end - end - - context 'skip_users parameter included' do - before { sign_in(user) } - - it 'skips the user IDs passed' do - get(:users, skip_users: [user, user2].map(&:id)) - - other_user_ids = [non_member, project.owner, project.creator].map(&:id) - response_user_ids = JSON.parse(response.body).map { |user| user['id'] } - - expect(response_user_ids).to contain_exactly(*other_user_ids) end end end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 4ae6364207b..a763e2c5ba8 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -89,12 +89,13 @@ describe GroupsController do context 'as the group owner' do before do - Sidekiq::Testing.fake! sign_in(user) end it 'schedules a group destroy' do - expect { delete :destroy, id: group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + Sidekiq::Testing.fake! do + expect { delete :destroy, id: group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + end end it 'redirects to the root path' do diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb new file mode 100644 index 00000000000..d0ad5e26dbd --- /dev/null +++ b/spec/controllers/projects/boards/issues_controller_spec.rb @@ -0,0 +1,120 @@ +require 'spec_helper' + +describe Projects::Boards::IssuesController do + let(:project) { create(:project_with_board) } + let(:user) { create(:user) } + + let(:planning) { create(:label, project: project, name: 'Planning') } + let(:development) { create(:label, project: project, name: 'Development') } + + let!(:list1) { create(:list, board: project.board, label: planning, position: 0) } + let!(:list2) { create(:list, board: project.board, label: development, position: 1) } + + before do + project.team << [user, :master] + end + + describe 'GET index' do + context 'with valid list id' do + it 'returns issues that have the list label applied' do + johndoe = create(:user, avatar: fixture_file_upload(File.join(Rails.root, 'spec/fixtures/dk.png'))) + create(:labeled_issue, project: project, labels: [planning]) + create(:labeled_issue, project: project, labels: [development]) + create(:labeled_issue, project: project, labels: [development], assignee: johndoe) + + list_issues user: user, list_id: list2 + + parsed_response = JSON.parse(response.body) + + expect(response).to match_response_schema('issues') + expect(parsed_response.length).to eq 2 + end + end + + context 'with invalid list id' do + it 'returns a not found 404 response' do + list_issues user: user, list_id: 999 + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + before do + allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true) + allow(Ability.abilities).to receive(:allowed?).with(user, :read_issue, project).and_return(false) + end + + it 'returns a successful 403 response' do + list_issues user: user, list_id: list2 + + expect(response).to have_http_status(403) + end + end + + def list_issues(user:, list_id:) + sign_in(user) + + get :index, namespace_id: project.namespace.to_param, + project_id: project.to_param, + list_id: list_id.to_param + end + end + + describe 'PATCH update' do + let(:issue) { create(:labeled_issue, project: project, labels: [planning]) } + + context 'with valid params' do + it 'returns a successful 200 response' do + move user: user, issue: issue, from_list_id: list1.id, to_list_id: list2.id + + expect(response).to have_http_status(200) + end + + it 'moves issue to the desired list' do + move user: user, issue: issue, from_list_id: list1.id, to_list_id: list2.id + + expect(issue.reload.labels).to contain_exactly(development) + end + end + + context 'with invalid params' do + it 'returns a unprocessable entity 422 response for invalid lists' do + move user: user, issue: issue, from_list_id: nil, to_list_id: nil + + expect(response).to have_http_status(422) + end + + it 'returns a not found 404 response for invalid issue id' do + move user: user, issue: 999, from_list_id: list1.id, to_list_id: list2.id + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + let(:guest) { create(:user) } + + before do + project.team << [guest, :guest] + end + + it 'returns a successful 403 response' do + move user: guest, issue: issue, from_list_id: list1.id, to_list_id: list2.id + + expect(response).to have_http_status(403) + end + end + + def move(user:, issue:, from_list_id:, to_list_id:) + sign_in(user) + + patch :update, namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: issue.to_param, + from_list_id: from_list_id, + to_list_id: to_list_id, + format: :json + end + end +end diff --git a/spec/controllers/projects/boards/lists_controller_spec.rb b/spec/controllers/projects/boards/lists_controller_spec.rb new file mode 100644 index 00000000000..9496636e3cc --- /dev/null +++ b/spec/controllers/projects/boards/lists_controller_spec.rb @@ -0,0 +1,241 @@ +require 'spec_helper' + +describe Projects::Boards::ListsController do + let(:project) { create(:project_with_board) } + let(:board) { project.board } + let(:user) { create(:user) } + let(:guest) { create(:user) } + + before do + project.team << [user, :master] + project.team << [guest, :guest] + end + + describe 'GET index' do + it 'returns a successful 200 response' do + read_board_list user: user + + expect(response).to have_http_status(200) + expect(response.content_type).to eq 'application/json' + end + + it 'returns a list of board lists' do + board = project.create_board + create(:backlog_list, board: board) + create(:list, board: board) + create(:done_list, board: board) + + read_board_list user: user + + parsed_response = JSON.parse(response.body) + + expect(response).to match_response_schema('lists') + expect(parsed_response.length).to eq 3 + end + + context 'with unauthorized user' do + before do + allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true) + allow(Ability.abilities).to receive(:allowed?).with(user, :read_list, project).and_return(false) + end + + it 'returns a successful 403 response' do + read_board_list user: user + + expect(response).to have_http_status(403) + end + end + + def read_board_list(user:) + sign_in(user) + + get :index, namespace_id: project.namespace.to_param, + project_id: project.to_param, + format: :json + end + end + + describe 'POST create' do + let(:label) { create(:label, project: project, name: 'Development') } + + context 'with valid params' do + it 'returns a successful 200 response' do + create_board_list user: user, label_id: label.id + + expect(response).to have_http_status(200) + end + + it 'returns the created list' do + create_board_list user: user, label_id: label.id + + expect(response).to match_response_schema('list') + end + end + + context 'with invalid params' do + it 'returns an error' do + create_board_list user: user, label_id: nil + + parsed_response = JSON.parse(response.body) + + expect(parsed_response['label']).to contain_exactly "can't be blank" + expect(response).to have_http_status(422) + end + end + + context 'with unauthorized user' do + let(:label) { create(:label, project: project, name: 'Development') } + + it 'returns a successful 403 response' do + create_board_list user: guest, label_id: label.id + + expect(response).to have_http_status(403) + end + end + + def create_board_list(user:, label_id:) + sign_in(user) + + post :create, namespace_id: project.namespace.to_param, + project_id: project.to_param, + list: { label_id: label_id }, + format: :json + end + end + + describe 'PATCH update' do + let!(:planning) { create(:list, board: board, position: 0) } + let!(:development) { create(:list, board: board, position: 1) } + + context 'with valid position' do + it 'returns a successful 200 response' do + move user: user, list: planning, position: 1 + + expect(response).to have_http_status(200) + end + + it 'moves the list to the desired position' do + move user: user, list: planning, position: 1 + + expect(planning.reload.position).to eq 1 + end + end + + context 'with invalid position' do + it 'returns a unprocessable entity 422 response' do + move user: user, list: planning, position: 6 + + expect(response).to have_http_status(422) + end + end + + context 'with invalid list id' do + it 'returns a not found 404 response' do + move user: user, list: 999, position: 1 + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + it 'returns a successful 403 response' do + move user: guest, list: planning, position: 6 + + expect(response).to have_http_status(403) + end + end + + def move(user:, list:, position:) + sign_in(user) + + patch :update, namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: list.to_param, + list: { position: position }, + format: :json + end + end + + describe 'DELETE destroy' do + let!(:planning) { create(:list, board: board, position: 0) } + + context 'with valid list id' do + it 'returns a successful 200 response' do + remove_board_list user: user, list: planning + + expect(response).to have_http_status(200) + end + + it 'removes list from board' do + expect { remove_board_list user: user, list: planning }.to change(board.lists, :size).by(-1) + end + end + + context 'with invalid list id' do + it 'returns a not found 404 response' do + remove_board_list user: user, list: 999 + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + it 'returns a successful 403 response' do + remove_board_list user: guest, list: planning + + expect(response).to have_http_status(403) + end + end + + def remove_board_list(user:, list:) + sign_in(user) + + delete :destroy, namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: list.to_param, + format: :json + end + end + + describe 'POST generate' do + context 'when board lists is empty' do + it 'returns a successful 200 response' do + generate_default_board_lists user: user + + expect(response).to have_http_status(200) + end + + it 'returns the defaults lists' do + generate_default_board_lists user: user + + expect(response).to match_response_schema('lists') + end + end + + context 'when board lists is not empty' do + it 'returns a unprocessable entity 422 response' do + create(:list, board: board) + + generate_default_board_lists user: user + + expect(response).to have_http_status(422) + end + end + + context 'with unauthorized user' do + it 'returns a successful 403 response' do + generate_default_board_lists user: guest + + expect(response).to have_http_status(403) + end + end + + def generate_default_board_lists(user:) + sign_in(user) + + post :generate, namespace_id: project.namespace.to_param, + project_id: project.to_param, + format: :json + end + end +end diff --git a/spec/controllers/projects/boards_controller_spec.rb b/spec/controllers/projects/boards_controller_spec.rb new file mode 100644 index 00000000000..75a6d39e82c --- /dev/null +++ b/spec/controllers/projects/boards_controller_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe Projects::BoardsController do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + + before do + project.team << [user, :master] + sign_in(user) + end + + describe 'GET show' do + it 'creates a new board when project does not have one' do + expect { read_board }.to change(Board, :count).by(1) + end + + it 'renders HTML template' do + read_board + + expect(response).to render_template :show + expect(response.content_type).to eq 'text/html' + end + + context 'with unauthorized user' do + before do + allow(Ability.abilities).to receive(:allowed?).with(user, :read_project, project).and_return(true) + allow(Ability.abilities).to receive(:allowed?).with(user, :read_board, project).and_return(false) + end + + it 'returns a successful 404 response' do + read_board + + expect(response).to have_http_status(404) + end + end + + def read_board(format: :html) + get :show, namespace_id: project.namespace.to_param, + project_id: project.to_param, + format: format + end + end +end diff --git a/spec/controllers/projects/discussions_controller_spec.rb b/spec/controllers/projects/discussions_controller_spec.rb new file mode 100644 index 00000000000..ff617fea847 --- /dev/null +++ b/spec/controllers/projects/discussions_controller_spec.rb @@ -0,0 +1,125 @@ +require 'spec_helper' + +describe Projects::DiscussionsController do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, source_project: project) } + let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } + let(:discussion) { note.discussion } + + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + merge_request_id: merge_request, + id: note.discussion_id + } + end + + describe 'POST resolve' do + before do + sign_in user + end + + context "when the user is not authorized to resolve the discussion" do + it "returns status 404" do + post :resolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the user is authorized to resolve the discussion" do + before do + project.team << [user, :developer] + end + + context "when the discussion is not resolvable" do + before do + note.update(system: true) + end + + it "returns status 404" do + post :resolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the discussion is resolvable" do + it "resolves the discussion" do + post :resolve, request_params + + expect(note.reload.discussion.resolved?).to be true + expect(note.reload.discussion.resolved_by).to eq(user) + end + + it "sends notifications if all discussions are resolved" do + expect_any_instance_of(MergeRequests::ResolvedDiscussionNotificationService).to receive(:execute).with(merge_request) + + post :resolve, request_params + end + + it "returns the name of the resolving user" do + post :resolve, request_params + + expect(JSON.parse(response.body)["resolved_by"]).to eq(user.name) + end + + it "returns status 200" do + post :resolve, request_params + + expect(response).to have_http_status(200) + end + end + end + end + + describe 'DELETE unresolve' do + before do + sign_in user + + note.discussion.resolve!(user) + end + + context "when the user is not authorized to resolve the discussion" do + it "returns status 404" do + delete :unresolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the user is authorized to resolve the discussion" do + before do + project.team << [user, :developer] + end + + context "when the discussion is not resolvable" do + before do + note.update(system: true) + end + + it "returns status 404" do + delete :unresolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the discussion is resolvable" do + it "unresolves the discussion" do + delete :unresolve, request_params + + expect(note.reload.discussion.resolved?).to be false + end + + it "returns status 200" do + delete :unresolve, request_params + + expect(response).to have_http_status(200) + end + end + end + end +end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index b6a0276846c..0836b71056c 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -274,8 +274,8 @@ describe Projects::IssuesController do describe 'POST #create' do context 'Akismet is enabled' do before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:check_for_spam?).and_return(true) - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true) + allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true) end def post_spam_issue @@ -300,6 +300,52 @@ describe Projects::IssuesController do expect(spam_logs[0].title).to eq('Spam Title') end end + + context 'user agent details are saved' do + before do + request.env['action_dispatch.remote_ip'] = '127.0.0.1' + end + + def post_new_issue + sign_in(user) + project = create(:empty_project, :public) + post :create, { + namespace_id: project.namespace.to_param, + project_id: project.to_param, + issue: { title: 'Title', description: 'Description' } + } + end + + it 'creates a user agent detail' do + expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1) + end + end + end + + describe 'POST #mark_as_spam' do + context 'properly submits to Akismet' do + before do + allow_any_instance_of(AkismetService).to receive_messages(submit_spam: true) + allow_any_instance_of(ApplicationSetting).to receive_messages(akismet_enabled: true) + end + + def post_spam + admin = create(:admin) + create(:user_agent_detail, subject: issue) + project.team << [admin, :master] + sign_in(admin) + post :mark_as_spam, { + namespace_id: project.namespace.path, + project_id: project.path, + id: issue.iid + } + end + + it 'updates issue' do + post_spam + expect(issue.submittable_as_spam?).to be_falsey + end + end end describe "DELETE #destroy" do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 69758494543..c64c2b075c5 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -4,6 +4,11 @@ describe Projects::MergeRequestsController do let(:project) { create(:project) } let(:user) { create(:user) } let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } + let(:merge_request_with_conflicts) do + create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr| + mr.mark_as_unmergeable + end + end before do sign_in(user) @@ -523,4 +528,135 @@ describe Projects::MergeRequestsController do end end end + + describe 'GET conflicts' do + let(:json_response) { JSON.parse(response.body) } + + context 'when the conflicts cannot be resolved in the UI' do + before do + allow_any_instance_of(Gitlab::Conflict::Parser). + to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + get :conflicts, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project.to_param, + id: merge_request_with_conflicts.iid, + format: 'json' + end + + it 'returns a 200 status code' do + expect(response).to have_http_status(:ok) + end + + it 'returns JSON with a message' do + expect(json_response.keys).to contain_exactly('message', 'type') + end + end + + context 'with valid conflicts' do + before do + get :conflicts, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project.to_param, + id: merge_request_with_conflicts.iid, + format: 'json' + end + + it 'includes meta info about the MR' do + expect(json_response['commit_message']).to include('Merge branch') + expect(json_response['commit_sha']).to match(/\h{40}/) + expect(json_response['source_branch']).to eq(merge_request_with_conflicts.source_branch) + expect(json_response['target_branch']).to eq(merge_request_with_conflicts.target_branch) + end + + it 'includes each file that has conflicts' do + filenames = json_response['files'].map { |file| file['new_path'] } + + expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb') + end + + it 'splits files into sections with lines' do + json_response['files'].each do |file| + file['sections'].each do |section| + expect(section).to include('conflict', 'lines') + + section['lines'].each do |line| + if section['conflict'] + expect(line['type']).to be_in(['old', 'new']) + expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer)) + else + if line['type'].nil? + expect(line['old_line']).not_to eq(nil) + expect(line['new_line']).not_to eq(nil) + else + expect(line['type']).to eq('match') + expect(line['old_line']).to eq(nil) + expect(line['new_line']).to eq(nil) + end + end + end + end + end + end + + it 'has unique section IDs across files' do + section_ids = json_response['files'].flat_map do |file| + file['sections'].map { |section| section['id'] }.compact + end + + expect(section_ids.uniq).to eq(section_ids) + end + end + end + + context 'POST resolve_conflicts' do + let(:json_response) { JSON.parse(response.body) } + let!(:original_head_sha) { merge_request_with_conflicts.diff_head_sha } + + def resolve_conflicts(sections) + post :resolve_conflicts, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project.to_param, + id: merge_request_with_conflicts.iid, + format: 'json', + sections: sections, + commit_message: 'Commit message' + end + + context 'with valid params' do + before do + resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin') + end + + it 'creates a new commit on the branch' do + expect(original_head_sha).not_to eq(merge_request_with_conflicts.source_branch_head.sha) + expect(merge_request_with_conflicts.source_branch_head.message).to include('Commit message') + end + + it 'returns an OK response' do + expect(response).to have_http_status(:ok) + end + end + + context 'when sections are missing' do + before do + resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head') + end + + it 'returns a 400 error' do + expect(response).to have_http_status(:bad_request) + end + + it 'has a message with the name of the first missing section' do + expect(json_response['message']).to include('6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9') + end + + it 'does not create a new commit' do + expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha) + end + end + end end diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index 75590c1ed4f..92e38b02615 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -1,4 +1,4 @@ -require('spec_helper') +require 'spec_helper' describe Projects::NotesController do let(:user) { create(:user) } @@ -6,7 +6,15 @@ describe Projects::NotesController do let(:issue) { create(:issue, project: project) } let(:note) { create(:note, noteable: issue, project: project) } - describe 'POST #toggle_award_emoji' do + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + id: note + } + end + + describe 'POST toggle_award_emoji' do before do sign_in(user) project.team << [user, :developer] @@ -14,23 +22,132 @@ describe Projects::NotesController do it "toggles the award emoji" do expect do - post(:toggle_award_emoji, namespace_id: project.namespace.path, - project_id: project.path, id: note.id, name: "thumbsup") + post(:toggle_award_emoji, request_params.merge(name: "thumbsup")) end.to change { note.award_emoji.count }.by(1) expect(response).to have_http_status(200) end it "removes the already awarded emoji" do - post(:toggle_award_emoji, namespace_id: project.namespace.path, - project_id: project.path, id: note.id, name: "thumbsup") + post(:toggle_award_emoji, request_params.merge(name: "thumbsup")) expect do - post(:toggle_award_emoji, namespace_id: project.namespace.path, - project_id: project.path, id: note.id, name: "thumbsup") + post(:toggle_award_emoji, request_params.merge(name: "thumbsup")) end.to change { AwardEmoji.count }.by(-1) expect(response).to have_http_status(200) end end + + describe "resolving and unresolving" do + let(:merge_request) { create(:merge_request, source_project: project) } + let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } + + describe 'POST resolve' do + before do + sign_in user + end + + context "when the user is not authorized to resolve the note" do + it "returns status 404" do + post :resolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the user is authorized to resolve the note" do + before do + project.team << [user, :developer] + end + + context "when the note is not resolvable" do + before do + note.update(system: true) + end + + it "returns status 404" do + post :resolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the note is resolvable" do + it "resolves the note" do + post :resolve, request_params + + expect(note.reload.resolved?).to be true + expect(note.reload.resolved_by).to eq(user) + end + + it "sends notifications if all discussions are resolved" do + expect_any_instance_of(MergeRequests::ResolvedDiscussionNotificationService).to receive(:execute).with(merge_request) + + post :resolve, request_params + end + + it "returns the name of the resolving user" do + post :resolve, request_params + + expect(JSON.parse(response.body)["resolved_by"]).to eq(user.name) + end + + it "returns status 200" do + post :resolve, request_params + + expect(response).to have_http_status(200) + end + end + end + end + + describe 'DELETE unresolve' do + before do + sign_in user + + note.resolve!(user) + end + + context "when the user is not authorized to resolve the note" do + it "returns status 404" do + delete :unresolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the user is authorized to resolve the note" do + before do + project.team << [user, :developer] + end + + context "when the note is not resolvable" do + before do + note.update(system: true) + end + + it "returns status 404" do + delete :unresolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the note is resolvable" do + it "unresolves the note" do + delete :unresolve, request_params + + expect(note.reload.resolved?).to be false + end + + it "returns status 200" do + delete :unresolve, request_params + + expect(response).to have_http_status(200) + end + end + end + end + end end diff --git a/spec/controllers/projects/templates_controller_spec.rb b/spec/controllers/projects/templates_controller_spec.rb new file mode 100644 index 00000000000..7b3a26d7ca7 --- /dev/null +++ b/spec/controllers/projects/templates_controller_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Projects::TemplatesController do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:user2) { create(:user) } + let(:file_path_1) { '.gitlab/issue_templates/bug.md' } + let(:body) { JSON.parse(response.body) } + + before do + project.team << [user, :developer] + sign_in(user) + end + + before do + project.team.add_user(user, Gitlab::Access::MASTER) + project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false) + end + + describe '#show' do + it 'renders template name and content as json' do + get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project.path, format: :json) + + expect(response.status).to eq(200) + expect(body["name"]).to eq("bug") + expect(body["content"]).to eq("something valid") + end + + it 'renders 404 when unauthorized' do + sign_in(user2) + get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project.path, format: :json) + + expect(response.status).to eq(404) + end + + it 'renders 404 when template type is not found' do + sign_in(user) + get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project.path, format: :json) + + expect(response.status).to eq(404) + end + + it 'renders 404 without errors' do + sign_in(user) + expect { get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project.path, format: :json) }.not_to raise_error + end + end +end diff --git a/spec/factories/boards.rb b/spec/factories/boards.rb new file mode 100644 index 00000000000..35c4a0b6f08 --- /dev/null +++ b/spec/factories/boards.rb @@ -0,0 +1,5 @@ +FactoryGirl.define do + factory :board do + project factory: :empty_project + end +end diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index 04d66020c87..ac2a1ba5dff 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -1,21 +1,3 @@ -# == Schema Information -# -# Table name: commits -# -# id :integer not null, primary key -# project_id :integer -# ref :string(255) -# sha :string(255) -# before_sha :string(255) -# push_data :text -# created_at :datetime -# updated_at :datetime -# tag :boolean default(FALSE) -# yaml_errors :text -# committed_at :datetime -# gl_project_id :integer -# - FactoryGirl.define do factory :ci_empty_pipeline, class: Ci::Pipeline do ref 'master' diff --git a/spec/factories/lists.rb b/spec/factories/lists.rb new file mode 100644 index 00000000000..9e3f06c682c --- /dev/null +++ b/spec/factories/lists.rb @@ -0,0 +1,20 @@ +FactoryGirl.define do + factory :list do + board + label + list_type :label + sequence(:position) + end + + factory :backlog_list, parent: :list do + list_type :backlog + label nil + position nil + end + + factory :done_list, parent: :list do + list_type :done + label nil + position nil + end +end diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index 3195fb3ddcc..4fd51a23490 100644 --- a/spec/factories/project_hooks.rb +++ b/spec/factories/project_hooks.rb @@ -5,5 +5,15 @@ FactoryGirl.define do trait :token do token { SecureRandom.hex(10) } end + + trait :all_events_enabled do + push_events true + merge_requests_events true + tag_push_events true + issues_events true + note_events true + build_events true + pipeline_events true + end end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index b682ced75ac..f82d68a1816 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -83,4 +83,10 @@ FactoryGirl.define do ) end end + + factory :project_with_board, parent: :empty_project do + after(:create) do |project| + project.create_board + end + end end diff --git a/spec/factories/protected_branches.rb b/spec/factories/protected_branches.rb index 5575852c2d7..b2695e0482a 100644 --- a/spec/factories/protected_branches.rb +++ b/spec/factories/protected_branches.rb @@ -3,26 +3,26 @@ FactoryGirl.define do name project - after(:create) do |protected_branch| - protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER) - protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER) + after(:build) do |protected_branch| + protected_branch.push_access_levels.new(access_level: Gitlab::Access::MASTER) + protected_branch.merge_access_levels.new(access_level: Gitlab::Access::MASTER) end trait :developers_can_push do after(:create) do |protected_branch| - protected_branch.push_access_level.update!(access_level: Gitlab::Access::DEVELOPER) + protected_branch.push_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER) end end trait :developers_can_merge do after(:create) do |protected_branch| - protected_branch.merge_access_level.update!(access_level: Gitlab::Access::DEVELOPER) + protected_branch.merge_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER) end end trait :no_one_can_push do after(:create) do |protected_branch| - protected_branch.push_access_level.update!(access_level: Gitlab::Access::NO_ACCESS) + protected_branch.push_access_levels.first.update!(access_level: Gitlab::Access::NO_ACCESS) end end end diff --git a/spec/factories/user_agent_details.rb b/spec/factories/user_agent_details.rb new file mode 100644 index 00000000000..9763cc0cf15 --- /dev/null +++ b/spec/factories/user_agent_details.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :user_agent_detail do + ip_address '127.0.0.1' + user_agent 'AppleWebKit/537.36' + association :subject, factory: :issue + end +end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb new file mode 100644 index 00000000000..8910c50c294 --- /dev/null +++ b/spec/features/boards/boards_spec.rb @@ -0,0 +1,622 @@ +require 'rails_helper' + +describe 'Issue Boards', feature: true, js: true do + include WaitForAjax + + let(:project) { create(:empty_project, :public) } + let(:user) { create(:user) } + let!(:user2) { create(:user) } + + before do + project.create_board + project.board.lists.create(list_type: :backlog) + project.board.lists.create(list_type: :done) + + project.team << [user, :master] + project.team << [user2, :master] + + login_as(user) + end + + context 'no lists' do + before do + visit namespace_project_board_path(project.namespace, project) + wait_for_vue_resource + expect(page).to have_selector('.board', count: 3) + end + + it 'shows blank state' do + expect(page).to have_content('Welcome to your Issue Board!') + end + + it 'hides the blank state when clicking nevermind button' do + page.within(find('.board-blank-state')) do + click_button("Nevermind, I'll use my own") + end + expect(page).to have_selector('.board', count: 2) + end + + it 'creates default lists' do + lists = ['Backlog', 'Development', 'Testing', 'Production', 'Ready', 'Done'] + + page.within(find('.board-blank-state')) do + click_button('Add default lists') + end + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 6) + + page.all('.board').each_with_index do |list, i| + expect(list.find('.board-title')).to have_content(lists[i]) + end + end + end + + context 'with lists' do + let(:milestone) { create(:milestone, project: project) } + + let(:planning) { create(:label, project: project, name: 'Planning') } + let(:development) { create(:label, project: project, name: 'Development') } + let(:testing) { create(:label, project: project, name: 'Testing') } + let(:bug) { create(:label, project: project, name: 'Bug') } + let!(:backlog) { create(:label, project: project, name: 'Backlog') } + let!(:done) { create(:label, project: project, name: 'Done') } + + let!(:list1) { create(:list, board: project.board, label: planning, position: 0) } + let!(:list2) { create(:list, board: project.board, label: development, position: 1) } + + let!(:confidential_issue) { create(:issue, :confidential, project: project, author: user) } + let!(:issue1) { create(:issue, project: project, assignee: user) } + let!(:issue2) { create(:issue, project: project, author: user2) } + let!(:issue3) { create(:issue, project: project) } + let!(:issue4) { create(:issue, project: project) } + let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone) } + let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development]) } + let!(:issue7) { create(:labeled_issue, project: project, labels: [development]) } + let!(:issue8) { create(:closed_issue, project: project) } + let!(:issue9) { create(:labeled_issue, project: project, labels: [testing, bug]) } + + before do + visit namespace_project_board_path(project.namespace, project) + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 4) + expect(find('.board:nth-child(1)')).to have_selector('.card') + expect(find('.board:nth-child(2)')).to have_selector('.card') + expect(find('.board:nth-child(3)')).to have_selector('.card') + expect(find('.board:nth-child(4)')).to have_selector('.card') + end + + it 'shows lists' do + expect(page).to have_selector('.board', count: 4) + end + + it 'shows issues in lists' do + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('2') + expect(page).to have_selector('.card', count: 2) + end + + page.within(find('.board:nth-child(3)')) do + expect(page.find('.board-header')).to have_content('2') + expect(page).to have_selector('.card', count: 2) + end + end + + it 'shows confidential issues with icon' do + page.within(find('.board', match: :first)) do + expect(page).to have_selector('.confidential-icon', count: 1) + end + end + + it 'allows user to delete board' do + page.within(find('.board:nth-child(2)')) do + find('.board-delete').click + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 3) + end + + it 'removes checkmark in new list dropdown after deleting' do + click_button 'Create new list' + wait_for_ajax + + page.within(find('.board:nth-child(2)')) do + find('.board-delete').click + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 3) + expect(find(".js-board-list-#{planning.id}", visible: false)).not_to have_css('.is-active') + end + + it 'infinite scrolls list' do + 50.times do + create(:issue, project: project) + end + + visit namespace_project_board_path(project.namespace, project) + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('20') + expect(page).to have_selector('.card', count: 20) + + evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight") + wait_for_vue_resource(spinner: false) + + expect(page.find('.board-header')).to have_content('40') + expect(page).to have_selector('.card', count: 40) + end + end + + context 'backlog' do + it 'shows issues in backlog with no labels' do + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('6') + expect(page).to have_selector('.card', count: 6) + end + end + + it 'is searchable' do + page.within(find('.board', match: :first)) do + find('.form-control').set issue1.title + + wait_for_vue_resource(spinner: false) + + expect(page).to have_selector('.card', count: 1) + end + end + + it 'clears search' do + page.within(find('.board', match: :first)) do + find('.form-control').set issue1.title + + expect(page).to have_selector('.card', count: 1) + + find('.board-search-clear-btn').click + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page).to have_selector('.card', count: 6) + end + end + + it 'moves issue from backlog into list' do + drag_to(list_to_index: 1) + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('5') + expect(page).to have_selector('.card', count: 5) + end + + wait_for_vue_resource + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('3') + expect(page).to have_selector('.card', count: 3) + end + end + end + + context 'done' do + it 'shows list of done issues' do + expect(find('.board:nth-child(4)')).to have_selector('.card', count: 1) + end + + it 'moves issue to done' do + drag_to(list_from_index: 0, list_to_index: 3) + + expect(find('.board:nth-child(4)')).to have_selector('.card', count: 2) + expect(find('.board:nth-child(4)')).to have_content(issue9.title) + expect(find('.board:nth-child(4)')).not_to have_content(planning.title) + end + + it 'removes all of the same issue to done' do + drag_to(list_from_index: 1, list_to_index: 3) + + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1) + expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1) + expect(find('.board:nth-child(4)')).to have_content(issue6.title) + expect(find('.board:nth-child(4)')).not_to have_content(planning.title) + end + end + + context 'lists' do + it 'changes position of list' do + drag_to(list_from_index: 1, list_to_index: 2, selector: '.board-header') + + expect(find('.board:nth-child(2)')).to have_content(development.title) + expect(find('.board:nth-child(2)')).to have_content(planning.title) + end + + it 'issue moves between lists' do + drag_to(list_from_index: 1, card_index: 1, list_to_index: 2) + + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1) + expect(find('.board:nth-child(3)')).to have_selector('.card', count: 3) + expect(find('.board:nth-child(3)')).to have_content(issue6.title) + expect(find('.board:nth-child(3)').all('.card').last).not_to have_content(development.title) + end + + it 'issue moves between lists' do + drag_to(list_from_index: 2, list_to_index: 1) + + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 3) + expect(find('.board:nth-child(3)')).to have_selector('.card', count: 1) + expect(find('.board:nth-child(2)')).to have_content(issue7.title) + expect(find('.board:nth-child(2)').all('.card').first).not_to have_content(planning.title) + end + + it 'issue moves from done' do + drag_to(list_from_index: 3, list_to_index: 1) + + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 3) + expect(find('.board:nth-child(2)')).to have_content(issue8.title) + end + + context 'issue card' do + it 'shows assignee' do + page.within(find('.board', match: :first)) do + expect(page).to have_selector('.avatar', count: 1) + end + end + end + + context 'new list' do + it 'shows all labels in new list dropdown' do + click_button 'Create new list' + wait_for_ajax + + page.within('.dropdown-menu-issues-board-new') do + expect(page).to have_content(planning.title) + expect(page).to have_content(development.title) + expect(page).to have_content(testing.title) + end + end + + it 'creates new list for label' do + click_button 'Create new list' + wait_for_ajax + + page.within('.dropdown-menu-issues-board-new') do + click_link testing.title + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 5) + end + + it 'creates new list for Backlog label' do + click_button 'Create new list' + wait_for_ajax + + page.within('.dropdown-menu-issues-board-new') do + click_link backlog.title + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 5) + end + + it 'creates new list for Done label' do + click_button 'Create new list' + wait_for_ajax + + page.within('.dropdown-menu-issues-board-new') do + click_link done.title + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 5) + end + + it 'moves issues from backlog into new list' do + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('6') + expect(page).to have_selector('.card', count: 6) + end + + click_button 'Create new list' + wait_for_ajax + + page.within('.dropdown-menu-issues-board-new') do + click_link testing.title + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('5') + expect(page).to have_selector('.card', count: 5) + end + end + end + end + + context 'filtering' do + it 'filters by author' do + page.within '.issues-filters' do + click_button('Author') + wait_for_ajax + + page.within '.dropdown-menu-author' do + click_link(user2.name) + end + wait_for_vue_resource(spinner: false) + + expect(find('.js-author-search')).to have_content(user2.name) + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + end + + it 'filters by assignee' do + page.within '.issues-filters' do + click_button('Assignee') + wait_for_ajax + + page.within '.dropdown-menu-assignee' do + click_link(user.name) + end + wait_for_vue_resource(spinner: false) + + expect(find('.js-assignee-search')).to have_content(user.name) + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + end + + it 'filters by milestone' do + page.within '.issues-filters' do + click_button('Milestone') + wait_for_ajax + + page.within '.milestone-filter' do + click_link(milestone.title) + end + wait_for_vue_resource(spinner: false) + + expect(find('.js-milestone-select')).to have_content(milestone.title) + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + end + + it 'filters by label' do + page.within '.issues-filters' do + click_button('Label') + wait_for_ajax + + page.within '.dropdown-menu-labels' do + click_link(testing.title) + wait_for_vue_resource(spinner: false) + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + end + + it 'infinite scrolls list with label filter' do + 50.times do + create(:labeled_issue, project: project, labels: [testing]) + end + + page.within '.issues-filters' do + click_button('Label') + wait_for_ajax + + page.within '.dropdown-menu-labels' do + click_link(testing.title) + wait_for_vue_resource(spinner: false) + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('20') + expect(page).to have_selector('.card', count: 20) + + evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight") + + expect(page.find('.board-header')).to have_content('40') + expect(page).to have_selector('.card', count: 40) + end + end + + it 'filters by multiple labels' do + page.within '.issues-filters' do + click_button('Label') + wait_for_ajax + + page.within(find('.dropdown-menu-labels')) do + click_link(testing.title) + wait_for_vue_resource(spinner: false) + click_link(bug.title) + wait_for_vue_resource(spinner: false) + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + end + + it 'filters by no label' do + page.within '.issues-filters' do + click_button('Label') + wait_for_ajax + + page.within '.dropdown-menu-labels' do + click_link("No Label") + wait_for_vue_resource(spinner: false) + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('5') + expect(page).to have_selector('.card', count: 5) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + end + + it 'filters by clicking label button on issue' do + page.within(find('.board', match: :first)) do + expect(page).to have_selector('.card', count: 6) + expect(find('.card', match: :first)).to have_content(bug.title) + click_button(bug.title) + wait_for_vue_resource(spinner: false) + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + + page.within('.labels-filter') do + expect(find('.dropdown-toggle-text')).to have_content(bug.title) + end + end + + it 'removes label filter by clicking label button on issue' do + page.within(find('.board', match: :first)) do + page.within(find('.card', match: :first)) do + click_button(bug.title) + end + wait_for_vue_resource(spinner: false) + + expect(page).to have_selector('.card', count: 1) + end + + wait_for_vue_resource + + page.within('.labels-filter') do + expect(find('.dropdown-toggle-text')).to have_content(bug.title) + end + end + end + end + + context 'signed out user' do + before do + logout + visit namespace_project_board_path(project.namespace, project) + wait_for_vue_resource + end + + it 'does not show create new list' do + expect(page).not_to have_selector('.js-new-board-list') + end + end + + context 'as guest user' do + let(:user_guest) { create(:user) } + + before do + project.team << [user_guest, :guest] + logout + login_as(user_guest) + visit namespace_project_board_path(project.namespace, project) + wait_for_vue_resource + end + + it 'does not show create new list' do + expect(page).not_to have_selector('.js-new-board-list') + end + end + + def drag_to(list_from_index: 0, card_index: 0, to_index: 0, list_to_index: 0, selector: '.board-list') + evaluate_script("simulateDrag({scrollable: document.getElementById('board-app'), from: {el: $('#{selector}').eq(#{list_from_index}).get(0), index: #{card_index}}, to: {el: $('.board-list').eq(#{list_to_index}).get(0), index: #{to_index}}});") + + Timeout.timeout(Capybara.default_max_wait_time) do + loop until page.evaluate_script('window.SIMULATE_DRAG_ACTIVE').zero? + end + + wait_for_vue_resource + end + + def wait_for_vue_resource(spinner: true) + Timeout.timeout(Capybara.default_max_wait_time) do + loop until page.evaluate_script('Vue.activeResources').zero? + end + + if spinner + expect(find('.boards-list')).not_to have_selector('.fa-spinner') + end + end +end diff --git a/spec/features/issuables/default_sort_order_spec.rb b/spec/features/issuables/default_sort_order_spec.rb index 9114f751b55..9a2b879e789 100644 --- a/spec/features/issuables/default_sort_order_spec.rb +++ b/spec/features/issuables/default_sort_order_spec.rb @@ -149,6 +149,30 @@ describe 'Projects > Issuables > Default sort order', feature: true do expect(last_issue).to include(first_created_issuable.title) end end + + context 'when the sort in the URL is id_desc' do + let(:issuable_type) { :issue } + + before { visit_issues(project, sort: 'id_desc') } + + it 'shows the sort order as last created' do + expect(find('.issues-other-filters')).to have_content('Last created') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + + context 'when the sort in the URL is id_asc' do + let(:issuable_type) { :issue } + + before { visit_issues(project, sort: 'id_asc') } + + it 'shows the sort order as oldest created' do + expect(find('.issues-other-filters')).to have_content('Oldest created') + expect(first_issue).to include(first_created_issuable.title) + expect(last_issue).to include(last_created_issuable.title) + end + end end def selected_sort_order diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb new file mode 100644 index 00000000000..2883e392694 --- /dev/null +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +feature 'Issues > User uses slash commands', feature: true, js: true do + include WaitForAjax + + it_behaves_like 'issuable record that supports slash commands in its description and notes', :issue do + let(:issuable) { create(:issue, project: project) } + end + + describe 'issue-only commands' do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + + before do + project.team << [user, :master] + login_with(user) + visit namespace_project_issue_path(project.namespace, project, issue) + end + + describe 'adding a due date from note' do + let(:issue) { create(:issue, project: project) } + + it 'does not create a note, and sets the due date accordingly' do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/due 2016-08-28" + click_button 'Comment' + end + + expect(page).not_to have_content '/due 2016-08-28' + expect(page).to have_content 'Your commands have been executed!' + + issue.reload + + expect(issue.due_date).to eq Date.new(2016, 8, 28) + end + end + + describe 'removing a due date from note' do + let(:issue) { create(:issue, project: project, due_date: Date.new(2016, 8, 28)) } + + it 'does not create a note, and removes the due date accordingly' do + expect(issue.due_date).to eq Date.new(2016, 8, 28) + + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/remove_due_date" + click_button 'Comment' + end + + expect(page).not_to have_content '/remove_due_date' + expect(page).to have_content 'Your commands have been executed!' + + issue.reload + + expect(issue.due_date).to be_nil + end + end + end +end diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb new file mode 100644 index 00000000000..930c36ade2b --- /dev/null +++ b/spec/features/merge_requests/conflicts_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +feature 'Merge request conflict resolution', js: true, feature: true do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project) } + + def create_merge_request(source_branch) + create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start', source_project: project) do |mr| + mr.mark_as_unmergeable + end + end + + context 'when a merge request can be resolved in the UI' do + let(:merge_request) { create_merge_request('conflict-resolvable') } + + before do + project.team << [user, :developer] + login_as(user) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'shows a link to the conflict resolution page' do + expect(page).to have_link('conflicts', href: /\/conflicts\Z/) + end + + context 'visiting the conflicts resolution page' do + before { click_link('conflicts', href: /\/conflicts\Z/) } + + it 'shows the conflicts' do + begin + expect(find('#conflicts')).to have_content('popen.rb') + rescue Capybara::Poltergeist::JavascriptError + retry + end + end + end + end + + UNRESOLVABLE_CONFLICTS = { + 'conflict-too-large' => 'when the conflicts contain a large file', + 'conflict-binary-file' => 'when the conflicts contain a binary file', + 'conflict-contains-conflict-markers' => 'when the conflicts contain a file with ambiguous conflict markers', + 'conflict-missing-side' => 'when the conflicts contain a file edited in one branch and deleted in another' + } + + UNRESOLVABLE_CONFLICTS.each do |source_branch, description| + context description do + let(:merge_request) { create_merge_request(source_branch) } + + before do + project.team << [user, :developer] + login_as(user) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'does not show a link to the conflict resolution page' do + expect(page).not_to have_link('conflicts', href: /\/conflicts\Z/) + end + + it 'shows an error if the conflicts page is visited directly' do + visit current_url + '/conflicts' + wait_for_ajax + + expect(find('#conflicts')).to have_content('Please try to resolve them locally.') + end + end + end +end diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb index 11c9de3c4bf..b963d1305b5 100644 --- a/spec/features/merge_requests/create_new_mr_spec.rb +++ b/spec/features/merge_requests/create_new_mr_spec.rb @@ -8,10 +8,11 @@ feature 'Create New Merge Request', feature: true, js: true do project.team << [user, :master] login_as user - visit namespace_project_merge_requests_path(project.namespace, project) end it 'generates a diff for an orphaned branch' do + visit namespace_project_merge_requests_path(project.namespace, project) + click_link 'New Merge Request' expect(page).to have_content('Source branch') expect(page).to have_content('Target branch') @@ -42,4 +43,20 @@ feature 'Create New Merge Request', feature: true, js: true do expect(page).not_to have_content private_project.to_reference end end + + it 'allows to change the diff view' do + visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { target_branch: 'master', source_branch: 'fix' }) + + click_link 'Changes' + + expect(page).to have_css('a.btn.active', text: 'Inline') + expect(page).not_to have_css('a.btn.active', text: 'Side-by-side') + + click_link 'Side-by-side' + + within '.merge-request' do + expect(page).not_to have_css('a.btn.active', text: 'Inline') + expect(page).to have_css('a.btn.active', text: 'Side-by-side') + end + end end diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb new file mode 100644 index 00000000000..c6adf7e4c56 --- /dev/null +++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb @@ -0,0 +1,497 @@ +require 'spec_helper' + +feature 'Diff notes resolve', feature: true, js: true do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user, title: "Bug NS-04") } + let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } + let(:path) { "files/ruby/popen.rb" } + let(:position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + old_line: nil, + new_line: 9, + diff_refs: merge_request.diff_refs + ) + end + + context 'no discussions' do + before do + project.team << [user, :master] + login_as user + note.destroy + visit_merge_request + end + + it 'displays no discussion resolved data' do + expect(page).not_to have_content('discussion resolved') + expect(page).not_to have_selector('.discussion-next-btn') + end + end + + context 'as authorized user' do + before do + project.team << [user, :master] + login_as user + visit_merge_request + end + + context 'single discussion' do + it 'shows text with how many discussions' do + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'allows user to mark a note as resolved' do + page.within '.diff-content .note' do + find('.line-resolve-btn').click + + expect(page).to have_selector('.line-resolve-btn.is-active') + expect(find('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") + end + + page.within '.diff-content' do + expect(page).to have_selector('.btn', text: 'Unresolve discussion') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to mark discussion as resolved' do + page.within '.diff-content' do + click_button 'Resolve discussion' + end + + page.within '.diff-content .note' do + expect(page).to have_selector('.line-resolve-btn.is-active') + + expect(find('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to unresolve discussion' do + page.within '.diff-content' do + click_button 'Resolve discussion' + click_button 'Unresolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'hides resolved discussion' do + page.within '.diff-content' do + click_button 'Resolve discussion' + end + + visit_merge_request + + expect(page).to have_selector('.discussion-body', visible: false) + end + + it 'allows user to resolve from reply form without a comment' do + page.within '.diff-content' do + click_button 'Reply...' + + click_button 'Resolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to unresolve from reply form without a comment' do + page.within '.diff-content' do + click_button 'Resolve discussion' + sleep 1 + + click_button 'Reply...' + + click_button 'Unresolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + expect(page).not_to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to comment & resolve discussion' do + page.within '.diff-content' do + click_button 'Reply...' + + find('.js-note-text').set 'testing' + + click_button 'Comment & resolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to comment & unresolve discussion' do + page.within '.diff-content' do + click_button 'Resolve discussion' + + click_button 'Reply...' + + find('.js-note-text').set 'testing' + + click_button 'Comment & unresolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'allows user to quickly scroll to next unresolved discussion' do + page.within '.line-resolve-all-container' do + page.find('.discussion-next-btn').click + end + + expect(page.evaluate_script("$('body').scrollTop()")).to be > 0 + end + + it 'hides jump to next button when all resolved' do + page.within '.diff-content' do + click_button 'Resolve discussion' + end + + expect(page).to have_selector('.discussion-next-btn', visible: false) + end + + it 'updates updated text after resolving note' do + page.within '.diff-content .note' do + find('.line-resolve-btn').click + end + + expect(page).to have_content("Resolved by #{user.name}") + end + + it 'hides jump to next discussion button' do + page.within '.discussion-reply-holder' do + expect(page).not_to have_selector('.discussion-next-btn') + end + end + end + + context 'multiple notes' do + before do + create(:diff_note_on_merge_request, project: project, noteable: merge_request) + end + + it 'does not mark discussion as resolved when resolving single note' do + page.within '.diff-content .note' do + first('.line-resolve-btn').click + sleep 1 + expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") + end + + expect(page).to have_content('Last updated') + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'resolves discussion' do + page.all('.note').each do |note| + note.find('.line-resolve-btn').click + end + + expect(page).to have_content('Resolved by') + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + end + end + end + + context 'muliple discussions' do + before do + create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) + visit_merge_request + end + + it 'shows text with how many discussions' do + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/2 discussions resolved') + end + end + + it 'allows user to mark a single note as resolved' do + click_button('Resolve discussion', match: :first) + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/2 discussions resolved') + end + end + + it 'allows user to mark all notes as resolved' do + page.all('.line-resolve-btn').each do |btn| + btn.click + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('2/2 discussions resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user user to mark all discussions as resolved' do + page.all('.discussion-reply-holder').each do |reply_holder| + page.within reply_holder do + click_button 'Resolve discussion' + end + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('2/2 discussions resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to quickly scroll to next unresolved discussion' do + page.within first('.discussion-reply-holder') do + click_button 'Resolve discussion' + end + + page.within '.line-resolve-all-container' do + page.find('.discussion-next-btn').click + end + + expect(page.evaluate_script("$('body').scrollTop()")).to be > 0 + end + + it 'updates updated text after resolving note' do + page.within first('.diff-content .note') do + find('.line-resolve-btn').click + end + + expect(page).to have_content("Resolved by #{user.name}") + end + + it 'shows jump to next discussion button' do + page.all('.discussion-reply-holder').each do |holder| + expect(holder).to have_selector('.discussion-next-btn') + end + end + + it 'displays next discussion even if hidden' do + page.all('.note-discussion').each do |discussion| + page.within discussion do + click_link 'Toggle discussion' + end + end + + page.within('.issuable-discussion #notes') do + expect(page).not_to have_selector('.btn', text: 'Resolve discussion') + end + + page.within '.line-resolve-all-container' do + page.find('.discussion-next-btn').click + end + + expect(find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion') + end + end + + context 'changes tab' do + it 'shows text with how many discussions' do + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'allows user to mark a note as resolved' do + page.within '.diff-content .note' do + find('.line-resolve-btn').click + + expect(page).to have_selector('.line-resolve-btn.is-active') + end + + page.within '.diff-content' do + expect(page).to have_selector('.btn', text: 'Unresolve discussion') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to mark discussion as resolved' do + page.within '.diff-content' do + click_button 'Resolve discussion' + end + + page.within '.diff-content .note' do + expect(page).to have_selector('.line-resolve-btn.is-active') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to unresolve discussion' do + page.within '.diff-content' do + click_button 'Resolve discussion' + click_button 'Unresolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'allows user to comment & resolve discussion' do + page.within '.diff-content' do + click_button 'Reply...' + + find('.js-note-text').set 'testing' + + click_button 'Comment & resolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to comment & unresolve discussion' do + page.within '.diff-content' do + click_button 'Resolve discussion' + + click_button 'Reply...' + + find('.js-note-text').set 'testing' + + click_button 'Comment & unresolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + end + end + + context 'as a guest' do + let(:guest) { create(:user) } + + before do + project.team << [guest, :guest] + login_as guest + end + + context 'someone elses merge request' do + before do + visit_merge_request + end + + it 'does not allow user to mark note as resolved' do + page.within '.diff-content .note' do + expect(page).not_to have_selector('.line-resolve-btn') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'does not allow user to mark discussion as resolved' do + page.within '.diff-content .note' do + expect(page).not_to have_selector('.btn', text: 'Resolve discussion') + end + end + end + + context 'guest users merge request' do + before do + mr = create(:merge_request_with_diffs, source_project: project, source_branch: 'markdown', author: guest, title: "Bug") + create(:diff_note_on_merge_request, project: project, noteable: mr) + visit_merge_request(mr) + end + + it 'allows user to mark a note as resolved' do + page.within '.diff-content .note' do + find('.line-resolve-btn').click + + expect(page).to have_selector('.line-resolve-btn.is-active') + end + + page.within '.diff-content' do + expect(page).to have_selector('.btn', text: 'Unresolve discussion') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + end + end + + context 'unauthorized user' do + context 'no resolved comments' do + before do + visit_merge_request + end + + it 'does not allow user to mark note as resolved' do + page.within '.diff-content .note' do + expect(page).not_to have_selector('.line-resolve-btn') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + end + + context 'resolved comment' do + before do + note.resolve!(user) + visit_merge_request + end + + it 'shows resolved icon' do + expect(page).to have_content '1/1 discussion resolved' + + click_link 'Toggle discussion' + expect(page).to have_selector('.line-resolve-btn.is-active') + end + + it 'does not allow user to click resolve button' do + expect(page).to have_selector('.line-resolve-btn.is-disabled') + click_link 'Toggle discussion' + + expect(page).to have_selector('.line-resolve-btn.is-disabled') + end + end + end + + def visit_merge_request(mr = nil) + mr = mr || merge_request + visit namespace_project_merge_request_path(mr.project.namespace, mr.project, mr) + end +end diff --git a/spec/features/merge_requests/pipelines_spec.rb b/spec/features/merge_requests/pipelines_spec.rb new file mode 100644 index 00000000000..9c4c0525267 --- /dev/null +++ b/spec/features/merge_requests/pipelines_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +feature 'Pipelines for Merge Requests', feature: true, js: true do + include WaitForAjax + + given(:user) { create(:user) } + given(:merge_request) { create(:merge_request) } + given(:project) { merge_request.target_project } + + before do + project.team << [user, :master] + login_as user + end + + context 'with pipelines' do + let!(:pipeline) do + create(:ci_empty_pipeline, + project: merge_request.source_project, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha) + end + + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + scenario 'user visits merge request pipelines tab' do + page.within('.merge-request-tabs') do + click_link('Pipelines') + end + wait_for_ajax + + expect(page).to have_selector('.pipeline-actions') + end + end + + context 'without pipelines' do + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + scenario 'user visits merge request page' do + page.within('.merge-request-tabs') do + expect(page).to have_no_link('Pipelines') + end + end + end +end diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb new file mode 100644 index 00000000000..d9ef0d18074 --- /dev/null +++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +feature 'Merge Requests > User uses slash commands', feature: true, js: true do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:merge_request) { create(:merge_request, source_project: project) } + let!(:milestone) { create(:milestone, project: project, title: 'ASAP') } + + it_behaves_like 'issuable record that supports slash commands in its description and notes', :merge_request do + let(:issuable) { create(:merge_request, source_project: project) } + let(:new_url_opts) { { merge_request: { source_branch: 'feature' } } } + end + + describe 'adding a due date from note' do + before do + project.team << [user, :master] + login_with(user) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'does not recognize the command nor create a note' do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/due 2016-08-28" + click_button 'Comment' + end + + expect(page).not_to have_content '/due 2016-08-28' + end + end +end diff --git a/spec/features/projects/badges/coverage_spec.rb b/spec/features/projects/badges/coverage_spec.rb new file mode 100644 index 00000000000..af86d3c338a --- /dev/null +++ b/spec/features/projects/badges/coverage_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +feature 'test coverage badge' do + given!(:user) { create(:user) } + given!(:project) { create(:project, :private) } + + given!(:pipeline) do + create(:ci_pipeline, project: project, + ref: 'master', + sha: project.commit.id) + end + + context 'when user has access to view badge' do + background do + project.team << [user, :developer] + login_as(user) + end + + scenario 'user requests coverage badge image for pipeline' do + create_job(coverage: 100, name: 'test:1') + create_job(coverage: 90, name: 'test:2') + + show_test_coverage_badge + + expect_coverage_badge('95%') + end + + scenario 'user requests coverage badge for specific job' do + create_job(coverage: 50, name: 'test:1') + create_job(coverage: 50, name: 'test:2') + create_job(coverage: 85, name: 'coverage') + + show_test_coverage_badge(job: 'coverage') + + expect_coverage_badge('85%') + end + + scenario 'user requests coverage badge for pipeline without coverage' do + create_job(coverage: nil, name: 'test') + + show_test_coverage_badge + + expect_coverage_badge('unknown') + end + end + + context 'when user does not have access to view badge' do + background { login_as(user) } + + scenario 'user requests test coverage badge image' do + show_test_coverage_badge + + expect(page).to have_http_status(404) + end + end + + def create_job(coverage:, name:) + create(:ci_build, name: name, + coverage: coverage, + pipeline: pipeline) + end + + def show_test_coverage_badge(job: nil) + visit coverage_namespace_project_badges_path( + project.namespace, project, ref: :master, job: job, format: :svg) + end + + def expect_coverage_badge(coverage) + svg = Nokogiri::XML.parse(page.body) + expect(page.response_headers['Content-Type']).to include('image/svg+xml') + expect(svg.at(%Q{text:contains("#{coverage}")})).to be_truthy + end +end diff --git a/spec/features/projects/badges/list_spec.rb b/spec/features/projects/badges/list_spec.rb index 75166bca119..67a4a5d1ab1 100644 --- a/spec/features/projects/badges/list_spec.rb +++ b/spec/features/projects/badges/list_spec.rb @@ -9,25 +9,43 @@ feature 'list of badges' do visit namespace_project_pipelines_settings_path(project.namespace, project) end - scenario 'user displays list of badges' do - expect(page).to have_content 'build status' - expect(page).to have_content 'Markdown' - expect(page).to have_content 'HTML' - expect(page).to have_css('.highlight', count: 2) - expect(page).to have_xpath("//img[@alt='build status']") + scenario 'user wants to see build status badge' do + page.within('.build-status') do + expect(page).to have_content 'build status' + expect(page).to have_content 'Markdown' + expect(page).to have_content 'HTML' + expect(page).to have_css('.highlight', count: 2) + expect(page).to have_xpath("//img[@alt='build status']") - page.within('.highlight', match: :first) do - expect(page).to have_content 'badges/master/build.svg' + page.within('.highlight', match: :first) do + expect(page).to have_content 'badges/master/build.svg' + end end end - scenario 'user changes current ref on badges list page', js: true do - first('.js-project-refs-dropdown').click + scenario 'user wants to see coverage report badge' do + page.within('.coverage-report') do + expect(page).to have_content 'coverage report' + expect(page).to have_content 'Markdown' + expect(page).to have_content 'HTML' + expect(page).to have_css('.highlight', count: 2) + expect(page).to have_xpath("//img[@alt='coverage report']") - page.within '.project-refs-form' do - click_link 'improve/awesome' + page.within('.highlight', match: :first) do + expect(page).to have_content 'badges/master/coverage.svg' + end end + end - expect(page).to have_content 'badges/improve/awesome/build.svg' + scenario 'user changes current ref of build status badge', js: true do + page.within('.build-status') do + first('.js-project-refs-dropdown').click + + page.within '.project-refs-form' do + click_link 'improve/awesome' + end + + expect(page).to have_content 'badges/improve/awesome/build.svg' + end end end diff --git a/spec/features/projects/files/editing_a_file_spec.rb b/spec/features/projects/files/editing_a_file_spec.rb new file mode 100644 index 00000000000..fe047e00409 --- /dev/null +++ b/spec/features/projects/files/editing_a_file_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +feature 'User wants to edit a file', feature: true do + include WaitForAjax + + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:commit_params) do + { + source_branch: project.default_branch, + target_branch: project.default_branch, + commit_message: "Committing First Update", + file_path: ".gitignore", + file_content: "First Update", + last_commit_sha: Gitlab::Git::Commit.last_for_path(project.repository, project.default_branch, + ".gitignore").sha + } + end + + background do + project.team << [user, :master] + login_as user + visit namespace_project_edit_blob_path(project.namespace, project, + File.join(project.default_branch, '.gitignore')) + end + + scenario 'file has been updated since the user opened the edit page' do + Files::UpdateService.new(project, user, commit_params).execute + + click_button 'Commit Changes' + + expect(page).to have_content 'Someone edited the file the same time you did.' + end +end diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb index dbd07464444..a521ce50f35 100644 --- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb +++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb @@ -23,7 +23,7 @@ feature 'project owner creates a license file', feature: true, js: true do select_template('MIT License') - file_content = find('.file-content') + file_content = first('.file-editor') expect(file_content).to have_content('The MIT License (MIT)') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") @@ -47,7 +47,7 @@ feature 'project owner creates a license file', feature: true, js: true do select_template('MIT License') - file_content = find('.file-content') + file_content = first('.file-editor') expect(file_content).to have_content('The MIT License (MIT)') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb index 45bf0c0d038..4453b6d485f 100644 --- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb +++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb @@ -23,7 +23,7 @@ feature 'project owner sees a link to create a license file in empty project', f select_template('MIT License') - file_content = find('.file-content') + file_content = first('.file-editor') expect(file_content).to have_content('The MIT License (MIT)') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 7835e1678ad..f707ccf4e93 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -3,8 +3,9 @@ require 'spec_helper' feature 'project import', feature: true, js: true do include Select2Helper - let(:user) { create(:admin) } - let!(:namespace) { create(:namespace, name: "asd", owner: user) } + let(:admin) { create(:admin) } + let(:normal_user) { create(:user) } + let!(:namespace) { create(:namespace, name: "asd", owner: admin) } let(:file) { File.join(Rails.root, 'spec', 'features', 'projects', 'import_export', 'test_project_export.tar.gz') } let(:export_path) { "#{Dir::tmpdir}/import_file_spec" } let(:project) { Project.last } @@ -12,66 +13,87 @@ feature 'project import', feature: true, js: true do background do allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) - login_as(user) end after(:each) do FileUtils.rm_rf(export_path, secure: true) end - scenario 'user imports an exported project successfully' do - expect(Project.all.count).to be_zero + context 'admin user' do + before do + login_as(admin) + end - visit new_project_path + scenario 'user imports an exported project successfully' do + expect(Project.all.count).to be_zero - select2('2', from: '#project_namespace_id') - fill_in :project_path, with: 'test-project-path', visible: true - click_link 'GitLab export' + visit new_project_path - expect(page).to have_content('GitLab project export') - expect(URI.parse(current_url).query).to eq('namespace_id=2&path=test-project-path') + select2('2', from: '#project_namespace_id') + fill_in :project_path, with: 'test-project-path', visible: true + click_link 'GitLab export' - attach_file('file', file) + expect(page).to have_content('GitLab project export') + expect(URI.parse(current_url).query).to eq('namespace_id=2&path=test-project-path') - click_on 'Import project' # import starts + attach_file('file', file) - expect(project).not_to be_nil - expect(project.issues).not_to be_empty - expect(project.merge_requests).not_to be_empty - expect(project_hook).to exist - expect(wiki_exists?).to be true - expect(project.import_status).to eq('finished') - end + click_on 'Import project' # import starts - scenario 'invalid project' do - project = create(:project, namespace_id: 2) + expect(project).not_to be_nil + expect(project.issues).not_to be_empty + expect(project.merge_requests).not_to be_empty + expect(project_hook).to exist + expect(wiki_exists?).to be true + expect(project.import_status).to eq('finished') + end - visit new_project_path + scenario 'invalid project' do + project = create(:project, namespace_id: 2) - select2('2', from: '#project_namespace_id') - fill_in :project_path, with: project.name, visible: true - click_link 'GitLab export' + visit new_project_path - attach_file('file', file) - click_on 'Import project' + select2('2', from: '#project_namespace_id') + fill_in :project_path, with: project.name, visible: true + click_link 'GitLab export' - page.within('.flash-container') do - expect(page).to have_content('Project could not be imported') + attach_file('file', file) + click_on 'Import project' + + page.within('.flash-container') do + expect(page).to have_content('Project could not be imported') + end + end + + scenario 'project with no name' do + create(:project, namespace_id: 2) + + visit new_project_path + + select2('2', from: '#project_namespace_id') + + # click on disabled element + find(:link, 'GitLab export').trigger('click') + + page.within('.flash-container') do + expect(page).to have_content('Please enter path and name') + end end end - scenario 'project with no name' do - create(:project, namespace_id: 2) + context 'normal user' do + before do + login_as(normal_user) + end - visit new_project_path + scenario 'non-admin user is not allowed to import a project' do + expect(Project.all.count).to be_zero - select2('2', from: '#project_namespace_id') + visit new_project_path - # click on disabled element - find(:link, 'GitLab export').trigger('click') + fill_in :project_path, with: 'test-project-path', visible: true - page.within('.flash-container') do - expect(page).to have_content('Please enter path and name') + expect(page).not_to have_content('GitLab export') end end diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb new file mode 100644 index 00000000000..4a83740621a --- /dev/null +++ b/spec/features/projects/issuable_templates_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +feature 'issuable templates', feature: true, js: true do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + + before do + project.team << [user, :master] + login_as user + end + + context 'user creates an issue using templates' do + let(:template_content) { 'this is a test "bug" template' } + let(:issue) { create(:issue, author: user, assignee: user, project: project) } + + background do + project.repository.commit_file(user, '.gitlab/issue_templates/bug.md', template_content, 'added issue template', 'master', false) + visit edit_namespace_project_issue_path project.namespace, project, issue + fill_in :'issue[title]', with: 'test issue title' + end + + scenario 'user selects "bug" template' do + select_template 'bug' + wait_for_ajax + preview_template + save_changes + end + end + + context 'user creates a merge request using templates' do + let(:template_content) { 'this is a test "feature-proposal" template' } + let(:merge_request) { create(:merge_request, :with_diffs, source_project: project) } + + background do + project.repository.commit_file(user, '.gitlab/merge_request_templates/feature-proposal.md', template_content, 'added merge request template', 'master', false) + visit edit_namespace_project_merge_request_path project.namespace, project, merge_request + fill_in :'merge_request[title]', with: 'test merge request title' + end + + scenario 'user selects "feature-proposal" template' do + select_template 'feature-proposal' + wait_for_ajax + preview_template + save_changes + end + end + + context 'user creates a merge request from a forked project using templates' do + let(:template_content) { 'this is a test "feature-proposal" template' } + let(:fork_user) { create(:user) } + let(:fork_project) { create(:project, :public) } + let(:merge_request) { create(:merge_request, :with_diffs, source_project: fork_project) } + + background do + logout + project.team << [fork_user, :developer] + fork_project.team << [fork_user, :master] + create(:forked_project_link, forked_to_project: fork_project, forked_from_project: project) + login_as fork_user + fork_project.repository.commit_file(fork_user, '.gitlab/merge_request_templates/feature-proposal.md', template_content, 'added merge request template', 'master', false) + visit edit_namespace_project_merge_request_path fork_project.namespace, fork_project, merge_request + fill_in :'merge_request[title]', with: 'test merge request title' + end + + scenario 'user selects "feature-proposal" template' do + select_template 'feature-proposal' + wait_for_ajax + preview_template + save_changes + end + end + + def preview_template + click_link 'Preview' + expect(page).to have_content template_content + end + + def save_changes + click_button "Save changes" + expect(page).to have_content template_content + end + + def select_template(name) + first('.js-issuable-selector').click + first('.js-issuable-selector-wrap .dropdown-content a', text: name).click + end +end diff --git a/spec/features/projects/issues/list_spec.rb b/spec/features/projects/issues/list_spec.rb new file mode 100644 index 00000000000..3137af074ca --- /dev/null +++ b/spec/features/projects/issues/list_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +feature 'Issues List' do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + + background do + project.team << [user, :developer] + + login_as(user) + end + + scenario 'user does not see create new list button' do + create(:issue, project: project) + + visit namespace_project_issues_path(project.namespace, project) + + expect(page).not_to have_selector('.js-new-board-list') + end +end diff --git a/spec/features/projects/merge_requests/list_spec.rb b/spec/features/projects/merge_requests/list_spec.rb new file mode 100644 index 00000000000..5dd58ad66a7 --- /dev/null +++ b/spec/features/projects/merge_requests/list_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +feature 'Merge Requests List' do + let(:user) { create(:user) } + let(:project) { create(:project) } + + background do + project.team << [user, :developer] + + login_as(user) + end + + scenario 'user does not see create new list button' do + create(:merge_request, source_project: project) + + visit namespace_project_merge_requests_path(project.namespace, project) + + expect(page).not_to have_selector('.js-new-board-list') + end +end diff --git a/spec/features/projects/pipelines_spec.rb b/spec/features/projects/pipelines_spec.rb index 29d150bc597..47482bc3cc9 100644 --- a/spec/features/projects/pipelines_spec.rb +++ b/spec/features/projects/pipelines_spec.rb @@ -193,7 +193,11 @@ describe "Pipelines" do end context 'playing manual build' do - before { click_link('Play') } + before do + within '.pipeline-holder' do + click_link('Play') + end + end it { expect(@manual.reload).to be_pending } end diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index 3499460c84d..a0ee6cab7ec 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -71,7 +71,10 @@ feature 'Projected Branches', feature: true, js: true do project.repository.add_branch(user, 'production-stable', 'master') project.repository.add_branch(user, 'staging-stable', 'master') project.repository.add_branch(user, 'development', 'master') - create(:protected_branch, project: project, name: "*-stable") + + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('*-stable') + click_on "Protect" visit namespace_project_protected_branches_path(project.namespace, project) click_on "2 matching branches" @@ -90,13 +93,17 @@ feature 'Projected Branches', feature: true, js: true do visit namespace_project_protected_branches_path(project.namespace, project) set_protected_branch_name('master') within('.new_protected_branch') do - find(".js-allowed-to-push").click - within(".dropdown.open .dropdown-menu") { click_on access_type_name } + allowed_to_push_button = find(".js-allowed-to-push") + + unless allowed_to_push_button.text == access_type_name + allowed_to_push_button.click + within(".dropdown.open .dropdown-menu") { click_on access_type_name } + end end click_on "Protect" expect(ProtectedBranch.count).to eq(1) - expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to eq([access_type_id]) end it "allows updating protected branches so that #{access_type_name} can push to them" do @@ -112,7 +119,7 @@ feature 'Projected Branches', feature: true, js: true do end wait_for_ajax - expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id) end end @@ -121,13 +128,17 @@ feature 'Projected Branches', feature: true, js: true do visit namespace_project_protected_branches_path(project.namespace, project) set_protected_branch_name('master') within('.new_protected_branch') do - find(".js-allowed-to-merge").click - within(".dropdown.open .dropdown-menu") { click_on access_type_name } + allowed_to_merge_button = find(".js-allowed-to-merge") + + unless allowed_to_merge_button.text == access_type_name + allowed_to_merge_button.click + within(".dropdown.open .dropdown-menu") { click_on access_type_name } + end end click_on "Protect" expect(ProtectedBranch.count).to eq(1) - expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to eq([access_type_id]) end it "allows updating protected branches so that #{access_type_name} can merge to them" do @@ -143,7 +154,7 @@ feature 'Projected Branches', feature: true, js: true do end wait_for_ajax - expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to include(access_type_id) end end end diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb index d370f90f7d9..a46e48c76ed 100644 --- a/spec/features/u2f_spec.rb +++ b/spec/features/u2f_spec.rb @@ -12,10 +12,12 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: end def register_u2f_device(u2f_device = nil) - u2f_device ||= FakeU2fDevice.new(page) + name = FFaker::Name.first_name + u2f_device ||= FakeU2fDevice.new(page, name) u2f_device.respond_to_u2f_registration click_on 'Setup New U2F Device' expect(page).to have_content('Your device was successfully set up') + fill_in "Pick a name", with: name click_on 'Register U2F Device' u2f_device end @@ -40,13 +42,14 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: end describe 'when 2FA via OTP is enabled' do - it 'allows registering a new device' do + it 'allows registering a new device with a name' do visit profile_account_path manage_two_factor_authentication expect(page.body).to match("You've already enabled two-factor authentication using mobile") - register_u2f_device + u2f_device = register_u2f_device + expect(page.body).to match(u2f_device.name) expect(page.body).to match('Your U2F device was registered') end @@ -55,15 +58,31 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: # First device manage_two_factor_authentication - register_u2f_device + first_device = register_u2f_device expect(page.body).to match('Your U2F device was registered') # Second device - manage_two_factor_authentication - register_u2f_device + second_device = register_u2f_device expect(page.body).to match('Your U2F device was registered') + + expect(page.body).to match(first_device.name) + expect(page.body).to match(second_device.name) + expect(U2fRegistration.count).to eq(2) + end + + it 'allows deleting a device' do + visit profile_account_path manage_two_factor_authentication - expect(page.body).to match('You have 2 U2F devices registered') + expect(page.body).to match("You've already enabled two-factor authentication using mobile") + + first_u2f_device = register_u2f_device + second_u2f_device = register_u2f_device + + click_on "Delete", match: :first + + expect(page.body).to match('Successfully deleted') + expect(page.body).not_to match(first_u2f_device.name) + expect(page.body).to match(second_u2f_device.name) end end @@ -208,7 +227,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: describe "when a given U2F device has not been registered" do it "does not allow logging in with that particular device" do - unregistered_device = FakeU2fDevice.new(page) + unregistered_device = FakeU2fDevice.new(page, FFaker::Name.first_name) login_as(user) unregistered_device.respond_to_u2f_authentication click_on "Login Via U2F Device" @@ -262,6 +281,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: end it "deletes u2f registrations" do + visit profile_account_path expect { click_on "Disable" }.to change { U2fRegistration.count }.by(-1) end end diff --git a/spec/finders/move_to_project_finder_spec.rb b/spec/finders/move_to_project_finder_spec.rb new file mode 100644 index 00000000000..4f3304f7b6d --- /dev/null +++ b/spec/finders/move_to_project_finder_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' + +describe MoveToProjectFinder do + let(:user) { create(:user) } + let(:project) { create(:project) } + + let(:no_access_project) { create(:project) } + let(:guest_project) { create(:project) } + let(:reporter_project) { create(:project) } + let(:developer_project) { create(:project) } + let(:master_project) { create(:project) } + + subject { described_class.new(user) } + + describe '#execute' do + context 'filter' do + it 'does not return projects under Gitlab::Access::REPORTER' do + guest_project.team << [user, :guest] + + expect(subject.execute(project)).to be_empty + end + + it 'returns projects equal or above Gitlab::Access::REPORTER ordered by id in descending order' do + reporter_project.team << [user, :reporter] + developer_project.team << [user, :developer] + master_project.team << [user, :master] + + expect(subject.execute(project).to_a).to eq([master_project, developer_project, reporter_project]) + end + + it 'does not include the source project' do + project.team << [user, :reporter] + + expect(subject.execute(project).to_a).to be_empty + end + + it 'does not return archived projects' do + reporter_project.team << [user, :reporter] + reporter_project.update_attributes(archived: true) + other_reporter_project = create(:project) + other_reporter_project.team << [user, :reporter] + + expect(subject.execute(project).to_a).to eq([other_reporter_project]) + end + + it 'does not return projects for which issues are disabled' do + reporter_project.team << [user, :reporter] + reporter_project.update_attributes(issues_enabled: false) + other_reporter_project = create(:project) + other_reporter_project.team << [user, :reporter] + + expect(subject.execute(project).to_a).to eq([other_reporter_project]) + end + end + + context 'search' do + it 'uses Project#search' do + expect(user).to receive_message_chain(:projects_where_can_admin_issues, :search) { Project.all } + + subject.execute(project, search: 'wadus') + end + + it 'returns projects matching a search query' do + foo_project = create(:project) + foo_project.team << [user, :master] + + wadus_project = create(:project, name: 'wadus') + wadus_project.team << [user, :master] + + expect(subject.execute(project).to_a).to eq([wadus_project, foo_project]) + expect(subject.execute(project, search: 'wadus').to_a).to eq([wadus_project]) + end + end + end +end diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json new file mode 100644 index 00000000000..299e4675d6f --- /dev/null +++ b/spec/fixtures/api/schemas/issue.json @@ -0,0 +1,40 @@ +{ + "type": "object", + "required" : [ + "iid", + "title", + "confidential" + ], + "properties" : { + "iid": { "type": "integer" }, + "title": { "type": "string" }, + "confidential": { "type": "boolean" }, + "labels": { + "type": ["array"], + "required": [ + "id", + "color", + "description", + "title", + "priority" + ], + "properties": { + "id": { "type": "integer" }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" + }, + "description": { "type": ["string", "null"] }, + "title": { "type": "string" }, + "priority": { "type": ["integer", "null"] } + } + }, + "assignee": { + "id": { "type": "integet" }, + "name": { "type": "string" }, + "username": { "type": "string" }, + "avatar_url": { "type": "uri" } + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/issues.json b/spec/fixtures/api/schemas/issues.json new file mode 100644 index 00000000000..0d2067f704a --- /dev/null +++ b/spec/fixtures/api/schemas/issues.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "issue.json" } +} diff --git a/spec/fixtures/api/schemas/list.json b/spec/fixtures/api/schemas/list.json new file mode 100644 index 00000000000..f070fa3b254 --- /dev/null +++ b/spec/fixtures/api/schemas/list.json @@ -0,0 +1,39 @@ +{ + "type": "object", + "required" : [ + "id", + "list_type", + "title", + "position" + ], + "properties" : { + "id": { "type": "integer" }, + "list_type": { + "type": "string", + "enum": ["backlog", "label", "done"] + }, + "label": { + "type": ["object"], + "required": [ + "id", + "color", + "description", + "title", + "priority" + ], + "properties": { + "id": { "type": "integer" }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" + }, + "description": { "type": ["string", "null"] }, + "title": { "type": "string" }, + "priority": { "type": ["integer", "null"] } + } + }, + "title": { "type": "string" }, + "position": { "type": ["integer", "null"] } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/lists.json b/spec/fixtures/api/schemas/lists.json new file mode 100644 index 00000000000..9f618aa9de5 --- /dev/null +++ b/spec/fixtures/api/schemas/lists.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "list.json" } +} diff --git a/spec/fixtures/emails/commands_in_reply.eml b/spec/fixtures/emails/commands_in_reply.eml new file mode 100644 index 00000000000..06bf60ab734 --- /dev/null +++ b/spec/fixtures/emails/commands_in_reply.eml @@ -0,0 +1,43 @@ +Return-Path: +Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 +Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for ; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for ; Thu, 13 Jun 2013 14:03:48 -0700 +Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 +Date: Thu, 13 Jun 2013 17:03:48 -0400 +From: Jake the Dog +To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo +Message-ID: +In-Reply-To: +References: +Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux' +Mime-Version: 1.0 +Content-Type: text/plain; + charset=ISO-8859-1 +Content-Transfer-Encoding: 7bit +X-Sieve: CMU Sieve 2.2 +X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu, + 13 Jun 2013 14:03:48 -0700 (PDT) +X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1 + +Cool! + +/close +/todo +/due tomorrow + + +On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta + wrote: +> +> +> +> eviltrout posted in 'Adventure Time Sux' on Discourse Meta: +> +> --- +> hey guys everyone knows adventure time sucks! +> +> --- +> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3 +> +> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences). +> diff --git a/spec/fixtures/emails/commands_only_reply.eml b/spec/fixtures/emails/commands_only_reply.eml new file mode 100644 index 00000000000..aed64224b06 --- /dev/null +++ b/spec/fixtures/emails/commands_only_reply.eml @@ -0,0 +1,41 @@ +Return-Path: +Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 +Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for ; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for ; Thu, 13 Jun 2013 14:03:48 -0700 +Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 +Date: Thu, 13 Jun 2013 17:03:48 -0400 +From: Jake the Dog +To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo +Message-ID: +In-Reply-To: +References: +Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux' +Mime-Version: 1.0 +Content-Type: text/plain; + charset=ISO-8859-1 +Content-Transfer-Encoding: 7bit +X-Sieve: CMU Sieve 2.2 +X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu, + 13 Jun 2013 14:03:48 -0700 (PDT) +X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1 + +/close +/todo +/due tomorrow + + +On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta + wrote: +> +> +> +> eviltrout posted in 'Adventure Time Sux' on Discourse Meta: +> +> --- +> hey guys everyone knows adventure time sucks! +> +> --- +> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3 +> +> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences). +> diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index 94972eed945..a43a7238c70 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -69,18 +69,40 @@ describe BlobHelper do end describe "#edit_blob_link" do - let(:project) { create(:project) } + let(:namespace) { create(:namespace, name: 'gitlab' )} + let(:project) { create(:project, namespace: namespace) } before do allow(self).to receive(:current_user).and_return(double) + allow(self).to receive(:can_collaborate_with_project?).and_return(true) end it 'verifies blob is text' do - expect(self).not_to receive(:blob_text_viewable?) + expect(helper).not_to receive(:blob_text_viewable?) button = edit_blob_link(project, 'refs/heads/master', 'README.md') expect(button).to start_with(' Gitlab::Access::OWNER, diff --git a/spec/helpers/page_layout_helper_spec.rb b/spec/helpers/page_layout_helper_spec.rb index cf632f594c7..dc07657e101 100644 --- a/spec/helpers/page_layout_helper_spec.rb +++ b/spec/helpers/page_layout_helper_spec.rb @@ -97,5 +97,14 @@ describe PageLayoutHelper do expect(tags).to include %q() end end + + it 'escapes content' do + allow(helper).to receive(:page_card_attributes) + .and_return(foo: %q{foo" http-equiv="refresh}.html_safe) + + tags = helper.page_card_meta_tags + + expect(tags).to include(%q{content="foo" http-equiv="refresh"}) + end end end diff --git a/spec/javascripts/abuse_reports_spec.js.es6 b/spec/javascripts/abuse_reports_spec.js.es6 new file mode 100644 index 00000000000..6bcfdf191c2 --- /dev/null +++ b/spec/javascripts/abuse_reports_spec.js.es6 @@ -0,0 +1,41 @@ +/*= require abuse_reports */ + +/*= require jquery */ + +((global) => { + const FIXTURE = 'abuse_reports.html'; + const MAX_MESSAGE_LENGTH = 500; + + function assertMaxLength($message) { + expect($message.text().length).toEqual(MAX_MESSAGE_LENGTH); + } + + describe('Abuse Reports', function() { + fixture.preload(FIXTURE); + + beforeEach(function() { + fixture.load(FIXTURE); + new global.AbuseReports(); + }); + + it('should truncate long messages', function() { + const $longMessage = $('#long'); + expect($longMessage.data('original-message')).toEqual(jasmine.anything()); + assertMaxLength($longMessage); + }); + + it('should not truncate short messages', function() { + const $shortMessage = $('#short'); + expect($shortMessage.data('original-message')).not.toEqual(jasmine.anything()); + }); + + it('should allow clicking a truncated message to expand and collapse the full message', function() { + const $longMessage = $('#long'); + $longMessage.click(); + expect($longMessage.data('original-message').length).toEqual($longMessage.text().length); + $longMessage.click(); + assertMaxLength($longMessage); + }); + }); + +})(window.gl); diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js index 3ddc163033e..fa32d0d7da5 100644 --- a/spec/javascripts/awards_handler_spec.js +++ b/spec/javascripts/awards_handler_spec.js @@ -143,6 +143,52 @@ return expect($votesBlock.find('[data-emoji=fire]').length).toBe(0); }); }); + describe('::addYouToUserList', function() { + it('should prepend "You" to the award tooltip', function() { + var $thumbsUpEmoji, $votesBlock, awardUrl; + awardUrl = awardsHandler.getAwardUrl(); + $votesBlock = $('.js-awards-block').eq(0); + $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'sam, jerry, max, and andy'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + return expect($thumbsUpEmoji.data("original-title")).toBe('You, sam, jerry, max, and andy'); + }); + return it('handles the special case where "You" is not cleanly comma seperated', function() { + var $thumbsUpEmoji, $votesBlock, awardUrl; + awardUrl = awardsHandler.getAwardUrl(); + $votesBlock = $('.js-awards-block').eq(0); + $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'sam'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + return expect($thumbsUpEmoji.data("original-title")).toBe('You and sam'); + }); + }); + describe('::removeYouToUserList', function() { + it('removes "You" from the front of the tooltip', function() { + var $thumbsUpEmoji, $votesBlock, awardUrl; + awardUrl = awardsHandler.getAwardUrl(); + $votesBlock = $('.js-awards-block').eq(0); + $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'You, sam, jerry, max, and andy'); + $thumbsUpEmoji.addClass('active'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + return expect($thumbsUpEmoji.data("original-title")).toBe('sam, jerry, max, and andy'); + }); + return it('handles the special case where "You" is not cleanly comma seperated', function() { + var $thumbsUpEmoji, $votesBlock, awardUrl; + awardUrl = awardsHandler.getAwardUrl(); + $votesBlock = $('.js-awards-block').eq(0); + $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'You and sam'); + $thumbsUpEmoji.addClass('active'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + return expect($thumbsUpEmoji.data("original-title")).toBe('sam'); + }); + }); describe('search', function() { return it('should filter the emoji', function() { $('.js-add-award').eq(0).click(); diff --git a/spec/javascripts/boards/boards_store_spec.js.es6 b/spec/javascripts/boards/boards_store_spec.js.es6 new file mode 100644 index 00000000000..078e4b00023 --- /dev/null +++ b/spec/javascripts/boards/boards_store_spec.js.es6 @@ -0,0 +1,164 @@ +//= require jquery +//= require jquery_ujs +//= require jquery.cookie +//= require vue +//= require vue-resource +//= require lib/utils/url_utility +//= require boards/models/issue +//= require boards/models/label +//= require boards/models/list +//= require boards/models/user +//= require boards/services/board_service +//= require boards/stores/boards_store +//= require ./mock_data + +(() => { + beforeEach(() => { + gl.boardService = new BoardService('/test/issue-boards/board'); + gl.issueBoards.BoardsStore.create(); + + $.cookie('issue_board_welcome_hidden', 'false'); + }); + + describe('Store', () => { + it('starts with a blank state', () => { + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0); + }); + + describe('lists', () => { + it('creates new list without persisting to DB', () => { + gl.issueBoards.BoardsStore.addList(listObj); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + }); + + it('finds list by ID', () => { + gl.issueBoards.BoardsStore.addList(listObj); + const list = gl.issueBoards.BoardsStore.findList('id', 1); + + expect(list.id).toBe(1); + }); + + it('finds list by type', () => { + gl.issueBoards.BoardsStore.addList(listObj); + const list = gl.issueBoards.BoardsStore.findList('type', 'label'); + + expect(list).toBeDefined(); + }); + + it('finds list limited by type', () => { + gl.issueBoards.BoardsStore.addList({ + id: 1, + position: 0, + title: 'Test', + list_type: 'backlog' + }); + const list = gl.issueBoards.BoardsStore.findList('id', 1, 'backlog'); + + expect(list).toBeDefined(); + }); + + it('gets issue when new list added', (done) => { + gl.issueBoards.BoardsStore.addList(listObj); + const list = gl.issueBoards.BoardsStore.findList('id', 1); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + + setTimeout(() => { + expect(list.issues.length).toBe(1); + expect(list.issues[0].id).toBe(1); + done(); + }, 0); + }); + + it('persists new list', (done) => { + gl.issueBoards.BoardsStore.new({ + title: 'Test', + type: 'label', + label: { + id: 1, + title: 'Testing', + color: 'red', + description: 'testing;' + } + }); + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + + setTimeout(() => { + const list = gl.issueBoards.BoardsStore.findList('id', 1); + expect(list).toBeDefined(); + expect(list.id).toBe(1); + expect(list.position).toBe(0); + done(); + }, 0); + }); + + it('check for blank state adding', () => { + expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true); + }); + + it('check for blank state not adding', () => { + gl.issueBoards.BoardsStore.addList(listObj); + expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(false); + }); + + it('check for blank state adding when backlog & done list exist', () => { + gl.issueBoards.BoardsStore.addList({ + list_type: 'backlog' + }); + gl.issueBoards.BoardsStore.addList({ + list_type: 'done' + }); + + expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true); + }); + + it('adds the blank state', () => { + gl.issueBoards.BoardsStore.addBlankState(); + + const list = gl.issueBoards.BoardsStore.findList('type', 'blank', 'blank'); + expect(list).toBeDefined(); + }); + + it('removes list from state', () => { + gl.issueBoards.BoardsStore.addList(listObj); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + + gl.issueBoards.BoardsStore.removeList(1, 'label'); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0); + }); + + it('moves the position of lists', () => { + const listOne = gl.issueBoards.BoardsStore.addList(listObj), + listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2); + + gl.issueBoards.BoardsStore.moveList(listOne, ['2', '1']); + + expect(listOne.position).toBe(1); + }); + + it('moves an issue from one list to another', (done) => { + const listOne = gl.issueBoards.BoardsStore.addList(listObj), + listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2); + + setTimeout(() => { + expect(listOne.issues.length).toBe(1); + expect(listTwo.issues.length).toBe(1); + + gl.issueBoards.BoardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(1)); + + expect(listOne.issues.length).toBe(0); + expect(listTwo.issues.length).toBe(1); + + done(); + }, 0); + }); + }); + }); +})(); diff --git a/spec/javascripts/boards/issue_spec.js.es6 b/spec/javascripts/boards/issue_spec.js.es6 new file mode 100644 index 00000000000..3569d1b98bd --- /dev/null +++ b/spec/javascripts/boards/issue_spec.js.es6 @@ -0,0 +1,83 @@ +//= require jquery +//= require jquery_ujs +//= require jquery.cookie +//= require vue +//= require vue-resource +//= require lib/utils/url_utility +//= require boards/models/issue +//= require boards/models/label +//= require boards/models/list +//= require boards/models/user +//= require boards/services/board_service +//= require boards/stores/boards_store +//= require ./mock_data + +describe('Issue model', () => { + let issue; + + beforeEach(() => { + gl.boardService = new BoardService('/test/issue-boards/board'); + gl.issueBoards.BoardsStore.create(); + + issue = new ListIssue({ + title: 'Testing', + iid: 1, + confidential: false, + labels: [{ + id: 1, + title: 'test', + color: 'red', + description: 'testing' + }] + }); + }); + + it('has label', () => { + expect(issue.labels.length).toBe(1); + }); + + it('add new label', () => { + issue.addLabel({ + id: 2, + title: 'bug', + color: 'blue', + description: 'bugs!' + }); + expect(issue.labels.length).toBe(2); + }); + + it('does not add existing label', () => { + issue.addLabel({ + id: 2, + title: 'test', + color: 'blue', + description: 'bugs!' + }); + + expect(issue.labels.length).toBe(1); + }); + + it('finds label', () => { + const label = issue.findLabel(issue.labels[0]); + expect(label).toBeDefined(); + }); + + it('removes label', () => { + const label = issue.findLabel(issue.labels[0]); + issue.removeLabel(label); + expect(issue.labels.length).toBe(0); + }); + + it('removes multiple labels', () => { + issue.addLabel({ + id: 2, + title: 'bug', + color: 'blue', + description: 'bugs!' + }); + expect(issue.labels.length).toBe(2); + + issue.removeLabels([issue.labels[0], issue.labels[1]]); + expect(issue.labels.length).toBe(0); + }); +}); diff --git a/spec/javascripts/boards/list_spec.js.es6 b/spec/javascripts/boards/list_spec.js.es6 new file mode 100644 index 00000000000..c206b794442 --- /dev/null +++ b/spec/javascripts/boards/list_spec.js.es6 @@ -0,0 +1,89 @@ +//= require jquery +//= require jquery_ujs +//= require jquery.cookie +//= require vue +//= require vue-resource +//= require lib/utils/url_utility +//= require boards/models/issue +//= require boards/models/label +//= require boards/models/list +//= require boards/models/user +//= require boards/services/board_service +//= require boards/stores/boards_store +//= require ./mock_data + +describe('List model', () => { + let list; + + beforeEach(() => { + gl.boardService = new BoardService('/test/issue-boards/board'); + gl.issueBoards.BoardsStore.create(); + + list = new List(listObj); + }); + + it('gets issues when created', (done) => { + setTimeout(() => { + expect(list.issues.length).toBe(1); + done(); + }, 0); + }); + + it('saves list and returns ID', (done) => { + list = new List({ + title: 'test', + label: { + id: 1, + title: 'test', + color: 'red' + } + }); + list.save(); + + setTimeout(() => { + expect(list.id).toBe(1); + expect(list.type).toBe('label'); + expect(list.position).toBe(0); + done(); + }, 0); + }); + + it('destroys the list', (done) => { + gl.issueBoards.BoardsStore.addList(listObj); + list = gl.issueBoards.BoardsStore.findList('id', 1); + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + list.destroy(); + + setTimeout(() => { + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0); + done(); + }, 0); + }); + + it('can\'t search when not backlog', () => { + expect(list.canSearch()).toBe(false); + }); + + it('can search when backlog', () => { + list.type = 'backlog'; + expect(list.canSearch()).toBe(true); + }); + + it('gets issue from list', (done) => { + setTimeout(() => { + const issue = list.findIssue(1); + expect(issue).toBeDefined(); + done(); + }, 0); + }); + + it('removes issue', (done) => { + setTimeout(() => { + const issue = list.findIssue(1); + expect(list.issues.length).toBe(1); + list.removeIssue(issue); + expect(list.issues.length).toBe(0); + done(); + }, 0); + }); +}); diff --git a/spec/javascripts/boards/mock_data.js.es6 b/spec/javascripts/boards/mock_data.js.es6 new file mode 100644 index 00000000000..0c37ec8354f --- /dev/null +++ b/spec/javascripts/boards/mock_data.js.es6 @@ -0,0 +1,53 @@ +const listObj = { + id: 1, + position: 0, + title: 'Test', + list_type: 'label', + label: { + id: 1, + title: 'Testing', + color: 'red', + description: 'testing;' + } +}; + +const listObjDuplicate = { + id: 2, + position: 1, + title: 'Test', + list_type: 'label', + label: { + id: 2, + title: 'Testing', + color: 'red', + description: 'testing;' + } +}; + +const BoardsMockData = { + 'GET': { + '/test/issue-boards/board/lists{/id}/issues': [{ + title: 'Testing', + iid: 1, + confidential: false, + labels: [] + }] + }, + 'POST': { + '/test/issue-boards/board/lists{/id}': listObj + }, + 'PUT': { + '/test/issue-boards/board/lists{/id}': {} + }, + 'DELETE': { + '/test/issue-boards/board/lists{/id}': {} + } +}; + +Vue.http.interceptors.push((request, next) => { + const body = BoardsMockData[request.method][request.url]; + + next(request.respondWith(JSON.stringify(body), { + status: 200 + })); +}); diff --git a/spec/javascripts/diff_comments_store_spec.js.es6 b/spec/javascripts/diff_comments_store_spec.js.es6 new file mode 100644 index 00000000000..22293d4de87 --- /dev/null +++ b/spec/javascripts/diff_comments_store_spec.js.es6 @@ -0,0 +1,122 @@ +//= require vue +//= require diff_notes/models/discussion +//= require diff_notes/models/note +//= require diff_notes/stores/comments +(() => { + function createDiscussion(noteId = 1, resolved = true) { + CommentsStore.create('a', noteId, true, resolved, 'test'); + }; + + beforeEach(() => { + CommentsStore.state = {}; + }); + + describe('New discussion', () => { + it('creates new discussion', () => { + expect(Object.keys(CommentsStore.state).length).toBe(0); + createDiscussion(); + expect(Object.keys(CommentsStore.state).length).toBe(1); + }); + + it('creates new note in discussion', () => { + createDiscussion(); + createDiscussion(2); + + const discussion = CommentsStore.state['a']; + expect(Object.keys(discussion.notes).length).toBe(2); + }); + }); + + describe('Get note', () => { + beforeEach(() => { + expect(Object.keys(CommentsStore.state).length).toBe(0); + createDiscussion(); + }); + + it('gets note by ID', () => { + const note = CommentsStore.get('a', 1); + expect(note).toBeDefined(); + expect(note.id).toBe(1); + }); + }); + + describe('Delete discussion', () => { + beforeEach(() => { + expect(Object.keys(CommentsStore.state).length).toBe(0); + createDiscussion(); + }); + + it('deletes discussion by ID', () => { + CommentsStore.delete('a', 1); + expect(Object.keys(CommentsStore.state).length).toBe(0); + }); + + it('deletes discussion when no more notes', () => { + createDiscussion(); + createDiscussion(2); + expect(Object.keys(CommentsStore.state).length).toBe(1); + expect(Object.keys(CommentsStore.state['a'].notes).length).toBe(2); + + CommentsStore.delete('a', 1); + CommentsStore.delete('a', 2); + expect(Object.keys(CommentsStore.state).length).toBe(0); + }); + }); + + describe('Update note', () => { + beforeEach(() => { + expect(Object.keys(CommentsStore.state).length).toBe(0); + createDiscussion(); + }); + + it('updates note to be unresolved', () => { + CommentsStore.update('a', 1, false, 'test'); + + const note = CommentsStore.get('a', 1); + expect(note.resolved).toBe(false); + }); + }); + + describe('Discussion resolved', () => { + beforeEach(() => { + expect(Object.keys(CommentsStore.state).length).toBe(0); + createDiscussion(); + }); + + it('is resolved with single note', () => { + const discussion = CommentsStore.state['a']; + expect(discussion.isResolved()).toBe(true); + }); + + it('is unresolved with 2 notes', () => { + const discussion = CommentsStore.state['a']; + createDiscussion(2, false); + console.log(discussion.isResolved()); + + expect(discussion.isResolved()).toBe(false); + }); + + it('is resolved with 2 notes', () => { + const discussion = CommentsStore.state['a']; + createDiscussion(2); + + expect(discussion.isResolved()).toBe(true); + }); + + it('resolve all notes', () => { + const discussion = CommentsStore.state['a']; + createDiscussion(2, false); + + discussion.resolveAllNotes(); + expect(discussion.isResolved()).toBe(true); + }); + + it('unresolve all notes', () => { + const discussion = CommentsStore.state['a']; + createDiscussion(2); + + discussion.unResolveAllNotes(); + expect(discussion.isResolved()).toBe(false); + }); + }); +})(); diff --git a/spec/javascripts/fixtures/abuse_reports.html.haml b/spec/javascripts/fixtures/abuse_reports.html.haml new file mode 100644 index 00000000000..2ec302abcb7 --- /dev/null +++ b/spec/javascripts/fixtures/abuse_reports.html.haml @@ -0,0 +1,16 @@ +.abuse-reports + .message#long + Cat ipsum dolor sit amet, hide head under blanket so no one can see. + Gate keepers of hell eat and than sleep on your face but hunt by meowing + loudly at 5am next to human slave food dispenser cats go for world + domination or chase laser, yet poop on grasses chirp at birds. Cat is love, + cat is life chase after silly colored fish toys around the house climb a + tree, wait for a fireman jump to fireman then scratch his face fall asleep + on the washing machine lies down always hungry so caticus cuteicus. Sit on + human. Spot something, big eyes, big eyes, crouch, shake butt, prepare to + pounce sleep in the bathroom sink hiss at vacuum cleaner hide head under + blanket so no one can see throwup on your pillow. + .message#short + Cat ipsum dolor sit amet, groom yourself 4 hours - checked, have your + beauty sleep 18 hours - checked, be fabulous for the rest of the day - + checked! for shake treat bag. diff --git a/spec/lib/gitlab/akismet_helper_spec.rb b/spec/lib/gitlab/akismet_helper_spec.rb deleted file mode 100644 index b08396da4d2..00000000000 --- a/spec/lib/gitlab/akismet_helper_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -require 'spec_helper' - -describe Gitlab::AkismetHelper, type: :helper do - let(:project) { create(:project, :public) } - let(:user) { create(:user) } - - before do - allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url)) - allow_any_instance_of(ApplicationSetting).to receive(:akismet_enabled).and_return(true) - allow_any_instance_of(ApplicationSetting).to receive(:akismet_api_key).and_return('12345') - end - - describe '#check_for_spam?' do - it 'returns true for public project' do - expect(helper.check_for_spam?(project)).to eq(true) - end - - it 'returns false for private project' do - project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE) - expect(helper.check_for_spam?(project)).to eq(false) - end - end - - describe '#is_spam?' do - it 'returns true for spam' do - environment = { - 'action_dispatch.remote_ip' => '127.0.0.1', - 'HTTP_USER_AGENT' => 'Test User Agent' - } - - allow_any_instance_of(::Akismet::Client).to receive(:check).and_return([true, true]) - expect(helper.is_spam?(environment, user, 'Is this spam?')).to eq(true) - end - end -end diff --git a/spec/lib/gitlab/badge/build/metadata_spec.rb b/spec/lib/gitlab/badge/build/metadata_spec.rb index ad5388215c2..d678e522721 100644 --- a/spec/lib/gitlab/badge/build/metadata_spec.rb +++ b/spec/lib/gitlab/badge/build/metadata_spec.rb @@ -1,37 +1,27 @@ require 'spec_helper' +require 'lib/gitlab/badge/shared/metadata' describe Gitlab::Badge::Build::Metadata do - let(:project) { create(:project) } - let(:branch) { 'master' } - let(:badge) { described_class.new(project, branch) } + let(:badge) { double(project: create(:project), ref: 'feature') } + let(:metadata) { described_class.new(badge) } - describe '#to_html' do - let(:html) { Nokogiri::HTML.parse(badge.to_html) } - let(:a_href) { html.at('a') } + it_behaves_like 'badge metadata' - it 'points to link' do - expect(a_href[:href]).to eq badge.link_url + describe '#title' do + it 'returns build status title' do + expect(metadata.title).to eq 'build status' end - - it 'contains clickable image' do - expect(a_href.children.first.name).to eq 'img' - end - end - - describe '#to_markdown' do - subject { badge.to_markdown } - - it { is_expected.to include badge.image_url } - it { is_expected.to include badge.link_url } end describe '#image_url' do - subject { badge.image_url } - it { is_expected.to include "badges/#{branch}/build.svg" } + it 'returns valid url' do + expect(metadata.image_url).to include 'badges/feature/build.svg' + end end describe '#link_url' do - subject { badge.link_url } - it { is_expected.to include "commits/#{branch}" } + it 'returns valid link' do + expect(metadata.link_url).to include 'commits/feature' + end end end diff --git a/spec/lib/gitlab/badge/build_spec.rb b/spec/lib/gitlab/badge/build/status_spec.rb similarity index 78% rename from spec/lib/gitlab/badge/build_spec.rb rename to spec/lib/gitlab/badge/build/status_spec.rb index bb8144d5122..38eebb2a176 100644 --- a/spec/lib/gitlab/badge/build_spec.rb +++ b/spec/lib/gitlab/badge/build/status_spec.rb @@ -1,11 +1,23 @@ require 'spec_helper' -describe Gitlab::Badge::Build do +describe Gitlab::Badge::Build::Status do let(:project) { create(:project) } let(:sha) { project.commit.sha } let(:branch) { 'master' } let(:badge) { described_class.new(project, branch) } + describe '#entity' do + it 'always says build' do + expect(badge.entity).to eq 'build' + end + end + + describe '#template' do + it 'returns badge template' do + expect(badge.template.key_text).to eq 'build' + end + end + describe '#metadata' do it 'returns badge metadata' do expect(badge.metadata.image_url) @@ -13,12 +25,6 @@ describe Gitlab::Badge::Build do end end - describe '#key_text' do - it 'always says build' do - expect(badge.key_text).to eq 'build' - end - end - context 'build exists' do let!(:build) { create_build(project, sha, branch) } @@ -30,12 +36,6 @@ describe Gitlab::Badge::Build do expect(badge.status).to eq 'success' end end - - describe '#value_text' do - it 'returns correct value text' do - expect(badge.value_text).to eq 'success' - end - end end context 'build failed' do @@ -46,12 +46,6 @@ describe Gitlab::Badge::Build do expect(badge.status).to eq 'failed' end end - - describe '#value_text' do - it 'has correct value text' do - expect(badge.value_text).to eq 'failed' - end - end end context 'when outdated pipeline for given ref exists' do @@ -87,12 +81,6 @@ describe Gitlab::Badge::Build do expect(badge.status).to eq 'unknown' end end - - describe '#value_text' do - it 'has correct value text' do - expect(badge.value_text).to eq 'unknown' - end - end end def create_build(project, sha, branch) diff --git a/spec/lib/gitlab/badge/build/template_spec.rb b/spec/lib/gitlab/badge/build/template_spec.rb index 86dead3c54e..a7e21fb8bb1 100644 --- a/spec/lib/gitlab/badge/build/template_spec.rb +++ b/spec/lib/gitlab/badge/build/template_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' describe Gitlab::Badge::Build::Template do - let(:status) { 'success' } - let(:template) { described_class.new(status) } + let(:badge) { double(entity: 'build', status: 'success') } + let(:template) { described_class.new(badge) } describe '#key_text' do it 'is always says build' do @@ -34,15 +34,15 @@ describe Gitlab::Badge::Build::Template do describe '#value_color' do context 'when status is success' do - let(:status) { 'success' } - it 'has expected color' do expect(template.value_color).to eq '#4c1' end end context 'when status is failed' do - let(:status) { 'failed' } + before do + allow(badge).to receive(:status).and_return('failed') + end it 'has expected color' do expect(template.value_color).to eq '#e05d44' @@ -50,7 +50,9 @@ describe Gitlab::Badge::Build::Template do end context 'when status is running' do - let(:status) { 'running' } + before do + allow(badge).to receive(:status).and_return('running') + end it 'has expected color' do expect(template.value_color).to eq '#dfb317' @@ -58,7 +60,9 @@ describe Gitlab::Badge::Build::Template do end context 'when status is unknown' do - let(:status) { 'unknown' } + before do + allow(badge).to receive(:status).and_return('unknown') + end it 'has expected color' do expect(template.value_color).to eq '#9f9f9f' @@ -66,7 +70,9 @@ describe Gitlab::Badge::Build::Template do end context 'when status does not match any known statuses' do - let(:status) { 'invalid status' } + before do + allow(badge).to receive(:status).and_return('invalid') + end it 'has expected color' do expect(template.value_color).to eq '#9f9f9f' diff --git a/spec/lib/gitlab/badge/coverage/metadata_spec.rb b/spec/lib/gitlab/badge/coverage/metadata_spec.rb new file mode 100644 index 00000000000..74eaf7eaf8b --- /dev/null +++ b/spec/lib/gitlab/badge/coverage/metadata_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' +require 'lib/gitlab/badge/shared/metadata' + +describe Gitlab::Badge::Coverage::Metadata do + let(:badge) do + double(project: create(:project), ref: 'feature', job: 'test') + end + + let(:metadata) { described_class.new(badge) } + + it_behaves_like 'badge metadata' + + describe '#title' do + it 'returns coverage report title' do + expect(metadata.title).to eq 'coverage report' + end + end + + describe '#image_url' do + it 'returns valid url' do + expect(metadata.image_url).to include 'badges/feature/coverage.svg' + end + end + + describe '#link_url' do + it 'returns valid link' do + expect(metadata.link_url).to include 'commits/feature' + end + end +end diff --git a/spec/lib/gitlab/badge/coverage/report_spec.rb b/spec/lib/gitlab/badge/coverage/report_spec.rb new file mode 100644 index 00000000000..1ff49602486 --- /dev/null +++ b/spec/lib/gitlab/badge/coverage/report_spec.rb @@ -0,0 +1,93 @@ +require 'spec_helper' + +describe Gitlab::Badge::Coverage::Report do + let(:project) { create(:project) } + let(:job_name) { nil } + + let(:badge) do + described_class.new(project, 'master', job_name) + end + + describe '#entity' do + it 'describes a coverage' do + expect(badge.entity).to eq 'coverage' + end + end + + describe '#metadata' do + it 'returns correct metadata' do + expect(badge.metadata.image_url).to include 'coverage.svg' + end + end + + describe '#template' do + it 'returns correct template' do + expect(badge.template.key_text).to eq 'coverage' + end + end + + shared_examples 'unknown coverage report' do + context 'particular job specified' do + let(:job_name) { '' } + + it 'returns nil' do + expect(badge.status).to be_nil + end + end + + context 'particular job not specified' do + let(:job_name) { nil } + + it 'returns nil' do + expect(badge.status).to be_nil + end + end + end + + context 'pipeline exists' do + let!(:pipeline) do + create(:ci_pipeline, project: project, + sha: project.commit.id, + ref: 'master') + end + + context 'builds exist' do + before do + create(:ci_build, name: 'first', pipeline: pipeline, coverage: 40) + create(:ci_build, pipeline: pipeline, coverage: 60) + end + + context 'particular job specified' do + let(:job_name) { 'first' } + + it 'returns coverage for the particular job' do + expect(badge.status).to eq 40 + end + end + + context 'particular job not specified' do + let(:job_name) { '' } + + it 'returns arithemetic mean for the pipeline' do + expect(badge.status).to eq 50 + end + end + end + + context 'builds do not exist' do + it_behaves_like 'unknown coverage report' + + context 'particular job specified' do + let(:job_name) { 'nonexistent' } + + it 'retruns nil' do + expect(badge.status).to be_nil + end + end + end + end + + context 'pipeline does not exist' do + it_behaves_like 'unknown coverage report' + end +end diff --git a/spec/lib/gitlab/badge/coverage/template_spec.rb b/spec/lib/gitlab/badge/coverage/template_spec.rb new file mode 100644 index 00000000000..383bae6e087 --- /dev/null +++ b/spec/lib/gitlab/badge/coverage/template_spec.rb @@ -0,0 +1,130 @@ +require 'spec_helper' + +describe Gitlab::Badge::Coverage::Template do + let(:badge) { double(entity: 'coverage', status: 90) } + let(:template) { described_class.new(badge) } + + describe '#key_text' do + it 'is always says coverage' do + expect(template.key_text).to eq 'coverage' + end + end + + describe '#value_text' do + context 'when coverage is known' do + it 'returns coverage percentage' do + expect(template.value_text).to eq '90%' + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'returns string that says coverage is unknown' do + expect(template.value_text).to eq 'unknown' + end + end + end + + describe '#key_width' do + it 'has a fixed key width' do + expect(template.key_width).to eq 62 + end + end + + describe '#value_width' do + context 'when coverage is known' do + it 'is narrower when coverage is known' do + expect(template.value_width).to eq 36 + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'is wider when coverage is unknown to fit text' do + expect(template.value_width).to eq 58 + end + end + end + + describe '#key_color' do + it 'always has the same color' do + expect(template.key_color).to eq '#555' + end + end + + describe '#value_color' do + context 'when coverage is good' do + before do + allow(badge).to receive(:status).and_return(98) + end + + it 'is green' do + expect(template.value_color).to eq '#4c1' + end + end + + context 'when coverage is acceptable' do + before do + allow(badge).to receive(:status).and_return(90) + end + + it 'is green-orange' do + expect(template.value_color).to eq '#a3c51c' + end + end + + context 'when coverage is medium' do + before do + allow(badge).to receive(:status).and_return(75) + end + + it 'is orange-yellow' do + expect(template.value_color).to eq '#dfb317' + end + end + + context 'when coverage is low' do + before do + allow(badge).to receive(:status).and_return(50) + end + + it 'is red' do + expect(template.value_color).to eq '#e05d44' + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'is grey' do + expect(template.value_color).to eq '#9f9f9f' + end + end + end + + describe '#width' do + context 'when coverage is known' do + it 'returns the key width plus value width' do + expect(template.width).to eq 98 + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'returns key width plus wider value width' do + expect(template.width).to eq 120 + end + end + end +end diff --git a/spec/lib/gitlab/badge/shared/metadata.rb b/spec/lib/gitlab/badge/shared/metadata.rb new file mode 100644 index 00000000000..0cf18514251 --- /dev/null +++ b/spec/lib/gitlab/badge/shared/metadata.rb @@ -0,0 +1,21 @@ +shared_examples 'badge metadata' do + describe '#to_html' do + let(:html) { Nokogiri::HTML.parse(metadata.to_html) } + let(:a_href) { html.at('a') } + + it 'points to link' do + expect(a_href[:href]).to eq metadata.link_url + end + + it 'contains clickable image' do + expect(a_href.children.first.name).to eq 'img' + end + end + + describe '#to_markdown' do + subject { metadata.to_markdown } + + it { is_expected.to include metadata.image_url } + it { is_expected.to include metadata.link_url } + end +end diff --git a/spec/lib/gitlab/conflict/file_collection_spec.rb b/spec/lib/gitlab/conflict/file_collection_spec.rb new file mode 100644 index 00000000000..39d892c18c0 --- /dev/null +++ b/spec/lib/gitlab/conflict/file_collection_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe Gitlab::Conflict::FileCollection, lib: true do + let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start') } + let(:file_collection) { Gitlab::Conflict::FileCollection.new(merge_request) } + + describe '#files' do + it 'returns an array of Conflict::Files' do + expect(file_collection.files).to all(be_an_instance_of(Gitlab::Conflict::File)) + end + end + + describe '#default_commit_message' do + it 'matches the format of the git CLI commit message' do + expect(file_collection.default_commit_message).to eq(< 0 + previous_line_number = conflict_file.sections[i - 1][:lines].map(&:old_line).compact.last + end + + if current_line_number == previous_line_number + 1 + expect(section[:lines].first.type).not_to eq('match') + else + expect(section[:lines].first.type).to eq('match') + expect(section[:lines].first.text).to match(/\A@@ -#{current_line_number},\d+ \+\d+,\d+ @@ module Gitlab\Z/) + end + end + end + + it 'sets conflict to false for sections with only unchanged lines' do + conflict_file.sections.reject { |section| section[:conflict] }.each do |section| + without_match = section[:lines].reject { |line| line.type == 'match' } + + expect(without_match).to all(have_attributes(type: nil)) + end + end + + it 'only includes a maximum of CONTEXT_LINES (plus an optional match line) in context sections' do + conflict_file.sections.reject { |section| section[:conflict] }.each do |section| + without_match = section[:lines].reject { |line| line.type == 'match' } + + expect(without_match.length).to be <= Gitlab::Conflict::File::CONTEXT_LINES * 2 + end + end + + it 'sets conflict to true for sections with only changed lines' do + conflict_file.sections.select { |section| section[:conflict] }.each do |section| + section[:lines].each do |line| + expect(line.type).to be_in(['new', 'old']) + end + end + end + + it 'adds unique IDs to conflict sections, and not to other sections' do + section_ids = [] + + conflict_file.sections.each do |section| + if section[:conflict] + expect(section).to have_key(:id) + section_ids << section[:id] + else + expect(section).not_to have_key(:id) + end + end + + expect(section_ids.uniq).to eq(section_ids) + end + + context 'with an example file' do + let(:file) do + <>>>>>> files/ruby/regex.rb +end + +# Some extra lines +# To force a match line +# To be created + +def path_regexp + default_regexp +end + +<<<<<<< files/ruby/regex.rb +def archive_formats_regexp + /(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/ +======= +def archive_formats_regex + %r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)} +>>>>>>> files/ruby/regex.rb +end + +def git_reference_regexp + # Valid git ref regexp, see: + # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html + %r{ + (?! + (?# doesn't begins with) + \/| (?# rule #6) + (?# doesn't contain) + .*(?: + [\/.]\.| (?# rule #1,3) + \/\/| (?# rule #6) + @\{| (?# rule #8) + \\ (?# rule #9) + ) + ) + [^\000-\040\177~^:?*\[]+ (?# rule #4-5) + (?# doesn't end with) + (?>>>>>> files/ruby/regex.rb +end +FILE + end + + let(:conflict_file) { Gitlab::Conflict::File.new({ data: file }, conflict, merge_request: merge_request) } + let(:sections) { conflict_file.sections } + + it 'sets the correct match line headers' do + expect(sections[0][:lines].first).to have_attributes(type: 'match', text: '@@ -3,14 +3,14 @@') + expect(sections[3][:lines].first).to have_attributes(type: 'match', text: '@@ -19,26 +19,26 @@ def path_regexp') + expect(sections[6][:lines].first).to have_attributes(type: 'match', text: '@@ -47,52 +47,52 @@ end') + end + + it 'does not add match lines where they are not needed' do + expect(sections[1][:lines].first.type).not_to eq('match') + expect(sections[2][:lines].first.type).not_to eq('match') + expect(sections[4][:lines].first.type).not_to eq('match') + expect(sections[5][:lines].first.type).not_to eq('match') + expect(sections[7][:lines].first.type).not_to eq('match') + end + + it 'creates context sections of the correct length' do + expect(sections[0][:lines].reject(&:type).length).to eq(3) + expect(sections[2][:lines].reject(&:type).length).to eq(3) + expect(sections[3][:lines].reject(&:type).length).to eq(3) + expect(sections[5][:lines].reject(&:type).length).to eq(3) + expect(sections[6][:lines].reject(&:type).length).to eq(3) + expect(sections[8][:lines].reject(&:type).length).to eq(1) + end + end + end + + describe '#as_json' do + it 'includes the blob path for the file' do + expect(conflict_file.as_json[:blob_path]). + to eq("/#{project.namespace.to_param}/#{merge_request.project.to_param}/blob/#{our_commit.oid}/files/ruby/regex.rb") + end + + it 'includes the blob icon for the file' do + expect(conflict_file.as_json[:blob_icon]).to eq('file-text-o') + end + end +end diff --git a/spec/lib/gitlab/conflict/parser_spec.rb b/spec/lib/gitlab/conflict/parser_spec.rb new file mode 100644 index 00000000000..65a828accde --- /dev/null +++ b/spec/lib/gitlab/conflict/parser_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' + +describe Gitlab::Conflict::Parser, lib: true do + let(:parser) { Gitlab::Conflict::Parser.new } + + describe '#parse' do + def parse_text(text) + parser.parse(text, our_path: 'README.md', their_path: 'README.md') + end + + context 'when the file has valid conflicts' do + let(:text) do + <>>>>>> files/ruby/regex.rb + end + + def path_regexp + default_regexp + end + +<<<<<<< files/ruby/regex.rb + def archive_formats_regexp + /(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/ +======= + def archive_formats_regex + %r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)} +>>>>>>> files/ruby/regex.rb + end + + def git_reference_regexp + # Valid git ref regexp, see: + # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html + %r{ + (?! + (?# doesn't begins with) + \/| (?# rule #6) + (?# doesn't contain) + .*(?: + [\/.]\.| (?# rule #1,3) + \/\/| (?# rule #6) + @\{| (?# rule #8) + \\ (?# rule #9) + ) + ) + [^\000-\040\177~^:?*\[]+ (?# rule #4-5) + (?# doesn't end with) + (?>>>>>> files/ruby/regex.rb + end + end +end +CONFLICT + end + + let(:lines) do + parser.parse(text, our_path: 'files/ruby/regex.rb', their_path: 'files/ruby/regex.rb') + end + + it 'sets our lines as new lines' do + expect(lines[8..13]).to all(have_attributes(type: 'new')) + expect(lines[26..27]).to all(have_attributes(type: 'new')) + expect(lines[56..57]).to all(have_attributes(type: 'new')) + end + + it 'sets their lines as old lines' do + expect(lines[14..19]).to all(have_attributes(type: 'old')) + expect(lines[28..29]).to all(have_attributes(type: 'old')) + expect(lines[58..59]).to all(have_attributes(type: 'old')) + end + + it 'sets non-conflicted lines as both' do + expect(lines[0..7]).to all(have_attributes(type: nil)) + expect(lines[20..25]).to all(have_attributes(type: nil)) + expect(lines[30..55]).to all(have_attributes(type: nil)) + expect(lines[60..62]).to all(have_attributes(type: nil)) + end + + it 'sets consecutive line numbers for index, old_pos, and new_pos' do + old_line_numbers = lines.select { |line| line.type != 'new' }.map(&:old_pos) + new_line_numbers = lines.select { |line| line.type != 'old' }.map(&:new_pos) + + expect(lines.map(&:index)).to eq(0.upto(62).to_a) + expect(old_line_numbers).to eq(1.upto(53).to_a) + expect(new_line_numbers).to eq(1.upto(53).to_a) + end + end + + context 'when the file contents include conflict delimiters' do + it 'raises UnexpectedDelimiter when there is a non-start delimiter first' do + expect { parse_text('=======') }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text('>>>>>>> README.md') }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text('>>>>>>> some-other-path.md') }. + not_to raise_error + end + + it 'raises UnexpectedDelimiter when a start delimiter is followed by a non-middle delimiter' do + start_text = "<<<<<<< README.md\n" + end_text = "\n=======\n>>>>>>> README.md" + + expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + start_text + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }. + not_to raise_error + end + + it 'raises UnexpectedDelimiter when a middle delimiter is followed by a non-end delimiter' do + start_text = "<<<<<<< README.md\n=======\n" + end_text = "\n>>>>>>> README.md" + + expect { parse_text(start_text + '=======' + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + start_text + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }. + not_to raise_error + end + + it 'raises MissingEndDelimiter when there is no end delimiter at the end' do + start_text = "<<<<<<< README.md\n=======\n" + + expect { parse_text(start_text) }. + to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter) + + expect { parse_text(start_text + '>>>>>>> some-other-path.md') }. + to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter) + end + end + + context 'other file types' do + it 'raises UnmergeableFile when lines is blank, indicating a binary file' do + expect { parse_text('') }. + to raise_error(Gitlab::Conflict::Parser::UnmergeableFile) + + expect { parse_text(nil) }. + to raise_error(Gitlab::Conflict::Parser::UnmergeableFile) + end + + it 'raises UnmergeableFile when the file is over 100 KB' do + expect { parse_text('a' * 102401) }. + to raise_error(Gitlab::Conflict::Parser::UnmergeableFile) + end + end + end +end diff --git a/spec/lib/gitlab/build_data_builder_spec.rb b/spec/lib/gitlab/data_builder/build_spec.rb similarity index 88% rename from spec/lib/gitlab/build_data_builder_spec.rb rename to spec/lib/gitlab/data_builder/build_spec.rb index 23ae5cfacc4..6c71e98066b 100644 --- a/spec/lib/gitlab/build_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/build_spec.rb @@ -1,11 +1,11 @@ require 'spec_helper' -describe 'Gitlab::BuildDataBuilder' do +describe Gitlab::DataBuilder::Build do let(:build) { create(:ci_build) } describe '.build' do let(:data) do - Gitlab::BuildDataBuilder.build(build) + described_class.build(build) end it { expect(data).to be_a(Hash) } diff --git a/spec/lib/gitlab/note_data_builder_spec.rb b/spec/lib/gitlab/data_builder/note_spec.rb similarity index 97% rename from spec/lib/gitlab/note_data_builder_spec.rb rename to spec/lib/gitlab/data_builder/note_spec.rb index 3d6bcdfd873..9a4dec91e56 100644 --- a/spec/lib/gitlab/note_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/note_spec.rb @@ -1,9 +1,9 @@ require 'spec_helper' -describe 'Gitlab::NoteDataBuilder', lib: true do +describe Gitlab::DataBuilder::Note, lib: true do let(:project) { create(:project) } let(:user) { create(:user) } - let(:data) { Gitlab::NoteDataBuilder.build(note, user) } + let(:data) { described_class.build(note, user) } let(:fixed_time) { Time.at(1425600000) } # Avoid time precision errors before(:each) do diff --git a/spec/lib/gitlab/data_builder/pipeline_spec.rb b/spec/lib/gitlab/data_builder/pipeline_spec.rb new file mode 100644 index 00000000000..a68f5943a6a --- /dev/null +++ b/spec/lib/gitlab/data_builder/pipeline_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Gitlab::DataBuilder::Pipeline do + let(:user) { create(:user) } + let(:project) { create(:project) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, + status: 'success', + sha: project.commit.sha, + ref: project.default_branch) + end + + let!(:build) { create(:ci_build, pipeline: pipeline) } + + describe '.build' do + let(:data) { described_class.build(pipeline) } + let(:attributes) { data[:object_attributes] } + let(:build_data) { data[:builds].first } + let(:project_data) { data[:project] } + + it { expect(attributes).to be_a(Hash) } + it { expect(attributes[:ref]).to eq(pipeline.ref) } + it { expect(attributes[:sha]).to eq(pipeline.sha) } + it { expect(attributes[:tag]).to eq(pipeline.tag) } + it { expect(attributes[:id]).to eq(pipeline.id) } + it { expect(attributes[:status]).to eq(pipeline.status) } + + it { expect(build_data).to be_a(Hash) } + it { expect(build_data[:id]).to eq(build.id) } + it { expect(build_data[:status]).to eq(build.status) } + + it { expect(project_data).to eq(project.hook_attrs(backward: false)) } + end +end diff --git a/spec/lib/gitlab/push_data_builder_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb similarity index 97% rename from spec/lib/gitlab/push_data_builder_spec.rb rename to spec/lib/gitlab/data_builder/push_spec.rb index 6bd7393aaa7..b73434e8dd7 100644 --- a/spec/lib/gitlab/push_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/push_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::PushDataBuilder, lib: true do +describe Gitlab::DataBuilder::Push, lib: true do let(:project) { create(:project) } let(:user) { create(:user) } diff --git a/spec/lib/gitlab/downtime_check/message_spec.rb b/spec/lib/gitlab/downtime_check/message_spec.rb index 93094cda776..a5a398abf78 100644 --- a/spec/lib/gitlab/downtime_check/message_spec.rb +++ b/spec/lib/gitlab/downtime_check/message_spec.rb @@ -5,13 +5,35 @@ describe Gitlab::DowntimeCheck::Message do it 'returns an ANSI formatted String for an offline migration' do message = described_class.new('foo.rb', true, 'hello') - expect(message.to_s).to eq("[\e[32moffline\e[0m]: foo.rb: hello") + expect(message.to_s).to eq("[\e[31moffline\e[0m]: foo.rb:\n\nhello\n\n") end it 'returns an ANSI formatted String for an online migration' do message = described_class.new('foo.rb') - expect(message.to_s).to eq("[\e[31monline\e[0m]: foo.rb") + expect(message.to_s).to eq("[\e[32monline\e[0m]: foo.rb") + end + end + + describe '#reason?' do + it 'returns false when no reason is specified' do + message = described_class.new('foo.rb') + + expect(message.reason?).to eq(false) + end + + it 'returns true when a reason is specified' do + message = described_class.new('foo.rb', true, 'hello') + + expect(message.reason?).to eq(true) + end + end + + describe '#reason' do + it 'strips excessive whitespace from the returned String' do + message = described_class.new('foo.rb', true, " hello\n world\n\n foo") + + expect(message.reason).to eq("hello\nworld\n\nfoo") end end end diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb index a2119b0dadf..4909fed6b77 100644 --- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -60,6 +60,67 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do it "raises an InvalidNoteError" do expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError) end + + context 'because the note was commands only' do + let!(:email_raw) { fixture_file("emails/commands_only_reply.eml") } + + context 'and current user cannot update noteable' do + it 'raises a CommandsOnlyNoteError' do + expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError) + end + end + + context 'and current user can update noteable' do + before do + project.team << [user, :developer] + end + + it 'does not raise an error' do + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + + # One system note is created for the 'close' event + expect { receiver.execute }.to change { noteable.notes.count }.by(1) + + expect(noteable.reload).to be_closed + expect(noteable.due_date).to eq(Date.tomorrow) + expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy + end + end + end + end + + context 'when the note contains slash commands' do + let!(:email_raw) { fixture_file("emails/commands_in_reply.eml") } + + context 'and current user cannot update noteable' do + it 'post a note and does not update the noteable' do + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + + # One system note is created for the new note + expect { receiver.execute }.to change { noteable.notes.count }.by(1) + + expect(noteable.reload).to be_open + expect(noteable.due_date).to be_nil + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + end + end + + context 'and current user can update noteable' do + before do + project.team << [user, :developer] + end + + it 'post a note and updates the noteable' do + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + + # One system note is created for the new note, one for the 'close' event + expect { receiver.execute }.to change { noteable.notes.count }.by(2) + + expect(noteable.reload).to be_closed + expect(noteable.due_date).to eq(Date.tomorrow) + expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy + end + end end context "when the reply is blank" do diff --git a/spec/lib/gitlab/import_export/reader_spec.rb b/spec/lib/gitlab/import_export/reader_spec.rb index b76e14deca1..b6dec41d218 100644 --- a/spec/lib/gitlab/import_export/reader_spec.rb +++ b/spec/lib/gitlab/import_export/reader_spec.rb @@ -12,7 +12,8 @@ describe Gitlab::ImportExport::Reader, lib: true do except: [:iid], include: [:merge_request_diff, :merge_request_test] } }, - { commit_statuses: { include: :commit } }] + { commit_statuses: { include: :commit } }, + { project_members: { include: { user: { only: [:email] } } } }] } end diff --git a/spec/lib/gitlab/metrics/metric_spec.rb b/spec/lib/gitlab/metrics/metric_spec.rb index f718d536130..f26fca52c50 100644 --- a/spec/lib/gitlab/metrics/metric_spec.rb +++ b/spec/lib/gitlab/metrics/metric_spec.rb @@ -23,6 +23,24 @@ describe Gitlab::Metrics::Metric do it { is_expected.to eq({ host: 'localtoast' }) } end + describe '#type' do + subject { metric.type } + + it { is_expected.to eq(:metric) } + end + + describe '#event?' do + it 'returns false for a regular metric' do + expect(metric.event?).to eq(false) + end + + it 'returns true for an event metric' do + expect(metric).to receive(:type).and_return(:event) + + expect(metric.event?).to eq(true) + end + end + describe '#to_hash' do it 'returns a Hash' do expect(metric.to_hash).to be_an_instance_of(Hash) diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb index f264ed64029..a30cb2a5e38 100644 --- a/spec/lib/gitlab/metrics/rack_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb @@ -45,6 +45,15 @@ describe Gitlab::Metrics::RackMiddleware do middleware.call(env) end + + it 'tracks any raised exceptions' do + expect(app).to receive(:call).with(env).and_raise(RuntimeError) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:add_event).with(:rails_exception) + + expect { middleware.call(env) }.to raise_error(RuntimeError) + end end describe '#transaction_from_env' do diff --git a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb index 4d2aa03e722..acaba785606 100644 --- a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb @@ -12,7 +12,9 @@ describe Gitlab::Metrics::SidekiqMiddleware do with('TestWorker#perform'). and_call_original - expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set). + with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish) middleware.call(worker, message, :test) { nil } @@ -25,10 +27,28 @@ describe Gitlab::Metrics::SidekiqMiddleware do with('TestWorker#perform'). and_call_original - expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set). + with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish) middleware.call(worker, {}, :test) { nil } end + + it 'tracks any raised exceptions' do + worker = double(:worker, class: double(:class, name: 'TestWorker')) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:run).and_raise(RuntimeError) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:add_event).with(:sidekiq_exception) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:finish) + + expect { middleware.call(worker, message, :test) }. + to raise_error(RuntimeError) + end end end diff --git a/spec/lib/gitlab/metrics/transaction_spec.rb b/spec/lib/gitlab/metrics/transaction_spec.rb index f1a191d9410..3887c04c832 100644 --- a/spec/lib/gitlab/metrics/transaction_spec.rb +++ b/spec/lib/gitlab/metrics/transaction_spec.rb @@ -142,5 +142,62 @@ describe Gitlab::Metrics::Transaction do transaction.submit end + + it 'does not add an action tag for events' do + transaction.action = 'Foo#bar' + transaction.add_event(:meow) + + hash = { + series: 'events', + tags: { event: :meow }, + values: { count: 1 }, + timestamp: an_instance_of(Fixnum) + } + + expect(Gitlab::Metrics).to receive(:submit_metrics). + with([hash]) + + transaction.submit + end + end + + describe '#add_event' do + it 'adds a metric' do + transaction.add_event(:meow) + + expect(transaction.metrics[0]).to be_an_instance_of(Gitlab::Metrics::Metric) + end + + it "does not prefix the metric's series name" do + transaction.add_event(:meow) + + metric = transaction.metrics[0] + + expect(metric.series).to eq(described_class::EVENT_SERIES) + end + + it 'tracks a counter for every event' do + transaction.add_event(:meow) + + metric = transaction.metrics[0] + + expect(metric.values).to eq(count: 1) + end + + it 'tracks the event name' do + transaction.add_event(:meow) + + metric = transaction.metrics[0] + + expect(metric.tags).to eq(event: :meow) + end + + it 'allows tracking of custom tags' do + transaction.add_event(:meow, animal: 'cat') + + metric = transaction.metrics[0] + + expect(metric.tags).to eq(event: :meow, animal: 'cat') + end end end diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb index 84f9475a0f8..ab6e311b1e8 100644 --- a/spec/lib/gitlab/metrics_spec.rb +++ b/spec/lib/gitlab/metrics_spec.rb @@ -153,4 +153,28 @@ describe Gitlab::Metrics do expect(described_class.series_prefix).to be_an_instance_of(String) end end + + describe '.add_event' do + context 'without a transaction' do + it 'does nothing' do + expect_any_instance_of(Gitlab::Metrics::Transaction). + not_to receive(:add_event) + + Gitlab::Metrics.add_event(:meow) + end + end + + context 'with a transaction' do + it 'adds an event' do + transaction = Gitlab::Metrics::Transaction.new + + expect(transaction).to receive(:add_event).with(:meow) + + expect(Gitlab::Metrics).to receive(:current_transaction). + and_return(transaction) + + Gitlab::Metrics.add_event(:meow) + end + end + end end diff --git a/spec/lib/gitlab/slash_commands/command_definition_spec.rb b/spec/lib/gitlab/slash_commands/command_definition_spec.rb new file mode 100644 index 00000000000..c9c2f314e57 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/command_definition_spec.rb @@ -0,0 +1,173 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::CommandDefinition do + subject { described_class.new(:command) } + + describe "#all_names" do + context "when the command has aliases" do + before do + subject.aliases = [:alias1, :alias2] + end + + it "returns an array with the name and aliases" do + expect(subject.all_names).to eq([:command, :alias1, :alias2]) + end + end + + context "when the command doesn't have aliases" do + it "returns an array with the name" do + expect(subject.all_names).to eq([:command]) + end + end + end + + describe "#noop?" do + context "when the command has an action block" do + before do + subject.action_block = proc { } + end + + it "returns false" do + expect(subject.noop?).to be false + end + end + + context "when the command doesn't have an action block" do + it "returns true" do + expect(subject.noop?).to be true + end + end + end + + describe "#available?" do + let(:opts) { { go: false } } + + context "when the command has a condition block" do + before do + subject.condition_block = proc { go } + end + + context "when the condition block returns true" do + before do + opts[:go] = true + end + + it "returns true" do + expect(subject.available?(opts)).to be true + end + end + + context "when the condition block returns false" do + it "returns false" do + expect(subject.available?(opts)).to be false + end + end + end + + context "when the command doesn't have a condition block" do + it "returns true" do + expect(subject.available?(opts)).to be true + end + end + end + + describe "#execute" do + let(:context) { OpenStruct.new(run: false) } + + context "when the command is a noop" do + it "doesn't execute the command" do + expect(context).not_to receive(:instance_exec) + + subject.execute(context, {}, nil) + + expect(context.run).to be false + end + end + + context "when the command is not a noop" do + before do + subject.action_block = proc { self.run = true } + end + + context "when the command is not available" do + before do + subject.condition_block = proc { false } + end + + it "doesn't execute the command" do + subject.execute(context, {}, nil) + + expect(context.run).to be false + end + end + + context "when the command is available" do + context "when the commnd has no arguments" do + before do + subject.action_block = proc { self.run = true } + end + + context "when the command is provided an argument" do + it "executes the command" do + subject.execute(context, {}, true) + + expect(context.run).to be true + end + end + + context "when the command is not provided an argument" do + it "executes the command" do + subject.execute(context, {}, nil) + + expect(context.run).to be true + end + end + end + + context "when the command has 1 required argument" do + before do + subject.action_block = ->(arg) { self.run = arg } + end + + context "when the command is provided an argument" do + it "executes the command" do + subject.execute(context, {}, true) + + expect(context.run).to be true + end + end + + context "when the command is not provided an argument" do + it "doesn't execute the command" do + subject.execute(context, {}, nil) + + expect(context.run).to be false + end + end + end + + context "when the command has 1 optional argument" do + before do + subject.action_block = proc { |arg = nil| self.run = arg || true } + end + + context "when the command is provided an argument" do + it "executes the command" do + subject.execute(context, {}, true) + + expect(context.run).to be true + end + end + + context "when the command is not provided an argument" do + it "executes the command" do + subject.execute(context, {}, nil) + + expect(context.run).to be true + end + end + end + end + end + end +end diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb new file mode 100644 index 00000000000..26217a0e3b2 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Dsl do + before :all do + DummyClass = Struct.new(:project) do + include Gitlab::SlashCommands::Dsl + + desc 'A command with no args' + command :no_args, :none do + "Hello World!" + end + + params 'The first argument' + command :one_arg, :once, :first do |arg1| + arg1 + end + + desc do + "A dynamic description for #{noteable.upcase}" + end + params 'The first argument', 'The second argument' + command :two_args do |arg1, arg2| + [arg1, arg2] + end + + command :cc + + condition do + project == 'foo' + end + command :cond_action do |arg| + arg + end + end + end + + describe '.command_definitions' do + it 'returns an array with commands definitions' do + no_args_def, one_arg_def, two_args_def, cc_def, cond_action_def = DummyClass.command_definitions + + expect(no_args_def.name).to eq(:no_args) + expect(no_args_def.aliases).to eq([:none]) + expect(no_args_def.description).to eq('A command with no args') + expect(no_args_def.params).to eq([]) + expect(no_args_def.condition_block).to be_nil + expect(no_args_def.action_block).to be_a_kind_of(Proc) + + expect(one_arg_def.name).to eq(:one_arg) + expect(one_arg_def.aliases).to eq([:once, :first]) + expect(one_arg_def.description).to eq('') + expect(one_arg_def.params).to eq(['The first argument']) + expect(one_arg_def.condition_block).to be_nil + expect(one_arg_def.action_block).to be_a_kind_of(Proc) + + expect(two_args_def.name).to eq(:two_args) + expect(two_args_def.aliases).to eq([]) + expect(two_args_def.to_h(noteable: "issue")[:description]).to eq('A dynamic description for ISSUE') + expect(two_args_def.params).to eq(['The first argument', 'The second argument']) + expect(two_args_def.condition_block).to be_nil + expect(two_args_def.action_block).to be_a_kind_of(Proc) + + expect(cc_def.name).to eq(:cc) + expect(cc_def.aliases).to eq([]) + expect(cc_def.description).to eq('') + expect(cc_def.params).to eq([]) + expect(cc_def.condition_block).to be_nil + expect(cc_def.action_block).to be_nil + + expect(cond_action_def.name).to eq(:cond_action) + expect(cond_action_def.aliases).to eq([]) + expect(cond_action_def.description).to eq('') + expect(cond_action_def.params).to eq([]) + expect(cond_action_def.condition_block).to be_a_kind_of(Proc) + expect(cond_action_def.action_block).to be_a_kind_of(Proc) + end + end +end diff --git a/spec/lib/gitlab/slash_commands/extractor_spec.rb b/spec/lib/gitlab/slash_commands/extractor_spec.rb new file mode 100644 index 00000000000..1e4954c4af8 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/extractor_spec.rb @@ -0,0 +1,215 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Extractor do + let(:definitions) do + Class.new do + include Gitlab::SlashCommands::Dsl + + command(:reopen, :open) { } + command(:assign) { } + command(:labels) { } + command(:power) { } + end.command_definitions + end + + let(:extractor) { described_class.new(definitions) } + + shared_examples 'command with no argument' do + it 'extracts command' do + msg, commands = extractor.extract_commands(original_msg) + + expect(commands).to eq [['reopen']] + expect(msg).to eq final_msg + end + end + + shared_examples 'command with a single argument' do + it 'extracts command' do + msg, commands = extractor.extract_commands(original_msg) + + expect(commands).to eq [['assign', '@joe']] + expect(msg).to eq final_msg + end + end + + shared_examples 'command with multiple arguments' do + it 'extracts command' do + msg, commands = extractor.extract_commands(original_msg) + + expect(commands).to eq [['labels', '~foo ~"bar baz" label']] + expect(msg).to eq final_msg + end + end + + describe '#extract_commands' do + describe 'command with no argument' do + context 'at the start of content' do + it_behaves_like 'command with no argument' do + let(:original_msg) { "/reopen\nworld" } + let(:final_msg) { "world" } + end + end + + context 'in the middle of content' do + it_behaves_like 'command with no argument' do + let(:original_msg) { "hello\n/reopen\nworld" } + let(:final_msg) { "hello\nworld" } + end + end + + context 'in the middle of a line' do + it 'does not extract command' do + msg = "hello\nworld /reopen" + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq "hello\nworld /reopen" + end + end + + context 'at the end of content' do + it_behaves_like 'command with no argument' do + let(:original_msg) { "hello\n/reopen" } + let(:final_msg) { "hello" } + end + end + end + + describe 'command with a single argument' do + context 'at the start of content' do + it_behaves_like 'command with a single argument' do + let(:original_msg) { "/assign @joe\nworld" } + let(:final_msg) { "world" } + end + end + + context 'in the middle of content' do + it_behaves_like 'command with a single argument' do + let(:original_msg) { "hello\n/assign @joe\nworld" } + let(:final_msg) { "hello\nworld" } + end + end + + context 'in the middle of a line' do + it 'does not extract command' do + msg = "hello\nworld /assign @joe" + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq "hello\nworld /assign @joe" + end + end + + context 'at the end of content' do + it_behaves_like 'command with a single argument' do + let(:original_msg) { "hello\n/assign @joe" } + let(:final_msg) { "hello" } + end + end + + context 'when argument is not separated with a space' do + it 'does not extract command' do + msg = "hello\n/assign@joe\nworld" + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq "hello\n/assign@joe\nworld" + end + end + end + + describe 'command with multiple arguments' do + context 'at the start of content' do + it_behaves_like 'command with multiple arguments' do + let(:original_msg) { %(/labels ~foo ~"bar baz" label\nworld) } + let(:final_msg) { "world" } + end + end + + context 'in the middle of content' do + it_behaves_like 'command with multiple arguments' do + let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label\nworld) } + let(:final_msg) { "hello\nworld" } + end + end + + context 'in the middle of a line' do + it 'does not extract command' do + msg = %(hello\nworld /labels ~foo ~"bar baz" label) + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq %(hello\nworld /labels ~foo ~"bar baz" label) + end + end + + context 'at the end of content' do + it_behaves_like 'command with multiple arguments' do + let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label) } + let(:final_msg) { "hello" } + end + end + + context 'when argument is not separated with a space' do + it 'does not extract command' do + msg = %(hello\n/labels~foo ~"bar baz" label\nworld) + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq %(hello\n/labels~foo ~"bar baz" label\nworld) + end + end + end + + it 'extracts command with multiple arguments and various prefixes' do + msg = %(hello\n/power @user.name %9.10 ~"bar baz.2"\nworld) + msg, commands = extractor.extract_commands(msg) + + expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']] + expect(msg).to eq "hello\nworld" + end + + it 'extracts multiple commands' do + msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/reopen) + msg, commands = extractor.extract_commands(msg) + + expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2" label'], ['reopen']] + expect(msg).to eq "hello\nworld" + end + + it 'does not alter original content if no command is found' do + msg = 'Fixes #123' + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq 'Fixes #123' + end + + it 'does not extract commands inside a blockcode' do + msg = "Hello\r\n```\r\nThis is some text\r\n/close\r\n/assign @user\r\n```\r\n\r\nWorld" + expected = msg.delete("\r") + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq expected + end + + it 'does not extract commands inside a blockquote' do + msg = "Hello\r\n>>>\r\nThis is some text\r\n/close\r\n/assign @user\r\n>>>\r\n\r\nWorld" + expected = msg.delete("\r") + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq expected + end + + it 'does not extract commands inside a HTML tag' do + msg = "Hello\r\n
    \r\nThis is some text\r\n/close\r\n/assign @user\r\n
    \r\n\r\nWorld" + expected = msg.delete("\r") + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq expected + end + end +end diff --git a/spec/lib/gitlab/template/gitignore_spec.rb b/spec/lib/gitlab/template/gitignore_template_spec.rb similarity index 88% rename from spec/lib/gitlab/template/gitignore_spec.rb rename to spec/lib/gitlab/template/gitignore_template_spec.rb index bc0ec9325cc..9750a012e22 100644 --- a/spec/lib/gitlab/template/gitignore_spec.rb +++ b/spec/lib/gitlab/template/gitignore_template_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Template::Gitignore do +describe Gitlab::Template::GitignoreTemplate do subject { described_class } describe '.all' do @@ -24,7 +24,7 @@ describe Gitlab::Template::Gitignore do it 'returns the Gitignore object of a valid file' do ruby = subject.find('Ruby') - expect(ruby).to be_a Gitlab::Template::Gitignore + expect(ruby).to be_a Gitlab::Template::GitignoreTemplate expect(ruby.name).to eq('Ruby') end end diff --git a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb new file mode 100644 index 00000000000..e3b8321eda3 --- /dev/null +++ b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe Gitlab::Template::GitlabCiYmlTemplate do + subject { described_class } + + describe '.all' do + it 'strips the gitlab-ci suffix' do + expect(subject.all.first.name).not_to end_with('.gitlab-ci.yml') + end + + it 'combines the globals and rest' do + all = subject.all.map(&:name) + + expect(all).to include('Elixir') + expect(all).to include('Docker') + expect(all).to include('Ruby') + end + end + + describe '.find' do + it 'returns nil if the file does not exist' do + expect(subject.find('mepmep-yadida')).to be nil + end + + it 'returns the GitlabCiYml object of a valid file' do + ruby = subject.find('Ruby') + + expect(ruby).to be_a Gitlab::Template::GitlabCiYmlTemplate + expect(ruby.name).to eq('Ruby') + end + end + + describe '#content' do + it 'loads the full file' do + gitignore = subject.new(Rails.root.join('vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml')) + + expect(gitignore.name).to eq 'Ruby' + expect(gitignore.content).to start_with('#') + end + end +end diff --git a/spec/lib/gitlab/template/issue_template_spec.rb b/spec/lib/gitlab/template/issue_template_spec.rb new file mode 100644 index 00000000000..f770857e958 --- /dev/null +++ b/spec/lib/gitlab/template/issue_template_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe Gitlab::Template::IssueTemplate do + subject { described_class } + + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:file_path_1) { '.gitlab/issue_templates/bug.md' } + let(:file_path_2) { '.gitlab/issue_templates/template_test.md' } + let(:file_path_3) { '.gitlab/issue_templates/feature_proposal.md' } + + before do + project.team.add_user(user, Gitlab::Access::MASTER) + project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false) + project.repository.commit_file(user, file_path_2, "template_test", "test 1", "master", false) + project.repository.commit_file(user, file_path_3, "feature_proposal", "test 2", "master", false) + end + + describe '.all' do + it 'strips the md suffix' do + expect(subject.all(project).first.name).not_to end_with('.issue_template') + end + + it 'combines the globals and rest' do + all = subject.all(project).map(&:name) + + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + end + + describe '.find' do + it 'returns nil if the file does not exist' do + expect { subject.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + it 'returns the issue object of a valid file' do + ruby = subject.find('bug', project) + + expect(ruby).to be_a Gitlab::Template::IssueTemplate + expect(ruby.name).to eq('bug') + end + end + + describe '.by_category' do + it 'return array of templates' do + all = subject.by_category('', project).map(&:name) + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + + context 'when repo is bare or empty' do + let(:empty_project) { create(:empty_project) } + before { empty_project.team.add_user(user, Gitlab::Access::MASTER) } + + it "returns empty array" do + templates = subject.by_category('', empty_project) + expect(templates).to be_empty + end + end + end + + describe '#content' do + it 'loads the full file' do + issue_template = subject.new('.gitlab/issue_templates/bug.md', project) + + expect(issue_template.name).to eq 'bug' + expect(issue_template.content).to eq('something valid') + end + + it 'raises error when file is not found' do + issue_template = subject.new('.gitlab/issue_templates/bugnot.md', project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + context "when repo is empty" do + let(:empty_project) { create(:empty_project) } + + before { empty_project.team.add_user(user, Gitlab::Access::MASTER) } + + it "raises file not found" do + issue_template = subject.new('.gitlab/issue_templates/not_existent.md', empty_project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + end + end +end diff --git a/spec/lib/gitlab/template/merge_request_template_spec.rb b/spec/lib/gitlab/template/merge_request_template_spec.rb new file mode 100644 index 00000000000..bb0f68043fa --- /dev/null +++ b/spec/lib/gitlab/template/merge_request_template_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe Gitlab::Template::MergeRequestTemplate do + subject { described_class } + + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:file_path_1) { '.gitlab/merge_request_templates/bug.md' } + let(:file_path_2) { '.gitlab/merge_request_templates/template_test.md' } + let(:file_path_3) { '.gitlab/merge_request_templates/feature_proposal.md' } + + before do + project.team.add_user(user, Gitlab::Access::MASTER) + project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false) + project.repository.commit_file(user, file_path_2, "template_test", "test 1", "master", false) + project.repository.commit_file(user, file_path_3, "feature_proposal", "test 2", "master", false) + end + + describe '.all' do + it 'strips the md suffix' do + expect(subject.all(project).first.name).not_to end_with('.issue_template') + end + + it 'combines the globals and rest' do + all = subject.all(project).map(&:name) + + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + end + + describe '.find' do + it 'returns nil if the file does not exist' do + expect { subject.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + it 'returns the merge request object of a valid file' do + ruby = subject.find('bug', project) + + expect(ruby).to be_a Gitlab::Template::MergeRequestTemplate + expect(ruby.name).to eq('bug') + end + end + + describe '.by_category' do + it 'return array of templates' do + all = subject.by_category('', project).map(&:name) + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + + context 'when repo is bare or empty' do + let(:empty_project) { create(:empty_project) } + before { empty_project.team.add_user(user, Gitlab::Access::MASTER) } + + it "returns empty array" do + templates = subject.by_category('', empty_project) + expect(templates).to be_empty + end + end + end + + describe '#content' do + it 'loads the full file' do + issue_template = subject.new('.gitlab/merge_request_templates/bug.md', project) + + expect(issue_template.name).to eq 'bug' + expect(issue_template.content).to eq('something valid') + end + + it 'raises error when file is not found' do + issue_template = subject.new('.gitlab/merge_request_templates/bugnot.md', project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + context "when repo is empty" do + let(:empty_project) { create(:empty_project) } + + before { empty_project.team.add_user(user, Gitlab::Access::MASTER) } + + it "raises file not found" do + issue_template = subject.new('.gitlab/merge_request_templates/not_existent.md', empty_project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + end + end +end diff --git a/spec/mailers/emails/merge_requests_spec.rb b/spec/mailers/emails/merge_requests_spec.rb new file mode 100644 index 00000000000..4d3811af254 --- /dev/null +++ b/spec/mailers/emails/merge_requests_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' +require 'email_spec' +require 'mailers/shared/notify' + +describe Notify, "merge request notifications" do + include EmailSpec::Matchers + + describe "#resolved_all_discussions_email" do + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:current_user) { create(:user) } + + subject { Notify.resolved_all_discussions_email(user.id, merge_request.id, current_user.id) } + + it "includes the name of the resolver" do + expect(subject).to have_body_text current_user.name + end + end +end diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 1e5d6a34f83..cee20234e1f 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -94,4 +94,26 @@ describe Blob do expect(blob.to_partial_path).to eq 'download' end end + + describe '#size_within_svg_limits?' do + let(:blob) { described_class.decorate(double(:blob)) } + + it 'returns true when the blob size is smaller than the SVG limit' do + expect(blob).to receive(:size).and_return(42) + + expect(blob.size_within_svg_limits?).to eq(true) + end + + it 'returns true when the blob size is equal to the SVG limit' do + expect(blob).to receive(:size).and_return(Blob::MAXIMUM_SVG_SIZE) + + expect(blob.size_within_svg_limits?).to eq(true) + end + + it 'returns false when the blob size is larger than the SVG limit' do + expect(blob).to receive(:size).and_return(1.terabyte) + + expect(blob.size_within_svg_limits?).to eq(false) + end + end end diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb new file mode 100644 index 00000000000..12d29540137 --- /dev/null +++ b/spec/models/board_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +describe Board do + describe 'relationships' do + it { is_expected.to belong_to(:project) } + it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + end +end diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index 5980f6ddc32..ee2c3d04984 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -42,7 +42,7 @@ describe Ci::Build, models: true do describe '#ignored?' do subject { build.ignored? } - context 'if build is not allowed to fail' do + context 'when build is not allowed to fail' do before do build.allow_failure = false end @@ -64,7 +64,7 @@ describe Ci::Build, models: true do end end - context 'if build is allowed to fail' do + context 'when build is allowed to fail' do before do build.allow_failure = true end @@ -92,7 +92,7 @@ describe Ci::Build, models: true do it { is_expected.to be_empty } - context 'if build.trace contains text' do + context 'when build.trace contains text' do let(:text) { 'example output' } before do build.trace = text @@ -102,7 +102,7 @@ describe Ci::Build, models: true do it { expect(subject.length).to be >= text.length } end - context 'if build.trace hides token' do + context 'when build.trace hides token' do let(:token) { 'my_secret_token' } before do @@ -283,13 +283,13 @@ describe Ci::Build, models: true do stub_ci_pipeline_yaml_file(config) end - context 'if config is not found' do + context 'when config is not found' do let(:config) { nil } it { is_expected.to eq(predefined_variables) } end - context 'if config does not have a questioned job' do + context 'when config does not have a questioned job' do let(:config) do YAML.dump({ test_other: { @@ -301,7 +301,7 @@ describe Ci::Build, models: true do it { is_expected.to eq(predefined_variables) } end - context 'if config has variables' do + context 'when config has variables' do let(:config) do YAML.dump({ test: { @@ -393,7 +393,7 @@ describe Ci::Build, models: true do it { is_expected.to be_falsey } end - context 'if there are runner' do + context 'when there are runners' do let(:runner) { create(:ci_runner) } before do @@ -423,29 +423,27 @@ describe Ci::Build, models: true do describe '#stuck?' do subject { build.stuck? } - %w(pending).each do |state| - context "if commit_status.status is #{state}" do + context "when commit_status.status is pending" do + before do + build.status = 'pending' + end + + it { is_expected.to be_truthy } + + context "and there are specific runner" do + let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) } + before do - build.status = state + build.project.runners << runner + runner.save end - it { is_expected.to be_truthy } - - context "and there are specific runner" do - let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) } - - before do - build.project.runners << runner - runner.save - end - - it { is_expected.to be_falsey } - end + it { is_expected.to be_falsey } end end - %w(success failed canceled running).each do |state| - context "if commit_status.status is #{state}" do + %w[success failed canceled running].each do |state| + context "when commit_status.status is #{state}" do before do build.status = state end @@ -767,7 +765,7 @@ describe Ci::Build, models: true do describe '#when' do subject { build.when } - context 'if is undefined' do + context 'when `when` is undefined' do before do build.when = nil end @@ -777,13 +775,13 @@ describe Ci::Build, models: true do stub_ci_pipeline_yaml_file(config) end - context 'if config is not found' do + context 'when config is not found' do let(:config) { nil } it { is_expected.to eq('on_success') } end - context 'if config does not have a questioned job' do + context 'when config does not have a questioned job' do let(:config) do YAML.dump({ test_other: { @@ -795,7 +793,7 @@ describe Ci::Build, models: true do it { is_expected.to eq('on_success') } end - context 'if config has when' do + context 'when config has `when`' do let(:config) do YAML.dump({ test: { @@ -881,7 +879,7 @@ describe Ci::Build, models: true do subject { build.play } - it 'enques a build' do + it 'enqueues a build' do is_expected.to be_pending is_expected.to eq(build) end @@ -901,7 +899,7 @@ describe Ci::Build, models: true do describe '#when' do subject { build.when } - context 'if is undefined' do + context 'when `when` is undefined' do before do build.when = nil end @@ -911,13 +909,13 @@ describe Ci::Build, models: true do stub_ci_pipeline_yaml_file(config) end - context 'if config is not found' do + context 'when config is not found' do let(:config) { nil } it { is_expected.to eq('on_success') } end - context 'if config does not have a questioned job' do + context 'when config does not have a questioned job' do let(:config) do YAML.dump({ test_other: { @@ -929,7 +927,7 @@ describe Ci::Build, models: true do it { is_expected.to eq('on_success') } end - context 'if config has when' do + context 'when config has when' do let(:config) do YAML.dump({ test: { diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 950833cb219..8137e9f8f71 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Ci::Pipeline, models: true do let(:project) { FactoryGirl.create :empty_project } - let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, project: project } + let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, status: 'created', project: project } it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } @@ -18,6 +18,8 @@ describe Ci::Pipeline, models: true do it { is_expected.to respond_to :git_author_email } it { is_expected.to respond_to :short_sha } + it { is_expected.to delegate_method(:stages).to(:statuses) } + describe '#valid_commit_sha' do context 'commit.sha can not start with 00000000' do before do @@ -310,4 +312,87 @@ describe Ci::Pipeline, models: true do it { is_expected.to eq('running') } end end + + describe '#execute_hooks' do + let!(:build_a) { create_build('a') } + let!(:build_b) { create_build('b') } + + let!(:hook) do + create(:project_hook, project: project, pipeline_events: enabled) + end + + before do + ProjectWebHookWorker.drain + end + + context 'with pipeline hooks enabled' do + let(:enabled) { true } + + before do + WebMock.stub_request(:post, hook.url) + end + + context 'with multiple builds' do + context 'when build is queued' do + before do + build_a.enqueue + build_b.enqueue + end + + it 'receive a pending event once' do + expect(WebMock).to have_requested_pipeline_hook('pending').once + end + end + + context 'when build is run' do + before do + build_a.enqueue + build_a.run + build_b.enqueue + build_b.run + end + + it 'receive a running event once' do + expect(WebMock).to have_requested_pipeline_hook('running').once + end + end + + context 'when all builds succeed' do + before do + build_a.success + build_b.success + end + + it 'receive a success event once' do + expect(WebMock).to have_requested_pipeline_hook('success').once + end + end + + def have_requested_pipeline_hook(status) + have_requested(:post, hook.url).with do |req| + json_body = JSON.parse(req.body) + json_body['object_attributes']['status'] == status && + json_body['builds'].length == 2 + end + end + end + end + + context 'with pipeline hooks disabled' do + let(:enabled) { false } + + before do + build_a.enqueue + build_b.enqueue + end + + it 'did not execute pipeline_hook after touched' do + expect(WebMock).not_to have_requested(:post, hook.url) + end + end + + def create_build(name) + create(:ci_build, :created, pipeline: pipeline, name: name) + end + end end diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb new file mode 100644 index 00000000000..32935bc0b09 --- /dev/null +++ b/spec/models/concerns/spammable_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe Issue, 'Spammable' do + let(:issue) { create(:issue, description: 'Test Desc.') } + + describe 'Associations' do + it { is_expected.to have_one(:user_agent_detail).dependent(:destroy) } + end + + describe 'ClassMethods' do + it 'should return correct attr_spammable' do + expect(issue.spammable_text).to eq("#{issue.title}\n#{issue.description}") + end + end + + describe 'InstanceMethods' do + it 'should be invalid if spam' do + issue = build(:issue, spam: true) + expect(issue.valid?).to be_falsey + end + + describe '#check_for_spam?' do + it 'returns true for public project' do + issue.project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) + expect(issue.check_for_spam?).to eq(true) + end + + it 'returns false for other visibility levels' do + expect(issue.check_for_spam?).to eq(false) + end + end + end +end diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 7df3df4bb9e..bfff639ad78 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -15,4 +15,28 @@ describe Deployment, models: true do it { is_expected.to validate_presence_of(:ref) } it { is_expected.to validate_presence_of(:sha) } + + describe '#includes_commit?' do + let(:project) { create(:project) } + let(:environment) { create(:environment, project: project) } + let(:deployment) do + create(:deployment, environment: environment, sha: project.commit.id) + end + + context 'when there is no project commit' do + it 'returns false' do + commit = project.commit('feature') + + expect(deployment.includes_commit?(commit)).to be false + end + end + + context 'when they share the same tree branch' do + it 'returns true' do + commit = project.commit + + expect(deployment.includes_commit?(commit)).to be true + end + end + end end diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index 1fa96eb1f15..6a640474cfe 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -103,7 +103,7 @@ describe DiffNote, models: true do describe "#active?" do context "when noteable is a commit" do - subject { create(:diff_note_on_commit, project: project, position: position) } + subject { build(:diff_note_on_commit, project: project, position: position) } it "returns true" do expect(subject.active?).to be true @@ -188,4 +188,300 @@ describe DiffNote, models: true do end end end + + describe "#resolvable?" do + context "when noteable is a commit" do + subject { create(:diff_note_on_commit, project: project, position: position) } + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + + context "when noteable is a merge request" do + context "when a system note" do + before do + subject.system = true + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + + context "when a regular note" do + it "returns true" do + expect(subject.resolvable?).to be true + end + end + end + end + + describe "#to_be_resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when resolved" do + before do + allow(subject).to receive(:resolved?).and_return(true) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when not resolved" do + before do + allow(subject).to receive(:resolved?).and_return(false) + end + + it "returns true" do + expect(subject.to_be_resolved?).to be true + end + end + end + end + + describe "#resolve!" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't set resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).to be_nil + end + + it "doesn't set resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to be_nil + end + + it "doesn't mark as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when already resolved" do + let(:user) { create(:user) } + + before do + subject.resolve!(user) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't change resolved_at" do + expect(subject.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } + end + + it "doesn't change resolved_by" do + expect(subject.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } + end + + it "doesn't change resolved status" do + expect(subject.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } + end + end + + context "when not yet resolved" do + it "returns true" do + expect(subject.resolve!(current_user)).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be true + end + end + end + end + + describe "#unresolve!" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when resolved" do + let(:user) { create(:user) } + + before do + subject.resolve!(user) + end + + it "returns true" do + expect(subject.unresolve!).to be true + end + + it "unsets resolved_at" do + subject.unresolve! + + expect(subject.resolved_at).to be_nil + end + + it "unsets resolved_by" do + subject.unresolve! + + expect(subject.resolved_by).to be_nil + end + + it "unmarks as resolved" do + subject.unresolve! + + expect(subject.resolved?).to be false + end + end + + context "when not resolved" do + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + end + end + + describe "#discussion" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.discussion).to be_nil + end + end + + context "when resolvable" do + let!(:diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: subject.position) } + let!(:diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) } + + let(:active_position2) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: 16, + new_line: 22, + diff_refs: merge_request.diff_refs + ) + end + + it "returns the discussion this note is in" do + discussion = subject.discussion + + expect(discussion.id).to eq(subject.discussion_id) + expect(discussion.notes).to eq([subject, diff_note2]) + end + end + end + + describe "#discussion_id" do + let(:note) { create(:diff_note_on_merge_request) } + + context "when it is newly created" do + it "has a discussion id" do + expect(note.discussion_id).not_to be_nil + expect(note.discussion_id).to match(/\A\h{40}\z/) + end + end + + context "when it didn't store a discussion id before" do + before do + note.update_column(:discussion_id, nil) + end + + it "has a discussion id" do + # The discussion_id is set in `after_initialize`, so `reload` won't work + reloaded_note = Note.find(note.id) + + expect(reloaded_note.discussion_id).not_to be_nil + expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) + end + end + end + + describe "#original_discussion_id" do + let(:note) { create(:diff_note_on_merge_request) } + + context "when it is newly created" do + it "has a discussion id" do + expect(note.original_discussion_id).not_to be_nil + expect(note.original_discussion_id).to match(/\A\h{40}\z/) + end + end + + context "when it didn't store a discussion id before" do + before do + note.update_column(:original_discussion_id, nil) + end + + it "has a discussion id" do + # The original_discussion_id is set in `after_initialize`, so `reload` won't work + reloaded_note = Note.find(note.id) + + expect(reloaded_note.original_discussion_id).not_to be_nil + expect(reloaded_note.original_discussion_id).to match(/\A\h{40}\z/) + end + end + end end diff --git a/spec/models/discussion_spec.rb b/spec/models/discussion_spec.rb new file mode 100644 index 00000000000..179f2e73662 --- /dev/null +++ b/spec/models/discussion_spec.rb @@ -0,0 +1,615 @@ +require 'spec_helper' + +describe Discussion, model: true do + subject { described_class.new([first_note, second_note, third_note]) } + + let(:first_note) { create(:diff_note_on_merge_request) } + let(:second_note) { create(:diff_note_on_merge_request) } + let(:third_note) { create(:diff_note_on_merge_request) } + + describe "#resolvable?" do + context "when a diff discussion" do + before do + allow(subject).to receive(:diff_discussion?).and_return(true) + end + + context "when all notes are unresolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(false) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + + context "when some notes are unresolvable and some notes are resolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.resolvable?).to be true + end + end + + context "when all notes are resolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(true) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.resolvable?).to be true + end + end + end + + context "when not a diff discussion" do + before do + allow(subject).to receive(:diff_discussion?).and_return(false) + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + end + + describe "#resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(true) + end + + it "returns true" do + expect(subject.resolved?).to be true + end + end + + context "when some resolvable notes are not resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(false) + end + + it "returns false" do + expect(subject.resolved?).to be false + end + end + end + end + + describe "#to_be_resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(true) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when some resolvable notes are not resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(false) + end + + it "returns true" do + expect(subject.to_be_resolved?).to be true + end + end + end + end + + describe "#can_resolve?" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when not signed in" do + let(:current_user) { nil } + + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + + context "when signed in" do + context "when the signed in user is the noteable author" do + before do + subject.noteable.author = current_user + end + + it "returns true" do + expect(subject.can_resolve?(current_user)).to be true + end + end + + context "when the signed in user can push to the project" do + before do + subject.project.team << [current_user, :master] + end + + it "returns true" do + expect(subject.can_resolve?(current_user)).to be true + end + end + + context "when the signed in user is a random user" do + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + end + end + end + + describe "#resolve!" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't set resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).to be_nil + end + + it "doesn't set resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to be_nil + end + + it "doesn't mark as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + let(:user) { create(:user) } + + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + first_note.resolve!(user) + third_note.resolve!(user) + end + + it "calls resolve! on every resolvable note" do + expect(first_note).to receive(:resolve!).with(current_user) + expect(second_note).not_to receive(:resolve!) + expect(third_note).to receive(:resolve!).with(current_user) + + subject.resolve!(current_user) + end + + it "doesn't change resolved_at on the resolved notes" do + expect(first_note.resolved_at).not_to be_nil + expect(third_note.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_at } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_at } + end + + it "doesn't change resolved_by on the resolved notes" do + expect(first_note.resolved_by).to eq(user) + expect(third_note.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_by } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_by } + end + + it "doesn't change the resolved state on the resolved notes" do + expect(first_note.resolved?).to be true + expect(third_note.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved? } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved? } + end + + it "doesn't change resolved_at" do + expect(subject.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } + end + + it "doesn't change resolved_by" do + expect(subject.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } + end + + it "doesn't change resolved state" do + expect(subject.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } + end + end + + context "when some resolvable notes are resolved" do + before do + first_note.resolve!(user) + end + + it "calls resolve! on every resolvable note" do + expect(first_note).to receive(:resolve!).with(current_user) + expect(second_note).not_to receive(:resolve!) + expect(third_note).to receive(:resolve!).with(current_user) + + subject.resolve!(current_user) + end + + it "doesn't change resolved_at on the resolved note" do + expect(first_note.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_at } + end + + it "doesn't change resolved_by on the resolved note" do + expect(first_note.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_by } + end + + it "doesn't change the resolved state on the resolved note" do + expect(first_note.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved? } + end + + it "sets resolved_at on the unresolved note" do + subject.resolve!(current_user) + + expect(third_note.resolved_at).not_to be_nil + end + + it "sets resolved_by on the unresolved note" do + subject.resolve!(current_user) + + expect(third_note.resolved_by).to eq(current_user) + end + + it "marks the unresolved note as resolved" do + subject.resolve!(current_user) + + expect(third_note.resolved?).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be true + end + end + + context "when no resolvable notes are resolved" do + it "calls resolve! on every resolvable note" do + expect(first_note).to receive(:resolve!).with(current_user) + expect(second_note).not_to receive(:resolve!) + expect(third_note).to receive(:resolve!).with(current_user) + + subject.resolve!(current_user) + end + + it "sets resolved_at on the unresolved notes" do + subject.resolve!(current_user) + + expect(first_note.resolved_at).not_to be_nil + expect(third_note.resolved_at).not_to be_nil + end + + it "sets resolved_by on the unresolved notes" do + subject.resolve!(current_user) + + expect(first_note.resolved_by).to eq(current_user) + expect(third_note.resolved_by).to eq(current_user) + end + + it "marks the unresolved notes as resolved" do + subject.resolve!(current_user) + + expect(first_note.resolved?).to be true + expect(third_note.resolved?).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be true + end + end + end + end + + describe "#unresolve!" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + + context "when resolvable" do + let(:user) { create(:user) } + + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + first_note.resolve!(user) + third_note.resolve!(user) + end + + it "calls unresolve! on every resolvable note" do + expect(first_note).to receive(:unresolve!) + expect(second_note).not_to receive(:unresolve!) + expect(third_note).to receive(:unresolve!) + + subject.unresolve! + end + + it "unsets resolved_at on the resolved notes" do + subject.unresolve! + + expect(first_note.resolved_at).to be_nil + expect(third_note.resolved_at).to be_nil + end + + it "unsets resolved_by on the resolved notes" do + subject.unresolve! + + expect(first_note.resolved_by).to be_nil + expect(third_note.resolved_by).to be_nil + end + + it "unmarks the resolved notes as resolved" do + subject.unresolve! + + expect(first_note.resolved?).to be false + expect(third_note.resolved?).to be false + end + + it "unsets resolved_at" do + subject.unresolve! + + expect(subject.resolved_at).to be_nil + end + + it "unsets resolved_by" do + subject.unresolve! + + expect(subject.resolved_by).to be_nil + end + + it "unmarks as resolved" do + subject.unresolve! + + expect(subject.resolved?).to be false + end + end + + context "when some resolvable notes are resolved" do + before do + first_note.resolve!(user) + end + + it "calls unresolve! on every resolvable note" do + expect(first_note).to receive(:unresolve!) + expect(second_note).not_to receive(:unresolve!) + expect(third_note).to receive(:unresolve!) + + subject.unresolve! + end + + it "unsets resolved_at on the resolved note" do + subject.unresolve! + + expect(first_note.resolved_at).to be_nil + end + + it "unsets resolved_by on the resolved note" do + subject.unresolve! + + expect(first_note.resolved_by).to be_nil + end + + it "unmarks the resolved note as resolved" do + subject.unresolve! + + expect(first_note.resolved?).to be false + end + end + + context "when no resolvable notes are resolved" do + it "calls unresolve! on every resolvable note" do + expect(first_note).to receive(:unresolve!) + expect(second_note).not_to receive(:unresolve!) + expect(third_note).to receive(:unresolve!) + + subject.unresolve! + end + end + end + end + + describe "#collapsed?" do + context "when a diff discussion" do + before do + allow(subject).to receive(:diff_discussion?).and_return(true) + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when resolved" do + before do + allow(subject).to receive(:resolved?).and_return(true) + end + + it "returns true" do + expect(subject.collapsed?).to be true + end + end + + context "when not resolved" do + before do + allow(subject).to receive(:resolved?).and_return(false) + end + + it "returns false" do + expect(subject.collapsed?).to be false + end + end + end + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + context "when active" do + before do + allow(subject).to receive(:active?).and_return(true) + end + + it "returns false" do + expect(subject.collapsed?).to be false + end + end + + context "when outdated" do + before do + allow(subject).to receive(:active?).and_return(false) + end + + it "returns true" do + expect(subject.collapsed?).to be true + end + end + end + end + + context "when not a diff discussion" do + before do + allow(subject).to receive(:diff_discussion?).and_return(false) + end + + it "returns false" do + expect(subject.collapsed?).to be false + end + end + end +end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 8a84ac0a7c7..c881897926e 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -30,4 +30,37 @@ describe Environment, models: true do expect(env.external_url).to be_nil end end + + describe '#includes_commit?' do + context 'without a last deployment' do + it "returns false" do + expect(environment.includes_commit?('HEAD')).to be false + end + end + + context 'with a last deployment' do + let(:project) { create(:project) } + let(:environment) { create(:environment, project: project) } + + let!(:deployment) do + create(:deployment, environment: environment, sha: project.commit('master').id) + end + + context 'in the same branch' do + it 'returns true' do + expect(environment.includes_commit?(RepoHelpers.sample_commit)).to be true + end + end + + context 'not in the same branch' do + before do + deployment.update(sha: project.commit('feature').id) + end + + it 'returns false' do + expect(environment.includes_commit?(RepoHelpers.sample_commit)).to be false + end + end + end + end end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 2a09063f857..5a5d1a5d60c 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -5,8 +5,10 @@ describe Label, models: true do describe 'associations' do it { is_expected.to belong_to(:project) } + it { is_expected.to have_many(:label_links).dependent(:destroy) } it { is_expected.to have_many(:issues).through(:label_links).source(:target) } + it { is_expected.to have_many(:lists).dependent(:destroy) } end describe 'modules' do diff --git a/spec/models/legacy_diff_note_spec.rb b/spec/models/legacy_diff_note_spec.rb index 2cfd26419ca..81517a18b74 100644 --- a/spec/models/legacy_diff_note_spec.rb +++ b/spec/models/legacy_diff_note_spec.rb @@ -73,4 +73,29 @@ describe LegacyDiffNote, models: true do end end end + + describe "#discussion_id" do + let(:note) { create(:note) } + + context "when it is newly created" do + it "has a discussion id" do + expect(note.discussion_id).not_to be_nil + expect(note.discussion_id).to match(/\A\h{40}\z/) + end + end + + context "when it didn't store a discussion id before" do + before do + note.update_column(:discussion_id, nil) + end + + it "has a discussion id" do + # The discussion_id is set in `after_initialize`, so `reload` won't work + reloaded_note = Note.find(note.id) + + expect(reloaded_note.discussion_id).not_to be_nil + expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) + end + end + end end diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb new file mode 100644 index 00000000000..9e1a52011c3 --- /dev/null +++ b/spec/models/list_spec.rb @@ -0,0 +1,117 @@ +require 'rails_helper' + +describe List do + describe 'relationships' do + it { is_expected.to belong_to(:board) } + it { is_expected.to belong_to(:label) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:board) } + it { is_expected.to validate_presence_of(:label) } + it { is_expected.to validate_presence_of(:list_type) } + it { is_expected.to validate_presence_of(:position) } + it { is_expected.to validate_numericality_of(:position).only_integer.is_greater_than_or_equal_to(0) } + + it 'validates uniqueness of label scoped to board_id' do + create(:list) + + expect(subject).to validate_uniqueness_of(:label_id).scoped_to(:board_id) + end + + context 'when list_type is set to backlog' do + subject { described_class.new(list_type: :backlog) } + + it { is_expected.not_to validate_presence_of(:label) } + it { is_expected.not_to validate_presence_of(:position) } + end + + context 'when list_type is set to done' do + subject { described_class.new(list_type: :done) } + + it { is_expected.not_to validate_presence_of(:label) } + it { is_expected.not_to validate_presence_of(:position) } + end + end + + describe '#destroy' do + it 'can be destroyed when when list_type is set to label' do + subject = create(:list) + + expect(subject.destroy).to be_truthy + end + + it 'can not be destroyed when list_type is set to backlog' do + subject = create(:backlog_list) + + expect(subject.destroy).to be_falsey + end + + it 'can not be destroyed when when list_type is set to done' do + subject = create(:done_list) + + expect(subject.destroy).to be_falsey + end + end + + describe '#destroyable?' do + it 'retruns true when list_type is set to label' do + subject.list_type = :label + + expect(subject).to be_destroyable + end + + it 'retruns false when list_type is set to backlog' do + subject.list_type = :backlog + + expect(subject).not_to be_destroyable + end + + it 'retruns false when list_type is set to done' do + subject.list_type = :done + + expect(subject).not_to be_destroyable + end + end + + describe '#movable?' do + it 'retruns true when list_type is set to label' do + subject.list_type = :label + + expect(subject).to be_movable + end + + it 'retruns false when list_type is set to backlog' do + subject.list_type = :backlog + + expect(subject).not_to be_movable + end + + it 'retruns false when list_type is set to done' do + subject.list_type = :done + + expect(subject).not_to be_movable + end + end + + describe '#title' do + it 'returns label name when list_type is set to label' do + subject.list_type = :label + subject.label = Label.new(name: 'Development') + + expect(subject.title).to eq 'Development' + end + + it 'returns Backlog when list_type is set to backlog' do + subject.list_type = :backlog + + expect(subject.title).to eq 'Backlog' + end + + it 'returns Done when list_type is set to done' do + subject.list_type = :done + + expect(subject.title).to eq 'Done' + end + end +end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index c066b65e7ba..573c8c6c9ce 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -456,6 +456,20 @@ describe MergeRequest, models: true do subject { create :merge_request, :simple } end + describe '#commits_sha' do + let(:commit0) { double('commit0', sha: 'sha1') } + let(:commit1) { double('commit1', sha: 'sha2') } + let(:commit2) { double('commit2', sha: 'sha3') } + + before do + allow(subject.merge_request_diff).to receive(:commits).and_return([commit0, commit1, commit2]) + end + + it 'returns sha of commits' do + expect(subject.commits_sha).to contain_exactly('sha1', 'sha2', 'sha3') + end + end + describe '#pipeline' do describe 'when the source project exists' do it 'returns the latest pipeline' do @@ -480,6 +494,19 @@ describe MergeRequest, models: true do end end + describe '#all_pipelines' do + let!(:pipelines) do + subject.merge_request_diff.commits.map do |commit| + create(:ci_empty_pipeline, project: subject.source_project, sha: commit.id, ref: subject.source_branch) + end + end + + it 'returns a pipelines from source projects with proper ordering' do + expect(subject.all_pipelines).not_to be_empty + expect(subject.all_pipelines).to eq(pipelines.reverse) + end + end + describe '#participants' do let(:project) { create(:project, :public) } @@ -674,6 +701,21 @@ describe MergeRequest, models: true do end end + describe "#environments" do + let(:project) { create(:project) } + let!(:environment) { create(:environment, project: project) } + let!(:environment1) { create(:environment, project: project) } + let!(:environment2) { create(:environment, project: project) } + let(:merge_request) { create(:merge_request, source_project: project) } + + it 'selects deployed environments' do + create(:deployment, environment: environment, sha: project.commit('master').id) + create(:deployment, environment: environment1, sha: project.commit('feature').id) + + expect(merge_request.environments).to eq [environment] + end + end + describe "#reload_diff" do let(:note) { create(:diff_note_on_merge_request, project: subject.project, noteable: subject) } @@ -763,4 +805,148 @@ describe MergeRequest, models: true do end end end + + context "discussion status" do + let(:first_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } + let(:second_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } + let(:third_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } + + before do + allow(subject).to receive(:diff_discussions).and_return([first_discussion, second_discussion, third_discussion]) + end + + describe "#discussions_resolvable?" do + context "when all discussions are unresolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(false) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolvable?).to be false + end + end + + context "when some discussions are unresolvable and some discussions are resolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolvable?).to be true + end + end + + context "when all discussions are resolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(true) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolvable?).to be true + end + end + end + + describe "#discussions_resolved?" do + context "when discussions are not resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolved?).to be false + end + end + + context "when discussions are resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(true) + + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable discussions are resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolved?).to be true + end + end + + context "when some resolvable discussions are not resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolved?).to be false + end + end + end + end + end + + describe '#conflicts_can_be_resolved_in_ui?' do + def create_merge_request(source_branch) + create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr| + mr.mark_as_unmergeable + end + end + + it 'returns a falsey value when the MR can be merged without conflicts' do + merge_request = create_merge_request('master') + merge_request.mark_as_mergeable + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the MR does not support new diff notes' do + merge_request = create_merge_request('conflict-resolvable') + merge_request.merge_request_diff.update_attributes(start_commit_sha: nil) + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a large file' do + merge_request = create_merge_request('conflict-too-large') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a binary file' do + merge_request = create_merge_request('conflict-binary-file') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a file with ambiguous conflict markers' do + merge_request = create_merge_request('conflict-contains-conflict-markers') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a file edited in one branch and deleted in another' do + merge_request = create_merge_request('conflict-missing-side') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a truthy value when the conflicts are resolvable in the UI' do + merge_request = create_merge_request('conflict-resolvable') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_truthy + end + end end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 53733d253f7..ef2747046b9 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Note, models: true do + include RepoHelpers + describe 'associations' do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:noteable).touch(true) } @@ -267,4 +269,81 @@ describe Note, models: true do expect(note.participants).to include(note.author) end end + + describe ".grouped_diff_discussions" do + let!(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let!(:active_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } + let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } + let!(:active_diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) } + let!(:outdated_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) } + let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) } + + let(:active_position2) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: 16, + new_line: 22, + diff_refs: merge_request.diff_refs + ) + end + + let(:outdated_position) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 9, + diff_refs: project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e").diff_refs + ) + end + + subject { merge_request.notes.grouped_diff_discussions } + + it "includes active discussions" do + discussions = subject.values + + expect(discussions.count).to eq(2) + expect(discussions.map(&:id)).to eq([active_diff_note1.discussion_id, active_diff_note3.discussion_id]) + expect(discussions.all?(&:active?)).to be true + + expect(discussions.first.notes).to eq([active_diff_note1, active_diff_note2]) + expect(discussions.last.notes).to eq([active_diff_note3]) + end + + it "doesn't include outdated discussions" do + expect(subject.values.map(&:id)).not_to include(outdated_diff_note1.discussion_id) + end + + it "groups the discussions by line code" do + expect(subject[active_diff_note1.line_code].id).to eq(active_diff_note1.discussion_id) + expect(subject[active_diff_note3.line_code].id).to eq(active_diff_note3.discussion_id) + end + end + + describe "#discussion_id" do + let(:note) { create(:note) } + + context "when it is newly created" do + it "has a discussion id" do + expect(note.discussion_id).not_to be_nil + expect(note.discussion_id).to match(/\A\h{40}\z/) + end + end + + context "when it didn't store a discussion id before" do + before do + note.update_column(:discussion_id, nil) + end + + it "has a discussion id" do + # The discussion_id is set in `after_initialize`, so `reload` won't work + reloaded_note = Note.find(note.id) + + expect(reloaded_note.discussion_id).not_to be_nil + expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) + end + end + end end diff --git a/spec/models/project_services/assembla_service_spec.rb b/spec/models/project_services/assembla_service_spec.rb index 00c4e0fb64c..d672d80156c 100644 --- a/spec/models/project_services/assembla_service_spec.rb +++ b/spec/models/project_services/assembla_service_spec.rb @@ -39,7 +39,7 @@ describe AssemblaService, models: true do token: 'verySecret', subdomain: 'project_name' ) - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) @api_url = 'https://atlas.assembla.com/spaces/project_name/github_tool?secret_key=verySecret' WebMock.stub_request(:post, @api_url) end diff --git a/spec/models/project_services/builds_email_service_spec.rb b/spec/models/project_services/builds_email_service_spec.rb index ca2cd8aa551..0194f9e2563 100644 --- a/spec/models/project_services/builds_email_service_spec.rb +++ b/spec/models/project_services/builds_email_service_spec.rb @@ -1,7 +1,9 @@ require 'spec_helper' describe BuildsEmailService do - let(:data) { Gitlab::BuildDataBuilder.build(create(:ci_build)) } + let(:data) do + Gitlab::DataBuilder::Build.build(create(:ci_build)) + end describe 'Validations' do context 'when service is active' do @@ -39,7 +41,7 @@ describe BuildsEmailService do describe '#test' do it 'sends email' do - data = Gitlab::BuildDataBuilder.build(create(:ci_build)) + data = Gitlab::DataBuilder::Build.build(create(:ci_build)) subject.recipients = 'test@gitlab.com' expect(BuildEmailWorker).to receive(:perform_async) @@ -49,7 +51,7 @@ describe BuildsEmailService do context 'notify only failed builds is true' do it 'sends email' do - data = Gitlab::BuildDataBuilder.build(create(:ci_build)) + data = Gitlab::DataBuilder::Build.build(create(:ci_build)) data[:build_status] = "success" subject.recipients = 'test@gitlab.com' diff --git a/spec/models/project_services/campfire_service_spec.rb b/spec/models/project_services/campfire_service_spec.rb index 1adf93258f3..c76ae21421b 100644 --- a/spec/models/project_services/campfire_service_spec.rb +++ b/spec/models/project_services/campfire_service_spec.rb @@ -54,7 +54,7 @@ describe CampfireService, models: true do subdomain: 'project-name', room: 'test-room' ) - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) @rooms_url = 'https://verySecret:X@project-name.campfirenow.com/rooms.json' @headers = { 'Content-Type' => 'application/json; charset=utf-8' } end diff --git a/spec/models/project_services/drone_ci_service_spec.rb b/spec/models/project_services/drone_ci_service_spec.rb index 3a8e67438fc..8ef892259f2 100644 --- a/spec/models/project_services/drone_ci_service_spec.rb +++ b/spec/models/project_services/drone_ci_service_spec.rb @@ -84,7 +84,9 @@ describe DroneCiService, models: true do include_context :drone_ci_service let(:user) { create(:user, username: 'username') } - let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:push_sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end it do service_hook = double diff --git a/spec/models/project_services/flowdock_service_spec.rb b/spec/models/project_services/flowdock_service_spec.rb index 6518098ceea..d2557019756 100644 --- a/spec/models/project_services/flowdock_service_spec.rb +++ b/spec/models/project_services/flowdock_service_spec.rb @@ -52,7 +52,7 @@ describe FlowdockService, models: true do service_hook: true, token: 'verySecret' ) - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) @api_url = 'https://api.flowdock.com/v1/messages' WebMock.stub_request(:post, @api_url) end diff --git a/spec/models/project_services/gemnasium_service_spec.rb b/spec/models/project_services/gemnasium_service_spec.rb index 2c5583bdaa2..3d0b6c9816b 100644 --- a/spec/models/project_services/gemnasium_service_spec.rb +++ b/spec/models/project_services/gemnasium_service_spec.rb @@ -55,7 +55,7 @@ describe GemnasiumService, models: true do token: 'verySecret', api_key: 'GemnasiumUserApiKey' ) - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) end it "calls Gemnasium service" do expect(Gemnasium::GitlabService).to receive(:execute).with(an_instance_of(Hash)).once diff --git a/spec/models/project_services/hipchat_service_spec.rb b/spec/models/project_services/hipchat_service_spec.rb index 1b383219eb9..34eafbe555d 100644 --- a/spec/models/project_services/hipchat_service_spec.rb +++ b/spec/models/project_services/hipchat_service_spec.rb @@ -48,7 +48,9 @@ describe HipchatService, models: true do let(:project_name) { project.name_with_namespace.gsub(/\s/, '') } let(:token) { 'verySecret' } let(:server_url) { 'https://hipchat.example.com'} - let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:push_sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end before(:each) do allow(hipchat).to receive_messages( @@ -108,7 +110,15 @@ describe HipchatService, models: true do end context 'tag_push events' do - let(:push_sample_data) { Gitlab::PushDataBuilder.build(project, user, Gitlab::Git::BLANK_SHA, '1' * 40, 'refs/tags/test', []) } + let(:push_sample_data) do + Gitlab::DataBuilder::Push.build( + project, + user, + Gitlab::Git::BLANK_SHA, + '1' * 40, + 'refs/tags/test', + []) + end it "calls Hipchat API for tag push events" do hipchat.execute(push_sample_data) @@ -185,7 +195,7 @@ describe HipchatService, models: true do end it "calls Hipchat API for commit comment events" do - data = Gitlab::NoteDataBuilder.build(commit_note, user) + data = Gitlab::DataBuilder::Note.build(commit_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -217,7 +227,7 @@ describe HipchatService, models: true do end it "calls Hipchat API for merge request comment events" do - data = Gitlab::NoteDataBuilder.build(merge_request_note, user) + data = Gitlab::DataBuilder::Note.build(merge_request_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -244,7 +254,7 @@ describe HipchatService, models: true do end it "calls Hipchat API for issue comment events" do - data = Gitlab::NoteDataBuilder.build(issue_note, user) + data = Gitlab::DataBuilder::Note.build(issue_note, user) hipchat.execute(data) message = hipchat.send(:create_message, data) @@ -270,7 +280,7 @@ describe HipchatService, models: true do end it "calls Hipchat API for snippet comment events" do - data = Gitlab::NoteDataBuilder.build(snippet_note, user) + data = Gitlab::DataBuilder::Note.build(snippet_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -293,7 +303,7 @@ describe HipchatService, models: true do context 'build events' do let(:pipeline) { create(:ci_empty_pipeline) } let(:build) { create(:ci_build, pipeline: pipeline) } - let(:data) { Gitlab::BuildDataBuilder.build(build) } + let(:data) { Gitlab::DataBuilder::Build.build(build) } context 'for failed' do before { build.drop } diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb index b528baaf15c..ffb17fd3259 100644 --- a/spec/models/project_services/irker_service_spec.rb +++ b/spec/models/project_services/irker_service_spec.rb @@ -46,25 +46,28 @@ describe IrkerService, models: true do let(:irker) { IrkerService.new } let(:user) { create(:user) } let(:project) { create(:project) } - let(:sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end let(:recipients) { '#commits irc://test.net/#test ftp://bad' } let(:colorize_messages) { '1' } before do + @irker_server = TCPServer.new 'localhost', 0 + allow(irker).to receive_messages( active: true, project: project, project_id: project.id, service_hook: true, - server_host: 'localhost', - server_port: 6659, + server_host: @irker_server.addr[2], + server_port: @irker_server.addr[1], default_irc_uri: 'irc://chat.freenode.net/', recipients: recipients, colorize_messages: colorize_messages) irker.valid? - @irker_server = TCPServer.new 'localhost', 6659 end after do diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 342403f6354..9037ca5cc20 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -66,7 +66,7 @@ describe JiraService, models: true do password: 'gitlab_jira_password' ) @jira_service.save # will build API URL, as api_url was not specified above - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) # https://github.com/bblimke/webmock#request-with-basic-authentication @api_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/transitions' @comment_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/comment' diff --git a/spec/models/project_services/pushover_service_spec.rb b/spec/models/project_services/pushover_service_spec.rb index 19c0270a493..5959c81577d 100644 --- a/spec/models/project_services/pushover_service_spec.rb +++ b/spec/models/project_services/pushover_service_spec.rb @@ -48,7 +48,9 @@ describe PushoverService, models: true do let(:pushover) { PushoverService.new } let(:user) { create(:user) } let(:project) { create(:project) } - let(:sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end let(:api_key) { 'verySecret' } let(:user_key) { 'verySecret' } diff --git a/spec/models/project_services/slack_service_spec.rb b/spec/models/project_services/slack_service_spec.rb index 45a5f4ef12a..28af68d13b4 100644 --- a/spec/models/project_services/slack_service_spec.rb +++ b/spec/models/project_services/slack_service_spec.rb @@ -45,7 +45,9 @@ describe SlackService, models: true do let(:slack) { SlackService.new } let(:user) { create(:user) } let(:project) { create(:project) } - let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:push_sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end let(:webhook_url) { 'https://hooks.slack.com/services/SVRWFV0VVAR97N/B02R25XN3/ZBqu7xMupaEEICInN685' } let(:username) { 'slack_username' } let(:channel) { 'slack_channel' } @@ -195,7 +197,7 @@ describe SlackService, models: true do it "uses the right channel" do slack.update_attributes(note_channel: "random") - note_data = Gitlab::NoteDataBuilder.build(issue_note, user) + note_data = Gitlab::DataBuilder::Note.build(issue_note, user) expect(Slack::Notifier).to receive(:new). with(webhook_url, channel: "random"). @@ -235,7 +237,7 @@ describe SlackService, models: true do end it "calls Slack API for commit comment events" do - data = Gitlab::NoteDataBuilder.build(commit_note, user) + data = Gitlab::DataBuilder::Note.build(commit_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -249,7 +251,7 @@ describe SlackService, models: true do end it "calls Slack API for merge request comment events" do - data = Gitlab::NoteDataBuilder.build(merge_request_note, user) + data = Gitlab::DataBuilder::Note.build(merge_request_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -262,7 +264,7 @@ describe SlackService, models: true do end it "calls Slack API for issue comment events" do - data = Gitlab::NoteDataBuilder.build(issue_note, user) + data = Gitlab::DataBuilder::Note.build(issue_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -276,7 +278,7 @@ describe SlackService, models: true do end it "calls Slack API for snippet comment events" do - data = Gitlab::NoteDataBuilder.build(snippet_note, user) + data = Gitlab::DataBuilder::Note.build(snippet_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 9c3b4712cab..d1f3a815290 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -23,6 +23,7 @@ describe Project, models: true do it { is_expected.to have_one(:slack_service).dependent(:destroy) } it { is_expected.to have_one(:pushover_service).dependent(:destroy) } it { is_expected.to have_one(:asana_service).dependent(:destroy) } + it { is_expected.to have_one(:board).dependent(:destroy) } it { is_expected.to have_many(:commit_statuses) } it { is_expected.to have_many(:pipelines) } it { is_expected.to have_many(:builds) } @@ -1089,13 +1090,13 @@ describe Project, models: true do let(:project) { create(:project) } it 'returns true when the branch matches a protected branch via direct match' do - project.protected_branches.create!(name: 'foo') + create(:protected_branch, project: project, name: "foo") expect(project.protected_branch?('foo')).to eq(true) end it 'returns true when the branch matches a protected branch via wildcard match' do - project.protected_branches.create!(name: 'production/*') + create(:protected_branch, project: project, name: "production/*") expect(project.protected_branch?('production/some-branch')).to eq(true) end @@ -1105,7 +1106,7 @@ describe Project, models: true do end it 'returns false when the branch does not match a protected branch via wildcard match' do - project.protected_branches.create!(name: 'production/*') + create(:protected_branch, project: project, name: "production/*") expect(project.protected_branch?('staging/some-branch')).to eq(false) end diff --git a/spec/models/user_agent_detail_spec.rb b/spec/models/user_agent_detail_spec.rb new file mode 100644 index 00000000000..a8c25766e73 --- /dev/null +++ b/spec/models/user_agent_detail_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +describe UserAgentDetail, type: :model do + describe '.submittable?' do + it 'is submittable when not already submitted' do + detail = build(:user_agent_detail) + + expect(detail.submittable?).to be_truthy + end + + it 'is not submittable when already submitted' do + detail = build(:user_agent_detail, submitted: true) + + expect(detail.submittable?).to be_falsey + end + end + + describe '.valid?' do + it 'is valid with a subject' do + detail = build(:user_agent_detail) + + expect(detail).to be_valid + end + + it 'is invalid without a subject' do + detail = build(:user_agent_detail, subject: nil) + + expect(detail).not_to be_valid + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index f67acbbef37..51e4780e2b1 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -895,7 +895,9 @@ describe User, models: true do subject { create(:user) } let!(:project1) { create(:project) } let!(:project2) { create(:project, forked_from_project: project1) } - let!(:push_data) { Gitlab::PushDataBuilder.build_sample(project2, subject) } + let!(:push_data) do + Gitlab::DataBuilder::Push.build_sample(project2, subject) + end let!(:push_event) { create(:event, action: Event::PUSHED, project: project2, target: project1, author: subject, data: push_data) } before do @@ -955,6 +957,53 @@ describe User, models: true do end end + describe '#projects_where_can_admin_issues' do + let(:user) { create(:user) } + + it 'includes projects for which the user access level is above or equal to reporter' do + create(:project) + reporter_project = create(:project) + developer_project = create(:project) + master_project = create(:project) + + reporter_project.team << [user, :reporter] + developer_project.team << [user, :developer] + master_project.team << [user, :master] + + expect(user.projects_where_can_admin_issues.to_a).to eq([master_project, developer_project, reporter_project]) + expect(user.can?(:admin_issue, master_project)).to eq(true) + expect(user.can?(:admin_issue, developer_project)).to eq(true) + expect(user.can?(:admin_issue, reporter_project)).to eq(true) + end + + it 'does not include for which the user access level is below reporter' do + project = create(:project) + guest_project = create(:project) + + guest_project.team << [user, :guest] + + expect(user.projects_where_can_admin_issues.to_a).to be_empty + expect(user.can?(:admin_issue, guest_project)).to eq(false) + expect(user.can?(:admin_issue, project)).to eq(false) + end + + it 'does not include archived projects' do + project = create(:project) + project.update_attributes(archived: true) + + expect(user.projects_where_can_admin_issues.to_a).to be_empty + expect(user.can?(:admin_issue, project)).to eq(false) + end + + it 'does not include projects for which issues are disabled' do + project = create(:project) + project.update_attributes(issues_enabled: false) + + expect(user.projects_where_can_admin_issues.to_a).to be_empty + expect(user.can?(:admin_issue, project)).to eq(false) + end + end + describe '#ci_authorized_runners' do let(:user) { create(:user) } let(:runner) { create(:ci_runner) } diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index 9444138f93d..3fd989dd7a6 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -243,7 +243,7 @@ describe API::API, api: true do end it "removes protected branch" do - project.protected_branches.create(name: branch_name) + create(:protected_branch, project: project, name: branch_name) delete api("/projects/#{project.id}/repository/branches/#{branch_name}", user) expect(response).to have_http_status(405) expect(json_response['message']).to eq('Protected branch cant be removed') diff --git a/spec/requests/api/builds_spec.rb b/spec/requests/api/builds_spec.rb index 41503885dd9..9a17a705b1e 100644 --- a/spec/requests/api/builds_spec.rb +++ b/spec/requests/api/builds_spec.rb @@ -407,4 +407,27 @@ describe API::API, api: true do end end end + + describe 'POST /projects/:id/builds/:build_id/play' do + before do + post api("/projects/#{project.id}/builds/#{build.id}/play", user) + end + + context 'on an playable build' do + let(:build) { create(:ci_build, :manual, project: project, pipeline: pipeline) } + + it 'plays the build' do + expect(response).to have_http_status 200 + expect(json_response['user']['id']).to eq(user.id) + expect(json_response['id']).to eq(build.id) + end + end + + context 'on a non-playable build' do + it 'returns a status code 400, Bad Request' do + expect(response).to have_http_status 400 + expect(response.body).to match("Unplayable Build") + end + end + end end diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb new file mode 100644 index 00000000000..8fa8c66db6c --- /dev/null +++ b/spec/requests/api/deployments_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:project) { deployment.environment.project } + let!(:deployment) { create(:deployment) } + + before do + project.team << [user, :master] + end + + describe 'GET /projects/:id/deployments' do + context 'as member of the project' do + it_behaves_like 'a paginated resources' do + let(:request) { get api("/projects/#{project.id}/deployments", user) } + end + + it 'returns projects deployments' do + get api("/projects/#{project.id}/deployments", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response.first['iid']).to eq(deployment.iid) + expect(json_response.first['sha']).to match /\A\h{40}\z/ + end + end + + context 'as non member' do + it 'returns a 404 status code' do + get api("/projects/#{project.id}/deployments", non_member) + + expect(response).to have_http_status(404) + end + end + end + + describe 'GET /projects/:id/deployments/:deployment_id' do + context 'as a member of the project' do + it 'returns the projects deployment' do + get api("/projects/#{project.id}/deployments/#{deployment.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['sha']).to match /\A\h{40}\z/ + expect(json_response['id']).to eq(deployment.id) + end + end + + context 'as non member' do + it 'returns a 404 status code' do + get api("/projects/#{project.id}/deployments/#{deployment.id}", non_member) + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb index 05e57905343..1898b07835d 100644 --- a/spec/requests/api/environments_spec.rb +++ b/spec/requests/api/environments_spec.rb @@ -26,6 +26,7 @@ describe API::API, api: true do expect(json_response.size).to eq(1) expect(json_response.first['name']).to eq(environment.name) expect(json_response.first['external_url']).to eq(environment.external_url) + expect(json_response.first['project']['id']).to eq(project.id) end end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 3cd4e981fb2..a40e1a93b71 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -531,8 +531,8 @@ describe API::API, api: true do describe 'POST /projects/:id/issues with spam filtering' do before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:check_for_spam?).and_return(true) - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true) + allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(AkismetService).to receive_messages(is_spam?: true) end let(:params) do @@ -554,7 +554,6 @@ describe API::API, api: true do expect(spam_logs[0].description).to eq('content here') expect(spam_logs[0].user).to eq(user) expect(spam_logs[0].noteable_type).to eq('Issue') - expect(spam_logs[0].project_id).to eq(project.id) end end diff --git a/spec/requests/api/oauth_tokens_spec.rb b/spec/requests/api/oauth_tokens_spec.rb new file mode 100644 index 00000000000..7e2cc50e591 --- /dev/null +++ b/spec/requests/api/oauth_tokens_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + context 'Resource Owner Password Credentials' do + def request_oauth_token(user) + post '/oauth/token', username: user.username, password: user.password, grant_type: 'password' + end + + context 'when user has 2FA enabled' do + it 'does not create an access token' do + user = create(:user, :two_factor) + + request_oauth_token(user) + + expect(response).to have_http_status(401) + expect(json_response['error']).to eq('invalid_grant') + end + end + + context 'when user does not have 2FA enabled' do + it 'creates an access token' do + user = create(:user) + + request_oauth_token(user) + + expect(response).to have_http_status(200) + expect(json_response['access_token']).not_to be_nil + end + end + end +end diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb new file mode 100644 index 00000000000..7011bdc9ec0 --- /dev/null +++ b/spec/requests/api/pipelines_spec.rb @@ -0,0 +1,133 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:project) { create(:project, creator_id: user.id) } + + let!(:pipeline) do + create(:ci_empty_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + before { project.team << [user, :master] } + + describe 'GET /projects/:id/pipelines ' do + it_behaves_like 'a paginated resources' do + let(:request) { get api("/projects/#{project.id}/pipelines", user) } + end + + context 'authorized user' do + it 'returns project pipelines' do + get api("/projects/#{project.id}/pipelines", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['sha']).to match /\A\h{40}\z/ + expect(json_response.first['id']).to eq pipeline.id + end + end + + context 'unauthorized user' do + it 'does not return project pipelines' do + get api("/projects/#{project.id}/pipelines", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response).not_to be_an Array + end + end + end + + describe 'GET /projects/:id/pipelines/:pipeline_id' do + context 'authorized user' do + it 'returns project pipelines' do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['sha']).to match /\A\h{40}\z/ + end + + it 'returns 404 when it does not exist' do + get api("/projects/#{project.id}/pipelines/123456", user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Not found' + expect(json_response['id']).to be nil + end + end + + context 'unauthorized user' do + it 'should not return a project pipeline' do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response['id']).to be nil + end + end + end + + describe 'POST /projects/:id/pipelines/:pipeline_id/retry' do + context 'authorized user' do + let!(:pipeline) do + create(:ci_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + let!(:build) { create(:ci_build, :failed, pipeline: pipeline) } + + it 'retries failed builds' do + expect do + post api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", user) + end.to change { pipeline.builds.count }.from(1).to(2) + + expect(response).to have_http_status(201) + expect(build.reload.retried?).to be true + end + end + + context 'unauthorized user' do + it 'should not return a project pipeline' do + post api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response['id']).to be nil + end + end + end + + describe 'POST /projects/:id/pipelines/:pipeline_id/cancel' do + let!(:pipeline) do + create(:ci_empty_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + let!(:build) { create(:ci_build, :running, pipeline: pipeline) } + + context 'authorized user' do + it 'retries failed builds' do + post api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", user) + + expect(response).to have_http_status(200) + expect(json_response['status']).to eq('canceled') + end + end + + context 'user without proper access rights' do + let!(:reporter) { create(:user) } + + before { project.team << [reporter, :reporter] } + + it 'rejects the action' do + post api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", reporter) + + expect(response).to have_http_status(403) + expect(pipeline.reload.status).to eq('pending') + end + end + end +end diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb index 34fac297923..914e88c9487 100644 --- a/spec/requests/api/project_hooks_spec.rb +++ b/spec/requests/api/project_hooks_spec.rb @@ -7,9 +7,9 @@ describe API::API, 'ProjectHooks', api: true do let!(:project) { create(:project, creator_id: user.id, namespace: user.namespace) } let!(:hook) do create(:project_hook, - project: project, url: "http://example.com", - push_events: true, merge_requests_events: true, tag_push_events: true, - issues_events: true, note_events: true, build_events: true, + :all_events_enabled, + project: project, + url: 'http://example.com', enable_ssl_verification: true) end @@ -33,6 +33,7 @@ describe API::API, 'ProjectHooks', api: true do expect(json_response.first['tag_push_events']).to eq(true) expect(json_response.first['note_events']).to eq(true) expect(json_response.first['build_events']).to eq(true) + expect(json_response.first['pipeline_events']).to eq(true) expect(json_response.first['enable_ssl_verification']).to eq(true) end end @@ -91,6 +92,7 @@ describe API::API, 'ProjectHooks', api: true do expect(json_response['tag_push_events']).to eq(false) expect(json_response['note_events']).to eq(false) expect(json_response['build_events']).to eq(false) + expect(json_response['pipeline_events']).to eq(false) expect(json_response['enable_ssl_verification']).to eq(true) end diff --git a/spec/requests/api/session_spec.rb b/spec/requests/api/session_spec.rb index 519e7ce12ad..acad1365ace 100644 --- a/spec/requests/api/session_spec.rb +++ b/spec/requests/api/session_spec.rb @@ -17,6 +17,17 @@ describe API::API, api: true do expect(json_response['can_create_project']).to eq(user.can_create_project?) expect(json_response['can_create_group']).to eq(user.can_create_group?) end + + context 'with 2FA enabled' do + it 'rejects sign in attempts' do + user = create(:user, :two_factor) + + post api('/session'), email: user.email, password: user.password + + expect(response).to have_http_status(401) + expect(response.body).to include('You have 2FA enabled.') + end + end end context 'when email has case-typo and password is valid' do diff --git a/spec/requests/api/templates_spec.rb b/spec/requests/api/templates_spec.rb index 68d0f41b489..5bd5b861792 100644 --- a/spec/requests/api/templates_spec.rb +++ b/spec/requests/api/templates_spec.rb @@ -3,50 +3,53 @@ require 'spec_helper' describe API::Templates, api: true do include ApiHelpers - describe 'the Template Entity' do - before { get api('/gitignores/Ruby') } + context 'global templates' do + describe 'the Template Entity' do + before { get api('/gitignores/Ruby') } - it { expect(json_response['name']).to eq('Ruby') } - it { expect(json_response['content']).to include('*.gem') } - end + it { expect(json_response['name']).to eq('Ruby') } + it { expect(json_response['content']).to include('*.gem') } + end - describe 'the TemplateList Entity' do - before { get api('/gitignores') } + describe 'the TemplateList Entity' do + before { get api('/gitignores') } - it { expect(json_response.first['name']).not_to be_nil } - it { expect(json_response.first['content']).to be_nil } - end + it { expect(json_response.first['name']).not_to be_nil } + it { expect(json_response.first['content']).to be_nil } + end - context 'requesting gitignores' do - describe 'GET /gitignores' do - it 'returns a list of available gitignore templates' do - get api('/gitignores') + context 'requesting gitignores' do + describe 'GET /gitignores' do + it 'returns a list of available gitignore templates' do + get api('/gitignores') - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.size).to be > 15 + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.size).to be > 15 + end end end - end - context 'requesting gitlab-ci-ymls' do - describe 'GET /gitlab_ci_ymls' do - it 'returns a list of available gitlab_ci_ymls' do - get api('/gitlab_ci_ymls') + context 'requesting gitlab-ci-ymls' do + describe 'GET /gitlab_ci_ymls' do + it 'returns a list of available gitlab_ci_ymls' do + get api('/gitlab_ci_ymls') - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.first['name']).not_to be_nil + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.first['name']).not_to be_nil + end end end - end - describe 'GET /gitlab_ci_ymls/Ruby' do - it 'adds a disclaimer on the top' do - get api('/gitlab_ci_ymls/Ruby') + describe 'GET /gitlab_ci_ymls/Ruby' do + it 'adds a disclaimer on the top' do + get api('/gitlab_ci_ymls/Ruby') - expect(response).to have_http_status(200) - expect(json_response['content']).to start_with("# This file is a template,") + expect(response).to have_http_status(200) + expect(json_response['name']).not_to be_nil + expect(json_response['content']).to start_with("# This file is a template,") + end end end end diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 8537c252b58..afaf4b7cefb 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -198,6 +198,45 @@ describe 'Git HTTP requests', lib: true do end end + context 'when user has 2FA enabled' do + let(:user) { create(:user, :two_factor) } + let(:access_token) { create(:personal_access_token, user: user) } + + before do + project.team << [user, :master] + end + + context 'when username and password are provided' do + it 'rejects the clone attempt' do + download("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response| + expect(response).to have_http_status(401) + expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP') + end + end + + it 'rejects the push attempt' do + upload("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response| + expect(response).to have_http_status(401) + expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP') + end + end + end + + context 'when username and personal access token are provided' do + it 'allows clones' do + download("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response| + expect(response).to have_http_status(200) + end + end + + it 'allows pushes' do + upload("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response| + expect(response).to have_http_status(200) + end + end + end + end + context "when blank password attempts follow a valid login" do def attempt_login(include_password) password = include_password ? user.password : "" diff --git a/spec/services/boards/create_service_spec.rb b/spec/services/boards/create_service_spec.rb new file mode 100644 index 00000000000..a1a4dd4c57c --- /dev/null +++ b/spec/services/boards/create_service_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Boards::CreateService, services: true do + describe '#execute' do + subject(:service) { described_class.new(project, double) } + + context 'when project does not have a board' do + let(:project) { create(:empty_project, board: nil) } + + it 'creates a new board' do + expect { service.execute }.to change(Board, :count).by(1) + end + + it 'creates default lists' do + service.execute + + expect(project.board.lists.size).to eq 2 + expect(project.board.lists.first).to be_backlog + expect(project.board.lists.last).to be_done + end + end + + context 'when project has a board' do + let!(:project) { create(:project_with_board) } + + it 'does not create a new board' do + expect { service.execute }.not_to change(Board, :count) + end + + it 'does not create board lists' do + expect { service.execute }.not_to change(project.board.lists, :count) + end + end + end +end diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb new file mode 100644 index 00000000000..f7f45983d26 --- /dev/null +++ b/spec/services/boards/issues/list_service_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +describe Boards::Issues::ListService, services: true do + describe '#execute' do + let(:user) { create(:user) } + let(:project) { create(:project_with_board) } + let(:board) { project.board } + + let(:bug) { create(:label, project: project, name: 'Bug') } + let(:development) { create(:label, project: project, name: 'Development') } + let(:testing) { create(:label, project: project, name: 'Testing') } + let(:p1) { create(:label, title: 'P1', project: project, priority: 1) } + let(:p2) { create(:label, title: 'P2', project: project, priority: 2) } + let(:p3) { create(:label, title: 'P3', project: project, priority: 3) } + + let!(:backlog) { create(:backlog_list, board: board) } + let!(:list1) { create(:list, board: board, label: development, position: 0) } + let!(:list2) { create(:list, board: board, label: testing, position: 1) } + let!(:done) { create(:done_list, board: board) } + + let!(:opened_issue1) { create(:labeled_issue, project: project, labels: [bug]) } + let!(:opened_issue2) { create(:labeled_issue, project: project, labels: [p2]) } + let!(:reopened_issue1) { create(:issue, :reopened, project: project) } + + let!(:list1_issue1) { create(:labeled_issue, project: project, labels: [p2, development]) } + let!(:list1_issue2) { create(:labeled_issue, project: project, labels: [development]) } + let!(:list1_issue3) { create(:labeled_issue, project: project, labels: [development, p1]) } + let!(:list2_issue1) { create(:labeled_issue, project: project, labels: [testing]) } + + let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) } + let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) } + let!(:closed_issue3) { create(:issue, :closed, project: project) } + let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1]) } + + before do + project.team << [user, :developer] + end + + it 'delegates search to IssuesFinder' do + params = { id: list1.id } + + expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original + + described_class.new(project, user, params).execute + end + + context 'sets default order to priority' do + it 'returns opened issues when listing issues from Backlog' do + params = { id: backlog.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1] + end + + it 'returns closed issues when listing issues from Done' do + params = { id: done.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [closed_issue4, closed_issue2, closed_issue3, closed_issue1] + end + + it 'returns opened issues that have label list applied when listing issues from a label list' do + params = { id: list1.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2] + end + end + end +end diff --git a/spec/services/boards/issues/move_service_spec.rb b/spec/services/boards/issues/move_service_spec.rb new file mode 100644 index 00000000000..0122159cab8 --- /dev/null +++ b/spec/services/boards/issues/move_service_spec.rb @@ -0,0 +1,140 @@ +require 'spec_helper' + +describe Boards::Issues::MoveService, services: true do + describe '#execute' do + let(:user) { create(:user) } + let(:project) { create(:project_with_board) } + let(:board) { project.board } + + let(:bug) { create(:label, project: project, name: 'Bug') } + let(:development) { create(:label, project: project, name: 'Development') } + let(:testing) { create(:label, project: project, name: 'Testing') } + + let!(:backlog) { create(:backlog_list, board: board) } + let!(:list1) { create(:list, board: board, label: development, position: 0) } + let!(:list2) { create(:list, board: board, label: testing, position: 1) } + let!(:done) { create(:done_list, board: board) } + + before do + project.team << [user, :developer] + end + + context 'when moving from backlog' do + it 'adds the label of the list it goes to' do + issue = create(:labeled_issue, project: project, labels: [bug]) + params = { from_list_id: backlog.id, to_list_id: list1.id } + + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug, development) + end + end + + context 'when moving to backlog' do + it 'removes all list-labels' do + issue = create(:labeled_issue, project: project, labels: [bug, development, testing]) + params = { from_list_id: list1.id, to_list_id: backlog.id } + + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug) + end + end + + context 'when moving from backlog to done' do + it 'closes the issue' do + issue = create(:labeled_issue, project: project, labels: [bug]) + params = { from_list_id: backlog.id, to_list_id: done.id } + + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug) + expect(issue).to be_closed + end + end + + context 'when moving an issue between lists' do + let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) } + let(:params) { { from_list_id: list1.id, to_list_id: list2.id } } + + it 'delegates the label changes to Issues::UpdateService' do + expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once + + described_class.new(project, user, params).execute(issue) + end + + it 'removes the label from the list it came from and adds the label of the list it goes to' do + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug, testing) + end + end + + context 'when moving to done' do + let(:issue) { create(:labeled_issue, project: project, labels: [bug, development, testing]) } + let(:params) { { from_list_id: list2.id, to_list_id: done.id } } + + it 'delegates the close proceedings to Issues::CloseService' do + expect_any_instance_of(Issues::CloseService).to receive(:execute).with(issue).once + + described_class.new(project, user, params).execute(issue) + end + + it 'removes all list-labels and close the issue' do + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug) + expect(issue).to be_closed + end + end + + context 'when moving from done' do + let(:issue) { create(:labeled_issue, :closed, project: project, labels: [bug]) } + let(:params) { { from_list_id: done.id, to_list_id: list2.id } } + + it 'delegates the re-open proceedings to Issues::ReopenService' do + expect_any_instance_of(Issues::ReopenService).to receive(:execute).with(issue).once + + described_class.new(project, user, params).execute(issue) + end + + it 'adds the label of the list it goes to and reopen the issue' do + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug, testing) + expect(issue).to be_reopened + end + end + + context 'when moving from done to backlog' do + it 'reopens the issue' do + issue = create(:labeled_issue, :closed, project: project, labels: [bug]) + params = { from_list_id: done.id, to_list_id: backlog.id } + + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug) + expect(issue).to be_reopened + end + end + + context 'when moving to same list' do + let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) } + let(:params) { { from_list_id: list1.id, to_list_id: list1.id } } + + it 'returns false' do + expect(described_class.new(project, user, params).execute(issue)).to eq false + end + + it 'keeps issues labels' do + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug, development) + end + end + end +end diff --git a/spec/services/boards/lists/create_service_spec.rb b/spec/services/boards/lists/create_service_spec.rb new file mode 100644 index 00000000000..5e7e145065e --- /dev/null +++ b/spec/services/boards/lists/create_service_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe Boards::Lists::CreateService, services: true do + describe '#execute' do + let(:project) { create(:project_with_board) } + let(:board) { project.board } + let(:user) { create(:user) } + let(:label) { create(:label, name: 'in-progress') } + + subject(:service) { described_class.new(project, user, label_id: label.id) } + + context 'when board lists is empty' do + it 'creates a new list at beginning of the list' do + list = service.execute + + expect(list.position).to eq 0 + end + end + + context 'when board lists has only a backlog list' do + it 'creates a new list at beginning of the list' do + create(:backlog_list, board: board) + + list = service.execute + + expect(list.position).to eq 0 + end + end + + context 'when board lists has only labels lists' do + it 'creates a new list at end of the lists' do + create(:list, board: board, position: 0) + create(:list, board: board, position: 1) + + list = service.execute + + expect(list.position).to eq 2 + end + end + + context 'when board lists has backlog, label and done lists' do + it 'creates a new list at end of the label lists' do + create(:backlog_list, board: board) + create(:done_list, board: board) + list1 = create(:list, board: board, position: 0) + + list2 = service.execute + + expect(list1.reload.position).to eq 0 + expect(list2.reload.position).to eq 1 + end + end + end +end diff --git a/spec/services/boards/lists/destroy_service_spec.rb b/spec/services/boards/lists/destroy_service_spec.rb new file mode 100644 index 00000000000..6eff445feee --- /dev/null +++ b/spec/services/boards/lists/destroy_service_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe Boards::Lists::DestroyService, services: true do + describe '#execute' do + let(:project) { create(:project_with_board) } + let(:board) { project.board } + let(:user) { create(:user) } + + context 'when list type is label' do + it 'removes list from board' do + list = create(:list, board: board) + service = described_class.new(project, user) + + expect { service.execute(list) }.to change(board.lists, :count).by(-1) + end + + it 'decrements position of higher lists' do + backlog = create(:backlog_list, board: board) + development = create(:list, board: board, position: 0) + review = create(:list, board: board, position: 1) + staging = create(:list, board: board, position: 2) + done = create(:done_list, board: board) + + described_class.new(project, user).execute(development) + + expect(backlog.reload.position).to be_nil + expect(review.reload.position).to eq 0 + expect(staging.reload.position).to eq 1 + expect(done.reload.position).to be_nil + end + end + + it 'does not remove list from board when list type is backlog' do + list = create(:backlog_list, board: board) + service = described_class.new(project, user) + + expect { service.execute(list) }.not_to change(board.lists, :count) + end + + it 'does not remove list from board when list type is done' do + list = create(:done_list, board: board) + service = described_class.new(project, user) + + expect { service.execute(list) }.not_to change(board.lists, :count) + end + end +end diff --git a/spec/services/boards/lists/generate_service_spec.rb b/spec/services/boards/lists/generate_service_spec.rb new file mode 100644 index 00000000000..9fd39122737 --- /dev/null +++ b/spec/services/boards/lists/generate_service_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Boards::Lists::GenerateService, services: true do + describe '#execute' do + let(:project) { create(:project_with_board) } + let(:board) { project.board } + let(:user) { create(:user) } + + subject(:service) { described_class.new(project, user) } + + context 'when board lists is empty' do + it 'creates the default lists' do + expect { service.execute }.to change(board.lists, :count).by(4) + end + end + + context 'when board lists is not empty' do + it 'does not creates the default lists' do + create(:list, board: board) + + expect { service.execute }.not_to change(board.lists, :count) + end + end + + context 'when project labels does not contains any list label' do + it 'creates labels' do + expect { service.execute }.to change(project.labels, :count).by(4) + end + end + + context 'when project labels contains some of list label' do + it 'creates the missing labels' do + create(:label, project: project, name: 'Development') + create(:label, project: project, name: 'Ready') + + expect { service.execute }.to change(project.labels, :count).by(2) + end + end + end +end diff --git a/spec/services/boards/lists/move_service_spec.rb b/spec/services/boards/lists/move_service_spec.rb new file mode 100644 index 00000000000..3e9b7d07fc6 --- /dev/null +++ b/spec/services/boards/lists/move_service_spec.rb @@ -0,0 +1,110 @@ +require 'spec_helper' + +describe Boards::Lists::MoveService, services: true do + describe '#execute' do + let(:project) { create(:project_with_board) } + let(:board) { project.board } + let(:user) { create(:user) } + + let!(:backlog) { create(:backlog_list, board: board) } + let!(:planning) { create(:list, board: board, position: 0) } + let!(:development) { create(:list, board: board, position: 1) } + let!(:review) { create(:list, board: board, position: 2) } + let!(:staging) { create(:list, board: board, position: 3) } + let!(:done) { create(:done_list, board: board) } + + context 'when list type is set to label' do + it 'keeps position of lists when new position is nil' do + service = described_class.new(project, user, position: nil) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is equal to old position' do + service = described_class.new(project, user, position: planning.position) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is negative' do + service = described_class.new(project, user, position: -1) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is equal to number of labels lists' do + service = described_class.new(project, user, position: board.lists.label.size) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is greater than number of labels lists' do + service = described_class.new(project, user, position: board.lists.label.size + 1) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'increments position of intermediate lists when new positon is equal to first position' do + service = described_class.new(project, user, position: 0) + + service.execute(staging) + + expect(current_list_positions).to eq [1, 2, 3, 0] + end + + it 'decrements position of intermediate lists when new positon is equal to last position' do + service = described_class.new(project, user, position: board.lists.label.last.position) + + service.execute(planning) + + expect(current_list_positions).to eq [3, 0, 1, 2] + end + + it 'decrements position of intermediate lists when new position is greater than old position' do + service = described_class.new(project, user, position: 2) + + service.execute(planning) + + expect(current_list_positions).to eq [2, 0, 1, 3] + end + + it 'increments position of intermediate lists when new position is lower than old position' do + service = described_class.new(project, user, position: 1) + + service.execute(staging) + + expect(current_list_positions).to eq [0, 2, 3, 1] + end + end + + it 'keeps position of lists when list type is backlog' do + service = described_class.new(project, user, position: 2) + + service.execute(backlog) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when list type is done' do + service = described_class.new(project, user, position: 2) + + service.execute(done) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + end + + def current_list_positions + [planning, development, review, staging].map { |list| list.reload.position } + end +end diff --git a/spec/services/files/update_service_spec.rb b/spec/services/files/update_service_spec.rb new file mode 100644 index 00000000000..d019e50649f --- /dev/null +++ b/spec/services/files/update_service_spec.rb @@ -0,0 +1,84 @@ +require "spec_helper" + +describe Files::UpdateService do + subject { described_class.new(project, user, commit_params) } + + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:file_path) { 'files/ruby/popen.rb' } + let(:new_contents) { "New Content" } + let(:commit_params) do + { + file_path: file_path, + commit_message: "Update File", + file_content: new_contents, + file_content_encoding: "text", + last_commit_sha: last_commit_sha, + source_project: project, + source_branch: project.default_branch, + target_branch: project.default_branch, + } + end + + before do + project.team << [user, :master] + end + + describe "#execute" do + context "when the file's last commit sha does not match the supplied last_commit_sha" do + let(:last_commit_sha) { "foo" } + + it "returns a hash with the correct error message and a :error status " do + expect { subject.execute }. + to raise_error(Files::UpdateService::FileChangedError, + "You are attempting to update a file that has changed since you started editing it.") + end + end + + context "when the file's last commit sha does match the supplied last_commit_sha" do + let(:last_commit_sha) { Gitlab::Git::Commit.last_for_path(project.repository, project.default_branch, file_path).sha } + + it "returns a hash with the :success status " do + results = subject.execute + + expect(results).to match({ status: :success }) + end + + it "updates the file with the new contents" do + subject.execute + + results = project.repository.blob_at_branch(project.default_branch, file_path) + + expect(results.data).to eq(new_contents) + end + end + + context "when the last_commit_sha is not supplied" do + let(:commit_params) do + { + file_path: file_path, + commit_message: "Update File", + file_content: new_contents, + file_content_encoding: "text", + source_project: project, + source_branch: project.default_branch, + target_branch: project.default_branch, + } + end + + it "returns a hash with the :success status " do + results = subject.execute + + expect(results).to match({ status: :success }) + end + + it "updates the file with the new contents" do + subject.execute + + results = project.repository.blob_at_branch(project.default_branch, file_path) + + expect(results.data).to eq(new_contents) + end + end + end +end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 80f6ebac86c..6ac1fa8f182 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -227,8 +227,8 @@ describe GitPushService, services: true do expect(project.default_branch).to eq("master") execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER) - expect(project.protected_branches.first.merge_access_level.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.first.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) + expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) end it "when pushing a branch for the first time with default branch protection disabled" do @@ -249,8 +249,8 @@ describe GitPushService, services: true do execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.last.push_access_level.access_level).to eq(Gitlab::Access::DEVELOPER) - expect(project.protected_branches.last.merge_access_level.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.last.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER]) + expect(project.protected_branches.last.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) end it "when pushing a branch for the first time with default branch protection set to 'developers can merge'" do @@ -260,8 +260,8 @@ describe GitPushService, services: true do expect(project.default_branch).to eq("master") execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER) - expect(project.protected_branches.first.merge_access_level.access_level).to eq(Gitlab::Access::DEVELOPER) + expect(project.protected_branches.first.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) + expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER]) end it "when pushing new commits to existing branch" do diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 1318607a388..aff022a573e 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe Issues::CloseService, services: true do let(:user) { create(:user) } let(:user2) { create(:user) } + let(:guest) { create(:user) } let(:issue) { create(:issue, assignee: user2) } let(:project) { issue.project } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) } @@ -10,13 +11,14 @@ describe Issues::CloseService, services: true do before do project.team << [user, :master] project.team << [user2, :developer] + project.team << [guest, :guest] end describe '#execute' do context "valid params" do before do perform_enqueued_jobs do - @issue = Issues::CloseService.new(project, user, {}).execute(issue) + @issue = described_class.new(project, user, {}).execute(issue) end end @@ -39,10 +41,22 @@ describe Issues::CloseService, services: true do end end + context 'current user is not authorized to close issue' do + before do + perform_enqueued_jobs do + @issue = described_class.new(project, guest).execute(issue) + end + end + + it 'does not close the issue' do + expect(@issue).to be_open + end + end + context "external issue tracker" do before do allow(project).to receive(:default_issues_tracker?).and_return(false) - @issue = Issues::CloseService.new(project, user, {}).execute(issue) + @issue = described_class.new(project, user, {}).execute(issue) end it { expect(@issue).to be_valid } diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 1ee9f3aae4d..fcc3c0a00bd 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -73,5 +73,7 @@ describe Issues::CreateService, services: true do end end end + + it_behaves_like 'new issuable record that supports slash commands' end end diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb new file mode 100644 index 00000000000..34a89fcd4e1 --- /dev/null +++ b/spec/services/issues/reopen_service_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe Issues::ReopenService, services: true do + let(:guest) { create(:user) } + let(:issue) { create(:issue, :closed) } + let(:project) { issue.project } + + before do + project.team << [guest, :guest] + end + + describe '#execute' do + context 'current user is not authorized to reopen issue' do + before do + perform_enqueued_jobs do + @issue = described_class.new(project, guest).execute(issue) + end + end + + it 'does not reopen the issue' do + expect(@issue).to be_closed + end + end + end +end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 088c3d48bf7..0313f424463 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -319,5 +319,10 @@ describe Issues::UpdateService, services: true do end end end + + context 'updating mentions' do + let(:mentionable) { issue } + include_examples 'updating mentions', Issues::UpdateService + end end end diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb index 403533be5d9..24c25e4350f 100644 --- a/spec/services/merge_requests/close_service_spec.rb +++ b/spec/services/merge_requests/close_service_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe MergeRequests::CloseService, services: true do let(:user) { create(:user) } let(:user2) { create(:user) } + let(:guest) { create(:user) } let(:merge_request) { create(:merge_request, assignee: user2) } let(:project) { merge_request.project } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: merge_request, author: user2) } @@ -10,11 +11,12 @@ describe MergeRequests::CloseService, services: true do before do project.team << [user, :master] project.team << [user2, :developer] + project.team << [guest, :guest] end describe '#execute' do context 'valid params' do - let(:service) { MergeRequests::CloseService.new(project, user, {}) } + let(:service) { described_class.new(project, user, {}) } before do allow(service).to receive(:execute_hooks) @@ -47,5 +49,17 @@ describe MergeRequests::CloseService, services: true do expect(todo.reload).to be_done end end + + context 'current user is not authorized to close merge request' do + before do + perform_enqueued_jobs do + @merge_request = described_class.new(project, guest).execute(merge_request) + end + end + + it 'does not close the merge request' do + expect(@merge_request).to be_open + end + end end end diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index b84a580967a..c1e4f8bd96b 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -17,7 +17,7 @@ describe MergeRequests::CreateService, services: true do } end - let(:service) { MergeRequests::CreateService.new(project, user, opts) } + let(:service) { described_class.new(project, user, opts) } before do project.team << [user, :master] @@ -74,5 +74,14 @@ describe MergeRequests::CreateService, services: true do end end end + + it_behaves_like 'new issuable record that supports slash commands' do + let(:default_params) do + { + source_branch: 'feature', + target_branch: 'master' + } + end + end end end diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb index 3419b8bf5e6..af7424a76a9 100644 --- a/spec/services/merge_requests/reopen_service_spec.rb +++ b/spec/services/merge_requests/reopen_service_spec.rb @@ -3,22 +3,23 @@ require 'spec_helper' describe MergeRequests::ReopenService, services: true do let(:user) { create(:user) } let(:user2) { create(:user) } - let(:merge_request) { create(:merge_request, assignee: user2) } + let(:guest) { create(:user) } + let(:merge_request) { create(:merge_request, :closed, assignee: user2) } let(:project) { merge_request.project } before do project.team << [user, :master] project.team << [user2, :developer] + project.team << [guest, :guest] end describe '#execute' do context 'valid params' do - let(:service) { MergeRequests::ReopenService.new(project, user, {}) } + let(:service) { described_class.new(project, user, {}) } before do allow(service).to receive(:execute_hooks) - merge_request.state = :closed perform_enqueued_jobs do service.execute(merge_request) end @@ -43,5 +44,17 @@ describe MergeRequests::ReopenService, services: true do expect(note.note).to include 'Status changed to reopened' end end + + context 'current user is not authorized to reopen merge request' do + before do + perform_enqueued_jobs do + @merge_request = described_class.new(project, guest).execute(merge_request) + end + end + + it 'does not reopen the merge request' do + expect(@merge_request).to be_closed + end + end end end diff --git a/spec/services/merge_requests/resolved_discussion_notification_service.rb b/spec/services/merge_requests/resolved_discussion_notification_service.rb new file mode 100644 index 00000000000..7ddd812e513 --- /dev/null +++ b/spec/services/merge_requests/resolved_discussion_notification_service.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe MergeRequests::ResolvedDiscussionNotificationService, services: true do + let(:merge_request) { create(:merge_request) } + let(:user) { create(:user) } + let(:project) { merge_request.project } + subject { described_class.new(project, user) } + + describe "#execute" do + context "when not all discussions are resolved" do + before do + allow(merge_request).to receive(:discussions_resolved?).and_return(false) + end + + it "doesn't add a system note" do + expect(SystemNoteService).not_to receive(:resolve_all_discussions) + + subject.execute(merge_request) + end + + it "doesn't send a notification email" do + expect_any_instance_of(NotificationService).not_to receive(:resolve_all_discussions) + + subject.execute(merge_request) + end + end + + context "when all discussions are resolved" do + before do + allow(merge_request).to receive(:discussions_resolved?).and_return(true) + end + + it "adds a system note" do + expect(SystemNoteService).to receive(:resolve_all_discussions).with(merge_request, project, user) + + subject.execute(merge_request) + end + + it "sends a notification email" do + expect_any_instance_of(NotificationService).to receive(:resolve_all_discussions).with(merge_request, user) + + subject.execute(merge_request) + end + end + end +end diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 283a336afd9..6dfeb581975 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -226,6 +226,11 @@ describe MergeRequests::UpdateService, services: true do end end + context 'updating mentions' do + let(:mentionable) { merge_request } + include_examples 'updating mentions', MergeRequests::UpdateService + end + context 'when MergeRequest has tasks' do before { update_merge_request({ description: "- [ ] Task 1\n- [ ] Task 2" }) } diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index 32753e84b31..93885c84dc3 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -4,22 +4,36 @@ describe Notes::CreateService, services: true do let(:project) { create(:empty_project) } let(:issue) { create(:issue, project: project) } let(:user) { create(:user) } + let(:opts) do + { note: 'Awesome comment', noteable_type: 'Issue', noteable_id: issue.id } + end describe '#execute' do + before do + project.team << [user, :master] + end + context "valid params" do before do - project.team << [user, :master] - opts = { - note: 'Awesome comment', - noteable_type: 'Issue', - noteable_id: issue.id - } - @note = Notes::CreateService.new(project, user, opts).execute end it { expect(@note).to be_valid } - it { expect(@note.note).to eq('Awesome comment') } + it { expect(@note.note).to eq(opts[:note]) } + end + + describe 'note with commands' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) { %(HELLO\n/close\n/assign @#{user.username}\nWORLD) } + + it 'saves the note and does not alter the note text' do + expect_any_instance_of(Issues::UpdateService).to receive(:execute).and_call_original + + note = described_class.new(project, user, opts.merge(note: note_text)).execute + + expect(note.note).to eq "HELLO\nWORLD" + end + end end end @@ -42,7 +56,7 @@ describe Notes::CreateService, services: true do it "creates regular note if emoji name is invalid" do opts = { - note: ':smile: moretext: ', + note: ':smile: moretext:', noteable_type: 'Issue', noteable_id: issue.id } diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb new file mode 100644 index 00000000000..4f231aab161 --- /dev/null +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -0,0 +1,140 @@ +require 'spec_helper' + +describe Notes::SlashCommandsService, services: true do + shared_context 'note on noteable' do + let(:project) { create(:empty_project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:assignee) { create(:user) } + end + + shared_examples 'note on noteable that does not support slash commands' do + include_context 'note on noteable' + + before do + note.note = note_text + end + + describe 'note with only command' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) { %(/close\n/assign @#{assignee.username}") } + + it 'saves the note and does not alter the note text' do + content, command_params = service.extract_commands(note) + + expect(content).to eq note_text + expect(command_params).to be_empty + end + end + end + + describe 'note with command & text' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) { %(HELLO\n/close\n/assign @#{assignee.username}\nWORLD) } + + it 'saves the note and does not alter the note text' do + content, command_params = service.extract_commands(note) + + expect(content).to eq note_text + expect(command_params).to be_empty + end + end + end + end + + shared_examples 'note on noteable that supports slash commands' do + include_context 'note on noteable' + + before do + note.note = note_text + end + + let!(:milestone) { create(:milestone, project: project) } + let!(:labels) { create_pair(:label, project: project) } + + describe 'note with only command' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) do + %(/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}") + end + + it 'closes noteable, sets labels, assigns, and sets milestone to noteable, and leave no note' do + content, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(content).to eq '' + expect(note.noteable).to be_closed + expect(note.noteable.labels).to match_array(labels) + expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.milestone).to eq(milestone) + end + end + + describe '/reopen' do + before do + note.noteable.close! + expect(note.noteable).to be_closed + end + let(:note_text) { '/reopen' } + + it 'opens the noteable, and leave no note' do + content, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(content).to eq '' + expect(note.noteable).to be_open + end + end + end + + describe 'note with command & text' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) do + %(HELLO\n/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}"\nWORLD) + end + + it 'closes noteable, sets labels, assigns, and sets milestone to noteable' do + content, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(content).to eq "HELLO\nWORLD" + expect(note.noteable).to be_closed + expect(note.noteable.labels).to match_array(labels) + expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.milestone).to eq(milestone) + end + end + + describe '/reopen' do + before do + note.noteable.close + expect(note.noteable).to be_closed + end + let(:note_text) { "HELLO\n/reopen\nWORLD" } + + it 'opens the noteable' do + content, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(content).to eq "HELLO\nWORLD" + expect(note.noteable).to be_open + end + end + end + end + + describe '#execute' do + let(:service) { described_class.new(project, master) } + + it_behaves_like 'note on noteable that supports slash commands' do + let(:note) { build(:note_on_issue, project: project) } + end + + it_behaves_like 'note on noteable that supports slash commands' do + let(:note) { build(:note_on_merge_request, project: project) } + end + + it_behaves_like 'note on noteable that does not support slash commands' do + let(:note) { build(:note_on_commit, project: project) } + end + end +end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 92b441c28ca..18da3b1b453 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -9,6 +9,28 @@ describe NotificationService, services: true do end end + shared_examples 'notifications for new mentions' do + def send_notifications(*new_mentions) + reset_delivered_emails! + notification.send(notification_method, mentionable, new_mentions, @u_disabled) + end + + it 'sends no emails when no new mentions are present' do + send_notifications + expect(ActionMailer::Base.deliveries).to be_empty + end + + it 'emails new mentions with a watch level higher than participant' do + send_notifications(@u_watcher, @u_participant_mentioned, @u_custom_global) + should_only_email(@u_watcher, @u_participant_mentioned, @u_custom_global) + end + + it 'does not email new mentions with a watch level equal to or less than participant' do + send_notifications(@u_participating, @u_mentioned) + expect(ActionMailer::Base.deliveries).to be_empty + end + end + describe 'Keys' do describe '#new_key' do let!(:key) { create(:personal_key) } @@ -399,6 +421,13 @@ describe NotificationService, services: true do end end + describe '#new_mentions_in_issue' do + let(:notification_method) { :new_mentions_in_issue } + let(:mentionable) { issue } + + include_examples 'notifications for new mentions' + end + describe '#reassigned_issue' do before do update_custom_notification(:reassign_issue, @u_guest_custom, project) @@ -700,6 +729,8 @@ describe NotificationService, services: true do before do build_team(merge_request.target_project) add_users_with_subscription(merge_request.target_project, merge_request) + update_custom_notification(:new_merge_request, @u_guest_custom, project) + update_custom_notification(:new_merge_request, @u_custom_global) ActionMailer::Base.deliveries.clear end @@ -763,6 +794,13 @@ describe NotificationService, services: true do end end + describe '#new_mentions_in_merge_request' do + let(:notification_method) { :new_mentions_in_merge_request } + let(:mentionable) { merge_request } + + include_examples 'notifications for new mentions' + end + describe '#reassigned_merge_request' do before do update_custom_notification(:reassign_merge_request, @u_guest_custom, project) @@ -1004,6 +1042,52 @@ describe NotificationService, services: true do end end end + + describe "#resolve_all_discussions" do + it do + notification.resolve_all_discussions(merge_request, @u_disabled) + + should_email(merge_request.assignee) + should_email(@u_watcher) + should_email(@u_participant_mentioned) + should_email(@subscriber) + should_email(@watcher_and_subscriber) + should_email(@u_guest_watcher) + should_not_email(@unsubscriber) + should_not_email(@u_participating) + should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) + end + + context 'participating' do + context 'by assignee' do + before do + merge_request.update_attribute(:assignee, @u_lazy_participant) + notification.resolve_all_discussions(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + + context 'by note' do + let!(:note) { create(:note_on_issue, noteable: merge_request, project_id: project.id, note: 'anything', author: @u_lazy_participant) } + + before { notification.resolve_all_discussions(merge_request, @u_disabled) } + + it { should_email(@u_lazy_participant) } + end + + context 'by author' do + before do + merge_request.author = @u_lazy_participant + merge_request.save + notification.resolve_all_discussions(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + end + end end describe 'Projects' do diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb new file mode 100644 index 00000000000..a616275e883 --- /dev/null +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -0,0 +1,384 @@ +require 'spec_helper' + +describe SlashCommands::InterpretService, services: true do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:issue) { create(:issue, project: project) } + let(:milestone) { create(:milestone, project: project, title: '9.10') } + let(:inprogress) { create(:label, project: project, title: 'In Progress') } + let(:bug) { create(:label, project: project, title: 'Bug') } + + before do + project.team << [user, :developer] + end + + describe '#execute' do + let(:service) { described_class.new(project, user) } + let(:merge_request) { create(:merge_request, source_project: project) } + + shared_examples 'reopen command' do + it 'returns state_event: "reopen" if content contains /reopen' do + issuable.close! + _, updates = service.execute(content, issuable) + + expect(updates).to eq(state_event: 'reopen') + end + end + + shared_examples 'close command' do + it 'returns state_event: "close" if content contains /close' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(state_event: 'close') + end + end + + shared_examples 'title command' do + it 'populates title: "A brand new title" if content contains /title A brand new title' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(title: 'A brand new title') + end + end + + shared_examples 'assign command' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(assignee_id: user.id) + end + end + + shared_examples 'unassign command' do + it 'populates assignee_id: nil if content contains /unassign' do + issuable.update(assignee_id: user.id) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(assignee_id: nil) + end + end + + shared_examples 'milestone command' do + it 'fetches milestone and populates milestone_id if content contains /milestone' do + milestone # populate the milestone + _, updates = service.execute(content, issuable) + + expect(updates).to eq(milestone_id: milestone.id) + end + end + + shared_examples 'remove_milestone command' do + it 'populates milestone_id: nil if content contains /remove_milestone' do + issuable.update(milestone_id: milestone.id) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(milestone_id: nil) + end + end + + shared_examples 'label command' do + it 'fetches label ids and populates add_label_ids if content contains /label' do + bug # populate the label + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(add_label_ids: [bug.id, inprogress.id]) + end + end + + shared_examples 'unlabel command' do + it 'fetches label ids and populates remove_label_ids if content contains /unlabel' do + issuable.update(label_ids: [inprogress.id]) # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(remove_label_ids: [inprogress.id]) + end + end + + shared_examples 'unlabel command with no argument' do + it 'populates label_ids: [] if content contains /unlabel with no arguments' do + issuable.update(label_ids: [inprogress.id]) # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(label_ids: []) + end + end + + shared_examples 'relabel command' do + it 'populates label_ids: [] if content contains /relabel' do + issuable.update(label_ids: [bug.id]) # populate the label + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(label_ids: [inprogress.id]) + end + end + + shared_examples 'todo command' do + it 'populates todo_event: "add" if content contains /todo' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(todo_event: 'add') + end + end + + shared_examples 'done command' do + it 'populates todo_event: "done" if content contains /done' do + TodoService.new.mark_todo(issuable, user) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(todo_event: 'done') + end + end + + shared_examples 'subscribe command' do + it 'populates subscription_event: "subscribe" if content contains /subscribe' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(subscription_event: 'subscribe') + end + end + + shared_examples 'unsubscribe command' do + it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do + issuable.subscribe(user) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(subscription_event: 'unsubscribe') + end + end + + shared_examples 'due command' do + it 'populates due_date: Date.new(2016, 8, 28) if content contains /due 2016-08-28' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(due_date: defined?(expected_date) ? expected_date : Date.new(2016, 8, 28)) + end + end + + shared_examples 'remove_due_date command' do + it 'populates due_date: nil if content contains /remove_due_date' do + issuable.update(due_date: Date.today) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(due_date: nil) + end + end + + shared_examples 'empty command' do + it 'populates {} if content contains an unsupported command' do + _, updates = service.execute(content, issuable) + + expect(updates).to be_empty + end + end + + it_behaves_like 'reopen command' do + let(:content) { '/reopen' } + let(:issuable) { issue } + end + + it_behaves_like 'reopen command' do + let(:content) { '/reopen' } + let(:issuable) { merge_request } + end + + it_behaves_like 'close command' do + let(:content) { '/close' } + let(:issuable) { issue } + end + + it_behaves_like 'close command' do + let(:content) { '/close' } + let(:issuable) { merge_request } + end + + it_behaves_like 'title command' do + let(:content) { '/title A brand new title' } + let(:issuable) { issue } + end + + it_behaves_like 'title command' do + let(:content) { '/title A brand new title' } + let(:issuable) { merge_request } + end + + it_behaves_like 'empty command' do + let(:content) { '/title' } + let(:issuable) { issue } + end + + it_behaves_like 'assign command' do + let(:content) { "/assign @#{user.username}" } + let(:issuable) { issue } + end + + it_behaves_like 'assign command' do + let(:content) { "/assign @#{user.username}" } + let(:issuable) { merge_request } + end + + it_behaves_like 'empty command' do + let(:content) { '/assign @abcd1234' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/assign' } + let(:issuable) { issue } + end + + it_behaves_like 'unassign command' do + let(:content) { '/unassign' } + let(:issuable) { issue } + end + + it_behaves_like 'unassign command' do + let(:content) { '/unassign' } + let(:issuable) { merge_request } + end + + it_behaves_like 'milestone command' do + let(:content) { "/milestone %#{milestone.title}" } + let(:issuable) { issue } + end + + it_behaves_like 'milestone command' do + let(:content) { "/milestone %#{milestone.title}" } + let(:issuable) { merge_request } + end + + it_behaves_like 'remove_milestone command' do + let(:content) { '/remove_milestone' } + let(:issuable) { issue } + end + + it_behaves_like 'remove_milestone command' do + let(:content) { '/remove_milestone' } + let(:issuable) { merge_request } + end + + it_behaves_like 'label command' do + let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { issue } + end + + it_behaves_like 'label command' do + let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { merge_request } + end + + it_behaves_like 'unlabel command' do + let(:content) { %(/unlabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'unlabel command' do + let(:content) { %(/unlabel ~"#{inprogress.title}") } + let(:issuable) { merge_request } + end + + it_behaves_like 'unlabel command with no argument' do + let(:content) { %(/unlabel) } + let(:issuable) { issue } + end + + it_behaves_like 'unlabel command with no argument' do + let(:content) { %(/unlabel) } + let(:issuable) { merge_request } + end + + it_behaves_like 'relabel command' do + let(:content) { %(/relabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'relabel command' do + let(:content) { %(/relabel ~"#{inprogress.title}") } + let(:issuable) { merge_request } + end + + it_behaves_like 'todo command' do + let(:content) { '/todo' } + let(:issuable) { issue } + end + + it_behaves_like 'todo command' do + let(:content) { '/todo' } + let(:issuable) { merge_request } + end + + it_behaves_like 'done command' do + let(:content) { '/done' } + let(:issuable) { issue } + end + + it_behaves_like 'done command' do + let(:content) { '/done' } + let(:issuable) { merge_request } + end + + it_behaves_like 'subscribe command' do + let(:content) { '/subscribe' } + let(:issuable) { issue } + end + + it_behaves_like 'subscribe command' do + let(:content) { '/subscribe' } + let(:issuable) { merge_request } + end + + it_behaves_like 'unsubscribe command' do + let(:content) { '/unsubscribe' } + let(:issuable) { issue } + end + + it_behaves_like 'unsubscribe command' do + let(:content) { '/unsubscribe' } + let(:issuable) { merge_request } + end + + it_behaves_like 'due command' do + let(:content) { '/due 2016-08-28' } + let(:issuable) { issue } + end + + it_behaves_like 'due command' do + let(:content) { '/due tomorrow' } + let(:issuable) { issue } + let(:expected_date) { Date.tomorrow } + end + + it_behaves_like 'due command' do + let(:content) { '/due 5 days from now' } + let(:issuable) { issue } + let(:expected_date) { 5.days.from_now.to_date } + end + + it_behaves_like 'due command' do + let(:content) { '/due in 2 days' } + let(:issuable) { issue } + let(:expected_date) { 2.days.from_now.to_date } + end + + it_behaves_like 'empty command' do + let(:content) { '/due foo bar' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/due 2016-08-28' } + let(:issuable) { merge_request } + end + + it_behaves_like 'remove_due_date command' do + let(:content) { '/remove_due_date' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/remove_due_date' } + let(:issuable) { merge_request } + end + end +end diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 6c3cbeae13c..296fd1bd5a4 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -194,12 +194,12 @@ describe TodoService, services: true do end end - describe '#mark_todos_as_done' do - it 'marks related todos for the user as done' do - first_todo = create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) - second_todo = create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) + shared_examples 'marking todos as done' do |meth| + let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } + let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } - service.mark_todos_as_done([first_todo, second_todo], john_doe) + it 'marks related todos for the user as done' do + service.send(meth, collection, john_doe) expect(first_todo.reload).to be_done expect(second_todo.reload).to be_done @@ -207,20 +207,30 @@ describe TodoService, services: true do describe 'cached counts' do it 'updates when todos change' do - todo = create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) - expect(john_doe.todos_done_count).to eq(0) - expect(john_doe.todos_pending_count).to eq(1) + expect(john_doe.todos_pending_count).to eq(2) expect(john_doe).to receive(:update_todos_count_cache).and_call_original - service.mark_todos_as_done([todo], john_doe) + service.send(meth, collection, john_doe) - expect(john_doe.todos_done_count).to eq(1) + expect(john_doe.todos_done_count).to eq(2) expect(john_doe.todos_pending_count).to eq(0) end end end + describe '#mark_todos_as_done' do + it_behaves_like 'marking todos as done', :mark_todos_as_done do + let(:collection) { [first_todo, second_todo] } + end + end + + describe '#mark_todos_as_done_by_ids' do + it_behaves_like 'marking todos as done', :mark_todos_as_done_by_ids do + let(:collection) { [first_todo, second_todo].map(&:id) } + end + end + describe '#new_note' do let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } @@ -290,6 +300,18 @@ describe TodoService, services: true do should_create_todo(user: author, target: unassigned_issue, action: Todo::MARKED) end end + + describe '#todo_exists?' do + it 'returns false when no todo exist for the given issuable' do + expect(service.todo_exist?(unassigned_issue, author)).to be_falsy + end + + it 'returns true when a todo exist for the given issuable' do + service.mark_todo(unassigned_issue, author) + + expect(service.todo_exist?(unassigned_issue, author)).to be_truthy + end + end end describe 'Merge Requests' do diff --git a/spec/support/api/schema_matcher.rb b/spec/support/api/schema_matcher.rb new file mode 100644 index 00000000000..e42d727672b --- /dev/null +++ b/spec/support/api/schema_matcher.rb @@ -0,0 +1,8 @@ +RSpec::Matchers.define :match_response_schema do |schema, **options| + match do |response| + schema_directory = "#{Dir.pwd}/spec/fixtures/api/schemas" + schema_path = "#{schema_directory}/#{schema}.json" + + JSON::Validator.validate!(schema_path, response.body, options) + end +end diff --git a/spec/support/email_helpers.rb b/spec/support/email_helpers.rb index a85ab22ce36..0bfc4685532 100644 --- a/spec/support/email_helpers.rb +++ b/spec/support/email_helpers.rb @@ -3,6 +3,16 @@ module EmailHelpers ActionMailer::Base.deliveries.map(&:to).flatten.count(user.email) == 1 end + def reset_delivered_emails! + ActionMailer::Base.deliveries.clear + end + + def should_only_email(*users) + users.each {|user| should_email(user) } + recipients = ActionMailer::Base.deliveries.flat_map(&:to) + expect(recipients.count).to eq(users.count) + end + def should_email(user) expect(sent_to_user?(user)).to be_truthy end diff --git a/spec/support/fake_u2f_device.rb b/spec/support/fake_u2f_device.rb index f550e9a0160..8c407b867fe 100644 --- a/spec/support/fake_u2f_device.rb +++ b/spec/support/fake_u2f_device.rb @@ -1,6 +1,9 @@ class FakeU2fDevice - def initialize(page) + attr_reader :name + + def initialize(page, name) @page = page + @name = name end def respond_to_u2f_registration diff --git a/spec/support/import_export/import_export.yml b/spec/support/import_export/import_export.yml index 3ceec506401..17136dee000 100644 --- a/spec/support/import_export/import_export.yml +++ b/spec/support/import_export/import_export.yml @@ -7,6 +7,8 @@ project_tree: - :merge_request_test - commit_statuses: - :commit + - project_members: + - :user included_attributes: project: @@ -14,6 +16,8 @@ included_attributes: - :path merge_requests: - :id + user: + - :email excluded_attributes: merge_requests: diff --git a/spec/support/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/issuable_create_service_slash_commands_shared_examples.rb new file mode 100644 index 00000000000..5f9645ed44f --- /dev/null +++ b/spec/support/issuable_create_service_slash_commands_shared_examples.rb @@ -0,0 +1,83 @@ +# Specifications for behavior common to all objects with executable attributes. +# It can take a `default_params`. + +shared_examples 'new issuable record that supports slash commands' do + let!(:project) { create(:project) } + let(:user) { create(:user).tap { |u| project.team << [u, :master] } } + let(:assignee) { create(:user) } + let!(:milestone) { create(:milestone, project: project) } + let!(:labels) { create_list(:label, 3, project: project) } + let(:base_params) { { title: FFaker::Lorem.sentence(3) } } + let(:params) { base_params.merge(defined?(default_params) ? default_params : {}).merge(example_params) } + let(:issuable) { described_class.new(project, user, params).execute } + + context 'with labels in command only' do + let(:example_params) do + { + description: "/label ~#{labels.first.name} ~#{labels.second.name}\n/unlabel ~#{labels.third.name}" + } + end + + it 'attaches labels to issuable' do + expect(issuable).to be_persisted + expect(issuable.label_ids).to match_array([labels.first.id, labels.second.id]) + end + end + + context 'with labels in params and command' do + let(:example_params) do + { + label_ids: [labels.second.id], + description: "/label ~#{labels.first.name}\n/unlabel ~#{labels.third.name}" + } + end + + it 'attaches all labels to issuable' do + expect(issuable).to be_persisted + expect(issuable.label_ids).to match_array([labels.first.id, labels.second.id]) + end + end + + context 'with assignee and milestone in command only' do + let(:example_params) do + { + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") + } + end + + it 'assigns and sets milestone to issuable' do + expect(issuable).to be_persisted + expect(issuable.assignee).to eq(assignee) + expect(issuable.milestone).to eq(milestone) + end + end + + context 'with assignee and milestone in params and command' do + let(:example_params) do + { + assignee: build_stubbed(:user), + milestone_id: double(:milestone), + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") + } + end + + it 'assigns and sets milestone to issuable from command' do + expect(issuable).to be_persisted + expect(issuable.assignee).to eq(assignee) + expect(issuable.milestone).to eq(milestone) + end + end + + describe '/close' do + let(:example_params) do + { + description: '/close' + } + end + + it 'returns an open issue' do + expect(issuable).to be_persisted + expect(issuable).to be_open + end + end +end diff --git a/spec/support/issuable_slash_commands_shared_examples.rb b/spec/support/issuable_slash_commands_shared_examples.rb new file mode 100644 index 00000000000..d2a49ea5c5e --- /dev/null +++ b/spec/support/issuable_slash_commands_shared_examples.rb @@ -0,0 +1,289 @@ +# Specifications for behavior common to all objects with executable attributes. +# It takes a `issuable_type`, and expect an `issuable`. + +shared_examples 'issuable record that supports slash commands in its description and notes' do |issuable_type| + let(:master) { create(:user) } + let(:assignee) { create(:user, username: 'bob') } + let(:guest) { create(:user) } + let(:project) { create(:project, :public) } + let!(:milestone) { create(:milestone, project: project, title: 'ASAP') } + let!(:label_bug) { create(:label, project: project, title: 'bug') } + let!(:label_feature) { create(:label, project: project, title: 'feature') } + let(:new_url_opts) { {} } + + before do + project.team << [master, :master] + project.team << [assignee, :developer] + project.team << [guest, :guest] + login_with(master) + end + + describe "new #{issuable_type}" do + context 'with commands in the description' do + it "creates the #{issuable_type} and interpret commands accordingly" do + visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts) + fill_in "#{issuable_type}_title", with: 'bug 345' + fill_in "#{issuable_type}_description", with: "bug description\n/label ~bug\n/milestone %\"ASAP\"" + click_button "Submit #{issuable_type}".humanize + + issuable = project.public_send(issuable_type.to_s.pluralize).first + + expect(issuable.description).to eq "bug description" + expect(issuable.labels).to eq [label_bug] + expect(issuable.milestone).to eq milestone + expect(page).to have_content 'bug 345' + expect(page).to have_content 'bug description' + end + end + end + + describe "note on #{issuable_type}" do + before do + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + end + + context 'with a note containing commands' do + it 'creates a note without the commands and interpret the commands accordingly' do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "Awesome!\n/assign @bob\n/label ~bug\n/milestone %\"ASAP\"" + click_button 'Comment' + end + + expect(page).to have_content 'Awesome!' + expect(page).not_to have_content '/assign @bob' + expect(page).not_to have_content '/label ~bug' + expect(page).not_to have_content '/milestone %"ASAP"' + + issuable.reload + note = issuable.notes.user.first + + expect(note.note).to eq "Awesome!" + expect(issuable.assignee).to eq assignee + expect(issuable.labels).to eq [label_bug] + expect(issuable.milestone).to eq milestone + end + end + + context 'with a note containing only commands' do + it 'does not create a note but interpret the commands accordingly' do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/assign @bob\n/label ~bug\n/milestone %\"ASAP\"" + click_button 'Comment' + end + + expect(page).not_to have_content '/assign @bob' + expect(page).not_to have_content '/label ~bug' + expect(page).not_to have_content '/milestone %"ASAP"' + expect(page).to have_content 'Your commands have been executed!' + + issuable.reload + + expect(issuable.notes.user).to be_empty + expect(issuable.assignee).to eq assignee + expect(issuable.labels).to eq [label_bug] + expect(issuable.milestone).to eq milestone + end + end + + context "with a note closing the #{issuable_type}" do + before do + expect(issuable).to be_open + end + + context "when current user can close #{issuable_type}" do + it "closes the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/close" + click_button 'Comment' + end + + expect(page).not_to have_content '/close' + expect(page).to have_content 'Your commands have been executed!' + + expect(issuable.reload).to be_closed + end + end + + context "when current user cannot close #{issuable_type}" do + before do + logout + login_with(guest) + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + end + + it "does not close the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/close" + click_button 'Comment' + end + + expect(page).not_to have_content '/close' + expect(page).not_to have_content 'Your commands have been executed!' + + expect(issuable).to be_open + end + end + end + + context "with a note reopening the #{issuable_type}" do + before do + issuable.close + expect(issuable).to be_closed + end + + context "when current user can reopen #{issuable_type}" do + it "reopens the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/reopen" + click_button 'Comment' + end + + expect(page).not_to have_content '/reopen' + expect(page).to have_content 'Your commands have been executed!' + + expect(issuable.reload).to be_open + end + end + + context "when current user cannot reopen #{issuable_type}" do + before do + logout + login_with(guest) + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + end + + it "does not reopen the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/reopen" + click_button 'Comment' + end + + expect(page).not_to have_content '/reopen' + expect(page).not_to have_content 'Your commands have been executed!' + + expect(issuable).to be_closed + end + end + end + + context "with a note changing the #{issuable_type}'s title" do + context "when current user can change title of #{issuable_type}" do + it "reopens the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/title Awesome new title" + click_button 'Comment' + end + + expect(page).not_to have_content '/title' + expect(page).to have_content 'Your commands have been executed!' + + expect(issuable.reload.title).to eq 'Awesome new title' + end + end + + context "when current user cannot change title of #{issuable_type}" do + before do + logout + login_with(guest) + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + end + + it "does not reopen the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/title Awesome new title" + click_button 'Comment' + end + + expect(page).not_to have_content '/title' + expect(page).not_to have_content 'Your commands have been executed!' + + expect(issuable.reload.title).not_to eq 'Awesome new title' + end + end + end + + context "with a note marking the #{issuable_type} as todo" do + it "creates a new todo for the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/todo" + click_button 'Comment' + end + + expect(page).not_to have_content '/todo' + expect(page).to have_content 'Your commands have been executed!' + + todos = TodosFinder.new(master).execute + todo = todos.first + + expect(todos.size).to eq 1 + expect(todo).to be_pending + expect(todo.target).to eq issuable + expect(todo.author).to eq master + expect(todo.user).to eq master + end + end + + context "with a note marking the #{issuable_type} as done" do + before do + TodoService.new.mark_todo(issuable, master) + end + + it "creates a new todo for the #{issuable_type}" do + todos = TodosFinder.new(master).execute + todo = todos.first + + expect(todos.size).to eq 1 + expect(todos.first).to be_pending + expect(todo.target).to eq issuable + expect(todo.author).to eq master + expect(todo.user).to eq master + + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/done" + click_button 'Comment' + end + + expect(page).not_to have_content '/done' + expect(page).to have_content 'Your commands have been executed!' + + expect(todo.reload).to be_done + end + end + + context "with a note subscribing to the #{issuable_type}" do + it "creates a new todo for the #{issuable_type}" do + expect(issuable.subscribed?(master)).to be_falsy + + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/subscribe" + click_button 'Comment' + end + + expect(page).not_to have_content '/subscribe' + expect(page).to have_content 'Your commands have been executed!' + + expect(issuable.subscribed?(master)).to be_truthy + end + end + + context "with a note unsubscribing to the #{issuable_type} as done" do + before do + issuable.subscribe(master) + end + + it "creates a new todo for the #{issuable_type}" do + expect(issuable.subscribed?(master)).to be_truthy + + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/unsubscribe" + click_button 'Comment' + end + + expect(page).not_to have_content '/unsubscribe' + expect(page).to have_content 'Your commands have been executed!' + + expect(issuable.subscribed?(master)).to be_falsy + end + end + end +end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 1c0c66969e3..edbbfc3c9e5 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -5,25 +5,31 @@ module TestEnv # When developing the seed repository, comment out the branch you will modify. BRANCH_SHA = { - 'empty-branch' => '7efb185', - 'ends-with.json' => '98b0d8b3', - 'flatten-dir' => 'e56497b', - 'feature' => '0b4bc9a', - 'feature_conflict' => 'bb5206f', - 'fix' => '48f0be4', - 'improve/awesome' => '5937ac0', - 'markdown' => '0ed8c6c', - 'lfs' => 'be93687', - 'master' => '5937ac0', - "'test'" => 'e56497b', - 'orphaned-branch' => '45127a9', - 'binary-encoding' => '7b1cf43', - 'gitattributes' => '5a62481', - 'expand-collapse-diffs' => '4842455', - 'expand-collapse-files' => '025db92', - 'expand-collapse-lines' => '238e82d', - 'video' => '8879059', - 'crlf-diff' => '5938907' + 'empty-branch' => '7efb185', + 'ends-with.json' => '98b0d8b3', + 'flatten-dir' => 'e56497b', + 'feature' => '0b4bc9a', + 'feature_conflict' => 'bb5206f', + 'fix' => '48f0be4', + 'improve/awesome' => '5937ac0', + 'markdown' => '0ed8c6c', + 'lfs' => 'be93687', + 'master' => '5937ac0', + "'test'" => 'e56497b', + 'orphaned-branch' => '45127a9', + 'binary-encoding' => '7b1cf43', + 'gitattributes' => '5a62481', + 'expand-collapse-diffs' => '4842455', + 'expand-collapse-files' => '025db92', + 'expand-collapse-lines' => '238e82d', + 'video' => '8879059', + 'crlf-diff' => '5938907', + 'conflict-start' => '14fa46b', + 'conflict-resolvable' => '1450cd6', + 'conflict-binary-file' => '259a6fb', + 'conflict-contains-conflict-markers' => '5e0964c', + 'conflict-missing-side' => 'eb227b3', + 'conflict-too-large' => '39fa04f', } # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily diff --git a/spec/support/updating_mentions_shared_examples.rb b/spec/support/updating_mentions_shared_examples.rb new file mode 100644 index 00000000000..e0c59a5c280 --- /dev/null +++ b/spec/support/updating_mentions_shared_examples.rb @@ -0,0 +1,32 @@ +RSpec.shared_examples 'updating mentions' do |service_class| + let(:mentioned_user) { create(:user) } + let(:service_class) { service_class } + + before { project.team << [mentioned_user, :developer] } + + def update_mentionable(opts) + reset_delivered_emails! + + perform_enqueued_jobs do + service_class.new(project, user, opts).execute(mentionable) + end + + mentionable.reload + end + + context 'in title' do + before { update_mentionable(title: mentioned_user.to_reference) } + + it 'emails only the newly-mentioned user' do + should_only_email(mentioned_user) + end + end + + context 'in description' do + before { update_mentionable(description: mentioned_user.to_reference) } + + it 'emails only the newly-mentioned user' do + should_only_email(mentioned_user) + end + end +end diff --git a/spec/views/layouts/_head.html.haml_spec.rb b/spec/views/layouts/_head.html.haml_spec.rb new file mode 100644 index 00000000000..3fddfb3b62f --- /dev/null +++ b/spec/views/layouts/_head.html.haml_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe 'layouts/_head' do + before do + stub_template 'layouts/_user_styles.html.haml' => '' + end + + it 'escapes HTML-safe strings in page_title' do + stub_helper_with_safe_string(:page_title) + + render + + expect(rendered).to match(%{content="foo" http-equiv="refresh"}) + end + + it 'escapes HTML-safe strings in page_description' do + stub_helper_with_safe_string(:page_description) + + render + + expect(rendered).to match(%{content="foo" http-equiv="refresh"}) + end + + it 'escapes HTML-safe strings in page_image' do + stub_helper_with_safe_string(:page_image) + + render + + expect(rendered).to match(%{content="foo" http-equiv="refresh"}) + end + + def stub_helper_with_safe_string(method) + allow_any_instance_of(PageLayoutHelper).to receive(method) + .and_return(%q{foo" http-equiv="refresh}.html_safe) + end +end diff --git a/spec/views/projects/merge_requests/_heading.html.haml_spec.rb b/spec/views/projects/merge_requests/_heading.html.haml_spec.rb new file mode 100644 index 00000000000..733b2dfa7ff --- /dev/null +++ b/spec/views/projects/merge_requests/_heading.html.haml_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe 'projects/merge_requests/widget/_heading' do + include Devise::TestHelpers + + context 'when released to an environment' do + let(:project) { merge_request.target_project } + let(:merge_request) { create(:merge_request, :merged) } + let(:environment) { create(:environment, project: project) } + let!(:deployment) do + create(:deployment, environment: environment, sha: project.commit('master').id) + end + + before do + assign(:merge_request, merge_request) + assign(:project, project) + + render + end + + it 'displays that the environment is deployed' do + expect(rendered).to match("Deployed to") + expect(rendered).to match("#{environment.name}") + end + end +end diff --git a/spec/workers/build_email_worker_spec.rb b/spec/workers/build_email_worker_spec.rb index 98deae0a588..788b92c1b84 100644 --- a/spec/workers/build_email_worker_spec.rb +++ b/spec/workers/build_email_worker_spec.rb @@ -5,7 +5,7 @@ describe BuildEmailWorker do let(:build) { create(:ci_build) } let(:user) { create(:user) } - let(:data) { Gitlab::BuildDataBuilder.build(build) } + let(:data) { Gitlab::DataBuilder::Build.build(build) } subject { BuildEmailWorker.new } diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb index 796751efe8d..eecc32875a5 100644 --- a/spec/workers/emails_on_push_worker_spec.rb +++ b/spec/workers/emails_on_push_worker_spec.rb @@ -5,7 +5,7 @@ describe EmailsOnPushWorker do let(:project) { create(:project) } let(:user) { create(:user) } - let(:data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) } let(:recipients) { user.email } let(:perform) { subject.perform(project.id, recipients, data.stringify_keys) } diff --git a/vendor/assets/javascripts/Sortable.js b/vendor/assets/javascripts/Sortable.js new file mode 100644 index 00000000000..eca7c5012b2 --- /dev/null +++ b/vendor/assets/javascripts/Sortable.js @@ -0,0 +1,1285 @@ +/**! + * Sortable + * @author RubaXa + * @license MIT + */ + + +(function (factory) { + "use strict"; + + if (typeof define === "function" && define.amd) { + define(factory); + } + else if (typeof module != "undefined" && typeof module.exports != "undefined") { + module.exports = factory(); + } + else if (typeof Package !== "undefined") { + Sortable = factory(); // export for Meteor.js + } + else { + /* jshint sub:true */ + window["Sortable"] = factory(); + } +})(function () { + "use strict"; + + var dragEl, + parentEl, + ghostEl, + cloneEl, + rootEl, + nextEl, + + scrollEl, + scrollParentEl, + + lastEl, + lastCSS, + lastParentCSS, + + oldIndex, + newIndex, + + activeGroup, + autoScroll = {}, + + tapEvt, + touchEvt, + + moved, + + /** @const */ + RSPACE = /\s+/g, + + expando = 'Sortable' + (new Date).getTime(), + + win = window, + document = win.document, + parseInt = win.parseInt, + + supportDraggable = !!('draggable' in document.createElement('div')), + supportCssPointerEvents = (function (el) { + el = document.createElement('x'); + el.style.cssText = 'pointer-events:auto'; + return el.style.pointerEvents === 'auto'; + })(), + + _silent = false, + + abs = Math.abs, + min = Math.min, + slice = [].slice, + + touchDragOverListeners = [], + + _autoScroll = _throttle(function (/**Event*/evt, /**Object*/options, /**HTMLElement*/rootEl) { + // Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=505521 + if (rootEl && options.scroll) { + var el, + rect, + sens = options.scrollSensitivity, + speed = options.scrollSpeed, + + x = evt.clientX, + y = evt.clientY, + + winWidth = window.innerWidth, + winHeight = window.innerHeight, + + vx, + vy + ; + + // Delect scrollEl + if (scrollParentEl !== rootEl) { + scrollEl = options.scroll; + scrollParentEl = rootEl; + + if (scrollEl === true) { + scrollEl = rootEl; + + do { + if ((scrollEl.offsetWidth < scrollEl.scrollWidth) || + (scrollEl.offsetHeight < scrollEl.scrollHeight) + ) { + break; + } + /* jshint boss:true */ + } while (scrollEl = scrollEl.parentNode); + } + } + + if (scrollEl) { + el = scrollEl; + rect = scrollEl.getBoundingClientRect(); + vx = (abs(rect.right - x) <= sens) - (abs(rect.left - x) <= sens); + vy = (abs(rect.bottom - y) <= sens) - (abs(rect.top - y) <= sens); + } + + + if (!(vx || vy)) { + vx = (winWidth - x <= sens) - (x <= sens); + vy = (winHeight - y <= sens) - (y <= sens); + + /* jshint expr:true */ + (vx || vy) && (el = win); + } + + + if (autoScroll.vx !== vx || autoScroll.vy !== vy || autoScroll.el !== el) { + autoScroll.el = el; + autoScroll.vx = vx; + autoScroll.vy = vy; + + clearInterval(autoScroll.pid); + + if (el) { + autoScroll.pid = setInterval(function () { + if (el === win) { + win.scrollTo(win.pageXOffset + vx * speed, win.pageYOffset + vy * speed); + } else { + vy && (el.scrollTop += vy * speed); + vx && (el.scrollLeft += vx * speed); + } + }, 24); + } + } + } + }, 30), + + _prepareGroup = function (options) { + var group = options.group; + + if (!group || typeof group != 'object') { + group = options.group = {name: group}; + } + + ['pull', 'put'].forEach(function (key) { + if (!(key in group)) { + group[key] = true; + } + }); + + options.groups = ' ' + group.name + (group.put.join ? ' ' + group.put.join(' ') : '') + ' '; + } + ; + + + + /** + * @class Sortable + * @param {HTMLElement} el + * @param {Object} [options] + */ + function Sortable(el, options) { + if (!(el && el.nodeType && el.nodeType === 1)) { + throw 'Sortable: `el` must be HTMLElement, and not ' + {}.toString.call(el); + } + + this.el = el; // root element + this.options = options = _extend({}, options); + + + // Export instance + el[expando] = this; + + + // Default options + var defaults = { + group: Math.random(), + sort: true, + disabled: false, + store: null, + handle: null, + scroll: true, + scrollSensitivity: 30, + scrollSpeed: 10, + draggable: /[uo]l/i.test(el.nodeName) ? 'li' : '>*', + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + ignore: 'a, img', + filter: null, + animation: 0, + setData: function (dataTransfer, dragEl) { + dataTransfer.setData('Text', dragEl.textContent); + }, + dropBubble: false, + dragoverBubble: false, + dataIdAttr: 'data-id', + delay: 0, + forceFallback: false, + fallbackClass: 'sortable-fallback', + fallbackOnBody: false, + fallbackTolerance: 0 + }; + + + // Set default options + for (var name in defaults) { + !(name in options) && (options[name] = defaults[name]); + } + + _prepareGroup(options); + + // Bind all private methods + for (var fn in this) { + if (fn.charAt(0) === '_') { + this[fn] = this[fn].bind(this); + } + } + + // Setup drag mode + this.nativeDraggable = options.forceFallback ? false : supportDraggable; + + // Bind events + _on(el, 'mousedown', this._onTapStart); + _on(el, 'touchstart', this._onTapStart); + + if (this.nativeDraggable) { + _on(el, 'dragover', this); + _on(el, 'dragenter', this); + } + + touchDragOverListeners.push(this._onDragOver); + + // Restore sorting + options.store && this.sort(options.store.get(this)); + } + + + Sortable.prototype = /** @lends Sortable.prototype */ { + constructor: Sortable, + + _onTapStart: function (/** Event|TouchEvent */evt) { + var _this = this, + el = this.el, + options = this.options, + type = evt.type, + touch = evt.touches && evt.touches[0], + target = (touch || evt).target, + originalTarget = target, + filter = options.filter, + startIndex; + + // Don't trigger start event when an element is been dragged, otherwise the evt.oldindex always wrong when set option.group. + if (dragEl) { + return; + } + + if (type === 'mousedown' && evt.button !== 0 || options.disabled) { + return; // only left button or enabled + } + + target = _closest(target, options.draggable, el); + + if (!target) { + return; + } + + if (options.handle && !_closest(originalTarget, options.handle, el)) { + return; + } + + // Get the index of the dragged element within its parent + startIndex = _index(target, options.draggable); + + // Check filter + if (typeof filter === 'function') { + if (filter.call(this, evt, target, this)) { + _dispatchEvent(_this, originalTarget, 'filter', target, el, startIndex); + evt.preventDefault(); + return; // cancel dnd + } + } + else if (filter) { + filter = filter.split(',').some(function (criteria) { + criteria = _closest(originalTarget, criteria.trim(), el); + + if (criteria) { + _dispatchEvent(_this, criteria, 'filter', target, el, startIndex); + return true; + } + }); + + if (filter) { + evt.preventDefault(); + return; // cancel dnd + } + } + + // Prepare `dragstart` + this._prepareDragStart(evt, touch, target, startIndex); + }, + + _prepareDragStart: function (/** Event */evt, /** Touch */touch, /** HTMLElement */target, /** Number */startIndex) { + var _this = this, + el = _this.el, + options = _this.options, + ownerDocument = el.ownerDocument, + dragStartFn; + + if (target && !dragEl && (target.parentNode === el)) { + tapEvt = evt; + + rootEl = el; + dragEl = target; + parentEl = dragEl.parentNode; + nextEl = dragEl.nextSibling; + activeGroup = options.group; + oldIndex = startIndex; + + this._lastX = (touch || evt).clientX; + this._lastY = (touch || evt).clientY; + + dragStartFn = function () { + // Delayed drag has been triggered + // we can re-enable the events: touchmove/mousemove + _this._disableDelayedDrag(); + + // Make the element draggable + dragEl.draggable = true; + + // Chosen item + _toggleClass(dragEl, _this.options.chosenClass, true); + + // Bind the events: dragstart/dragend + _this._triggerDragStart(touch); + + // Drag start event + _dispatchEvent(_this, rootEl, 'choose', dragEl, rootEl, oldIndex); + }; + + // Disable "draggable" + options.ignore.split(',').forEach(function (criteria) { + _find(dragEl, criteria.trim(), _disableDraggable); + }); + + _on(ownerDocument, 'mouseup', _this._onDrop); + _on(ownerDocument, 'touchend', _this._onDrop); + _on(ownerDocument, 'touchcancel', _this._onDrop); + + if (options.delay) { + // If the user moves the pointer or let go the click or touch + // before the delay has been reached: + // disable the delayed drag + _on(ownerDocument, 'mouseup', _this._disableDelayedDrag); + _on(ownerDocument, 'touchend', _this._disableDelayedDrag); + _on(ownerDocument, 'touchcancel', _this._disableDelayedDrag); + _on(ownerDocument, 'mousemove', _this._disableDelayedDrag); + _on(ownerDocument, 'touchmove', _this._disableDelayedDrag); + + _this._dragStartTimer = setTimeout(dragStartFn, options.delay); + } else { + dragStartFn(); + } + } + }, + + _disableDelayedDrag: function () { + var ownerDocument = this.el.ownerDocument; + + clearTimeout(this._dragStartTimer); + _off(ownerDocument, 'mouseup', this._disableDelayedDrag); + _off(ownerDocument, 'touchend', this._disableDelayedDrag); + _off(ownerDocument, 'touchcancel', this._disableDelayedDrag); + _off(ownerDocument, 'mousemove', this._disableDelayedDrag); + _off(ownerDocument, 'touchmove', this._disableDelayedDrag); + }, + + _triggerDragStart: function (/** Touch */touch) { + if (touch) { + // Touch device support + tapEvt = { + target: dragEl, + clientX: touch.clientX, + clientY: touch.clientY + }; + + this._onDragStart(tapEvt, 'touch'); + } + else if (!this.nativeDraggable) { + this._onDragStart(tapEvt, true); + } + else { + _on(dragEl, 'dragend', this); + _on(rootEl, 'dragstart', this._onDragStart); + } + + try { + if (document.selection) { + document.selection.empty(); + } else { + window.getSelection().removeAllRanges(); + } + } catch (err) { + } + }, + + _dragStarted: function () { + if (rootEl && dragEl) { + // Apply effect + _toggleClass(dragEl, this.options.ghostClass, true); + + Sortable.active = this; + + // Drag start event + _dispatchEvent(this, rootEl, 'start', dragEl, rootEl, oldIndex); + } + }, + + _emulateDragOver: function () { + if (touchEvt) { + if (this._lastX === touchEvt.clientX && this._lastY === touchEvt.clientY) { + return; + } + + this._lastX = touchEvt.clientX; + this._lastY = touchEvt.clientY; + + if (!supportCssPointerEvents) { + _css(ghostEl, 'display', 'none'); + } + + var target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY), + parent = target, + groupName = ' ' + this.options.group.name + '', + i = touchDragOverListeners.length; + + if (parent) { + do { + if (parent[expando] && parent[expando].options.groups.indexOf(groupName) > -1) { + while (i--) { + touchDragOverListeners[i]({ + clientX: touchEvt.clientX, + clientY: touchEvt.clientY, + target: target, + rootEl: parent + }); + } + + break; + } + + target = parent; // store last element + } + /* jshint boss:true */ + while (parent = parent.parentNode); + } + + if (!supportCssPointerEvents) { + _css(ghostEl, 'display', ''); + } + } + }, + + + _onTouchMove: function (/**TouchEvent*/evt) { + if (tapEvt) { + var options = this.options, + fallbackTolerance = options.fallbackTolerance, + touch = evt.touches ? evt.touches[0] : evt, + dx = touch.clientX - tapEvt.clientX, + dy = touch.clientY - tapEvt.clientY, + translate3d = evt.touches ? 'translate3d(' + dx + 'px,' + dy + 'px,0)' : 'translate(' + dx + 'px,' + dy + 'px)'; + + // only set the status to dragging, when we are actually dragging + if (!Sortable.active) { + if (fallbackTolerance && + min(abs(touch.clientX - this._lastX), abs(touch.clientY - this._lastY)) < fallbackTolerance + ) { + return; + } + + this._dragStarted(); + } + + // as well as creating the ghost element on the document body + this._appendGhost(); + + moved = true; + touchEvt = touch; + + _css(ghostEl, 'webkitTransform', translate3d); + _css(ghostEl, 'mozTransform', translate3d); + _css(ghostEl, 'msTransform', translate3d); + _css(ghostEl, 'transform', translate3d); + + evt.preventDefault(); + } + }, + + _appendGhost: function () { + if (!ghostEl) { + var rect = dragEl.getBoundingClientRect(), + css = _css(dragEl), + options = this.options, + ghostRect; + + ghostEl = dragEl.cloneNode(true); + + _toggleClass(ghostEl, options.ghostClass, false); + _toggleClass(ghostEl, options.fallbackClass, true); + + _css(ghostEl, 'top', rect.top - parseInt(css.marginTop, 10)); + _css(ghostEl, 'left', rect.left - parseInt(css.marginLeft, 10)); + _css(ghostEl, 'width', rect.width); + _css(ghostEl, 'height', rect.height); + _css(ghostEl, 'opacity', '0.8'); + _css(ghostEl, 'position', 'fixed'); + _css(ghostEl, 'zIndex', '100000'); + _css(ghostEl, 'pointerEvents', 'none'); + + options.fallbackOnBody && document.body.appendChild(ghostEl) || rootEl.appendChild(ghostEl); + + // Fixing dimensions. + ghostRect = ghostEl.getBoundingClientRect(); + _css(ghostEl, 'width', rect.width * 2 - ghostRect.width); + _css(ghostEl, 'height', rect.height * 2 - ghostRect.height); + } + }, + + _onDragStart: function (/**Event*/evt, /**boolean*/useFallback) { + var dataTransfer = evt.dataTransfer, + options = this.options; + + this._offUpEvents(); + + if (activeGroup.pull == 'clone') { + cloneEl = dragEl.cloneNode(true); + _css(cloneEl, 'display', 'none'); + rootEl.insertBefore(cloneEl, dragEl); + _dispatchEvent(this, rootEl, 'clone', dragEl); + } + + if (useFallback) { + if (useFallback === 'touch') { + // Bind touch events + _on(document, 'touchmove', this._onTouchMove); + _on(document, 'touchend', this._onDrop); + _on(document, 'touchcancel', this._onDrop); + } else { + // Old brwoser + _on(document, 'mousemove', this._onTouchMove); + _on(document, 'mouseup', this._onDrop); + } + + this._loopId = setInterval(this._emulateDragOver, 50); + } + else { + if (dataTransfer) { + dataTransfer.effectAllowed = 'move'; + options.setData && options.setData.call(this, dataTransfer, dragEl); + } + + _on(document, 'drop', this); + setTimeout(this._dragStarted, 0); + } + }, + + _onDragOver: function (/**Event*/evt) { + var el = this.el, + target, + dragRect, + revert, + options = this.options, + group = options.group, + groupPut = group.put, + isOwner = (activeGroup === group), + canSort = options.sort; + + if (evt.preventDefault !== void 0) { + evt.preventDefault(); + !options.dragoverBubble && evt.stopPropagation(); + } + + moved = true; + + if (activeGroup && !options.disabled && + (isOwner + ? canSort || (revert = !rootEl.contains(dragEl)) // Reverting item into the original list + : activeGroup.pull && groupPut && ( + (activeGroup.name === group.name) || // by Name + (groupPut.indexOf && ~groupPut.indexOf(activeGroup.name)) // by Array + ) + ) && + (evt.rootEl === void 0 || evt.rootEl === this.el) // touch fallback + ) { + // Smart auto-scrolling + _autoScroll(evt, options, this.el); + + if (_silent) { + return; + } + + target = _closest(evt.target, options.draggable, el); + dragRect = dragEl.getBoundingClientRect(); + + if (revert) { + _cloneHide(true); + parentEl = rootEl; // actualization + + if (cloneEl || nextEl) { + rootEl.insertBefore(dragEl, cloneEl || nextEl); + } + else if (!canSort) { + rootEl.appendChild(dragEl); + } + + return; + } + + + if ((el.children.length === 0) || (el.children[0] === ghostEl) || + (el === evt.target) && (target = _ghostIsLast(el, evt)) + ) { + + if (target) { + if (target.animated) { + return; + } + + targetRect = target.getBoundingClientRect(); + } + + _cloneHide(isOwner); + + if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect) !== false) { + if (!dragEl.contains(el)) { + el.appendChild(dragEl); + parentEl = el; // actualization + } + + this._animate(dragRect, dragEl); + target && this._animate(targetRect, target); + } + } + else if (target && !target.animated && target !== dragEl && (target.parentNode[expando] !== void 0)) { + if (lastEl !== target) { + lastEl = target; + lastCSS = _css(target); + lastParentCSS = _css(target.parentNode); + } + + + var targetRect = target.getBoundingClientRect(), + width = targetRect.right - targetRect.left, + height = targetRect.bottom - targetRect.top, + floating = /left|right|inline/.test(lastCSS.cssFloat + lastCSS.display) + || (lastParentCSS.display == 'flex' && lastParentCSS['flex-direction'].indexOf('row') === 0), + isWide = (target.offsetWidth > dragEl.offsetWidth), + isLong = (target.offsetHeight > dragEl.offsetHeight), + halfway = (floating ? (evt.clientX - targetRect.left) / width : (evt.clientY - targetRect.top) / height) > 0.5, + nextSibling = target.nextElementSibling, + moveVector = _onMove(rootEl, el, dragEl, dragRect, target, targetRect), + after + ; + + if (moveVector !== false) { + _silent = true; + setTimeout(_unsilent, 30); + + _cloneHide(isOwner); + + if (moveVector === 1 || moveVector === -1) { + after = (moveVector === 1); + } + else if (floating) { + var elTop = dragEl.offsetTop, + tgTop = target.offsetTop; + + if (elTop === tgTop) { + after = (target.previousElementSibling === dragEl) && !isWide || halfway && isWide; + } + else if (target.previousElementSibling === dragEl || dragEl.previousElementSibling === target) { + after = (evt.clientY - targetRect.top) / height > 0.5; + } else { + after = tgTop > elTop; + } + } else { + after = (nextSibling !== dragEl) && !isLong || halfway && isLong; + } + + if (!dragEl.contains(el)) { + if (after && !nextSibling) { + el.appendChild(dragEl); + } else { + target.parentNode.insertBefore(dragEl, after ? nextSibling : target); + } + } + + parentEl = dragEl.parentNode; // actualization + + this._animate(dragRect, dragEl); + this._animate(targetRect, target); + } + } + } + }, + + _animate: function (prevRect, target) { + var ms = this.options.animation; + + if (ms) { + var currentRect = target.getBoundingClientRect(); + + _css(target, 'transition', 'none'); + _css(target, 'transform', 'translate3d(' + + (prevRect.left - currentRect.left) + 'px,' + + (prevRect.top - currentRect.top) + 'px,0)' + ); + + target.offsetWidth; // repaint + + _css(target, 'transition', 'all ' + ms + 'ms'); + _css(target, 'transform', 'translate3d(0,0,0)'); + + clearTimeout(target.animated); + target.animated = setTimeout(function () { + _css(target, 'transition', ''); + _css(target, 'transform', ''); + target.animated = false; + }, ms); + } + }, + + _offUpEvents: function () { + var ownerDocument = this.el.ownerDocument; + + _off(document, 'touchmove', this._onTouchMove); + _off(ownerDocument, 'mouseup', this._onDrop); + _off(ownerDocument, 'touchend', this._onDrop); + _off(ownerDocument, 'touchcancel', this._onDrop); + }, + + _onDrop: function (/**Event*/evt) { + var el = this.el, + options = this.options; + + clearInterval(this._loopId); + clearInterval(autoScroll.pid); + clearTimeout(this._dragStartTimer); + + // Unbind events + _off(document, 'mousemove', this._onTouchMove); + + if (this.nativeDraggable) { + _off(document, 'drop', this); + _off(el, 'dragstart', this._onDragStart); + } + + this._offUpEvents(); + + if (evt) { + if (moved) { + evt.preventDefault(); + !options.dropBubble && evt.stopPropagation(); + } + + ghostEl && ghostEl.parentNode.removeChild(ghostEl); + + if (dragEl) { + if (this.nativeDraggable) { + _off(dragEl, 'dragend', this); + } + + _disableDraggable(dragEl); + + // Remove class's + _toggleClass(dragEl, this.options.ghostClass, false); + _toggleClass(dragEl, this.options.chosenClass, false); + + if (rootEl !== parentEl) { + newIndex = _index(dragEl, options.draggable); + + if (newIndex >= 0) { + // drag from one list and drop into another + _dispatchEvent(null, parentEl, 'sort', dragEl, rootEl, oldIndex, newIndex); + _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex); + + // Add event + _dispatchEvent(null, parentEl, 'add', dragEl, rootEl, oldIndex, newIndex); + + // Remove event + _dispatchEvent(this, rootEl, 'remove', dragEl, rootEl, oldIndex, newIndex); + } + } + else { + // Remove clone + cloneEl && cloneEl.parentNode.removeChild(cloneEl); + + if (dragEl.nextSibling !== nextEl) { + // Get the index of the dragged element within its parent + newIndex = _index(dragEl, options.draggable); + + if (newIndex >= 0) { + // drag & drop within the same list + _dispatchEvent(this, rootEl, 'update', dragEl, rootEl, oldIndex, newIndex); + _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex); + } + } + } + + if (Sortable.active) { + if (newIndex === null || newIndex === -1) { + newIndex = oldIndex; + } + + _dispatchEvent(this, rootEl, 'end', dragEl, rootEl, oldIndex, newIndex); + + // Save sorting + this.save(); + } + } + + } + + this._nulling(); + }, + + _nulling: function () { + rootEl = + dragEl = + parentEl = + ghostEl = + nextEl = + cloneEl = + + scrollEl = + scrollParentEl = + + tapEvt = + touchEvt = + + moved = + newIndex = + + lastEl = + lastCSS = + + activeGroup = + Sortable.active = null; + }, + + handleEvent: function (/**Event*/evt) { + var type = evt.type; + + if (type === 'dragover' || type === 'dragenter') { + if (dragEl) { + this._onDragOver(evt); + _globalDragOver(evt); + } + } + else if (type === 'drop' || type === 'dragend') { + this._onDrop(evt); + } + }, + + + /** + * Serializes the item into an array of string. + * @returns {String[]} + */ + toArray: function () { + var order = [], + el, + children = this.el.children, + i = 0, + n = children.length, + options = this.options; + + for (; i < n; i++) { + el = children[i]; + if (_closest(el, options.draggable, this.el)) { + order.push(el.getAttribute(options.dataIdAttr) || _generateId(el)); + } + } + + return order; + }, + + + /** + * Sorts the elements according to the array. + * @param {String[]} order order of the items + */ + sort: function (order) { + var items = {}, rootEl = this.el; + + this.toArray().forEach(function (id, i) { + var el = rootEl.children[i]; + + if (_closest(el, this.options.draggable, rootEl)) { + items[id] = el; + } + }, this); + + order.forEach(function (id) { + if (items[id]) { + rootEl.removeChild(items[id]); + rootEl.appendChild(items[id]); + } + }); + }, + + + /** + * Save the current sorting + */ + save: function () { + var store = this.options.store; + store && store.set(this); + }, + + + /** + * For each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree. + * @param {HTMLElement} el + * @param {String} [selector] default: `options.draggable` + * @returns {HTMLElement|null} + */ + closest: function (el, selector) { + return _closest(el, selector || this.options.draggable, this.el); + }, + + + /** + * Set/get option + * @param {string} name + * @param {*} [value] + * @returns {*} + */ + option: function (name, value) { + var options = this.options; + + if (value === void 0) { + return options[name]; + } else { + options[name] = value; + + if (name === 'group') { + _prepareGroup(options); + } + } + }, + + + /** + * Destroy + */ + destroy: function () { + var el = this.el; + + el[expando] = null; + + _off(el, 'mousedown', this._onTapStart); + _off(el, 'touchstart', this._onTapStart); + + if (this.nativeDraggable) { + _off(el, 'dragover', this); + _off(el, 'dragenter', this); + } + + // Remove draggable attributes + Array.prototype.forEach.call(el.querySelectorAll('[draggable]'), function (el) { + el.removeAttribute('draggable'); + }); + + touchDragOverListeners.splice(touchDragOverListeners.indexOf(this._onDragOver), 1); + + this._onDrop(); + + this.el = el = null; + } + }; + + + function _cloneHide(state) { + if (cloneEl && (cloneEl.state !== state)) { + _css(cloneEl, 'display', state ? 'none' : ''); + !state && cloneEl.state && rootEl.insertBefore(cloneEl, dragEl); + cloneEl.state = state; + } + } + + + function _closest(/**HTMLElement*/el, /**String*/selector, /**HTMLElement*/ctx) { + if (el) { + ctx = ctx || document; + + do { + if ((selector === '>*' && el.parentNode === ctx) || _matches(el, selector)) { + return el; + } + } + while (el !== ctx && (el = el.parentNode)); + } + + return null; + } + + + function _globalDragOver(/**Event*/evt) { + if (evt.dataTransfer) { + evt.dataTransfer.dropEffect = 'move'; + } + evt.preventDefault(); + } + + + function _on(el, event, fn) { + el.addEventListener(event, fn, false); + } + + + function _off(el, event, fn) { + el.removeEventListener(event, fn, false); + } + + + function _toggleClass(el, name, state) { + if (el) { + if (el.classList) { + el.classList[state ? 'add' : 'remove'](name); + } + else { + var className = (' ' + el.className + ' ').replace(RSPACE, ' ').replace(' ' + name + ' ', ' '); + el.className = (className + (state ? ' ' + name : '')).replace(RSPACE, ' '); + } + } + } + + + function _css(el, prop, val) { + var style = el && el.style; + + if (style) { + if (val === void 0) { + if (document.defaultView && document.defaultView.getComputedStyle) { + val = document.defaultView.getComputedStyle(el, ''); + } + else if (el.currentStyle) { + val = el.currentStyle; + } + + return prop === void 0 ? val : val[prop]; + } + else { + if (!(prop in style)) { + prop = '-webkit-' + prop; + } + + style[prop] = val + (typeof val === 'string' ? '' : 'px'); + } + } + } + + + function _find(ctx, tagName, iterator) { + if (ctx) { + var list = ctx.getElementsByTagName(tagName), i = 0, n = list.length; + + if (iterator) { + for (; i < n; i++) { + iterator(list[i], i); + } + } + + return list; + } + + return []; + } + + + + function _dispatchEvent(sortable, rootEl, name, targetEl, fromEl, startIndex, newIndex) { + var evt = document.createEvent('Event'), + options = (sortable || rootEl[expando]).options, + onName = 'on' + name.charAt(0).toUpperCase() + name.substr(1); + + evt.initEvent(name, true, true); + + evt.to = rootEl; + evt.from = fromEl || rootEl; + evt.item = targetEl || rootEl; + evt.clone = cloneEl; + + evt.oldIndex = startIndex; + evt.newIndex = newIndex; + + rootEl.dispatchEvent(evt); + + if (options[onName]) { + options[onName].call(sortable, evt); + } + } + + + function _onMove(fromEl, toEl, dragEl, dragRect, targetEl, targetRect) { + var evt, + sortable = fromEl[expando], + onMoveFn = sortable.options.onMove, + retVal; + + evt = document.createEvent('Event'); + evt.initEvent('move', true, true); + + evt.to = toEl; + evt.from = fromEl; + evt.dragged = dragEl; + evt.draggedRect = dragRect; + evt.related = targetEl || toEl; + evt.relatedRect = targetRect || toEl.getBoundingClientRect(); + + fromEl.dispatchEvent(evt); + + if (onMoveFn) { + retVal = onMoveFn.call(sortable, evt); + } + + return retVal; + } + + + function _disableDraggable(el) { + el.draggable = false; + } + + + function _unsilent() { + _silent = false; + } + + + /** @returns {HTMLElement|false} */ + function _ghostIsLast(el, evt) { + var lastEl = el.lastElementChild, + rect = lastEl.getBoundingClientRect(); + + return ((evt.clientY - (rect.top + rect.height) > 5) || (evt.clientX - (rect.right + rect.width) > 5)) && lastEl; // min delta + } + + + /** + * Generate id + * @param {HTMLElement} el + * @returns {String} + * @private + */ + function _generateId(el) { + var str = el.tagName + el.className + el.src + el.href + el.textContent, + i = str.length, + sum = 0; + + while (i--) { + sum += str.charCodeAt(i); + } + + return sum.toString(36); + } + + /** + * Returns the index of an element within its parent for a selected set of + * elements + * @param {HTMLElement} el + * @param {selector} selector + * @return {number} + */ + function _index(el, selector) { + var index = 0; + + if (!el || !el.parentNode) { + return -1; + } + + while (el && (el = el.previousElementSibling)) { + if ((el.nodeName.toUpperCase() !== 'TEMPLATE') && (selector === '>*' || _matches(el, selector))) { + index++; + } + } + + return index; + } + + function _matches(/**HTMLElement*/el, /**String*/selector) { + if (el) { + selector = selector.split('.'); + + var tag = selector.shift().toUpperCase(), + re = new RegExp('\\s(' + selector.join('|') + ')(?=\\s)', 'g'); + + return ( + (tag === '' || el.nodeName.toUpperCase() == tag) && + (!selector.length || ((' ' + el.className + ' ').match(re) || []).length == selector.length) + ); + } + + return false; + } + + function _throttle(callback, ms) { + var args, _this; + + return function () { + if (args === void 0) { + args = arguments; + _this = this; + + setTimeout(function () { + if (args.length === 1) { + callback.call(_this, args[0]); + } else { + callback.apply(_this, args); + } + + args = void 0; + }, ms); + } + }; + } + + function _extend(dst, src) { + if (dst && src) { + for (var key in src) { + if (src.hasOwnProperty(key)) { + dst[key] = src[key]; + } + } + } + + return dst; + } + + + // Export utils + Sortable.utils = { + on: _on, + off: _off, + css: _css, + find: _find, + is: function (el, selector) { + return !!_closest(el, selector, el); + }, + extend: _extend, + throttle: _throttle, + closest: _closest, + toggleClass: _toggleClass, + index: _index + }; + + + /** + * Create sortable instance + * @param {HTMLElement} el + * @param {Object} [options] + */ + Sortable.create = function (el, options) { + return new Sortable(el, options); + }; + + + // Export + Sortable.version = '1.4.2'; + return Sortable; +}); diff --git a/vendor/assets/javascripts/clipboard.js b/vendor/assets/javascripts/clipboard.js index 1b1f4f0bd63..39d7d2306f8 100644 --- a/vendor/assets/javascripts/clipboard.js +++ b/vendor/assets/javascripts/clipboard.js @@ -154,12 +154,12 @@ function E () { E.prototype = { on: function (name, callback, ctx) { var e = this.e || (this.e = {}); - + (e[name] || (e[name] = [])).push({ fn: callback, ctx: ctx }); - + return this; }, @@ -169,7 +169,7 @@ E.prototype = { self.off(name, fn); callback.apply(ctx, arguments); }; - + return this.on(name, fn, ctx); }, @@ -178,11 +178,11 @@ E.prototype = { var evtArr = ((this.e || (this.e = {}))[name] || []).slice(); var i = 0; var len = evtArr.length; - + for (i; i < len; i++) { evtArr[i].fn.apply(evtArr[i].ctx, data); } - + return this; }, @@ -190,21 +190,21 @@ E.prototype = { var e = this.e || (this.e = {}); var evts = e[name]; var liveEvents = []; - + if (evts && callback) { for (var i = 0, len = evts.length; i < len; i++) { if (evts[i].fn !== callback) liveEvents.push(evts[i]); } } - + // Remove event from queue to prevent memory leak // Suggested by https://github.com/lazd // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910 - (liveEvents.length) + (liveEvents.length) ? e[name] = liveEvents : delete e[name]; - + return this; } }; @@ -618,4 +618,4 @@ exports['default'] = Clipboard; module.exports = exports['default']; },{"./clipboard-action":6,"delegate-events":1,"tiny-emitter":5}]},{},[7])(7) -}); \ No newline at end of file +}); diff --git a/vendor/assets/javascripts/vue-resource.full.js b/vendor/assets/javascripts/vue-resource.full.js new file mode 100644 index 00000000000..d7981dbec7e --- /dev/null +++ b/vendor/assets/javascripts/vue-resource.full.js @@ -0,0 +1,1318 @@ +/*! + * vue-resource v0.9.3 + * https://github.com/vuejs/vue-resource + * Released under the MIT License. + */ + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.VueResource = factory()); +}(this, function () { 'use strict'; + + /** + * Promises/A+ polyfill v1.1.4 (https://github.com/bramstein/promis) + */ + + var RESOLVED = 0; + var REJECTED = 1; + var PENDING = 2; + + function Promise$2(executor) { + + this.state = PENDING; + this.value = undefined; + this.deferred = []; + + var promise = this; + + try { + executor(function (x) { + promise.resolve(x); + }, function (r) { + promise.reject(r); + }); + } catch (e) { + promise.reject(e); + } + } + + Promise$2.reject = function (r) { + return new Promise$2(function (resolve, reject) { + reject(r); + }); + }; + + Promise$2.resolve = function (x) { + return new Promise$2(function (resolve, reject) { + resolve(x); + }); + }; + + Promise$2.all = function all(iterable) { + return new Promise$2(function (resolve, reject) { + var count = 0, + result = []; + + if (iterable.length === 0) { + resolve(result); + } + + function resolver(i) { + return function (x) { + result[i] = x; + count += 1; + + if (count === iterable.length) { + resolve(result); + } + }; + } + + for (var i = 0; i < iterable.length; i += 1) { + Promise$2.resolve(iterable[i]).then(resolver(i), reject); + } + }); + }; + + Promise$2.race = function race(iterable) { + return new Promise$2(function (resolve, reject) { + for (var i = 0; i < iterable.length; i += 1) { + Promise$2.resolve(iterable[i]).then(resolve, reject); + } + }); + }; + + var p$1 = Promise$2.prototype; + + p$1.resolve = function resolve(x) { + var promise = this; + + if (promise.state === PENDING) { + if (x === promise) { + throw new TypeError('Promise settled with itself.'); + } + + var called = false; + + try { + var then = x && x['then']; + + if (x !== null && typeof x === 'object' && typeof then === 'function') { + then.call(x, function (x) { + if (!called) { + promise.resolve(x); + } + called = true; + }, function (r) { + if (!called) { + promise.reject(r); + } + called = true; + }); + return; + } + } catch (e) { + if (!called) { + promise.reject(e); + } + return; + } + + promise.state = RESOLVED; + promise.value = x; + promise.notify(); + } + }; + + p$1.reject = function reject(reason) { + var promise = this; + + if (promise.state === PENDING) { + if (reason === promise) { + throw new TypeError('Promise settled with itself.'); + } + + promise.state = REJECTED; + promise.value = reason; + promise.notify(); + } + }; + + p$1.notify = function notify() { + var promise = this; + + nextTick(function () { + if (promise.state !== PENDING) { + while (promise.deferred.length) { + var deferred = promise.deferred.shift(), + onResolved = deferred[0], + onRejected = deferred[1], + resolve = deferred[2], + reject = deferred[3]; + + try { + if (promise.state === RESOLVED) { + if (typeof onResolved === 'function') { + resolve(onResolved.call(undefined, promise.value)); + } else { + resolve(promise.value); + } + } else if (promise.state === REJECTED) { + if (typeof onRejected === 'function') { + resolve(onRejected.call(undefined, promise.value)); + } else { + reject(promise.value); + } + } + } catch (e) { + reject(e); + } + } + } + }); + }; + + p$1.then = function then(onResolved, onRejected) { + var promise = this; + + return new Promise$2(function (resolve, reject) { + promise.deferred.push([onResolved, onRejected, resolve, reject]); + promise.notify(); + }); + }; + + p$1.catch = function (onRejected) { + return this.then(undefined, onRejected); + }; + + var PromiseObj = window.Promise || Promise$2; + + function Promise$1(executor, context) { + + if (executor instanceof PromiseObj) { + this.promise = executor; + } else { + this.promise = new PromiseObj(executor.bind(context)); + } + + this.context = context; + } + + Promise$1.all = function (iterable, context) { + return new Promise$1(PromiseObj.all(iterable), context); + }; + + Promise$1.resolve = function (value, context) { + return new Promise$1(PromiseObj.resolve(value), context); + }; + + Promise$1.reject = function (reason, context) { + return new Promise$1(PromiseObj.reject(reason), context); + }; + + Promise$1.race = function (iterable, context) { + return new Promise$1(PromiseObj.race(iterable), context); + }; + + var p = Promise$1.prototype; + + p.bind = function (context) { + this.context = context; + return this; + }; + + p.then = function (fulfilled, rejected) { + + if (fulfilled && fulfilled.bind && this.context) { + fulfilled = fulfilled.bind(this.context); + } + + if (rejected && rejected.bind && this.context) { + rejected = rejected.bind(this.context); + } + + return new Promise$1(this.promise.then(fulfilled, rejected), this.context); + }; + + p.catch = function (rejected) { + + if (rejected && rejected.bind && this.context) { + rejected = rejected.bind(this.context); + } + + return new Promise$1(this.promise.catch(rejected), this.context); + }; + + p.finally = function (callback) { + + return this.then(function (value) { + callback.call(this); + return value; + }, function (reason) { + callback.call(this); + return PromiseObj.reject(reason); + }); + }; + + var debug = false; + var util = {}; + var array = []; + function Util (Vue) { + util = Vue.util; + debug = Vue.config.debug || !Vue.config.silent; + } + + function warn(msg) { + if (typeof console !== 'undefined' && debug) { + console.warn('[VueResource warn]: ' + msg); + } + } + + function error(msg) { + if (typeof console !== 'undefined') { + console.error(msg); + } + } + + function nextTick(cb, ctx) { + return util.nextTick(cb, ctx); + } + + function trim(str) { + return str.replace(/^\s*|\s*$/g, ''); + } + + var isArray = Array.isArray; + + function isString(val) { + return typeof val === 'string'; + } + + function isBoolean(val) { + return val === true || val === false; + } + + function isFunction(val) { + return typeof val === 'function'; + } + + function isObject(obj) { + return obj !== null && typeof obj === 'object'; + } + + function isPlainObject(obj) { + return isObject(obj) && Object.getPrototypeOf(obj) == Object.prototype; + } + + function isFormData(obj) { + return typeof FormData !== 'undefined' && obj instanceof FormData; + } + + function when(value, fulfilled, rejected) { + + var promise = Promise$1.resolve(value); + + if (arguments.length < 2) { + return promise; + } + + return promise.then(fulfilled, rejected); + } + + function options(fn, obj, opts) { + + opts = opts || {}; + + if (isFunction(opts)) { + opts = opts.call(obj); + } + + return merge(fn.bind({ $vm: obj, $options: opts }), fn, { $options: opts }); + } + + function each(obj, iterator) { + + var i, key; + + if (typeof obj.length == 'number') { + for (i = 0; i < obj.length; i++) { + iterator.call(obj[i], obj[i], i); + } + } else if (isObject(obj)) { + for (key in obj) { + if (obj.hasOwnProperty(key)) { + iterator.call(obj[key], obj[key], key); + } + } + } + + return obj; + } + + var assign = Object.assign || _assign; + + function merge(target) { + + var args = array.slice.call(arguments, 1); + + args.forEach(function (source) { + _merge(target, source, true); + }); + + return target; + } + + function defaults(target) { + + var args = array.slice.call(arguments, 1); + + args.forEach(function (source) { + + for (var key in source) { + if (target[key] === undefined) { + target[key] = source[key]; + } + } + }); + + return target; + } + + function _assign(target) { + + var args = array.slice.call(arguments, 1); + + args.forEach(function (source) { + _merge(target, source); + }); + + return target; + } + + function _merge(target, source, deep) { + for (var key in source) { + if (deep && (isPlainObject(source[key]) || isArray(source[key]))) { + if (isPlainObject(source[key]) && !isPlainObject(target[key])) { + target[key] = {}; + } + if (isArray(source[key]) && !isArray(target[key])) { + target[key] = []; + } + _merge(target[key], source[key], deep); + } else if (source[key] !== undefined) { + target[key] = source[key]; + } + } + } + + function root (options, next) { + + var url = next(options); + + if (isString(options.root) && !url.match(/^(https?:)?\//)) { + url = options.root + '/' + url; + } + + return url; + } + + function query (options, next) { + + var urlParams = Object.keys(Url.options.params), + query = {}, + url = next(options); + + each(options.params, function (value, key) { + if (urlParams.indexOf(key) === -1) { + query[key] = value; + } + }); + + query = Url.params(query); + + if (query) { + url += (url.indexOf('?') == -1 ? '?' : '&') + query; + } + + return url; + } + + /** + * URL Template v2.0.6 (https://github.com/bramstein/url-template) + */ + + function expand(url, params, variables) { + + var tmpl = parse(url), + expanded = tmpl.expand(params); + + if (variables) { + variables.push.apply(variables, tmpl.vars); + } + + return expanded; + } + + function parse(template) { + + var operators = ['+', '#', '.', '/', ';', '?', '&'], + variables = []; + + return { + vars: variables, + expand: function (context) { + return template.replace(/\{([^\{\}]+)\}|([^\{\}]+)/g, function (_, expression, literal) { + if (expression) { + + var operator = null, + values = []; + + if (operators.indexOf(expression.charAt(0)) !== -1) { + operator = expression.charAt(0); + expression = expression.substr(1); + } + + expression.split(/,/g).forEach(function (variable) { + var tmp = /([^:\*]*)(?::(\d+)|(\*))?/.exec(variable); + values.push.apply(values, getValues(context, operator, tmp[1], tmp[2] || tmp[3])); + variables.push(tmp[1]); + }); + + if (operator && operator !== '+') { + + var separator = ','; + + if (operator === '?') { + separator = '&'; + } else if (operator !== '#') { + separator = operator; + } + + return (values.length !== 0 ? operator : '') + values.join(separator); + } else { + return values.join(','); + } + } else { + return encodeReserved(literal); + } + }); + } + }; + } + + function getValues(context, operator, key, modifier) { + + var value = context[key], + result = []; + + if (isDefined(value) && value !== '') { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + value = value.toString(); + + if (modifier && modifier !== '*') { + value = value.substring(0, parseInt(modifier, 10)); + } + + result.push(encodeValue(operator, value, isKeyOperator(operator) ? key : null)); + } else { + if (modifier === '*') { + if (Array.isArray(value)) { + value.filter(isDefined).forEach(function (value) { + result.push(encodeValue(operator, value, isKeyOperator(operator) ? key : null)); + }); + } else { + Object.keys(value).forEach(function (k) { + if (isDefined(value[k])) { + result.push(encodeValue(operator, value[k], k)); + } + }); + } + } else { + var tmp = []; + + if (Array.isArray(value)) { + value.filter(isDefined).forEach(function (value) { + tmp.push(encodeValue(operator, value)); + }); + } else { + Object.keys(value).forEach(function (k) { + if (isDefined(value[k])) { + tmp.push(encodeURIComponent(k)); + tmp.push(encodeValue(operator, value[k].toString())); + } + }); + } + + if (isKeyOperator(operator)) { + result.push(encodeURIComponent(key) + '=' + tmp.join(',')); + } else if (tmp.length !== 0) { + result.push(tmp.join(',')); + } + } + } + } else { + if (operator === ';') { + result.push(encodeURIComponent(key)); + } else if (value === '' && (operator === '&' || operator === '?')) { + result.push(encodeURIComponent(key) + '='); + } else if (value === '') { + result.push(''); + } + } + + return result; + } + + function isDefined(value) { + return value !== undefined && value !== null; + } + + function isKeyOperator(operator) { + return operator === ';' || operator === '&' || operator === '?'; + } + + function encodeValue(operator, value, key) { + + value = operator === '+' || operator === '#' ? encodeReserved(value) : encodeURIComponent(value); + + if (key) { + return encodeURIComponent(key) + '=' + value; + } else { + return value; + } + } + + function encodeReserved(str) { + return str.split(/(%[0-9A-Fa-f]{2})/g).map(function (part) { + if (!/%[0-9A-Fa-f]/.test(part)) { + part = encodeURI(part); + } + return part; + }).join(''); + } + + function template (options) { + + var variables = [], + url = expand(options.url, options.params, variables); + + variables.forEach(function (key) { + delete options.params[key]; + }); + + return url; + } + + /** + * Service for URL templating. + */ + + var ie = document.documentMode; + var el = document.createElement('a'); + + function Url(url, params) { + + var self = this || {}, + options = url, + transform; + + if (isString(url)) { + options = { url: url, params: params }; + } + + options = merge({}, Url.options, self.$options, options); + + Url.transforms.forEach(function (handler) { + transform = factory(handler, transform, self.$vm); + }); + + return transform(options); + } + + /** + * Url options. + */ + + Url.options = { + url: '', + root: null, + params: {} + }; + + /** + * Url transforms. + */ + + Url.transforms = [template, query, root]; + + /** + * Encodes a Url parameter string. + * + * @param {Object} obj + */ + + Url.params = function (obj) { + + var params = [], + escape = encodeURIComponent; + + params.add = function (key, value) { + + if (isFunction(value)) { + value = value(); + } + + if (value === null) { + value = ''; + } + + this.push(escape(key) + '=' + escape(value)); + }; + + serialize(params, obj); + + return params.join('&').replace(/%20/g, '+'); + }; + + /** + * Parse a URL and return its components. + * + * @param {String} url + */ + + Url.parse = function (url) { + + if (ie) { + el.href = url; + url = el.href; + } + + el.href = url; + + return { + href: el.href, + protocol: el.protocol ? el.protocol.replace(/:$/, '') : '', + port: el.port, + host: el.host, + hostname: el.hostname, + pathname: el.pathname.charAt(0) === '/' ? el.pathname : '/' + el.pathname, + search: el.search ? el.search.replace(/^\?/, '') : '', + hash: el.hash ? el.hash.replace(/^#/, '') : '' + }; + }; + + function factory(handler, next, vm) { + return function (options) { + return handler.call(vm, options, next); + }; + } + + function serialize(params, obj, scope) { + + var array = isArray(obj), + plain = isPlainObject(obj), + hash; + + each(obj, function (value, key) { + + hash = isObject(value) || isArray(value); + + if (scope) { + key = scope + '[' + (plain || hash ? key : '') + ']'; + } + + if (!scope && array) { + params.add(value.name, value.value); + } else if (hash) { + serialize(params, value, key); + } else { + params.add(key, value); + } + }); + } + + function xdrClient (request) { + return new Promise$1(function (resolve) { + + var xdr = new XDomainRequest(), + handler = function (event) { + + var response = request.respondWith(xdr.responseText, { + status: xdr.status, + statusText: xdr.statusText + }); + + resolve(response); + }; + + request.abort = function () { + return xdr.abort(); + }; + + xdr.open(request.method, request.getUrl(), true); + xdr.timeout = 0; + xdr.onload = handler; + xdr.onerror = handler; + xdr.ontimeout = function () {}; + xdr.onprogress = function () {}; + xdr.send(request.getBody()); + }); + } + + var ORIGIN_URL = Url.parse(location.href); + var SUPPORTS_CORS = 'withCredentials' in new XMLHttpRequest(); + + function cors (request, next) { + + if (!isBoolean(request.crossOrigin) && crossOrigin(request)) { + request.crossOrigin = true; + } + + if (request.crossOrigin) { + + if (!SUPPORTS_CORS) { + request.client = xdrClient; + } + + delete request.emulateHTTP; + } + + next(); + } + + function crossOrigin(request) { + + var requestUrl = Url.parse(Url(request)); + + return requestUrl.protocol !== ORIGIN_URL.protocol || requestUrl.host !== ORIGIN_URL.host; + } + + function body (request, next) { + + if (request.emulateJSON && isPlainObject(request.body)) { + request.body = Url.params(request.body); + request.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + + if (isFormData(request.body)) { + delete request.headers['Content-Type']; + } + + if (isPlainObject(request.body)) { + request.body = JSON.stringify(request.body); + } + + next(function (response) { + + var contentType = response.headers['Content-Type']; + + if (isString(contentType) && contentType.indexOf('application/json') === 0) { + + try { + response.data = response.json(); + } catch (e) { + response.data = null; + } + } else { + response.data = response.text(); + } + }); + } + + function jsonpClient (request) { + return new Promise$1(function (resolve) { + + var name = request.jsonp || 'callback', + callback = '_jsonp' + Math.random().toString(36).substr(2), + body = null, + handler, + script; + + handler = function (event) { + + var status = 0; + + if (event.type === 'load' && body !== null) { + status = 200; + } else if (event.type === 'error') { + status = 404; + } + + resolve(request.respondWith(body, { status: status })); + + delete window[callback]; + document.body.removeChild(script); + }; + + request.params[name] = callback; + + window[callback] = function (result) { + body = JSON.stringify(result); + }; + + script = document.createElement('script'); + script.src = request.getUrl(); + script.type = 'text/javascript'; + script.async = true; + script.onload = handler; + script.onerror = handler; + + document.body.appendChild(script); + }); + } + + function jsonp (request, next) { + + if (request.method == 'JSONP') { + request.client = jsonpClient; + } + + next(function (response) { + + if (request.method == 'JSONP') { + response.data = response.json(); + } + }); + } + + function before (request, next) { + + if (isFunction(request.before)) { + request.before.call(this, request); + } + + next(); + } + + /** + * HTTP method override Interceptor. + */ + + function method (request, next) { + + if (request.emulateHTTP && /^(PUT|PATCH|DELETE)$/i.test(request.method)) { + request.headers['X-HTTP-Method-Override'] = request.method; + request.method = 'POST'; + } + + next(); + } + + function header (request, next) { + + request.method = request.method.toUpperCase(); + request.headers = assign({}, Http.headers.common, !request.crossOrigin ? Http.headers.custom : {}, Http.headers[request.method.toLowerCase()], request.headers); + + next(); + } + + /** + * Timeout Interceptor. + */ + + function timeout (request, next) { + + var timeout; + + if (request.timeout) { + timeout = setTimeout(function () { + request.abort(); + }, request.timeout); + } + + next(function (response) { + + clearTimeout(timeout); + }); + } + + function xhrClient (request) { + return new Promise$1(function (resolve) { + + var xhr = new XMLHttpRequest(), + handler = function (event) { + + var response = request.respondWith('response' in xhr ? xhr.response : xhr.responseText, { + status: xhr.status === 1223 ? 204 : xhr.status, // IE9 status bug + statusText: xhr.status === 1223 ? 'No Content' : trim(xhr.statusText), + headers: parseHeaders(xhr.getAllResponseHeaders()) + }); + + resolve(response); + }; + + request.abort = function () { + return xhr.abort(); + }; + + xhr.open(request.method, request.getUrl(), true); + xhr.timeout = 0; + xhr.onload = handler; + xhr.onerror = handler; + + if (request.progress) { + if (request.method === 'GET') { + xhr.addEventListener('progress', request.progress); + } else if (/^(POST|PUT)$/i.test(request.method)) { + xhr.upload.addEventListener('progress', request.progress); + } + } + + if (request.credentials === true) { + xhr.withCredentials = true; + } + + each(request.headers || {}, function (value, header) { + xhr.setRequestHeader(header, value); + }); + + xhr.send(request.getBody()); + }); + } + + function parseHeaders(str) { + + var headers = {}, + value, + name, + i; + + each(trim(str).split('\n'), function (row) { + + i = row.indexOf(':'); + name = trim(row.slice(0, i)); + value = trim(row.slice(i + 1)); + + if (headers[name]) { + + if (isArray(headers[name])) { + headers[name].push(value); + } else { + headers[name] = [headers[name], value]; + } + } else { + + headers[name] = value; + } + }); + + return headers; + } + + function Client (context) { + + var reqHandlers = [sendRequest], + resHandlers = [], + handler; + + if (!isObject(context)) { + context = null; + } + + function Client(request) { + return new Promise$1(function (resolve) { + + function exec() { + + handler = reqHandlers.pop(); + + if (isFunction(handler)) { + handler.call(context, request, next); + } else { + warn('Invalid interceptor of type ' + typeof handler + ', must be a function'); + next(); + } + } + + function next(response) { + + if (isFunction(response)) { + + resHandlers.unshift(response); + } else if (isObject(response)) { + + resHandlers.forEach(function (handler) { + response = when(response, function (response) { + return handler.call(context, response) || response; + }); + }); + + when(response, resolve); + + return; + } + + exec(); + } + + exec(); + }, context); + } + + Client.use = function (handler) { + reqHandlers.push(handler); + }; + + return Client; + } + + function sendRequest(request, resolve) { + + var client = request.client || xhrClient; + + resolve(client(request)); + } + + var classCallCheck = function (instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + }; + + /** + * HTTP Response. + */ + + var Response = function () { + function Response(body, _ref) { + var url = _ref.url; + var headers = _ref.headers; + var status = _ref.status; + var statusText = _ref.statusText; + classCallCheck(this, Response); + + + this.url = url; + this.body = body; + this.headers = headers || {}; + this.status = status || 0; + this.statusText = statusText || ''; + this.ok = status >= 200 && status < 300; + } + + Response.prototype.text = function text() { + return this.body; + }; + + Response.prototype.blob = function blob() { + return new Blob([this.body]); + }; + + Response.prototype.json = function json() { + return JSON.parse(this.body); + }; + + return Response; + }(); + + var Request = function () { + function Request(options) { + classCallCheck(this, Request); + + + this.method = 'GET'; + this.body = null; + this.params = {}; + this.headers = {}; + + assign(this, options); + } + + Request.prototype.getUrl = function getUrl() { + return Url(this); + }; + + Request.prototype.getBody = function getBody() { + return this.body; + }; + + Request.prototype.respondWith = function respondWith(body, options) { + return new Response(body, assign(options || {}, { url: this.getUrl() })); + }; + + return Request; + }(); + + /** + * Service for sending network requests. + */ + + var CUSTOM_HEADERS = { 'X-Requested-With': 'XMLHttpRequest' }; + var COMMON_HEADERS = { 'Accept': 'application/json, text/plain, */*' }; + var JSON_CONTENT_TYPE = { 'Content-Type': 'application/json;charset=utf-8' }; + + function Http(options) { + + var self = this || {}, + client = Client(self.$vm); + + defaults(options || {}, self.$options, Http.options); + + Http.interceptors.forEach(function (handler) { + client.use(handler); + }); + + return client(new Request(options)).then(function (response) { + + return response.ok ? response : Promise$1.reject(response); + }, function (response) { + + if (response instanceof Error) { + error(response); + } + + return Promise$1.reject(response); + }); + } + + Http.options = {}; + + Http.headers = { + put: JSON_CONTENT_TYPE, + post: JSON_CONTENT_TYPE, + patch: JSON_CONTENT_TYPE, + delete: JSON_CONTENT_TYPE, + custom: CUSTOM_HEADERS, + common: COMMON_HEADERS + }; + + Http.interceptors = [before, timeout, method, body, jsonp, header, cors]; + + ['get', 'delete', 'head', 'jsonp'].forEach(function (method) { + + Http[method] = function (url, options) { + return this(assign(options || {}, { url: url, method: method })); + }; + }); + + ['post', 'put', 'patch'].forEach(function (method) { + + Http[method] = function (url, body, options) { + return this(assign(options || {}, { url: url, method: method, body: body })); + }; + }); + + function Resource(url, params, actions, options) { + + var self = this || {}, + resource = {}; + + actions = assign({}, Resource.actions, actions); + + each(actions, function (action, name) { + + action = merge({ url: url, params: params || {} }, options, action); + + resource[name] = function () { + return (self.$http || Http)(opts(action, arguments)); + }; + }); + + return resource; + } + + function opts(action, args) { + + var options = assign({}, action), + params = {}, + body; + + switch (args.length) { + + case 2: + + params = args[0]; + body = args[1]; + + break; + + case 1: + + if (/^(POST|PUT|PATCH)$/i.test(options.method)) { + body = args[0]; + } else { + params = args[0]; + } + + break; + + case 0: + + break; + + default: + + throw 'Expected up to 4 arguments [params, body], got ' + args.length + ' arguments'; + } + + options.body = body; + options.params = assign({}, options.params, params); + + return options; + } + + Resource.actions = { + + get: { method: 'GET' }, + save: { method: 'POST' }, + query: { method: 'GET' }, + update: { method: 'PUT' }, + remove: { method: 'DELETE' }, + delete: { method: 'DELETE' } + + }; + + function plugin(Vue) { + + if (plugin.installed) { + return; + } + + Util(Vue); + + Vue.url = Url; + Vue.http = Http; + Vue.resource = Resource; + Vue.Promise = Promise$1; + + Object.defineProperties(Vue.prototype, { + + $url: { + get: function () { + return options(Vue.url, this, this.$options.url); + } + }, + + $http: { + get: function () { + return options(Vue.http, this, this.$options.http); + } + }, + + $resource: { + get: function () { + return Vue.resource.bind(this); + } + }, + + $promise: { + get: function () { + var _this = this; + + return function (executor) { + return new Vue.Promise(executor, _this); + }; + } + } + + }); + } + + if (typeof window !== 'undefined' && window.Vue) { + window.Vue.use(plugin); + } + + return plugin; + +})); \ No newline at end of file diff --git a/vendor/assets/javascripts/vue-resource.js.erb b/vendor/assets/javascripts/vue-resource.js.erb new file mode 100644 index 00000000000..8001775ce98 --- /dev/null +++ b/vendor/assets/javascripts/vue-resource.js.erb @@ -0,0 +1,2 @@ +<% type = Rails.env.development? ? 'full' : 'min' %> +<%= File.read(Rails.root.join("vendor/assets/javascripts/vue-resource.#{type}.js")) %> diff --git a/vendor/assets/javascripts/vue-resource.min.js b/vendor/assets/javascripts/vue-resource.min.js new file mode 100644 index 00000000000..6bff73a2a67 --- /dev/null +++ b/vendor/assets/javascripts/vue-resource.min.js @@ -0,0 +1,7 @@ +/*! + * vue-resource v0.9.3 + * https://github.com/vuejs/vue-resource + * Released under the MIT License. + */ + +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):t.VueResource=n()}(this,function(){"use strict";function t(t){this.state=Z,this.value=void 0,this.deferred=[];var n=this;try{t(function(t){n.resolve(t)},function(t){n.reject(t)})}catch(e){n.reject(e)}}function n(t,n){t instanceof nt?this.promise=t:this.promise=new nt(t.bind(n)),this.context=n}function e(t){rt=t.util,ot=t.config.debug||!t.config.silent}function o(t){"undefined"!=typeof console&&ot&&console.warn("[VueResource warn]: "+t)}function r(t){"undefined"!=typeof console&&console.error(t)}function i(t,n){return rt.nextTick(t,n)}function u(t){return t.replace(/^\s*|\s*$/g,"")}function s(t){return"string"==typeof t}function c(t){return t===!0||t===!1}function a(t){return"function"==typeof t}function f(t){return null!==t&&"object"==typeof t}function h(t){return f(t)&&Object.getPrototypeOf(t)==Object.prototype}function p(t){return"undefined"!=typeof FormData&&t instanceof FormData}function l(t,e,o){var r=n.resolve(t);return arguments.length<2?r:r.then(e,o)}function d(t,n,e){return e=e||{},a(e)&&(e=e.call(n)),v(t.bind({$vm:n,$options:e}),t,{$options:e})}function m(t,n){var e,o;if("number"==typeof t.length)for(e=0;e=200&&i<300}return t.prototype.text=function(){return this.body},t.prototype.blob=function(){return new Blob([this.body])},t.prototype.json=function(){return JSON.parse(this.body)},t}(),dt=function(){function t(n){pt(this,t),this.method="GET",this.body=null,this.params={},this.headers={},st(this,n)}return t.prototype.getUrl=function(){return R(this)},t.prototype.getBody=function(){return this.body},t.prototype.respondWith=function(t,n){return new lt(t,st(n||{},{url:this.getUrl()}))},t}(),mt={"X-Requested-With":"XMLHttpRequest"},vt={Accept:"application/json, text/plain, */*"},yt={"Content-Type":"application/json;charset=utf-8"};return V.options={},V.headers={put:yt,post:yt,patch:yt,"delete":yt,custom:mt,common:vt},V.interceptors=[D,X,J,L,N,M,H],["get","delete","head","jsonp"].forEach(function(t){V[t]=function(n,e){return this(st(e||{},{url:n,method:t}))}}),["post","put","patch"].forEach(function(t){V[t]=function(n,e,o){return this(st(o||{},{url:n,method:t,body:e}))}}),_.actions={get:{method:"GET"},save:{method:"POST"},query:{method:"GET"},update:{method:"PUT"},remove:{method:"DELETE"},"delete":{method:"DELETE"}},"undefined"!=typeof window&&window.Vue&&window.Vue.use(K),K}); \ No newline at end of file diff --git a/vendor/assets/javascripts/vue.full.js b/vendor/assets/javascripts/vue.full.js new file mode 100644 index 00000000000..7ae95897a01 --- /dev/null +++ b/vendor/assets/javascripts/vue.full.js @@ -0,0 +1,10073 @@ +/*! + * Vue.js v1.0.26 + * (c) 2016 Evan You + * Released under the MIT License. + */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.Vue = factory()); +}(this, function () { 'use strict'; + + function set(obj, key, val) { + if (hasOwn(obj, key)) { + obj[key] = val; + return; + } + if (obj._isVue) { + set(obj._data, key, val); + return; + } + var ob = obj.__ob__; + if (!ob) { + obj[key] = val; + return; + } + ob.convert(key, val); + ob.dep.notify(); + if (ob.vms) { + var i = ob.vms.length; + while (i--) { + var vm = ob.vms[i]; + vm._proxy(key); + vm._digest(); + } + } + return val; + } + + /** + * Delete a property and trigger change if necessary. + * + * @param {Object} obj + * @param {String} key + */ + + function del(obj, key) { + if (!hasOwn(obj, key)) { + return; + } + delete obj[key]; + var ob = obj.__ob__; + if (!ob) { + if (obj._isVue) { + delete obj._data[key]; + obj._digest(); + } + return; + } + ob.dep.notify(); + if (ob.vms) { + var i = ob.vms.length; + while (i--) { + var vm = ob.vms[i]; + vm._unproxy(key); + vm._digest(); + } + } + } + + var hasOwnProperty = Object.prototype.hasOwnProperty; + /** + * Check whether the object has the property. + * + * @param {Object} obj + * @param {String} key + * @return {Boolean} + */ + + function hasOwn(obj, key) { + return hasOwnProperty.call(obj, key); + } + + /** + * Check if an expression is a literal value. + * + * @param {String} exp + * @return {Boolean} + */ + + var literalValueRE = /^\s?(true|false|-?[\d\.]+|'[^']*'|"[^"]*")\s?$/; + + function isLiteral(exp) { + return literalValueRE.test(exp); + } + + /** + * Check if a string starts with $ or _ + * + * @param {String} str + * @return {Boolean} + */ + + function isReserved(str) { + var c = (str + '').charCodeAt(0); + return c === 0x24 || c === 0x5F; + } + + /** + * Guard text output, make sure undefined outputs + * empty string + * + * @param {*} value + * @return {String} + */ + + function _toString(value) { + return value == null ? '' : value.toString(); + } + + /** + * Check and convert possible numeric strings to numbers + * before setting back to data + * + * @param {*} value + * @return {*|Number} + */ + + function toNumber(value) { + if (typeof value !== 'string') { + return value; + } else { + var parsed = Number(value); + return isNaN(parsed) ? value : parsed; + } + } + + /** + * Convert string boolean literals into real booleans. + * + * @param {*} value + * @return {*|Boolean} + */ + + function toBoolean(value) { + return value === 'true' ? true : value === 'false' ? false : value; + } + + /** + * Strip quotes from a string + * + * @param {String} str + * @return {String | false} + */ + + function stripQuotes(str) { + var a = str.charCodeAt(0); + var b = str.charCodeAt(str.length - 1); + return a === b && (a === 0x22 || a === 0x27) ? str.slice(1, -1) : str; + } + + /** + * Camelize a hyphen-delmited string. + * + * @param {String} str + * @return {String} + */ + + var camelizeRE = /-(\w)/g; + + function camelize(str) { + return str.replace(camelizeRE, toUpper); + } + + function toUpper(_, c) { + return c ? c.toUpperCase() : ''; + } + + /** + * Hyphenate a camelCase string. + * + * @param {String} str + * @return {String} + */ + + var hyphenateRE = /([a-z\d])([A-Z])/g; + + function hyphenate(str) { + return str.replace(hyphenateRE, '$1-$2').toLowerCase(); + } + + /** + * Converts hyphen/underscore/slash delimitered names into + * camelized classNames. + * + * e.g. my-component => MyComponent + * some_else => SomeElse + * some/comp => SomeComp + * + * @param {String} str + * @return {String} + */ + + var classifyRE = /(?:^|[-_\/])(\w)/g; + + function classify(str) { + return str.replace(classifyRE, toUpper); + } + + /** + * Simple bind, faster than native + * + * @param {Function} fn + * @param {Object} ctx + * @return {Function} + */ + + function bind(fn, ctx) { + return function (a) { + var l = arguments.length; + return l ? l > 1 ? fn.apply(ctx, arguments) : fn.call(ctx, a) : fn.call(ctx); + }; + } + + /** + * Convert an Array-like object to a real Array. + * + * @param {Array-like} list + * @param {Number} [start] - start index + * @return {Array} + */ + + function toArray(list, start) { + start = start || 0; + var i = list.length - start; + var ret = new Array(i); + while (i--) { + ret[i] = list[i + start]; + } + return ret; + } + + /** + * Mix properties into target object. + * + * @param {Object} to + * @param {Object} from + */ + + function extend(to, from) { + var keys = Object.keys(from); + var i = keys.length; + while (i--) { + to[keys[i]] = from[keys[i]]; + } + return to; + } + + /** + * Quick object check - this is primarily used to tell + * Objects from primitive values when we know the value + * is a JSON-compliant type. + * + * @param {*} obj + * @return {Boolean} + */ + + function isObject(obj) { + return obj !== null && typeof obj === 'object'; + } + + /** + * Strict object type check. Only returns true + * for plain JavaScript objects. + * + * @param {*} obj + * @return {Boolean} + */ + + var toString = Object.prototype.toString; + var OBJECT_STRING = '[object Object]'; + + function isPlainObject(obj) { + return toString.call(obj) === OBJECT_STRING; + } + + /** + * Array type check. + * + * @param {*} obj + * @return {Boolean} + */ + + var isArray = Array.isArray; + + /** + * Define a property. + * + * @param {Object} obj + * @param {String} key + * @param {*} val + * @param {Boolean} [enumerable] + */ + + function def(obj, key, val, enumerable) { + Object.defineProperty(obj, key, { + value: val, + enumerable: !!enumerable, + writable: true, + configurable: true + }); + } + + /** + * Debounce a function so it only gets called after the + * input stops arriving after the given wait period. + * + * @param {Function} func + * @param {Number} wait + * @return {Function} - the debounced function + */ + + function _debounce(func, wait) { + var timeout, args, context, timestamp, result; + var later = function later() { + var last = Date.now() - timestamp; + if (last < wait && last >= 0) { + timeout = setTimeout(later, wait - last); + } else { + timeout = null; + result = func.apply(context, args); + if (!timeout) context = args = null; + } + }; + return function () { + context = this; + args = arguments; + timestamp = Date.now(); + if (!timeout) { + timeout = setTimeout(later, wait); + } + return result; + }; + } + + /** + * Manual indexOf because it's slightly faster than + * native. + * + * @param {Array} arr + * @param {*} obj + */ + + function indexOf(arr, obj) { + var i = arr.length; + while (i--) { + if (arr[i] === obj) return i; + } + return -1; + } + + /** + * Make a cancellable version of an async callback. + * + * @param {Function} fn + * @return {Function} + */ + + function cancellable(fn) { + var cb = function cb() { + if (!cb.cancelled) { + return fn.apply(this, arguments); + } + }; + cb.cancel = function () { + cb.cancelled = true; + }; + return cb; + } + + /** + * Check if two values are loosely equal - that is, + * if they are plain objects, do they have the same shape? + * + * @param {*} a + * @param {*} b + * @return {Boolean} + */ + + function looseEqual(a, b) { + /* eslint-disable eqeqeq */ + return a == b || (isObject(a) && isObject(b) ? JSON.stringify(a) === JSON.stringify(b) : false); + /* eslint-enable eqeqeq */ + } + + var hasProto = ('__proto__' in {}); + + // Browser environment sniffing + var inBrowser = typeof window !== 'undefined' && Object.prototype.toString.call(window) !== '[object Object]'; + + // detect devtools + var devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__; + + // UA sniffing for working around browser-specific quirks + var UA = inBrowser && window.navigator.userAgent.toLowerCase(); + var isIE = UA && UA.indexOf('trident') > 0; + var isIE9 = UA && UA.indexOf('msie 9.0') > 0; + var isAndroid = UA && UA.indexOf('android') > 0; + var isIos = UA && /(iphone|ipad|ipod|ios)/i.test(UA); + var iosVersionMatch = isIos && UA.match(/os ([\d_]+)/); + var iosVersion = iosVersionMatch && iosVersionMatch[1].split('_'); + + // detecting iOS UIWebView by indexedDB + var hasMutationObserverBug = iosVersion && Number(iosVersion[0]) >= 9 && Number(iosVersion[1]) >= 3 && !window.indexedDB; + + var transitionProp = undefined; + var transitionEndEvent = undefined; + var animationProp = undefined; + var animationEndEvent = undefined; + + // Transition property/event sniffing + if (inBrowser && !isIE9) { + var isWebkitTrans = window.ontransitionend === undefined && window.onwebkittransitionend !== undefined; + var isWebkitAnim = window.onanimationend === undefined && window.onwebkitanimationend !== undefined; + transitionProp = isWebkitTrans ? 'WebkitTransition' : 'transition'; + transitionEndEvent = isWebkitTrans ? 'webkitTransitionEnd' : 'transitionend'; + animationProp = isWebkitAnim ? 'WebkitAnimation' : 'animation'; + animationEndEvent = isWebkitAnim ? 'webkitAnimationEnd' : 'animationend'; + } + + /** + * Defer a task to execute it asynchronously. Ideally this + * should be executed as a microtask, so we leverage + * MutationObserver if it's available, and fallback to + * setTimeout(0). + * + * @param {Function} cb + * @param {Object} ctx + */ + + var nextTick = (function () { + var callbacks = []; + var pending = false; + var timerFunc; + function nextTickHandler() { + pending = false; + var copies = callbacks.slice(0); + callbacks = []; + for (var i = 0; i < copies.length; i++) { + copies[i](); + } + } + + /* istanbul ignore if */ + if (typeof MutationObserver !== 'undefined' && !hasMutationObserverBug) { + var counter = 1; + var observer = new MutationObserver(nextTickHandler); + var textNode = document.createTextNode(counter); + observer.observe(textNode, { + characterData: true + }); + timerFunc = function () { + counter = (counter + 1) % 2; + textNode.data = counter; + }; + } else { + // webpack attempts to inject a shim for setImmediate + // if it is used as a global, so we have to work around that to + // avoid bundling unnecessary code. + var context = inBrowser ? window : typeof global !== 'undefined' ? global : {}; + timerFunc = context.setImmediate || setTimeout; + } + return function (cb, ctx) { + var func = ctx ? function () { + cb.call(ctx); + } : cb; + callbacks.push(func); + if (pending) return; + pending = true; + timerFunc(nextTickHandler, 0); + }; + })(); + + var _Set = undefined; + /* istanbul ignore if */ + if (typeof Set !== 'undefined' && Set.toString().match(/native code/)) { + // use native Set when available. + _Set = Set; + } else { + // a non-standard Set polyfill that only works with primitive keys. + _Set = function () { + this.set = Object.create(null); + }; + _Set.prototype.has = function (key) { + return this.set[key] !== undefined; + }; + _Set.prototype.add = function (key) { + this.set[key] = 1; + }; + _Set.prototype.clear = function () { + this.set = Object.create(null); + }; + } + + function Cache(limit) { + this.size = 0; + this.limit = limit; + this.head = this.tail = undefined; + this._keymap = Object.create(null); + } + + var p = Cache.prototype; + + /** + * Put into the cache associated with . + * Returns the entry which was removed to make room for + * the new entry. Otherwise undefined is returned. + * (i.e. if there was enough room already). + * + * @param {String} key + * @param {*} value + * @return {Entry|undefined} + */ + + p.put = function (key, value) { + var removed; + + var entry = this.get(key, true); + if (!entry) { + if (this.size === this.limit) { + removed = this.shift(); + } + entry = { + key: key + }; + this._keymap[key] = entry; + if (this.tail) { + this.tail.newer = entry; + entry.older = this.tail; + } else { + this.head = entry; + } + this.tail = entry; + this.size++; + } + entry.value = value; + + return removed; + }; + + /** + * Purge the least recently used (oldest) entry from the + * cache. Returns the removed entry or undefined if the + * cache was empty. + */ + + p.shift = function () { + var entry = this.head; + if (entry) { + this.head = this.head.newer; + this.head.older = undefined; + entry.newer = entry.older = undefined; + this._keymap[entry.key] = undefined; + this.size--; + } + return entry; + }; + + /** + * Get and register recent use of . Returns the value + * associated with or undefined if not in cache. + * + * @param {String} key + * @param {Boolean} returnEntry + * @return {Entry|*} + */ + + p.get = function (key, returnEntry) { + var entry = this._keymap[key]; + if (entry === undefined) return; + if (entry === this.tail) { + return returnEntry ? entry : entry.value; + } + // HEAD--------------TAIL + // <.older .newer> + // <--- add direction -- + // A B C E + if (entry.newer) { + if (entry === this.head) { + this.head = entry.newer; + } + entry.newer.older = entry.older; // C <-- E. + } + if (entry.older) { + entry.older.newer = entry.newer; // C. --> E + } + entry.newer = undefined; // D --x + entry.older = this.tail; // D. --> E + if (this.tail) { + this.tail.newer = entry; // E. <-- D + } + this.tail = entry; + return returnEntry ? entry : entry.value; + }; + + var cache$1 = new Cache(1000); + var filterTokenRE = /[^\s'"]+|'[^']*'|"[^"]*"/g; + var reservedArgRE = /^in$|^-?\d+/; + + /** + * Parser state + */ + + var str; + var dir; + var c; + var prev; + var i; + var l; + var lastFilterIndex; + var inSingle; + var inDouble; + var curly; + var square; + var paren; + /** + * Push a filter to the current directive object + */ + + function pushFilter() { + var exp = str.slice(lastFilterIndex, i).trim(); + var filter; + if (exp) { + filter = {}; + var tokens = exp.match(filterTokenRE); + filter.name = tokens[0]; + if (tokens.length > 1) { + filter.args = tokens.slice(1).map(processFilterArg); + } + } + if (filter) { + (dir.filters = dir.filters || []).push(filter); + } + lastFilterIndex = i + 1; + } + + /** + * Check if an argument is dynamic and strip quotes. + * + * @param {String} arg + * @return {Object} + */ + + function processFilterArg(arg) { + if (reservedArgRE.test(arg)) { + return { + value: toNumber(arg), + dynamic: false + }; + } else { + var stripped = stripQuotes(arg); + var dynamic = stripped === arg; + return { + value: dynamic ? arg : stripped, + dynamic: dynamic + }; + } + } + + /** + * Parse a directive value and extract the expression + * and its filters into a descriptor. + * + * Example: + * + * "a + 1 | uppercase" will yield: + * { + * expression: 'a + 1', + * filters: [ + * { name: 'uppercase', args: null } + * ] + * } + * + * @param {String} s + * @return {Object} + */ + + function parseDirective(s) { + var hit = cache$1.get(s); + if (hit) { + return hit; + } + + // reset parser state + str = s; + inSingle = inDouble = false; + curly = square = paren = 0; + lastFilterIndex = 0; + dir = {}; + + for (i = 0, l = str.length; i < l; i++) { + prev = c; + c = str.charCodeAt(i); + if (inSingle) { + // check single quote + if (c === 0x27 && prev !== 0x5C) inSingle = !inSingle; + } else if (inDouble) { + // check double quote + if (c === 0x22 && prev !== 0x5C) inDouble = !inDouble; + } else if (c === 0x7C && // pipe + str.charCodeAt(i + 1) !== 0x7C && str.charCodeAt(i - 1) !== 0x7C) { + if (dir.expression == null) { + // first filter, end of expression + lastFilterIndex = i + 1; + dir.expression = str.slice(0, i).trim(); + } else { + // already has filter + pushFilter(); + } + } else { + switch (c) { + case 0x22: + inDouble = true;break; // " + case 0x27: + inSingle = true;break; // ' + case 0x28: + paren++;break; // ( + case 0x29: + paren--;break; // ) + case 0x5B: + square++;break; // [ + case 0x5D: + square--;break; // ] + case 0x7B: + curly++;break; // { + case 0x7D: + curly--;break; // } + } + } + } + + if (dir.expression == null) { + dir.expression = str.slice(0, i).trim(); + } else if (lastFilterIndex !== 0) { + pushFilter(); + } + + cache$1.put(s, dir); + return dir; + } + +var directive = Object.freeze({ + parseDirective: parseDirective + }); + + var regexEscapeRE = /[-.*+?^${}()|[\]\/\\]/g; + var cache = undefined; + var tagRE = undefined; + var htmlRE = undefined; + /** + * Escape a string so it can be used in a RegExp + * constructor. + * + * @param {String} str + */ + + function escapeRegex(str) { + return str.replace(regexEscapeRE, '\\$&'); + } + + function compileRegex() { + var open = escapeRegex(config.delimiters[0]); + var close = escapeRegex(config.delimiters[1]); + var unsafeOpen = escapeRegex(config.unsafeDelimiters[0]); + var unsafeClose = escapeRegex(config.unsafeDelimiters[1]); + tagRE = new RegExp(unsafeOpen + '((?:.|\\n)+?)' + unsafeClose + '|' + open + '((?:.|\\n)+?)' + close, 'g'); + htmlRE = new RegExp('^' + unsafeOpen + '((?:.|\\n)+?)' + unsafeClose + '$'); + // reset cache + cache = new Cache(1000); + } + + /** + * Parse a template text string into an array of tokens. + * + * @param {String} text + * @return {Array | null} + * - {String} type + * - {String} value + * - {Boolean} [html] + * - {Boolean} [oneTime] + */ + + function parseText(text) { + if (!cache) { + compileRegex(); + } + var hit = cache.get(text); + if (hit) { + return hit; + } + if (!tagRE.test(text)) { + return null; + } + var tokens = []; + var lastIndex = tagRE.lastIndex = 0; + var match, index, html, value, first, oneTime; + /* eslint-disable no-cond-assign */ + while (match = tagRE.exec(text)) { + /* eslint-enable no-cond-assign */ + index = match.index; + // push text token + if (index > lastIndex) { + tokens.push({ + value: text.slice(lastIndex, index) + }); + } + // tag token + html = htmlRE.test(match[0]); + value = html ? match[1] : match[2]; + first = value.charCodeAt(0); + oneTime = first === 42; // * + value = oneTime ? value.slice(1) : value; + tokens.push({ + tag: true, + value: value.trim(), + html: html, + oneTime: oneTime + }); + lastIndex = index + match[0].length; + } + if (lastIndex < text.length) { + tokens.push({ + value: text.slice(lastIndex) + }); + } + cache.put(text, tokens); + return tokens; + } + + /** + * Format a list of tokens into an expression. + * e.g. tokens parsed from 'a {{b}} c' can be serialized + * into one single expression as '"a " + b + " c"'. + * + * @param {Array} tokens + * @param {Vue} [vm] + * @return {String} + */ + + function tokensToExp(tokens, vm) { + if (tokens.length > 1) { + return tokens.map(function (token) { + return formatToken(token, vm); + }).join('+'); + } else { + return formatToken(tokens[0], vm, true); + } + } + + /** + * Format a single token. + * + * @param {Object} token + * @param {Vue} [vm] + * @param {Boolean} [single] + * @return {String} + */ + + function formatToken(token, vm, single) { + return token.tag ? token.oneTime && vm ? '"' + vm.$eval(token.value) + '"' : inlineFilters(token.value, single) : '"' + token.value + '"'; + } + + /** + * For an attribute with multiple interpolation tags, + * e.g. attr="some-{{thing | filter}}", in order to combine + * the whole thing into a single watchable expression, we + * have to inline those filters. This function does exactly + * that. This is a bit hacky but it avoids heavy changes + * to directive parser and watcher mechanism. + * + * @param {String} exp + * @param {Boolean} single + * @return {String} + */ + + var filterRE = /[^|]\|[^|]/; + function inlineFilters(exp, single) { + if (!filterRE.test(exp)) { + return single ? exp : '(' + exp + ')'; + } else { + var dir = parseDirective(exp); + if (!dir.filters) { + return '(' + exp + ')'; + } else { + return 'this._applyFilters(' + dir.expression + // value + ',null,' + // oldValue (null for read) + JSON.stringify(dir.filters) + // filter descriptors + ',false)'; // write? + } + } + } + +var text = Object.freeze({ + compileRegex: compileRegex, + parseText: parseText, + tokensToExp: tokensToExp + }); + + var delimiters = ['{{', '}}']; + var unsafeDelimiters = ['{{{', '}}}']; + + var config = Object.defineProperties({ + + /** + * Whether to print debug messages. + * Also enables stack trace for warnings. + * + * @type {Boolean} + */ + + debug: false, + + /** + * Whether to suppress warnings. + * + * @type {Boolean} + */ + + silent: false, + + /** + * Whether to use async rendering. + */ + + async: true, + + /** + * Whether to warn against errors caught when evaluating + * expressions. + */ + + warnExpressionErrors: true, + + /** + * Whether to allow devtools inspection. + * Disabled by default in production builds. + */ + + devtools: 'development' !== 'production', + + /** + * Internal flag to indicate the delimiters have been + * changed. + * + * @type {Boolean} + */ + + _delimitersChanged: true, + + /** + * List of asset types that a component can own. + * + * @type {Array} + */ + + _assetTypes: ['component', 'directive', 'elementDirective', 'filter', 'transition', 'partial'], + + /** + * prop binding modes + */ + + _propBindingModes: { + ONE_WAY: 0, + TWO_WAY: 1, + ONE_TIME: 2 + }, + + /** + * Max circular updates allowed in a batcher flush cycle. + */ + + _maxUpdateCount: 100 + + }, { + delimiters: { /** + * Interpolation delimiters. Changing these would trigger + * the text parser to re-compile the regular expressions. + * + * @type {Array} + */ + + get: function get() { + return delimiters; + }, + set: function set(val) { + delimiters = val; + compileRegex(); + }, + configurable: true, + enumerable: true + }, + unsafeDelimiters: { + get: function get() { + return unsafeDelimiters; + }, + set: function set(val) { + unsafeDelimiters = val; + compileRegex(); + }, + configurable: true, + enumerable: true + } + }); + + var warn = undefined; + var formatComponentName = undefined; + + if ('development' !== 'production') { + (function () { + var hasConsole = typeof console !== 'undefined'; + + warn = function (msg, vm) { + if (hasConsole && !config.silent) { + console.error('[Vue warn]: ' + msg + (vm ? formatComponentName(vm) : '')); + } + }; + + formatComponentName = function (vm) { + var name = vm._isVue ? vm.$options.name : vm.name; + return name ? ' (found in component: <' + hyphenate(name) + '>)' : ''; + }; + })(); + } + + /** + * Append with transition. + * + * @param {Element} el + * @param {Element} target + * @param {Vue} vm + * @param {Function} [cb] + */ + + function appendWithTransition(el, target, vm, cb) { + applyTransition(el, 1, function () { + target.appendChild(el); + }, vm, cb); + } + + /** + * InsertBefore with transition. + * + * @param {Element} el + * @param {Element} target + * @param {Vue} vm + * @param {Function} [cb] + */ + + function beforeWithTransition(el, target, vm, cb) { + applyTransition(el, 1, function () { + before(el, target); + }, vm, cb); + } + + /** + * Remove with transition. + * + * @param {Element} el + * @param {Vue} vm + * @param {Function} [cb] + */ + + function removeWithTransition(el, vm, cb) { + applyTransition(el, -1, function () { + remove(el); + }, vm, cb); + } + + /** + * Apply transitions with an operation callback. + * + * @param {Element} el + * @param {Number} direction + * 1: enter + * -1: leave + * @param {Function} op - the actual DOM operation + * @param {Vue} vm + * @param {Function} [cb] + */ + + function applyTransition(el, direction, op, vm, cb) { + var transition = el.__v_trans; + if (!transition || + // skip if there are no js hooks and CSS transition is + // not supported + !transition.hooks && !transitionEndEvent || + // skip transitions for initial compile + !vm._isCompiled || + // if the vm is being manipulated by a parent directive + // during the parent's compilation phase, skip the + // animation. + vm.$parent && !vm.$parent._isCompiled) { + op(); + if (cb) cb(); + return; + } + var action = direction > 0 ? 'enter' : 'leave'; + transition[action](op, cb); + } + +var transition = Object.freeze({ + appendWithTransition: appendWithTransition, + beforeWithTransition: beforeWithTransition, + removeWithTransition: removeWithTransition, + applyTransition: applyTransition + }); + + /** + * Query an element selector if it's not an element already. + * + * @param {String|Element} el + * @return {Element} + */ + + function query(el) { + if (typeof el === 'string') { + var selector = el; + el = document.querySelector(el); + if (!el) { + 'development' !== 'production' && warn('Cannot find element: ' + selector); + } + } + return el; + } + + /** + * Check if a node is in the document. + * Note: document.documentElement.contains should work here + * but always returns false for comment nodes in phantomjs, + * making unit tests difficult. This is fixed by doing the + * contains() check on the node's parentNode instead of + * the node itself. + * + * @param {Node} node + * @return {Boolean} + */ + + function inDoc(node) { + if (!node) return false; + var doc = node.ownerDocument.documentElement; + var parent = node.parentNode; + return doc === node || doc === parent || !!(parent && parent.nodeType === 1 && doc.contains(parent)); + } + + /** + * Get and remove an attribute from a node. + * + * @param {Node} node + * @param {String} _attr + */ + + function getAttr(node, _attr) { + var val = node.getAttribute(_attr); + if (val !== null) { + node.removeAttribute(_attr); + } + return val; + } + + /** + * Get an attribute with colon or v-bind: prefix. + * + * @param {Node} node + * @param {String} name + * @return {String|null} + */ + + function getBindAttr(node, name) { + var val = getAttr(node, ':' + name); + if (val === null) { + val = getAttr(node, 'v-bind:' + name); + } + return val; + } + + /** + * Check the presence of a bind attribute. + * + * @param {Node} node + * @param {String} name + * @return {Boolean} + */ + + function hasBindAttr(node, name) { + return node.hasAttribute(name) || node.hasAttribute(':' + name) || node.hasAttribute('v-bind:' + name); + } + + /** + * Insert el before target + * + * @param {Element} el + * @param {Element} target + */ + + function before(el, target) { + target.parentNode.insertBefore(el, target); + } + + /** + * Insert el after target + * + * @param {Element} el + * @param {Element} target + */ + + function after(el, target) { + if (target.nextSibling) { + before(el, target.nextSibling); + } else { + target.parentNode.appendChild(el); + } + } + + /** + * Remove el from DOM + * + * @param {Element} el + */ + + function remove(el) { + el.parentNode.removeChild(el); + } + + /** + * Prepend el to target + * + * @param {Element} el + * @param {Element} target + */ + + function prepend(el, target) { + if (target.firstChild) { + before(el, target.firstChild); + } else { + target.appendChild(el); + } + } + + /** + * Replace target with el + * + * @param {Element} target + * @param {Element} el + */ + + function replace(target, el) { + var parent = target.parentNode; + if (parent) { + parent.replaceChild(el, target); + } + } + + /** + * Add event listener shorthand. + * + * @param {Element} el + * @param {String} event + * @param {Function} cb + * @param {Boolean} [useCapture] + */ + + function on(el, event, cb, useCapture) { + el.addEventListener(event, cb, useCapture); + } + + /** + * Remove event listener shorthand. + * + * @param {Element} el + * @param {String} event + * @param {Function} cb + */ + + function off(el, event, cb) { + el.removeEventListener(event, cb); + } + + /** + * For IE9 compat: when both class and :class are present + * getAttribute('class') returns wrong value... + * + * @param {Element} el + * @return {String} + */ + + function getClass(el) { + var classname = el.className; + if (typeof classname === 'object') { + classname = classname.baseVal || ''; + } + return classname; + } + + /** + * In IE9, setAttribute('class') will result in empty class + * if the element also has the :class attribute; However in + * PhantomJS, setting `className` does not work on SVG elements... + * So we have to do a conditional check here. + * + * @param {Element} el + * @param {String} cls + */ + + function setClass(el, cls) { + /* istanbul ignore if */ + if (isIE9 && !/svg$/.test(el.namespaceURI)) { + el.className = cls; + } else { + el.setAttribute('class', cls); + } + } + + /** + * Add class with compatibility for IE & SVG + * + * @param {Element} el + * @param {String} cls + */ + + function addClass(el, cls) { + if (el.classList) { + el.classList.add(cls); + } else { + var cur = ' ' + getClass(el) + ' '; + if (cur.indexOf(' ' + cls + ' ') < 0) { + setClass(el, (cur + cls).trim()); + } + } + } + + /** + * Remove class with compatibility for IE & SVG + * + * @param {Element} el + * @param {String} cls + */ + + function removeClass(el, cls) { + if (el.classList) { + el.classList.remove(cls); + } else { + var cur = ' ' + getClass(el) + ' '; + var tar = ' ' + cls + ' '; + while (cur.indexOf(tar) >= 0) { + cur = cur.replace(tar, ' '); + } + setClass(el, cur.trim()); + } + if (!el.className) { + el.removeAttribute('class'); + } + } + + /** + * Extract raw content inside an element into a temporary + * container div + * + * @param {Element} el + * @param {Boolean} asFragment + * @return {Element|DocumentFragment} + */ + + function extractContent(el, asFragment) { + var child; + var rawContent; + /* istanbul ignore if */ + if (isTemplate(el) && isFragment(el.content)) { + el = el.content; + } + if (el.hasChildNodes()) { + trimNode(el); + rawContent = asFragment ? document.createDocumentFragment() : document.createElement('div'); + /* eslint-disable no-cond-assign */ + while (child = el.firstChild) { + /* eslint-enable no-cond-assign */ + rawContent.appendChild(child); + } + } + return rawContent; + } + + /** + * Trim possible empty head/tail text and comment + * nodes inside a parent. + * + * @param {Node} node + */ + + function trimNode(node) { + var child; + /* eslint-disable no-sequences */ + while ((child = node.firstChild, isTrimmable(child))) { + node.removeChild(child); + } + while ((child = node.lastChild, isTrimmable(child))) { + node.removeChild(child); + } + /* eslint-enable no-sequences */ + } + + function isTrimmable(node) { + return node && (node.nodeType === 3 && !node.data.trim() || node.nodeType === 8); + } + + /** + * Check if an element is a template tag. + * Note if the template appears inside an SVG its tagName + * will be in lowercase. + * + * @param {Element} el + */ + + function isTemplate(el) { + return el.tagName && el.tagName.toLowerCase() === 'template'; + } + + /** + * Create an "anchor" for performing dom insertion/removals. + * This is used in a number of scenarios: + * - fragment instance + * - v-html + * - v-if + * - v-for + * - component + * + * @param {String} content + * @param {Boolean} persist - IE trashes empty textNodes on + * cloneNode(true), so in certain + * cases the anchor needs to be + * non-empty to be persisted in + * templates. + * @return {Comment|Text} + */ + + function createAnchor(content, persist) { + var anchor = config.debug ? document.createComment(content) : document.createTextNode(persist ? ' ' : ''); + anchor.__v_anchor = true; + return anchor; + } + + /** + * Find a component ref attribute that starts with $. + * + * @param {Element} node + * @return {String|undefined} + */ + + var refRE = /^v-ref:/; + + function findRef(node) { + if (node.hasAttributes()) { + var attrs = node.attributes; + for (var i = 0, l = attrs.length; i < l; i++) { + var name = attrs[i].name; + if (refRE.test(name)) { + return camelize(name.replace(refRE, '')); + } + } + } + } + + /** + * Map a function to a range of nodes . + * + * @param {Node} node + * @param {Node} end + * @param {Function} op + */ + + function mapNodeRange(node, end, op) { + var next; + while (node !== end) { + next = node.nextSibling; + op(node); + node = next; + } + op(end); + } + + /** + * Remove a range of nodes with transition, store + * the nodes in a fragment with correct ordering, + * and call callback when done. + * + * @param {Node} start + * @param {Node} end + * @param {Vue} vm + * @param {DocumentFragment} frag + * @param {Function} cb + */ + + function removeNodeRange(start, end, vm, frag, cb) { + var done = false; + var removed = 0; + var nodes = []; + mapNodeRange(start, end, function (node) { + if (node === end) done = true; + nodes.push(node); + removeWithTransition(node, vm, onRemoved); + }); + function onRemoved() { + removed++; + if (done && removed >= nodes.length) { + for (var i = 0; i < nodes.length; i++) { + frag.appendChild(nodes[i]); + } + cb && cb(); + } + } + } + + /** + * Check if a node is a DocumentFragment. + * + * @param {Node} node + * @return {Boolean} + */ + + function isFragment(node) { + return node && node.nodeType === 11; + } + + /** + * Get outerHTML of elements, taking care + * of SVG elements in IE as well. + * + * @param {Element} el + * @return {String} + */ + + function getOuterHTML(el) { + if (el.outerHTML) { + return el.outerHTML; + } else { + var container = document.createElement('div'); + container.appendChild(el.cloneNode(true)); + return container.innerHTML; + } + } + + var commonTagRE = /^(div|p|span|img|a|b|i|br|ul|ol|li|h1|h2|h3|h4|h5|h6|code|pre|table|th|td|tr|form|label|input|select|option|nav|article|section|header|footer)$/i; + var reservedTagRE = /^(slot|partial|component)$/i; + + var isUnknownElement = undefined; + if ('development' !== 'production') { + isUnknownElement = function (el, tag) { + if (tag.indexOf('-') > -1) { + // http://stackoverflow.com/a/28210364/1070244 + return el.constructor === window.HTMLUnknownElement || el.constructor === window.HTMLElement; + } else { + return (/HTMLUnknownElement/.test(el.toString()) && + // Chrome returns unknown for several HTML5 elements. + // https://code.google.com/p/chromium/issues/detail?id=540526 + // Firefox returns unknown for some "Interactive elements." + !/^(data|time|rtc|rb|details|dialog|summary)$/.test(tag) + ); + } + }; + } + + /** + * Check if an element is a component, if yes return its + * component id. + * + * @param {Element} el + * @param {Object} options + * @return {Object|undefined} + */ + + function checkComponentAttr(el, options) { + var tag = el.tagName.toLowerCase(); + var hasAttrs = el.hasAttributes(); + if (!commonTagRE.test(tag) && !reservedTagRE.test(tag)) { + if (resolveAsset(options, 'components', tag)) { + return { id: tag }; + } else { + var is = hasAttrs && getIsBinding(el, options); + if (is) { + return is; + } else if ('development' !== 'production') { + var expectedTag = options._componentNameMap && options._componentNameMap[tag]; + if (expectedTag) { + warn('Unknown custom element: <' + tag + '> - ' + 'did you mean <' + expectedTag + '>? ' + 'HTML is case-insensitive, remember to use kebab-case in templates.'); + } else if (isUnknownElement(el, tag)) { + warn('Unknown custom element: <' + tag + '> - did you ' + 'register the component correctly? For recursive components, ' + 'make sure to provide the "name" option.'); + } + } + } + } else if (hasAttrs) { + return getIsBinding(el, options); + } + } + + /** + * Get "is" binding from an element. + * + * @param {Element} el + * @param {Object} options + * @return {Object|undefined} + */ + + function getIsBinding(el, options) { + // dynamic syntax + var exp = el.getAttribute('is'); + if (exp != null) { + if (resolveAsset(options, 'components', exp)) { + el.removeAttribute('is'); + return { id: exp }; + } + } else { + exp = getBindAttr(el, 'is'); + if (exp != null) { + return { id: exp, dynamic: true }; + } + } + } + + /** + * Option overwriting strategies are functions that handle + * how to merge a parent option value and a child option + * value into the final value. + * + * All strategy functions follow the same signature: + * + * @param {*} parentVal + * @param {*} childVal + * @param {Vue} [vm] + */ + + var strats = config.optionMergeStrategies = Object.create(null); + + /** + * Helper that recursively merges two data objects together. + */ + + function mergeData(to, from) { + var key, toVal, fromVal; + for (key in from) { + toVal = to[key]; + fromVal = from[key]; + if (!hasOwn(to, key)) { + set(to, key, fromVal); + } else if (isObject(toVal) && isObject(fromVal)) { + mergeData(toVal, fromVal); + } + } + return to; + } + + /** + * Data + */ + + strats.data = function (parentVal, childVal, vm) { + if (!vm) { + // in a Vue.extend merge, both should be functions + if (!childVal) { + return parentVal; + } + if (typeof childVal !== 'function') { + 'development' !== 'production' && warn('The "data" option should be a function ' + 'that returns a per-instance value in component ' + 'definitions.', vm); + return parentVal; + } + if (!parentVal) { + return childVal; + } + // when parentVal & childVal are both present, + // we need to return a function that returns the + // merged result of both functions... no need to + // check if parentVal is a function here because + // it has to be a function to pass previous merges. + return function mergedDataFn() { + return mergeData(childVal.call(this), parentVal.call(this)); + }; + } else if (parentVal || childVal) { + return function mergedInstanceDataFn() { + // instance merge + var instanceData = typeof childVal === 'function' ? childVal.call(vm) : childVal; + var defaultData = typeof parentVal === 'function' ? parentVal.call(vm) : undefined; + if (instanceData) { + return mergeData(instanceData, defaultData); + } else { + return defaultData; + } + }; + } + }; + + /** + * El + */ + + strats.el = function (parentVal, childVal, vm) { + if (!vm && childVal && typeof childVal !== 'function') { + 'development' !== 'production' && warn('The "el" option should be a function ' + 'that returns a per-instance value in component ' + 'definitions.', vm); + return; + } + var ret = childVal || parentVal; + // invoke the element factory if this is instance merge + return vm && typeof ret === 'function' ? ret.call(vm) : ret; + }; + + /** + * Hooks and param attributes are merged as arrays. + */ + + strats.init = strats.created = strats.ready = strats.attached = strats.detached = strats.beforeCompile = strats.compiled = strats.beforeDestroy = strats.destroyed = strats.activate = function (parentVal, childVal) { + return childVal ? parentVal ? parentVal.concat(childVal) : isArray(childVal) ? childVal : [childVal] : parentVal; + }; + + /** + * Assets + * + * When a vm is present (instance creation), we need to do + * a three-way merge between constructor options, instance + * options and parent options. + */ + + function mergeAssets(parentVal, childVal) { + var res = Object.create(parentVal || null); + return childVal ? extend(res, guardArrayAssets(childVal)) : res; + } + + config._assetTypes.forEach(function (type) { + strats[type + 's'] = mergeAssets; + }); + + /** + * Events & Watchers. + * + * Events & watchers hashes should not overwrite one + * another, so we merge them as arrays. + */ + + strats.watch = strats.events = function (parentVal, childVal) { + if (!childVal) return parentVal; + if (!parentVal) return childVal; + var ret = {}; + extend(ret, parentVal); + for (var key in childVal) { + var parent = ret[key]; + var child = childVal[key]; + if (parent && !isArray(parent)) { + parent = [parent]; + } + ret[key] = parent ? parent.concat(child) : [child]; + } + return ret; + }; + + /** + * Other object hashes. + */ + + strats.props = strats.methods = strats.computed = function (parentVal, childVal) { + if (!childVal) return parentVal; + if (!parentVal) return childVal; + var ret = Object.create(null); + extend(ret, parentVal); + extend(ret, childVal); + return ret; + }; + + /** + * Default strategy. + */ + + var defaultStrat = function defaultStrat(parentVal, childVal) { + return childVal === undefined ? parentVal : childVal; + }; + + /** + * Make sure component options get converted to actual + * constructors. + * + * @param {Object} options + */ + + function guardComponents(options) { + if (options.components) { + var components = options.components = guardArrayAssets(options.components); + var ids = Object.keys(components); + var def; + if ('development' !== 'production') { + var map = options._componentNameMap = {}; + } + for (var i = 0, l = ids.length; i < l; i++) { + var key = ids[i]; + if (commonTagRE.test(key) || reservedTagRE.test(key)) { + 'development' !== 'production' && warn('Do not use built-in or reserved HTML elements as component ' + 'id: ' + key); + continue; + } + // record a all lowercase <-> kebab-case mapping for + // possible custom element case error warning + if ('development' !== 'production') { + map[key.replace(/-/g, '').toLowerCase()] = hyphenate(key); + } + def = components[key]; + if (isPlainObject(def)) { + components[key] = Vue.extend(def); + } + } + } + } + + /** + * Ensure all props option syntax are normalized into the + * Object-based format. + * + * @param {Object} options + */ + + function guardProps(options) { + var props = options.props; + var i, val; + if (isArray(props)) { + options.props = {}; + i = props.length; + while (i--) { + val = props[i]; + if (typeof val === 'string') { + options.props[val] = null; + } else if (val.name) { + options.props[val.name] = val; + } + } + } else if (isPlainObject(props)) { + var keys = Object.keys(props); + i = keys.length; + while (i--) { + val = props[keys[i]]; + if (typeof val === 'function') { + props[keys[i]] = { type: val }; + } + } + } + } + + /** + * Guard an Array-format assets option and converted it + * into the key-value Object format. + * + * @param {Object|Array} assets + * @return {Object} + */ + + function guardArrayAssets(assets) { + if (isArray(assets)) { + var res = {}; + var i = assets.length; + var asset; + while (i--) { + asset = assets[i]; + var id = typeof asset === 'function' ? asset.options && asset.options.name || asset.id : asset.name || asset.id; + if (!id) { + 'development' !== 'production' && warn('Array-syntax assets must provide a "name" or "id" field.'); + } else { + res[id] = asset; + } + } + return res; + } + return assets; + } + + /** + * Merge two option objects into a new one. + * Core utility used in both instantiation and inheritance. + * + * @param {Object} parent + * @param {Object} child + * @param {Vue} [vm] - if vm is present, indicates this is + * an instantiation merge. + */ + + function mergeOptions(parent, child, vm) { + guardComponents(child); + guardProps(child); + if ('development' !== 'production') { + if (child.propsData && !vm) { + warn('propsData can only be used as an instantiation option.'); + } + } + var options = {}; + var key; + if (child['extends']) { + parent = typeof child['extends'] === 'function' ? mergeOptions(parent, child['extends'].options, vm) : mergeOptions(parent, child['extends'], vm); + } + if (child.mixins) { + for (var i = 0, l = child.mixins.length; i < l; i++) { + var mixin = child.mixins[i]; + var mixinOptions = mixin.prototype instanceof Vue ? mixin.options : mixin; + parent = mergeOptions(parent, mixinOptions, vm); + } + } + for (key in parent) { + mergeField(key); + } + for (key in child) { + if (!hasOwn(parent, key)) { + mergeField(key); + } + } + function mergeField(key) { + var strat = strats[key] || defaultStrat; + options[key] = strat(parent[key], child[key], vm, key); + } + return options; + } + + /** + * Resolve an asset. + * This function is used because child instances need access + * to assets defined in its ancestor chain. + * + * @param {Object} options + * @param {String} type + * @param {String} id + * @param {Boolean} warnMissing + * @return {Object|Function} + */ + + function resolveAsset(options, type, id, warnMissing) { + /* istanbul ignore if */ + if (typeof id !== 'string') { + return; + } + var assets = options[type]; + var camelizedId; + var res = assets[id] || + // camelCase ID + assets[camelizedId = camelize(id)] || + // Pascal Case ID + assets[camelizedId.charAt(0).toUpperCase() + camelizedId.slice(1)]; + if ('development' !== 'production' && warnMissing && !res) { + warn('Failed to resolve ' + type.slice(0, -1) + ': ' + id, options); + } + return res; + } + + var uid$1 = 0; + + /** + * A dep is an observable that can have multiple + * directives subscribing to it. + * + * @constructor + */ + function Dep() { + this.id = uid$1++; + this.subs = []; + } + + // the current target watcher being evaluated. + // this is globally unique because there could be only one + // watcher being evaluated at any time. + Dep.target = null; + + /** + * Add a directive subscriber. + * + * @param {Directive} sub + */ + + Dep.prototype.addSub = function (sub) { + this.subs.push(sub); + }; + + /** + * Remove a directive subscriber. + * + * @param {Directive} sub + */ + + Dep.prototype.removeSub = function (sub) { + this.subs.$remove(sub); + }; + + /** + * Add self as a dependency to the target watcher. + */ + + Dep.prototype.depend = function () { + Dep.target.addDep(this); + }; + + /** + * Notify all subscribers of a new value. + */ + + Dep.prototype.notify = function () { + // stablize the subscriber list first + var subs = toArray(this.subs); + for (var i = 0, l = subs.length; i < l; i++) { + subs[i].update(); + } + }; + + var arrayProto = Array.prototype; + var arrayMethods = Object.create(arrayProto) + + /** + * Intercept mutating methods and emit events + */ + + ;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) { + // cache original method + var original = arrayProto[method]; + def(arrayMethods, method, function mutator() { + // avoid leaking arguments: + // http://jsperf.com/closure-with-arguments + var i = arguments.length; + var args = new Array(i); + while (i--) { + args[i] = arguments[i]; + } + var result = original.apply(this, args); + var ob = this.__ob__; + var inserted; + switch (method) { + case 'push': + inserted = args; + break; + case 'unshift': + inserted = args; + break; + case 'splice': + inserted = args.slice(2); + break; + } + if (inserted) ob.observeArray(inserted); + // notify change + ob.dep.notify(); + return result; + }); + }); + + /** + * Swap the element at the given index with a new value + * and emits corresponding event. + * + * @param {Number} index + * @param {*} val + * @return {*} - replaced element + */ + + def(arrayProto, '$set', function $set(index, val) { + if (index >= this.length) { + this.length = Number(index) + 1; + } + return this.splice(index, 1, val)[0]; + }); + + /** + * Convenience method to remove the element at given index or target element reference. + * + * @param {*} item + */ + + def(arrayProto, '$remove', function $remove(item) { + /* istanbul ignore if */ + if (!this.length) return; + var index = indexOf(this, item); + if (index > -1) { + return this.splice(index, 1); + } + }); + + var arrayKeys = Object.getOwnPropertyNames(arrayMethods); + + /** + * By default, when a reactive property is set, the new value is + * also converted to become reactive. However in certain cases, e.g. + * v-for scope alias and props, we don't want to force conversion + * because the value may be a nested value under a frozen data structure. + * + * So whenever we want to set a reactive property without forcing + * conversion on the new value, we wrap that call inside this function. + */ + + var shouldConvert = true; + + function withoutConversion(fn) { + shouldConvert = false; + fn(); + shouldConvert = true; + } + + /** + * Observer class that are attached to each observed + * object. Once attached, the observer converts target + * object's property keys into getter/setters that + * collect dependencies and dispatches updates. + * + * @param {Array|Object} value + * @constructor + */ + + function Observer(value) { + this.value = value; + this.dep = new Dep(); + def(value, '__ob__', this); + if (isArray(value)) { + var augment = hasProto ? protoAugment : copyAugment; + augment(value, arrayMethods, arrayKeys); + this.observeArray(value); + } else { + this.walk(value); + } + } + + // Instance methods + + /** + * Walk through each property and convert them into + * getter/setters. This method should only be called when + * value type is Object. + * + * @param {Object} obj + */ + + Observer.prototype.walk = function (obj) { + var keys = Object.keys(obj); + for (var i = 0, l = keys.length; i < l; i++) { + this.convert(keys[i], obj[keys[i]]); + } + }; + + /** + * Observe a list of Array items. + * + * @param {Array} items + */ + + Observer.prototype.observeArray = function (items) { + for (var i = 0, l = items.length; i < l; i++) { + observe(items[i]); + } + }; + + /** + * Convert a property into getter/setter so we can emit + * the events when the property is accessed/changed. + * + * @param {String} key + * @param {*} val + */ + + Observer.prototype.convert = function (key, val) { + defineReactive(this.value, key, val); + }; + + /** + * Add an owner vm, so that when $set/$delete mutations + * happen we can notify owner vms to proxy the keys and + * digest the watchers. This is only called when the object + * is observed as an instance's root $data. + * + * @param {Vue} vm + */ + + Observer.prototype.addVm = function (vm) { + (this.vms || (this.vms = [])).push(vm); + }; + + /** + * Remove an owner vm. This is called when the object is + * swapped out as an instance's $data object. + * + * @param {Vue} vm + */ + + Observer.prototype.removeVm = function (vm) { + this.vms.$remove(vm); + }; + + // helpers + + /** + * Augment an target Object or Array by intercepting + * the prototype chain using __proto__ + * + * @param {Object|Array} target + * @param {Object} src + */ + + function protoAugment(target, src) { + /* eslint-disable no-proto */ + target.__proto__ = src; + /* eslint-enable no-proto */ + } + + /** + * Augment an target Object or Array by defining + * hidden properties. + * + * @param {Object|Array} target + * @param {Object} proto + */ + + function copyAugment(target, src, keys) { + for (var i = 0, l = keys.length; i < l; i++) { + var key = keys[i]; + def(target, key, src[key]); + } + } + + /** + * Attempt to create an observer instance for a value, + * returns the new observer if successfully observed, + * or the existing observer if the value already has one. + * + * @param {*} value + * @param {Vue} [vm] + * @return {Observer|undefined} + * @static + */ + + function observe(value, vm) { + if (!value || typeof value !== 'object') { + return; + } + var ob; + if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { + ob = value.__ob__; + } else if (shouldConvert && (isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue) { + ob = new Observer(value); + } + if (ob && vm) { + ob.addVm(vm); + } + return ob; + } + + /** + * Define a reactive property on an Object. + * + * @param {Object} obj + * @param {String} key + * @param {*} val + */ + + function defineReactive(obj, key, val) { + var dep = new Dep(); + + var property = Object.getOwnPropertyDescriptor(obj, key); + if (property && property.configurable === false) { + return; + } + + // cater for pre-defined getter/setters + var getter = property && property.get; + var setter = property && property.set; + + var childOb = observe(val); + Object.defineProperty(obj, key, { + enumerable: true, + configurable: true, + get: function reactiveGetter() { + var value = getter ? getter.call(obj) : val; + if (Dep.target) { + dep.depend(); + if (childOb) { + childOb.dep.depend(); + } + if (isArray(value)) { + for (var e, i = 0, l = value.length; i < l; i++) { + e = value[i]; + e && e.__ob__ && e.__ob__.dep.depend(); + } + } + } + return value; + }, + set: function reactiveSetter(newVal) { + var value = getter ? getter.call(obj) : val; + if (newVal === value) { + return; + } + if (setter) { + setter.call(obj, newVal); + } else { + val = newVal; + } + childOb = observe(newVal); + dep.notify(); + } + }); + } + + + + var util = Object.freeze({ + defineReactive: defineReactive, + set: set, + del: del, + hasOwn: hasOwn, + isLiteral: isLiteral, + isReserved: isReserved, + _toString: _toString, + toNumber: toNumber, + toBoolean: toBoolean, + stripQuotes: stripQuotes, + camelize: camelize, + hyphenate: hyphenate, + classify: classify, + bind: bind, + toArray: toArray, + extend: extend, + isObject: isObject, + isPlainObject: isPlainObject, + def: def, + debounce: _debounce, + indexOf: indexOf, + cancellable: cancellable, + looseEqual: looseEqual, + isArray: isArray, + hasProto: hasProto, + inBrowser: inBrowser, + devtools: devtools, + isIE: isIE, + isIE9: isIE9, + isAndroid: isAndroid, + isIos: isIos, + iosVersionMatch: iosVersionMatch, + iosVersion: iosVersion, + hasMutationObserverBug: hasMutationObserverBug, + get transitionProp () { return transitionProp; }, + get transitionEndEvent () { return transitionEndEvent; }, + get animationProp () { return animationProp; }, + get animationEndEvent () { return animationEndEvent; }, + nextTick: nextTick, + get _Set () { return _Set; }, + query: query, + inDoc: inDoc, + getAttr: getAttr, + getBindAttr: getBindAttr, + hasBindAttr: hasBindAttr, + before: before, + after: after, + remove: remove, + prepend: prepend, + replace: replace, + on: on, + off: off, + setClass: setClass, + addClass: addClass, + removeClass: removeClass, + extractContent: extractContent, + trimNode: trimNode, + isTemplate: isTemplate, + createAnchor: createAnchor, + findRef: findRef, + mapNodeRange: mapNodeRange, + removeNodeRange: removeNodeRange, + isFragment: isFragment, + getOuterHTML: getOuterHTML, + mergeOptions: mergeOptions, + resolveAsset: resolveAsset, + checkComponentAttr: checkComponentAttr, + commonTagRE: commonTagRE, + reservedTagRE: reservedTagRE, + get warn () { return warn; } + }); + + var uid = 0; + + function initMixin (Vue) { + /** + * The main init sequence. This is called for every + * instance, including ones that are created from extended + * constructors. + * + * @param {Object} options - this options object should be + * the result of merging class + * options and the options passed + * in to the constructor. + */ + + Vue.prototype._init = function (options) { + options = options || {}; + + this.$el = null; + this.$parent = options.parent; + this.$root = this.$parent ? this.$parent.$root : this; + this.$children = []; + this.$refs = {}; // child vm references + this.$els = {}; // element references + this._watchers = []; // all watchers as an array + this._directives = []; // all directives + + // a uid + this._uid = uid++; + + // a flag to avoid this being observed + this._isVue = true; + + // events bookkeeping + this._events = {}; // registered callbacks + this._eventsCount = {}; // for $broadcast optimization + + // fragment instance properties + this._isFragment = false; + this._fragment = // @type {DocumentFragment} + this._fragmentStart = // @type {Text|Comment} + this._fragmentEnd = null; // @type {Text|Comment} + + // lifecycle state + this._isCompiled = this._isDestroyed = this._isReady = this._isAttached = this._isBeingDestroyed = this._vForRemoving = false; + this._unlinkFn = null; + + // context: + // if this is a transcluded component, context + // will be the common parent vm of this instance + // and its host. + this._context = options._context || this.$parent; + + // scope: + // if this is inside an inline v-for, the scope + // will be the intermediate scope created for this + // repeat fragment. this is used for linking props + // and container directives. + this._scope = options._scope; + + // fragment: + // if this instance is compiled inside a Fragment, it + // needs to reigster itself as a child of that fragment + // for attach/detach to work properly. + this._frag = options._frag; + if (this._frag) { + this._frag.children.push(this); + } + + // push self into parent / transclusion host + if (this.$parent) { + this.$parent.$children.push(this); + } + + // merge options. + options = this.$options = mergeOptions(this.constructor.options, options, this); + + // set ref + this._updateRef(); + + // initialize data as empty object. + // it will be filled up in _initData(). + this._data = {}; + + // call init hook + this._callHook('init'); + + // initialize data observation and scope inheritance. + this._initState(); + + // setup event system and option events. + this._initEvents(); + + // call created hook + this._callHook('created'); + + // if `el` option is passed, start compilation. + if (options.el) { + this.$mount(options.el); + } + }; + } + + var pathCache = new Cache(1000); + + // actions + var APPEND = 0; + var PUSH = 1; + var INC_SUB_PATH_DEPTH = 2; + var PUSH_SUB_PATH = 3; + + // states + var BEFORE_PATH = 0; + var IN_PATH = 1; + var BEFORE_IDENT = 2; + var IN_IDENT = 3; + var IN_SUB_PATH = 4; + var IN_SINGLE_QUOTE = 5; + var IN_DOUBLE_QUOTE = 6; + var AFTER_PATH = 7; + var ERROR = 8; + + var pathStateMachine = []; + + pathStateMachine[BEFORE_PATH] = { + 'ws': [BEFORE_PATH], + 'ident': [IN_IDENT, APPEND], + '[': [IN_SUB_PATH], + 'eof': [AFTER_PATH] + }; + + pathStateMachine[IN_PATH] = { + 'ws': [IN_PATH], + '.': [BEFORE_IDENT], + '[': [IN_SUB_PATH], + 'eof': [AFTER_PATH] + }; + + pathStateMachine[BEFORE_IDENT] = { + 'ws': [BEFORE_IDENT], + 'ident': [IN_IDENT, APPEND] + }; + + pathStateMachine[IN_IDENT] = { + 'ident': [IN_IDENT, APPEND], + '0': [IN_IDENT, APPEND], + 'number': [IN_IDENT, APPEND], + 'ws': [IN_PATH, PUSH], + '.': [BEFORE_IDENT, PUSH], + '[': [IN_SUB_PATH, PUSH], + 'eof': [AFTER_PATH, PUSH] + }; + + pathStateMachine[IN_SUB_PATH] = { + "'": [IN_SINGLE_QUOTE, APPEND], + '"': [IN_DOUBLE_QUOTE, APPEND], + '[': [IN_SUB_PATH, INC_SUB_PATH_DEPTH], + ']': [IN_PATH, PUSH_SUB_PATH], + 'eof': ERROR, + 'else': [IN_SUB_PATH, APPEND] + }; + + pathStateMachine[IN_SINGLE_QUOTE] = { + "'": [IN_SUB_PATH, APPEND], + 'eof': ERROR, + 'else': [IN_SINGLE_QUOTE, APPEND] + }; + + pathStateMachine[IN_DOUBLE_QUOTE] = { + '"': [IN_SUB_PATH, APPEND], + 'eof': ERROR, + 'else': [IN_DOUBLE_QUOTE, APPEND] + }; + + /** + * Determine the type of a character in a keypath. + * + * @param {Char} ch + * @return {String} type + */ + + function getPathCharType(ch) { + if (ch === undefined) { + return 'eof'; + } + + var code = ch.charCodeAt(0); + + switch (code) { + case 0x5B: // [ + case 0x5D: // ] + case 0x2E: // . + case 0x22: // " + case 0x27: // ' + case 0x30: + // 0 + return ch; + + case 0x5F: // _ + case 0x24: + // $ + return 'ident'; + + case 0x20: // Space + case 0x09: // Tab + case 0x0A: // Newline + case 0x0D: // Return + case 0xA0: // No-break space + case 0xFEFF: // Byte Order Mark + case 0x2028: // Line Separator + case 0x2029: + // Paragraph Separator + return 'ws'; + } + + // a-z, A-Z + if (code >= 0x61 && code <= 0x7A || code >= 0x41 && code <= 0x5A) { + return 'ident'; + } + + // 1-9 + if (code >= 0x31 && code <= 0x39) { + return 'number'; + } + + return 'else'; + } + + /** + * Format a subPath, return its plain form if it is + * a literal string or number. Otherwise prepend the + * dynamic indicator (*). + * + * @param {String} path + * @return {String} + */ + + function formatSubPath(path) { + var trimmed = path.trim(); + // invalid leading 0 + if (path.charAt(0) === '0' && isNaN(path)) { + return false; + } + return isLiteral(trimmed) ? stripQuotes(trimmed) : '*' + trimmed; + } + + /** + * Parse a string path into an array of segments + * + * @param {String} path + * @return {Array|undefined} + */ + + function parse(path) { + var keys = []; + var index = -1; + var mode = BEFORE_PATH; + var subPathDepth = 0; + var c, newChar, key, type, transition, action, typeMap; + + var actions = []; + + actions[PUSH] = function () { + if (key !== undefined) { + keys.push(key); + key = undefined; + } + }; + + actions[APPEND] = function () { + if (key === undefined) { + key = newChar; + } else { + key += newChar; + } + }; + + actions[INC_SUB_PATH_DEPTH] = function () { + actions[APPEND](); + subPathDepth++; + }; + + actions[PUSH_SUB_PATH] = function () { + if (subPathDepth > 0) { + subPathDepth--; + mode = IN_SUB_PATH; + actions[APPEND](); + } else { + subPathDepth = 0; + key = formatSubPath(key); + if (key === false) { + return false; + } else { + actions[PUSH](); + } + } + }; + + function maybeUnescapeQuote() { + var nextChar = path[index + 1]; + if (mode === IN_SINGLE_QUOTE && nextChar === "'" || mode === IN_DOUBLE_QUOTE && nextChar === '"') { + index++; + newChar = '\\' + nextChar; + actions[APPEND](); + return true; + } + } + + while (mode != null) { + index++; + c = path[index]; + + if (c === '\\' && maybeUnescapeQuote()) { + continue; + } + + type = getPathCharType(c); + typeMap = pathStateMachine[mode]; + transition = typeMap[type] || typeMap['else'] || ERROR; + + if (transition === ERROR) { + return; // parse error + } + + mode = transition[0]; + action = actions[transition[1]]; + if (action) { + newChar = transition[2]; + newChar = newChar === undefined ? c : newChar; + if (action() === false) { + return; + } + } + + if (mode === AFTER_PATH) { + keys.raw = path; + return keys; + } + } + } + + /** + * External parse that check for a cache hit first + * + * @param {String} path + * @return {Array|undefined} + */ + + function parsePath(path) { + var hit = pathCache.get(path); + if (!hit) { + hit = parse(path); + if (hit) { + pathCache.put(path, hit); + } + } + return hit; + } + + /** + * Get from an object from a path string + * + * @param {Object} obj + * @param {String} path + */ + + function getPath(obj, path) { + return parseExpression(path).get(obj); + } + + /** + * Warn against setting non-existent root path on a vm. + */ + + var warnNonExistent; + if ('development' !== 'production') { + warnNonExistent = function (path, vm) { + warn('You are setting a non-existent path "' + path.raw + '" ' + 'on a vm instance. Consider pre-initializing the property ' + 'with the "data" option for more reliable reactivity ' + 'and better performance.', vm); + }; + } + + /** + * Set on an object from a path + * + * @param {Object} obj + * @param {String | Array} path + * @param {*} val + */ + + function setPath(obj, path, val) { + var original = obj; + if (typeof path === 'string') { + path = parse(path); + } + if (!path || !isObject(obj)) { + return false; + } + var last, key; + for (var i = 0, l = path.length; i < l; i++) { + last = obj; + key = path[i]; + if (key.charAt(0) === '*') { + key = parseExpression(key.slice(1)).get.call(original, original); + } + if (i < l - 1) { + obj = obj[key]; + if (!isObject(obj)) { + obj = {}; + if ('development' !== 'production' && last._isVue) { + warnNonExistent(path, last); + } + set(last, key, obj); + } + } else { + if (isArray(obj)) { + obj.$set(key, val); + } else if (key in obj) { + obj[key] = val; + } else { + if ('development' !== 'production' && obj._isVue) { + warnNonExistent(path, obj); + } + set(obj, key, val); + } + } + } + return true; + } + +var path = Object.freeze({ + parsePath: parsePath, + getPath: getPath, + setPath: setPath + }); + + var expressionCache = new Cache(1000); + + var allowedKeywords = 'Math,Date,this,true,false,null,undefined,Infinity,NaN,' + 'isNaN,isFinite,decodeURI,decodeURIComponent,encodeURI,' + 'encodeURIComponent,parseInt,parseFloat'; + var allowedKeywordsRE = new RegExp('^(' + allowedKeywords.replace(/,/g, '\\b|') + '\\b)'); + + // keywords that don't make sense inside expressions + var improperKeywords = 'break,case,class,catch,const,continue,debugger,default,' + 'delete,do,else,export,extends,finally,for,function,if,' + 'import,in,instanceof,let,return,super,switch,throw,try,' + 'var,while,with,yield,enum,await,implements,package,' + 'protected,static,interface,private,public'; + var improperKeywordsRE = new RegExp('^(' + improperKeywords.replace(/,/g, '\\b|') + '\\b)'); + + var wsRE = /\s/g; + var newlineRE = /\n/g; + var saveRE = /[\{,]\s*[\w\$_]+\s*:|('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*\$\{|\}(?:[^`\\]|\\.)*`|`(?:[^`\\]|\\.)*`)|new |typeof |void /g; + var restoreRE = /"(\d+)"/g; + var pathTestRE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['.*?'\]|\[".*?"\]|\[\d+\]|\[[A-Za-z_$][\w$]*\])*$/; + var identRE = /[^\w$\.](?:[A-Za-z_$][\w$]*)/g; + var literalValueRE$1 = /^(?:true|false|null|undefined|Infinity|NaN)$/; + + function noop() {} + + /** + * Save / Rewrite / Restore + * + * When rewriting paths found in an expression, it is + * possible for the same letter sequences to be found in + * strings and Object literal property keys. Therefore we + * remove and store these parts in a temporary array, and + * restore them after the path rewrite. + */ + + var saved = []; + + /** + * Save replacer + * + * The save regex can match two possible cases: + * 1. An opening object literal + * 2. A string + * If matched as a plain string, we need to escape its + * newlines, since the string needs to be preserved when + * generating the function body. + * + * @param {String} str + * @param {String} isString - str if matched as a string + * @return {String} - placeholder with index + */ + + function save(str, isString) { + var i = saved.length; + saved[i] = isString ? str.replace(newlineRE, '\\n') : str; + return '"' + i + '"'; + } + + /** + * Path rewrite replacer + * + * @param {String} raw + * @return {String} + */ + + function rewrite(raw) { + var c = raw.charAt(0); + var path = raw.slice(1); + if (allowedKeywordsRE.test(path)) { + return raw; + } else { + path = path.indexOf('"') > -1 ? path.replace(restoreRE, restore) : path; + return c + 'scope.' + path; + } + } + + /** + * Restore replacer + * + * @param {String} str + * @param {String} i - matched save index + * @return {String} + */ + + function restore(str, i) { + return saved[i]; + } + + /** + * Rewrite an expression, prefixing all path accessors with + * `scope.` and generate getter/setter functions. + * + * @param {String} exp + * @return {Function} + */ + + function compileGetter(exp) { + if (improperKeywordsRE.test(exp)) { + 'development' !== 'production' && warn('Avoid using reserved keywords in expression: ' + exp); + } + // reset state + saved.length = 0; + // save strings and object literal keys + var body = exp.replace(saveRE, save).replace(wsRE, ''); + // rewrite all paths + // pad 1 space here because the regex matches 1 extra char + body = (' ' + body).replace(identRE, rewrite).replace(restoreRE, restore); + return makeGetterFn(body); + } + + /** + * Build a getter function. Requires eval. + * + * We isolate the try/catch so it doesn't affect the + * optimization of the parse function when it is not called. + * + * @param {String} body + * @return {Function|undefined} + */ + + function makeGetterFn(body) { + try { + /* eslint-disable no-new-func */ + return new Function('scope', 'return ' + body + ';'); + /* eslint-enable no-new-func */ + } catch (e) { + if ('development' !== 'production') { + /* istanbul ignore if */ + if (e.toString().match(/unsafe-eval|CSP/)) { + warn('It seems you are using the default build of Vue.js in an environment ' + 'with Content Security Policy that prohibits unsafe-eval. ' + 'Use the CSP-compliant build instead: ' + 'http://vuejs.org/guide/installation.html#CSP-compliant-build'); + } else { + warn('Invalid expression. ' + 'Generated function body: ' + body); + } + } + return noop; + } + } + + /** + * Compile a setter function for the expression. + * + * @param {String} exp + * @return {Function|undefined} + */ + + function compileSetter(exp) { + var path = parsePath(exp); + if (path) { + return function (scope, val) { + setPath(scope, path, val); + }; + } else { + 'development' !== 'production' && warn('Invalid setter expression: ' + exp); + } + } + + /** + * Parse an expression into re-written getter/setters. + * + * @param {String} exp + * @param {Boolean} needSet + * @return {Function} + */ + + function parseExpression(exp, needSet) { + exp = exp.trim(); + // try cache + var hit = expressionCache.get(exp); + if (hit) { + if (needSet && !hit.set) { + hit.set = compileSetter(hit.exp); + } + return hit; + } + var res = { exp: exp }; + res.get = isSimplePath(exp) && exp.indexOf('[') < 0 + // optimized super simple getter + ? makeGetterFn('scope.' + exp) + // dynamic getter + : compileGetter(exp); + if (needSet) { + res.set = compileSetter(exp); + } + expressionCache.put(exp, res); + return res; + } + + /** + * Check if an expression is a simple path. + * + * @param {String} exp + * @return {Boolean} + */ + + function isSimplePath(exp) { + return pathTestRE.test(exp) && + // don't treat literal values as paths + !literalValueRE$1.test(exp) && + // Math constants e.g. Math.PI, Math.E etc. + exp.slice(0, 5) !== 'Math.'; + } + +var expression = Object.freeze({ + parseExpression: parseExpression, + isSimplePath: isSimplePath + }); + + // we have two separate queues: one for directive updates + // and one for user watcher registered via $watch(). + // we want to guarantee directive updates to be called + // before user watchers so that when user watchers are + // triggered, the DOM would have already been in updated + // state. + + var queue = []; + var userQueue = []; + var has = {}; + var circular = {}; + var waiting = false; + + /** + * Reset the batcher's state. + */ + + function resetBatcherState() { + queue.length = 0; + userQueue.length = 0; + has = {}; + circular = {}; + waiting = false; + } + + /** + * Flush both queues and run the watchers. + */ + + function flushBatcherQueue() { + var _again = true; + + _function: while (_again) { + _again = false; + + runBatcherQueue(queue); + runBatcherQueue(userQueue); + // user watchers triggered more watchers, + // keep flushing until it depletes + if (queue.length) { + _again = true; + continue _function; + } + // dev tool hook + /* istanbul ignore if */ + if (devtools && config.devtools) { + devtools.emit('flush'); + } + resetBatcherState(); + } + } + + /** + * Run the watchers in a single queue. + * + * @param {Array} queue + */ + + function runBatcherQueue(queue) { + // do not cache length because more watchers might be pushed + // as we run existing watchers + for (var i = 0; i < queue.length; i++) { + var watcher = queue[i]; + var id = watcher.id; + has[id] = null; + watcher.run(); + // in dev build, check and stop circular updates. + if ('development' !== 'production' && has[id] != null) { + circular[id] = (circular[id] || 0) + 1; + if (circular[id] > config._maxUpdateCount) { + warn('You may have an infinite update loop for watcher ' + 'with expression "' + watcher.expression + '"', watcher.vm); + break; + } + } + } + queue.length = 0; + } + + /** + * Push a watcher into the watcher queue. + * Jobs with duplicate IDs will be skipped unless it's + * pushed when the queue is being flushed. + * + * @param {Watcher} watcher + * properties: + * - {Number} id + * - {Function} run + */ + + function pushWatcher(watcher) { + var id = watcher.id; + if (has[id] == null) { + // push watcher into appropriate queue + var q = watcher.user ? userQueue : queue; + has[id] = q.length; + q.push(watcher); + // queue the flush + if (!waiting) { + waiting = true; + nextTick(flushBatcherQueue); + } + } + } + + var uid$2 = 0; + + /** + * A watcher parses an expression, collects dependencies, + * and fires callback when the expression value changes. + * This is used for both the $watch() api and directives. + * + * @param {Vue} vm + * @param {String|Function} expOrFn + * @param {Function} cb + * @param {Object} options + * - {Array} filters + * - {Boolean} twoWay + * - {Boolean} deep + * - {Boolean} user + * - {Boolean} sync + * - {Boolean} lazy + * - {Function} [preProcess] + * - {Function} [postProcess] + * @constructor + */ + function Watcher(vm, expOrFn, cb, options) { + // mix in options + if (options) { + extend(this, options); + } + var isFn = typeof expOrFn === 'function'; + this.vm = vm; + vm._watchers.push(this); + this.expression = expOrFn; + this.cb = cb; + this.id = ++uid$2; // uid for batching + this.active = true; + this.dirty = this.lazy; // for lazy watchers + this.deps = []; + this.newDeps = []; + this.depIds = new _Set(); + this.newDepIds = new _Set(); + this.prevError = null; // for async error stacks + // parse expression for getter/setter + if (isFn) { + this.getter = expOrFn; + this.setter = undefined; + } else { + var res = parseExpression(expOrFn, this.twoWay); + this.getter = res.get; + this.setter = res.set; + } + this.value = this.lazy ? undefined : this.get(); + // state for avoiding false triggers for deep and Array + // watchers during vm._digest() + this.queued = this.shallow = false; + } + + /** + * Evaluate the getter, and re-collect dependencies. + */ + + Watcher.prototype.get = function () { + this.beforeGet(); + var scope = this.scope || this.vm; + var value; + try { + value = this.getter.call(scope, scope); + } catch (e) { + if ('development' !== 'production' && config.warnExpressionErrors) { + warn('Error when evaluating expression ' + '"' + this.expression + '": ' + e.toString(), this.vm); + } + } + // "touch" every property so they are all tracked as + // dependencies for deep watching + if (this.deep) { + traverse(value); + } + if (this.preProcess) { + value = this.preProcess(value); + } + if (this.filters) { + value = scope._applyFilters(value, null, this.filters, false); + } + if (this.postProcess) { + value = this.postProcess(value); + } + this.afterGet(); + return value; + }; + + /** + * Set the corresponding value with the setter. + * + * @param {*} value + */ + + Watcher.prototype.set = function (value) { + var scope = this.scope || this.vm; + if (this.filters) { + value = scope._applyFilters(value, this.value, this.filters, true); + } + try { + this.setter.call(scope, scope, value); + } catch (e) { + if ('development' !== 'production' && config.warnExpressionErrors) { + warn('Error when evaluating setter ' + '"' + this.expression + '": ' + e.toString(), this.vm); + } + } + // two-way sync for v-for alias + var forContext = scope.$forContext; + if (forContext && forContext.alias === this.expression) { + if (forContext.filters) { + 'development' !== 'production' && warn('It seems you are using two-way binding on ' + 'a v-for alias (' + this.expression + '), and the ' + 'v-for has filters. This will not work properly. ' + 'Either remove the filters or use an array of ' + 'objects and bind to object properties instead.', this.vm); + return; + } + forContext._withLock(function () { + if (scope.$key) { + // original is an object + forContext.rawValue[scope.$key] = value; + } else { + forContext.rawValue.$set(scope.$index, value); + } + }); + } + }; + + /** + * Prepare for dependency collection. + */ + + Watcher.prototype.beforeGet = function () { + Dep.target = this; + }; + + /** + * Add a dependency to this directive. + * + * @param {Dep} dep + */ + + Watcher.prototype.addDep = function (dep) { + var id = dep.id; + if (!this.newDepIds.has(id)) { + this.newDepIds.add(id); + this.newDeps.push(dep); + if (!this.depIds.has(id)) { + dep.addSub(this); + } + } + }; + + /** + * Clean up for dependency collection. + */ + + Watcher.prototype.afterGet = function () { + Dep.target = null; + var i = this.deps.length; + while (i--) { + var dep = this.deps[i]; + if (!this.newDepIds.has(dep.id)) { + dep.removeSub(this); + } + } + var tmp = this.depIds; + this.depIds = this.newDepIds; + this.newDepIds = tmp; + this.newDepIds.clear(); + tmp = this.deps; + this.deps = this.newDeps; + this.newDeps = tmp; + this.newDeps.length = 0; + }; + + /** + * Subscriber interface. + * Will be called when a dependency changes. + * + * @param {Boolean} shallow + */ + + Watcher.prototype.update = function (shallow) { + if (this.lazy) { + this.dirty = true; + } else if (this.sync || !config.async) { + this.run(); + } else { + // if queued, only overwrite shallow with non-shallow, + // but not the other way around. + this.shallow = this.queued ? shallow ? this.shallow : false : !!shallow; + this.queued = true; + // record before-push error stack in debug mode + /* istanbul ignore if */ + if ('development' !== 'production' && config.debug) { + this.prevError = new Error('[vue] async stack trace'); + } + pushWatcher(this); + } + }; + + /** + * Batcher job interface. + * Will be called by the batcher. + */ + + Watcher.prototype.run = function () { + if (this.active) { + var value = this.get(); + if (value !== this.value || + // Deep watchers and watchers on Object/Arrays should fire even + // when the value is the same, because the value may + // have mutated; but only do so if this is a + // non-shallow update (caused by a vm digest). + (isObject(value) || this.deep) && !this.shallow) { + // set new value + var oldValue = this.value; + this.value = value; + // in debug + async mode, when a watcher callbacks + // throws, we also throw the saved before-push error + // so the full cross-tick stack trace is available. + var prevError = this.prevError; + /* istanbul ignore if */ + if ('development' !== 'production' && config.debug && prevError) { + this.prevError = null; + try { + this.cb.call(this.vm, value, oldValue); + } catch (e) { + nextTick(function () { + throw prevError; + }, 0); + throw e; + } + } else { + this.cb.call(this.vm, value, oldValue); + } + } + this.queued = this.shallow = false; + } + }; + + /** + * Evaluate the value of the watcher. + * This only gets called for lazy watchers. + */ + + Watcher.prototype.evaluate = function () { + // avoid overwriting another watcher that is being + // collected. + var current = Dep.target; + this.value = this.get(); + this.dirty = false; + Dep.target = current; + }; + + /** + * Depend on all deps collected by this watcher. + */ + + Watcher.prototype.depend = function () { + var i = this.deps.length; + while (i--) { + this.deps[i].depend(); + } + }; + + /** + * Remove self from all dependencies' subcriber list. + */ + + Watcher.prototype.teardown = function () { + if (this.active) { + // remove self from vm's watcher list + // this is a somewhat expensive operation so we skip it + // if the vm is being destroyed or is performing a v-for + // re-render (the watcher list is then filtered by v-for). + if (!this.vm._isBeingDestroyed && !this.vm._vForRemoving) { + this.vm._watchers.$remove(this); + } + var i = this.deps.length; + while (i--) { + this.deps[i].removeSub(this); + } + this.active = false; + this.vm = this.cb = this.value = null; + } + }; + + /** + * Recrusively traverse an object to evoke all converted + * getters, so that every nested property inside the object + * is collected as a "deep" dependency. + * + * @param {*} val + */ + + var seenObjects = new _Set(); + function traverse(val, seen) { + var i = undefined, + keys = undefined; + if (!seen) { + seen = seenObjects; + seen.clear(); + } + var isA = isArray(val); + var isO = isObject(val); + if ((isA || isO) && Object.isExtensible(val)) { + if (val.__ob__) { + var depId = val.__ob__.dep.id; + if (seen.has(depId)) { + return; + } else { + seen.add(depId); + } + } + if (isA) { + i = val.length; + while (i--) traverse(val[i], seen); + } else if (isO) { + keys = Object.keys(val); + i = keys.length; + while (i--) traverse(val[keys[i]], seen); + } + } + } + + var text$1 = { + + bind: function bind() { + this.attr = this.el.nodeType === 3 ? 'data' : 'textContent'; + }, + + update: function update(value) { + this.el[this.attr] = _toString(value); + } + }; + + var templateCache = new Cache(1000); + var idSelectorCache = new Cache(1000); + + var map = { + efault: [0, '', ''], + legend: [1, '
    ', '
    '], + tr: [2, '', '
    '], + col: [2, '', '
    '] + }; + + map.td = map.th = [3, '', '
    ']; + + map.option = map.optgroup = [1, '']; + + map.thead = map.tbody = map.colgroup = map.caption = map.tfoot = [1, '', '
    ']; + + map.g = map.defs = map.symbol = map.use = map.image = map.text = map.circle = map.ellipse = map.line = map.path = map.polygon = map.polyline = map.rect = [1, '', '']; + + /** + * Check if a node is a supported template node with a + * DocumentFragment content. + * + * @param {Node} node + * @return {Boolean} + */ + + function isRealTemplate(node) { + return isTemplate(node) && isFragment(node.content); + } + + var tagRE$1 = /<([\w:-]+)/; + var entityRE = /&#?\w+?;/; + var commentRE = /