diff --git a/CHANGELOG b/CHANGELOG
index 9299639a3ab33073d484a2c97e4ede801df16fb8..22c5cd7b79bff381b14e3fa25494ad4c2de8a365 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,36 +1,72 @@
Please view this file on the master branch, on stable branches it's out of date.
-v 8.11.0 (unreleased)
+v 8.11.3
+ - Don't show resolve conflicts link before MR status is updated
+ - Fix IE11 fork button bug !598
+ - Don't prevent viewing the MR when git refs for conflicts can't be found on disk
+ - Allow system info page to handle case where info is unavailable
+ - Fix external issue tracker "Issues" link leading to 404s
+ - Don't try to show merge conflict resolution info if a merge conflict contains non-UTF-8 characters
+ - Label list shows all issues (opened or closed) with that label
+ - Automatically expand hidden discussions when accessed by a permalink !5585 (Mike Greiling)
+
+v 8.11.2
+ - Show "Create Merge Request" widget for push events to fork projects on the source project. !5978
+ - Use gitlab-workhorse 0.7.11 !5983
+ - Does not halt the GitHub import process when an error occurs. !5763
+ - Fix file links on project page when default view is Files !5933
+ - Fixed enter key in search input not working !5888
+
+v 8.11.1
+ - Pulled due to packaging error.
+
+v 8.11.0
+ - Use test coverage value from the latest successful pipeline in badge. !5862
- Add test coverage report badge. !5708
- Remove the http_parser.rb dependency by removing the tinder gem. !5758 (tbalthazar)
+ - Add Koding (online IDE) integration
- Ability to specify branches for Pivotal Tracker integration (Egor Lynko)
- Fix don't pass a local variable called `i` to a partial. !20510 (herminiotorres)
- Fix rename `add_users_into_project` and `projects_ids`. !20512 (herminiotorres)
+ - Fix adding line comments on the initial commit to a repo !5900
- 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)
- API: Endpoints for enabling and disabling deploy keys
- 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)
+ - Added tooltip listing label names to the labels value in the collapsed issuable sidebar
- 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
+ - Fix commit mention font inconsistency (ClemMakesApps)
+ - Do not escape URI when extracting path !5878 (winniehell)
- Fix filter label tooltip HTML rendering (ClemMakesApps)
- Cache the commit author in RequestStore to avoid extra lookups in PostReceive
- 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 wall clock time when showing a pipeline. !5734
- Show member roles to all users on members page
- Project.visible_to_user is instrumented again
- Fix awardable button mutuality loading spinners (ClemMakesApps)
+ - Sort todos by date and priority
- 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
@@ -40,12 +76,18 @@ v 8.11.0 (unreleased)
- Various redundant database indexes have been removed
- Update `timeago` plugin to use multiple string/locale settings
- Remove unused images (ClemMakesApps)
+ - Get issue and merge request description templates from repositories
+ - 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
@@ -54,14 +96,16 @@ 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)
- Admin page now references docs instead of a specific file !5600 (AnAverageHuman)
- - Add a way to send an email and create an issue based on private personal token. Find the email address from issues page. !3363
- Fix filter input alignment (ClemMakesApps)
- Include old revision in merge request update hooks (Ben Boeckel)
- Add build event color in HipChat messages (David Eisner)
@@ -73,6 +117,7 @@ v 8.11.0 (unreleased)
- The overhead of instrumented method calls has been reduced
- Remove `search_id` of labels dropdown filter to fix 'Missleading URI for labels in Merge Requests and Issues view'. !5368 (Scott Le)
- Load project invited groups and members eagerly in `ProjectTeam#fetch_members`
+ - Add pipeline events hook
- Bump gitlab_git to speedup DiffCollection iterations
- Rewrite description of a blocked user in admin settings. (Elias Werberich)
- Make branches sortable without push permission !5462 (winniehell)
@@ -87,35 +132,48 @@ v 8.11.0 (unreleased)
- 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
+ - Add the option to set the expiration date for the project membership when giving a user access to a project. !5599 (Adam Niedzielski)
- Sensible state specific default sort order for issues and merge requests !5453 (tomb0y)
- Fix bug where destroying a namespace would not always destroy projects
- Fix RequestProfiler::Middleware error when code is reloaded in development
+ - Allow horizontal scrolling of code blocks in issue body
- Catch what warden might throw when profiling requests to re-throw it
- Avoid commit lookup on diff_helper passing existing local variable to the helper method
- Add description to new_issue email and new_merge_request_email in text/plain content type. !5663 (dixpac)
- 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 notification_service argument error of declined invitation emails
- 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 (unreleased)
- Fix import/export configuration missing some included attributes
@@ -140,6 +198,7 @@ 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.2
- User can now search branches by name. !5144
diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION
index 619b537668489eba5ff985e81afa2c1228281818..18091983f59ddde8105e566545a0d9e4a12a4f1c 100644
--- a/GITLAB_SHELL_VERSION
+++ b/GITLAB_SHELL_VERSION
@@ -1 +1 @@
-3.3.3
+3.4.0
diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION
index e7c7d3cc3c89ada8384d34fee65534801993b979..b4d6d12101febdd4c5792ced9aae7600069d928e 100644
--- a/GITLAB_WORKHORSE_VERSION
+++ b/GITLAB_WORKHORSE_VERSION
@@ -1 +1 @@
-0.7.8
+0.7.11
diff --git a/Gemfile b/Gemfile
index 8b44b54e22c0cacaa9d80462026bc194ff64bc06..194379dd687b668529eea386ee1ed5d0980d26a0 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'
@@ -347,5 +349,5 @@ gem 'paranoia', '~> 2.0'
gem 'health_check', '~> 2.1.0'
# System information
-gem 'vmstat', '~> 2.1.1'
+gem 'vmstat', '~> 2.2'
gem 'sys-filesystem', '~> 1.1.6'
diff --git a/Gemfile.lock b/Gemfile.lock
index 2244c20203b966dcafc6e066fea11e64cc1ba93c..0c28975060cfea0762871c5d1b89ae14919dfb88 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
@@ -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)
@@ -769,7 +772,7 @@ GEM
coercible (~> 1.0)
descendants_tracker (~> 0.0, >= 0.0.3)
equalizer (~> 0.0, >= 0.0.9)
- vmstat (2.1.1)
+ vmstat (2.2.0)
warden (1.2.6)
rack (>= 1.0)
web-console (2.3.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)
@@ -975,7 +980,7 @@ DEPENDENCIES
unicorn-worker-killer (~> 0.4.2)
version_sorter (~> 2.1.0)
virtus (~> 1.0.1)
- vmstat (~> 2.1.1)
+ vmstat (~> 2.2)
web-console (~> 2.0)
webmock (~> 1.21.0)
wikicloth (= 0.8.1)
diff --git a/VERSION b/VERSION
index 542e7824102447bf9fe4091b802e0713848e87c4..dba04c1e1786b2c5f953ebf2c24c6fcc615c2d9a 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-8.11.0-pre
+8.11.3
diff --git a/app/assets/images/koding-logo.svg b/app/assets/images/koding-logo.svg
new file mode 100644
index 0000000000000000000000000000000000000000..ad89d684d94d63c7e0091b6c4ee215cf3e511bf1
--- /dev/null
+++ b/app/assets/images/koding-logo.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 49c2ac0dac3fb9fc469b52315616772a5f26e9be..84b292e59c643756ceefa00024be7529557b8a68 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 f1aab067351daab478fe0303b6169544ea69a056..ce9cbb63b6704c481b44e7c677dab2d9e0081469 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -26,8 +26,6 @@
/*= require bootstrap/tooltip */
/*= require bootstrap/popover */
/*= require select2 */
-/*= require ace/ace */
-/*= require ace/ext-searchbox */
/*= require underscore */
/*= require dropzone */
/*= require mousetrap */
@@ -41,6 +39,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 +222,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/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js
index 1b7b63489ea3fc5eb300a90ed84ada71f3af53fb..8ac1ba7665e352fdeb4b3c8067a21aa81895b66a 100644
--- a/app/assets/javascripts/behaviors/toggler_behavior.js
+++ b/app/assets/javascripts/behaviors/toggler_behavior.js
@@ -1,10 +1,26 @@
-(function() {
+(function(w) {
$(function() {
- return $("body").on("click", ".js-toggle-button", function(e) {
- $(this).find('i').toggleClass('fa fa-chevron-down').toggleClass('fa fa-chevron-up');
- $(this).closest(".js-toggle-container").find(".js-toggle-content").toggle();
- return e.preventDefault();
+ $('.js-toggle-button').on('click', function(e) {
+ e.preventDefault();
+ $(this)
+ .find('.fa')
+ .toggleClass('fa-chevron-down fa-chevron-up')
+ .end()
+ .closest('.js-toggle-container')
+ .find('.js-toggle-content')
+ .toggle()
+ ;
});
- });
-}).call(this);
+ // If we're accessing a permalink, ensure it is not inside a
+ // closed js-toggle-container!
+ var hash = w.gl.utils.getLocationHash();
+ var anchor = hash && document.getElementById(hash);
+ var container = anchor && $(anchor).closest('.js-toggle-container');
+
+ if (container && container.find('.js-toggle-content').is(':hidden')) {
+ container.find('.js-toggle-button').trigger('click');
+ anchor.scrollIntoView();
+ }
+ });
+})(window);
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 2cf0a6631b8d4e3ab8b90dc4283d940c3413b72d..b0a37ef0e0a4f57b1b76b51ab24c3f63e48bc432 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/blob_edit/blob_edit_bundle.js b/app/assets/javascripts/blob_edit/blob_edit_bundle.js
new file mode 100644
index 0000000000000000000000000000000000000000..2afef43f3d6d53ae525b614353db51167c35d0b2
--- /dev/null
+++ b/app/assets/javascripts/blob_edit/blob_edit_bundle.js
@@ -0,0 +1,12 @@
+/*= require_tree . */
+
+(function() {
+ $(function() {
+ var url = $(".js-edit-blob-form").data("relative-url-root");
+ url += $(".js-edit-blob-form").data("assets-prefix");
+
+ var blob = new EditBlob(url, $('.js-edit-blob-form').data('blob-language'));
+ new NewCommitForm($('.js-edit-blob-form'));
+ });
+
+}).call(this);
diff --git a/app/assets/javascripts/blob/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
similarity index 100%
rename from app/assets/javascripts/blob/edit_blob.js
rename to app/assets/javascripts/blob_edit/edit_blob.js
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 0000000000000000000000000000000000000000..a612cf0f1aed57eac5776581db40626bfb5e0918
--- /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 0000000000000000000000000000000000000000..d7f4107cb021d1971aef227c1ebb4b99762af8e4
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board.js.es6
@@ -0,0 +1,81 @@
+//= 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) => {
+ gl.issueBoards.onEnd();
+
+ 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);
+ });
+ }
+ }
+ });
+
+ 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 0000000000000000000000000000000000000000..63d72d857d9c496463cd8aaa1b235369e9c903fc
--- /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 0000000000000000000000000000000000000000..4a7cfeaeab22e29c46982ac1129f6eba76835aa1
--- /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 0000000000000000000000000000000000000000..34653cd48ef190289643cc651de33c3a9fa13eca
--- /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 0000000000000000000000000000000000000000..a6644e9eb8c4b0d342b7d250bd125cbedf4d3f32
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_list.js.es6
@@ -0,0 +1,87 @@
+//= 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;
+
+ gl.issueBoards.onStart();
+ },
+ onAdd: (e) => {
+ gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue);
+ },
+ onRemove: (e) => {
+ this.$refs.issue[e.oldIndex].$destroy(true);
+ }
+ });
+
+ 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 0000000000000000000000000000000000000000..1a4d81579700ae7e82ec08f268eaf5e66f9c143d
--- /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 0000000000000000000000000000000000000000..44addb3ea98180700315ce1561a9b30833be0202
--- /dev/null
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6
@@ -0,0 +1,35 @@
+((w) => {
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.onStart = () => {
+ $('.has-tooltip').tooltip('hide')
+ .tooltip('disable');
+ document.body.classList.add('is-dragging');
+ };
+
+ gl.issueBoards.onEnd = () => {
+ $('.has-tooltip').tooltip('enable');
+ document.body.classList.remove('is-dragging');
+ };
+
+ gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch;
+
+ gl.issueBoards.getBoardSortableDefaultOptions = (obj) => {
+ let defaultSortOptions = {
+ forceFallback: true,
+ fallbackClass: 'is-dragging',
+ fallbackOnBody: true,
+ ghostClass: 'is-ghost',
+ filter: '.has-tooltip',
+ delay: gl.issueBoards.touchEnabled ? 100 : 0,
+ scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100,
+ scrollSpeed: 20,
+ onStart: gl.issueBoards.onStart,
+ onEnd: gl.issueBoards.onEnd
+ }
+
+ 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 0000000000000000000000000000000000000000..eb082103de9ceccbc62fd4f83fbd43411e5f4392
--- /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 0000000000000000000000000000000000000000..583829552cd398347d95bb32b0a2bcf6481d43cf
--- /dev/null
+++ b/app/assets/javascripts/boards/models/label.js.es6
@@ -0,0 +1,10 @@
+class ListLabel {
+ constructor (obj) {
+ this.id = obj.id;
+ this.title = obj.title;
+ this.color = obj.color;
+ this.textColor = obj.text_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 0000000000000000000000000000000000000000..be2b8c568a83c5dec6e0ccbdb6842d27dd38de6a
--- /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 0000000000000000000000000000000000000000..904b3a68507a3d1e897cc8cb938bfad6001198ea
--- /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 0000000000000000000000000000000000000000..9b80fb2e99fd89614c331b8f4189023a5b340be6
--- /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 0000000000000000000000000000000000000000..18f26a1f911177e0828af1b640dd96e8a5ee250f
--- /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 0000000000000000000000000000000000000000..75f8b73019555ee8af9ba3a5e8e3ddde3f9d2ae8
--- /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 0000000000000000000000000000000000000000..f9f9f7999d4679e55313a07e0af6a8492ffa0916
--- /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 3d9b824d40618af5a509fe56f1653576e5029bcd..0d7d29bb0d0272934685ec09289359ab08620e75 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 c82798cc6a58b079e268969b646db5dffedba3b2..c43af17442b46be1cdd0c041d486d6ac6bdff599 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 0000000000000000000000000000000000000000..46d1c3f00c1e599a6b24d4edd2a42103da9c3c18
--- /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 0000000000000000000000000000000000000000..48bc7d7780501e49a328b12046c88d5cd9a0ebf0
--- /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 0000000000000000000000000000000000000000..ad80d1118dfb85e354354109a8fdb02422fa3978
--- /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 0000000000000000000000000000000000000000..be6ebc77947071eb327bc7181a8ef59016fb86c4
--- /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 0000000000000000000000000000000000000000..9e383b14a3e9a19a058bea516b6055d17c4892d9
--- /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 0000000000000000000000000000000000000000..e373b06b1ebd51662ec17282be2c32629db56d6e
--- /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 0000000000000000000000000000000000000000..22d9cf6c857b2c05282f502fe73678d9aaca1e54
--- /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 0000000000000000000000000000000000000000..a05f885201d4faadfc24b7aade073e81608abb15
--- /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 0000000000000000000000000000000000000000..d278678085b99a7b27f80a75a8ca4f75c161031a
--- /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 0000000000000000000000000000000000000000..488714e4870acff069d9fdd393645e95883967b7
--- /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 0000000000000000000000000000000000000000..f2d2d389c38cc9489ee4ba0a069e4a73d312072e
--- /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 0000000000000000000000000000000000000000..de771ff814beae43c11066fd3e17620563493359
--- /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 0000000000000000000000000000000000000000..69522e1dac51d881735acec8891bb5f5c2469e9b
--- /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 3946e861976605152b2e16162c0916c728764047..7d507c7a1c9f14634999cc1c0cb0a59d46e38309 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -20,6 +20,9 @@
path = page.split(':');
shortcut_handler = null;
switch (page) {
+ case 'projects:boards:show':
+ shortcut_handler = new ShortcutsNavigation();
+ break;
case 'projects:issues:index':
Issuable.init();
new IssuableBulkActions();
@@ -55,6 +58,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 +66,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 +91,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();
@@ -122,10 +129,12 @@
new NotificationsDropdown();
break;
case 'groups:group_members:index':
+ new gl.MemberExpirationDate();
new GroupMembers();
new UsersSelect();
break;
case 'projects:project_members:index':
+ new gl.MemberExpirationDate();
new ProjectMembers();
new UsersSelect();
break;
@@ -167,6 +176,7 @@
new BuildArtifacts();
break;
case 'projects:group_links:index':
+ new gl.MemberExpirationDate();
new GroupsSelect();
break;
case 'search:show':
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 09b5eb398d4431ca2890a6e8f21b0a30c2dee997..b2e49b71fec2eb0b5a33142b3dcb48e7d4718666 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 2e5b15f4b77ea4c512c52ff4ee816c3fac861c60..3dca06d36b1f9115f2352d515d7b4ff50301c001 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/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index d3394fae3f9c758f595e512b6c447a6026345686..0179b320a3b3672e3cbf5bd716350cbeb4b3712f 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -31,8 +31,7 @@
this.input
.on('keydown', function (e) {
var keyCode = e.which;
-
- if (keyCode === 13) {
+ if (keyCode === 13 && !options.elIsInput) {
e.preventDefault()
}
})
@@ -47,7 +46,7 @@
} else if (this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) {
$inputContainer.removeClass(HAS_VALUE_CLASS);
}
- if (keyCode === 13) {
+ if (keyCode === 13 && !options.elIsInput) {
return false;
}
if (this.options.remote) {
@@ -111,9 +110,9 @@
matches = fuzzaldrinPlus.match($el.text().trim(), search_text);
if (!$el.is('.dropdown-header')) {
if (matches.length) {
- return $el.show();
+ return $el.show().removeClass('option-hidden');
} else {
- return $el.hide();
+ return $el.hide().addClass('option-hidden');
}
}
});
@@ -179,7 +178,7 @@
})();
GitLabDropdown = (function() {
- var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, currentIndex;
+ var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, currentIndex;
LOADING_CLASS = "is-loading";
@@ -191,6 +190,12 @@
currentIndex = -1;
+ NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link, .option-hidden';
+
+ SELECTABLE_CLASSES = ".dropdown-content li:not(" + NON_SELECTABLE_CLASSES + ")";
+
+ CURSOR_SELECT_SCROLL_PADDING = 5
+
FILTER_INPUT = '.dropdown-input .dropdown-input-field';
function GitLabDropdown(el1, options) {
@@ -213,6 +218,7 @@
if (this.options.data) {
if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) {
this.fullData = this.options.data;
+ currentIndex = -1;
this.parseData(this.options.data);
} else {
this.remote = new GitLabDropdownRemote(this.options.data, {
@@ -232,6 +238,7 @@
}
if (this.options.filterable) {
this.filter = new GitLabDropdownFilter(this.filterInput, {
+ elIsInput: $(this.el).is('input'),
filterInputBlur: this.filterInputBlur,
filterByText: this.options.filterByText,
onFilter: this.options.onFilter,
@@ -240,7 +247,7 @@
keys: searchFields,
elements: (function(_this) {
return function() {
- selector = '.dropdown-content li:not(.divider)';
+ selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
if (_this.dropdown.find('.dropdown-toggle-page').length) {
selector = ".dropdown-page-one " + selector;
}
@@ -256,12 +263,16 @@
return function(data) {
_this.parseData(data);
if (_this.filterInput.val() !== '') {
- selector = '.dropdown-content li:not(.divider):visible';
+ selector = SELECTABLE_CLASSES;
if (_this.dropdown.find('.dropdown-toggle-page').length) {
selector = ".dropdown-page-one " + selector;
}
- $(selector, _this.dropdown).first().find('a').addClass('is-focused');
- return currentIndex = 0;
+ if ($(_this.el).is('input')) {
+ currentIndex = -1;
+ } else {
+ $(selector, _this.dropdown).first().find('a').addClass('is-focused');
+ currentIndex = 0;
+ }
}
};
})(this)
@@ -376,7 +387,7 @@
var $target;
if (this.options.multiSelect) {
$target = $(e.target);
- if (!$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && !$target.data('is-link')) {
+ if ($target && !$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && !$target.data('is-link')) {
e.stopPropagation();
return false;
} else {
@@ -387,7 +398,7 @@
GitLabDropdown.prototype.opened = function() {
var contentHtml;
- currentIndex = -1;
+ this.resetRows();
this.addArrowKeyEvent();
if (this.options.setIndeterminateIds) {
this.options.setIndeterminateIds.call(this);
@@ -410,6 +421,7 @@
GitLabDropdown.prototype.hidden = function(e) {
var $input;
+ this.resetRows();
this.removeArrayKeyEvent();
$input = this.dropdown.find(".dropdown-input-field");
if (this.options.filterable) {
@@ -463,14 +475,15 @@
return " ";
}
if (data.header != null) {
- return "";
+ return _.template('')({ header: data.header });
}
if (this.options.renderRow) {
html = this.options.renderRow.call(this.options, data, this);
} else {
if (!selected) {
value = this.options.id ? this.options.id(data) : data.id;
- fieldName = this.options.fieldName;
+ fieldName = typeof this.options.fieldName === 'function' ? this.options.fieldName() : this.options.fieldName;
+
field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']");
if (field.length) {
selected = true;
@@ -494,11 +507,16 @@
text = this.highlightTextMatches(text, this.filterInput.val());
}
if (group) {
- groupAttrs = "data-group='" + group + "' data-index='" + index + "'";
+ groupAttrs = 'data-group=' + group + ' data-index=' + index;
} else {
groupAttrs = '';
}
- html = " " + text + " ";
+ html = _.template(' class="<%- cssClass %>"><%= text %> ')({
+ url: url,
+ groupAttrs: groupAttrs,
+ cssClass: cssClass,
+ text: text
+ });
}
return html;
};
@@ -520,20 +538,8 @@
return html = "";
};
- GitLabDropdown.prototype.highlightRow = function(index) {
- var selector;
- if (this.filterInput.val() !== "") {
- selector = '.dropdown-content li:first-child a';
- if (this.dropdown.find(".dropdown-toggle-page").length) {
- selector = ".dropdown-page-one .dropdown-content li:first-child a";
- }
- return this.getElement(selector).addClass('is-focused');
- }
- };
-
GitLabDropdown.prototype.rowClicked = function(el) {
var field, fieldName, groupName, isInput, selectedIndex, selectedObject, value;
- fieldName = this.options.fieldName;
isInput = $(this.el).is('input');
if (this.renderedData) {
groupName = el.data('group');
@@ -545,6 +551,7 @@
selectedObject = this.renderedData[selectedIndex];
}
}
+ fieldName = typeof this.options.fieldName === 'function' ? this.options.fieldName(selectedObject) : this.options.fieldName;
value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id;
if (isInput) {
field = $(this.el);
@@ -559,10 +566,9 @@
field.remove();
}
if (this.options.toggleLabel) {
- return this.updateLabel(selectedObject, el, this);
- } else {
- return selectedObject;
+ this.updateLabel(selectedObject, el, this);
}
+ return selectedObject;
} else if (el.hasClass(INDETERMINATE_CLASS)) {
el.addClass(ACTIVE_CLASS);
el.removeClass(INDETERMINATE_CLASS);
@@ -570,7 +576,7 @@
field.remove();
}
if (!field.length && fieldName) {
- this.addInput(fieldName, value);
+ this.addInput(fieldName, value, selectedObject);
}
return selectedObject;
} else {
@@ -589,7 +595,7 @@
}
if (value != null) {
if (!field.length && fieldName) {
- this.addInput(fieldName, value);
+ this.addInput(fieldName, value, selectedObject);
} else {
field.val(value).trigger('change');
}
@@ -598,24 +604,37 @@
}
};
- GitLabDropdown.prototype.addInput = function(fieldName, value) {
+ GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject) {
var $input;
$input = $(' ').attr('type', 'hidden').attr('name', fieldName).val(value);
if (this.options.inputId != null) {
$input.attr('id', this.options.inputId);
}
+ if (selectedObject && selectedObject.type) {
+ $input.attr('data-type', selectedObject.type);
+ }
return this.dropdown.before($input);
};
GitLabDropdown.prototype.selectRowAtIndex = function(index) {
var $el, selector;
- selector = ".dropdown-content li:not(.divider,.dropdown-header,.separator):eq(" + index + ") a";
+ // If we pass an option index
+ if (typeof index !== "undefined") {
+ selector = SELECTABLE_CLASSES + ":eq(" + index + ") a";
+ } else {
+ selector = ".dropdown-content .is-focused";
+ }
if (this.dropdown.find(".dropdown-toggle-page").length) {
selector = ".dropdown-page-one " + selector;
}
$el = $(selector, this.dropdown);
if ($el.length) {
- return $el.first().trigger('click');
+ var href = $el.attr('href');
+ if (href && href !== '#') {
+ Turbolinks.visit(href);
+ } else {
+ $el.first().trigger('click');
+ }
}
};
@@ -623,7 +642,7 @@
var $input, ARROW_KEY_CODES, selector;
ARROW_KEY_CODES = [38, 40];
$input = this.dropdown.find(".dropdown-input-field");
- selector = '.dropdown-content li:not(.divider,.dropdown-header,.separator):visible';
+ selector = SELECTABLE_CLASSES;
if (this.dropdown.find(".dropdown-toggle-page").length) {
selector = ".dropdown-page-one " + selector;
}
@@ -651,7 +670,7 @@
return false;
}
if (currentKeyCode === 13 && currentIndex !== -1) {
- return _this.selectRowAtIndex($('.is-focused', _this.dropdown).closest('li').index() - 1);
+ _this.selectRowAtIndex();
}
};
})(this));
@@ -661,6 +680,11 @@
return $('body').off('keydown');
};
+ GitLabDropdown.prototype.resetRows = function resetRows() {
+ currentIndex = -1;
+ $('.is-focused', this.dropdown).removeClass('is-focused');
+ };
+
GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) {
var $dropdownContent, $listItem, dropdownContentBottom, dropdownContentHeight, dropdownContentTop, dropdownScrollTop, listItemBottom, listItemHeight, listItemTop;
$('.is-focused', this.dropdown).removeClass('is-focused');
@@ -674,10 +698,14 @@
listItemHeight = $listItem.outerHeight();
listItemTop = $listItem.prop('offsetTop');
listItemBottom = listItemTop + listItemHeight;
- if (listItemBottom > dropdownContentBottom + dropdownScrollTop) {
- return $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom);
- } else if (listItemTop < dropdownContentTop + dropdownScrollTop) {
- return $dropdownContent.scrollTop(listItemTop - dropdownContentTop);
+ if (!index) {
+ $dropdownContent.scrollTop(0)
+ } else if (index === ($listItems.length - 1)) {
+ $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight'));
+ } else if (listItemBottom > (dropdownContentBottom + dropdownScrollTop)) {
+ $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING);
+ } else if (listItemTop < (dropdownContentTop + dropdownScrollTop)) {
+ return $dropdownContent.scrollTop(listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING);
}
};
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 675dd5b7ceaa45ff080e420152bbc1ef6200e6df..565dbeacdb3627bf8b5d98edba3bdf7374367f85 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, $sidebarLabelTooltip;
$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');
@@ -23,11 +21,8 @@
$block = $selectbox.closest('.block');
$form = $dropdown.closest('form');
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
+ $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
$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 +31,13 @@
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();
- });
+
+ $sidebarLabelTooltip.tooltip();
+
+ if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) {
+ 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() {
@@ -109,7 +57,7 @@
dataType: 'JSON',
data: data
}).done(function(data) {
- var labelCount, template;
+ var labelCount, template, labelTooltipTitle, labelTitles;
$loading.fadeOut();
$dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide();
@@ -123,6 +71,27 @@
}
$value.removeAttr('style').html(template);
$sidebarCollapsedValue.text(labelCount);
+
+ if (data.labels.length) {
+ labelTitles = data.labels.map(function(label) {
+ return label.title;
+ });
+
+ if (labelTitles.length > 5) {
+ labelTitles = labelTitles.slice(0, 5);
+ labelTitles.push('and ' + (data.labels.length - 5) + ' more');
+ }
+
+ labelTooltipTitle = labelTitles.join(', ');
+ } else {
+ labelTooltipTitle = '';
+ $sidebarLabelTooltip.tooltip('destroy');
+ }
+
+ $sidebarLabelTooltip
+ .attr('title', labelTooltipTitle)
+ .tooltip('fixTitle');
+
$('.has-tooltip', $value).tooltip({
container: 'body'
});
@@ -270,6 +239,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 +261,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 +270,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/ace.js b/app/assets/javascripts/lib/ace.js
new file mode 100644
index 0000000000000000000000000000000000000000..4cdf99cae72578e74b28e564563a5db555039fa8
--- /dev/null
+++ b/app/assets/javascripts/lib/ace.js
@@ -0,0 +1,2 @@
+/*= require ace-rails-ap */
+/*= require ace/ext-searchbox */
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index fffbfd19745d7887c2533f130ef3ecefb44a886e..533310cc87c22d88a863663d13698f1a64c1ef42 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -43,7 +43,7 @@
}
return newUrl;
};
- return w.gl.utils.removeParamQueryString = function(url, param) {
+ w.gl.utils.removeParamQueryString = function(url, param) {
var urlVariables, variables;
url = decodeURIComponent(url);
urlVariables = url.split('&');
@@ -59,6 +59,16 @@
return results;
})()).join('&');
};
+ w.gl.utils.getLocationHash = function(url) {
+ var hashIndex;
+ if (typeof url === 'undefined') {
+ // Note: We can't use window.location.hash here because it's
+ // not consistent across browsers - Firefox will pre-decode it
+ url = window.location.href;
+ }
+ hashIndex = url.indexOf('#');
+ return hashIndex === -1 ? null : url.substring(hashIndex + 1);
+ };
})(window);
}).call(this);
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
new file mode 100644
index 0000000000000000000000000000000000000000..1935af491f713d3c3d613471285b4068eb11cd8d
--- /dev/null
+++ b/app/assets/javascripts/member_expiration_date.js
@@ -0,0 +1,32 @@
+(function() {
+ // Add datepickers to all `js-access-expiration-date` elements. If those elements are
+ // children of an element with the `clearable-input` class, and have a sibling
+ // `js-clear-input` element, then show that element when there is a value in the
+ // datepicker, and make clicking on that element clear the field.
+ //
+ gl.MemberExpirationDate = function() {
+ function toggleClearInput() {
+ $(this).closest('.clearable-input').toggleClass('has-value', $(this).val() !== '');
+ }
+
+ var inputs = $('.js-access-expiration-date');
+
+ inputs.datepicker({
+ dateFormat: 'yy-mm-dd',
+ minDate: 1,
+ onSelect: toggleClearInput
+ });
+
+ inputs.next('.js-clear-input').on('click', function(event) {
+ event.preventDefault();
+
+ var input = $(this).closest('.clearable-input').find('.js-access-expiration-date');
+ input.datepicker('setDate', null);
+ toggleClearInput.call(input);
+ });
+
+ inputs.on('blur', toggleClearInput);
+
+ inputs.each(toggleClearInput);
+ };
+}).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 0000000000000000000000000000000000000000..cd92df8ddc5de268e043ea4d9f7c9410696a5ebd
--- /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 0000000000000000000000000000000000000000..77bffbcb403be6e4835ad33121ae1b81241f3084
--- /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 47e6dd1084d2a6a6527d49e0dedc0f46ee58c9c8..56ebf84c4f6d0484fd808a93ab090716dabc4e90 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 52c2ed6101259ca038913ee0cb46f02a8e1218a2..ad08209d61e55f598b2019c4fbc6b11da707848a 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 362aaa906d033ce110e6c838cc7cbc14fd9d0dfd..bd35b6f679d18db622510b9a3c354b261e6b1c6d 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 a0b65d20c03425c71954e1b6d3e968f485dd740c..e897ebdf6304bcdc337fae6d2a60c4d6f3ff779d 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 9ece474d9941ea4c8f64bcfe67d98c2da3993022..d0d5cad813a24eb9064560c019e2d20964b9e14b 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 0000000000000000000000000000000000000000..bf33eb1010053e8d09aee6120d8629b9b13add11
--- /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/project_members.js b/app/assets/javascripts/project_members.js
index f6a796b325aba297ab0312047e7c6cfce5459c3d..78f7b48bc7d726d043ea51a09c27bd04c79b17ba 100644
--- a/app/assets/javascripts/project_members.js
+++ b/app/assets/javascripts/project_members.js
@@ -5,9 +5,6 @@
return $(this).fadeOut();
});
}
-
return ProjectMembers;
-
})();
-
}).call(this);
diff --git a/app/assets/javascripts/protected_branch_access_dropdown.js.es6 b/app/assets/javascripts/protected_branch_access_dropdown.js.es6
index 2fbb088fa04e2d945d85db55186b7d24102d8c11..7aeb5f9251402b5936355c442a590c1125c0c8fb 100644
--- a/app/assets/javascripts/protected_branch_access_dropdown.js.es6
+++ b/app/assets/javascripts/protected_branch_access_dropdown.js.es6
@@ -10,8 +10,12 @@
selectable: true,
inputId: $dropdown.data('input-id'),
fieldName: $dropdown.data('field-name'),
- toggleLabel(item) {
- return item.text;
+ toggleLabel(item, el) {
+ if (el.is('.is-active')) {
+ return item.text;
+ } else {
+ return 'Select';
+ }
},
clicked(item, $el, e) {
e.preventDefault();
diff --git a/app/assets/javascripts/protected_branch_create.js.es6 b/app/assets/javascripts/protected_branch_create.js.es6
index 00e20a03b04b0a74f92e5cc88e8f0c80e9d88161..46beca469b99a0da6c9823a2970036e46ddaff94 100644
--- a/app/assets/javascripts/protected_branch_create.js.es6
+++ b/app/assets/javascripts/protected_branch_create.js.es6
@@ -44,12 +44,10 @@
// 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');
- }
+ this.$form.find('input[type="submit"]').attr('disabled', !($branchInput.val() && $allowedToMergeInput.length && $allowedToPushInput.length));
}
}
diff --git a/app/assets/javascripts/protected_branch_edit.js.es6 b/app/assets/javascripts/protected_branch_edit.js.es6
index 8d42e268ebc478fd8beecb93777b6bf52af6b5c2..40bc4adb71bf86a230f4bbb5879cbd7e7d49cb98 100644
--- a/app/assets/javascripts/protected_branch_edit.js.es6
+++ b/app/assets/javascripts/protected_branch_edit.js.es6
@@ -31,6 +31,9 @@
const $allowedToMergeInput = this.$wrap.find(`input[name="${this.$allowedToMergeDropdown.data('fieldName')}"]`);
const $allowedToPushInput = this.$wrap.find(`input[name="${this.$allowedToPushDropdown.data('fieldName')}"]`);
+ // Do not update if one dropdown has not selected any option
+ if (!($allowedToMergeInput.length && $allowedToPushInput.length)) return;
+
$.ajax({
type: 'POST',
url: this.$wrap.data('url'),
@@ -39,12 +42,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/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 990f6536eb2a69a4ca8b2d9425e4a8cb2a0d0066..227e8c696b4ffce37f8fd682c3da4e2871c1ebaa 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -7,7 +7,9 @@
KEYCODE = {
ESCAPE: 27,
BACKSPACE: 8,
- ENTER: 13
+ ENTER: 13,
+ UP: 38,
+ DOWN: 40
};
function SearchAutocomplete(opts) {
@@ -223,6 +225,12 @@
case KEYCODE.ESCAPE:
this.restoreOriginalState();
break;
+ case KEYCODE.ENTER:
+ this.disableAutocomplete();
+ break;
+ case KEYCODE.UP:
+ case KEYCODE.DOWN:
+ return;
default:
if (this.searchInput.val() === '') {
this.disableAutocomplete();
@@ -319,9 +327,11 @@
};
SearchAutocomplete.prototype.disableAutocomplete = function() {
- this.searchInput.addClass('disabled');
- this.dropdown.removeClass('open');
- return this.restoreMenu();
+ if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('open')) {
+ this.searchInput.addClass('disabled');
+ this.dropdown.removeClass('open').trigger('hidden.bs.dropdown');
+ this.restoreMenu();
+ }
};
SearchAutocomplete.prototype.restoreMenu = function() {
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index b9ae497b0e598e132c098dce19188d9cc4005ec0..156b9b8abec32199ca3fbce26e5d3758670fb02b 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/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js
new file mode 100644
index 0000000000000000000000000000000000000000..855e97eb301ac56fde11741f46335eec74b321ff
--- /dev/null
+++ b/app/assets/javascripts/snippet/snippet_bundle.js
@@ -0,0 +1,12 @@
+/*= require_tree . */
+
+(function() {
+ $(function() {
+ var editor = ace.edit("editor")
+
+ $(".snippet-form-holder form").on('submit', function() {
+ $(".snippet-file-content").val(editor.getValue());
+ });
+ });
+
+}).call(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 0000000000000000000000000000000000000000..c32ddf802199e1483123269cbcb72f30ed000145
--- /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 0000000000000000000000000000000000000000..bd8cdde033ef98ac77a9d8254b565ace9ea520c7
--- /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 65d362e072c48527734a81d2f96e9039e1ac2a72..bad82868ab045dbdcd18e83ee6610336757c917a 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 542a53f0377f924f06dbc104f99251f0d56797e9..897bc49e7df0e098b98128d4479d0bd446351dec 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 f1fe1697d304798c8fc5cfef347137d17e419411..5a08a92f81e4eba9887a59ef7a52b67c96b19bb0 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -200,7 +200,7 @@
svg {
height: 15px;
- width: auto;
+ width: 15px;
position: relative;
top: 2px;
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index e8eafa1589900bb3cc23033133f243cdf18dd6a1..f1635a537630cb57b4a459f4749d8dbb9341c500 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -56,9 +56,13 @@
position: absolute;
top: 50%;
right: 6px;
- margin-top: -4px;
+ margin-top: -6px;
color: $dropdown-toggle-icon-color;
font-size: 10px;
+ &.fa-spinner {
+ font-size: 16px;
+ margin-top: -8px;
+ }
}
&:hover, {
@@ -406,6 +410,7 @@
font-size: 14px;
a {
+ cursor: pointer;
padding-left: 10px;
}
}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 96565da1bc9ae788741cdaa94f4bf339e5cd015e..edea4ad00eb3085e6fa052cf2877e68634c82f12 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 5ec5a96a597d508767fc65fcd5b36ab4dc96577e..d2d60ed71967e11d667c72f61c76518c052bf8ce 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 3fa4a22258dfa3eb25dda78b70c46802a2cc1134..015fe3debf9b0faf6ec0dac10c1b9196ed14c83e 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/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 8659604cb8bf11c42f35102549fdb418308ce225..06874a993faff390e9bc1628af4605e2b6a9b2cc 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -14,12 +14,20 @@
margin-top: 0;
}
+ // Single code lines should wrap
code {
font-family: $monospace_font;
- white-space: pre;
+ white-space: pre-wrap;
word-wrap: normal;
}
+ // Multi-line code blocks should scroll horizontally
+ pre {
+ code {
+ white-space: pre;
+ }
+ }
+
kbd {
display: inline-block;
padding: 3px 5px;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index ca720022539cfe006cee1645e2d75e7502278ee8..5da390118c66250cf123858de8501e88426e6d78 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 77a73dc379b43b253781c7b2232c5c8b193c2ba0..16ffbe57a99fbe93f6587700468f0e9ab983ea33 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 80a509a7c1ac9fa3b949bf8269228d7aa4bc7b9a..7de920e074b37a01aeaa519ae3a3e3701167ec37 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 c62bd021aefda15de6fe2baea4d02b965b97e294..b11499c71eec8e065e899011e0143143c704cfbf 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 524cfaf90c309c43d0aac388acbaf80fc87984b3..657bb5e3cd964109a49e58ccdb12b59ef355bf06 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 31a4e3deaac866c8446a9ef2b55c72c547a03418..36a80a916b2df218d3a9420b20528712fa4db5ce 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 33aedf1f7c101c002fbf04d661ca35a75f285582..5bfe9bcb443dd9a9aff5d13dd505f5751165fa04 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/boards.scss b/app/assets/stylesheets/pages/boards.scss
new file mode 100644
index 0000000000000000000000000000000000000000..9ac4d801ac426fbe8a745eaeff31ab6b913e0cba
--- /dev/null
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -0,0 +1,306 @@
+[v-cloak] {
+ display: none;
+}
+
+.user-can-drag {
+ cursor: -webkit-grab;
+ cursor: grab;
+}
+
+.is-dragging {
+ // Important because plugin sets inline CSS
+ opacity: 1!important;
+
+ * {
+ // !important to make sure no style can override this when dragging
+ cursor: -webkit-grabbing!important;
+ cursor: grabbing!important;
+ }
+}
+
+.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(85vw - 15px);
+ max-width: calc(85vw - 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-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;
+}
+
+.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;
+ }
+
+ &:not(:last-child) {
+ margin-bottom: 5px;
+ }
+
+ a {
+ cursor: pointer;
+ }
+
+ .label {
+ border: 0;
+ outline: 0;
+ }
+
+ .confidential-icon {
+ margin-right: 5px;
+ }
+}
+
+.card-title {
+ margin: 0;
+ font-size: 1em;
+
+ a {
+ color: inherit;
+ }
+}
+
+.card-footer {
+ margin-top: 5px;
+ line-height: 25px;
+
+ .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 e26f8f7080d7ae94f7abf91ccac6c63dbb9f783a..c1bb250b42d35b151a58729906fd9efe4ff7f96a 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,101 @@
}
.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 {
+ color: $gl-text-color;
+ }
+ }
+
+ .build-job {
+ position: relative;
+
+ .fa {
+ position: absolute;
+ left: 15px;
+ top: 20px;
+ display: none;
+ }
+
+ &.active {
+ font-weight: bold;
+
+ .fa {
+ display: block;
+ }
+ }
+
+ &:hover {
+ background-color: $row-hover;
+ }
+ }
+ }
}
.build-detail-row {
diff --git a/app/assets/stylesheets/pages/commit.scss b/app/assets/stylesheets/pages/commit.scss
index bbe0c6c5f1fa1842bcf0528c402ecb3701c80a90..53ec0002afed6a60016c499b556e2854bb3c21a9 100644
--- a/app/assets/stylesheets/pages/commit.scss
+++ b/app/assets/stylesheets/pages/commit.scss
@@ -66,6 +66,15 @@
margin-left: 8px;
}
}
+
+ .ci-status-link {
+
+ svg {
+ position: relative;
+ top: 2px;
+ margin: 0 2px 0 3px;
+ }
+ }
}
.ci-status-link {
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss
index 1b389d83525da3cd5e2bfcad042bd1cda135218d..4d9c73c6840a02456836801acc3d3a70c03d0692 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/pages/detail_page.scss
@@ -34,11 +34,4 @@
}
}
}
-
- .wiki {
- code {
- white-space: pre-wrap;
- word-break: keep-all;
- }
- }
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 7a50bc9c8320f369f47da2728a1ff4d8f515e601..46c4a11aa2eb07ad642e3d68bdedd413bfe170e0 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 0000000000000000000000000000000000000000..1f499897c165bee80aeb1af77ea45f3d23c9e742
--- /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 b463626951849c30664185ed8630981257c577d4..fcdaf671538a66e1fdafd5bd5c3e0a484b5c8e43 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -374,3 +374,10 @@
}
}
}
+
+.merge-request-details {
+
+ .title {
+ margin-bottom: 20px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 3784010348a0dcdbfa0876081f16283d08c7197d..bd875b9823ffed1efebbc513110648bc5289f34f 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 a2b5437e5031080ed00835470d1967fd7d17bf70..08d1692c888c5110268116aff95c6cf890140fb0 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 21919fe4d73ff3d28d9c65c5dcf5e307cf7cbef3..6fa097e3bf165a1b877fe7978a4fa97c2e489abf 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -229,3 +229,203 @@
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;
+
+ svg {
+ height: 12px;
+ width: 12px;
+ position: relative;
+ top: 1px;
+
+ path {
+ fill: $layout-link-gray;
+ }
+ }
+ }
+
+ .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;
+ }
+
+ // 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 46371ec6871fa7569c69b5feed3d6b9c698b1356..6f58203f49c2632ae6c63da90fff008c8b8b95d8 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 cf9aa02600d74b9e9a694d4e3ac79e56bec3aafe..91253edf01e1cc2ed6b215a5357492c02f0f7f67 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -719,3 +719,29 @@ pre.light-well {
width: 300px;
}
}
+
+.clearable-input {
+ position: relative;
+
+ .clear-icon {
+ @extend .fa-times;
+ display: none;
+ position: absolute;
+ right: 7px;
+ top: 7px;
+ color: $location-icon-color;
+
+ &:before {
+ font-family: FontAwesome;
+ font-weight: normal;
+ font-style: normal;
+ }
+ }
+
+ &.has-value {
+ .clear-icon {
+ cursor: pointer;
+ display: block;
+ }
+ }
+}
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 9e1dc15de849c53075870720aab4668a9a5c1d56..6ef7cf0bae66bf9b072e5448f0f9c4c5f0327fe4 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -109,6 +109,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:sentry_dsn,
:akismet_enabled,
:akismet_api_key,
+ :koding_enabled,
+ :koding_url,
:email_author_in_body,
:repository_checks_enabled,
:metrics_packet_size,
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 4ce18321649dac629f511ff1b6789d7a34aa97b6..cdfa8d91a2880d3a65d8a88a8e15a7b19a50f4d2 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -42,7 +42,7 @@ class Admin::GroupsController < Admin::ApplicationController
end
def members_update
- @group.add_users(params[:user_ids].split(','), params[:access_level], current_user)
+ @group.add_users(params[:user_ids].split(','), params[:access_level], current_user: current_user)
redirect_to [:admin, @group], notice: 'Users were successfully added.'
end
diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb
index 8be35f00a775f70e27ef584d56d1993477c42b6c..9433da02f646deac3323fcb366d15e9595b2e003 100644
--- a/app/controllers/admin/impersonations_controller.rb
+++ b/app/controllers/admin/impersonations_controller.rb
@@ -7,7 +7,7 @@ class Admin::ImpersonationsController < Admin::ApplicationController
warden.set_user(impersonator, scope: :user)
- Gitlab::AppLogger.info("User #{original_user.username} has stopped impersonating #{impersonator.username}")
+ Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{original_user.username}")
session[:impersonator_id] = nil
diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb
index e4c730088269169b3b5b3953d77e1e540b6776f7..ca04a17caa14b8b395b53ed1201beda33c1e79ae 100644
--- a/app/controllers/admin/system_info_controller.rb
+++ b/app/controllers/admin/system_info_controller.rb
@@ -29,7 +29,8 @@ class Admin::SystemInfoController < Admin::ApplicationController
]
def show
- system_info = Vmstat.snapshot
+ @cpus = Vmstat.cpu rescue nil
+ @memory = Vmstat.memory rescue nil
mounts = Sys::Filesystem.mounts
@disks = []
@@ -50,10 +51,5 @@ class Admin::SystemInfoController < Admin::ApplicationController
rescue Sys::Filesystem::Error
end
end
-
- @cpus = system_info.cpus.length
-
- @mem_used = system_info.memory.active_bytes
- @mem_total = system_info.memory.total_bytes
end
end
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index d828d163c280e1e40d7257a12594ab96b2300828..b48668eea87295385631f753ef973086e207b2ea 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 c802922e0af83c1b80728fac462e7111861ffd2c..b5e79099e39590d94c114ebb81e78d4a1fccc38a 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 471d15af9130aa1e2962730053b7113cbaf021a3..a69877edfd40b786925787593137e4a22f065921 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/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 1243bb96d4d45b3828cb602fee32d03f4d3e0ca3..d425d0f90146c918d428856ae977cabc5fdca5b6 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -2,11 +2,12 @@ class Dashboard::TodosController < Dashboard::ApplicationController
before_action :find_todos, only: [:index, :destroy_all]
def index
+ @sort = params[:sort]
@todos = @todos.page(params[:page])
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 +28,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/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 9fc41a125364f74a8b0f7651fcb4cb995d01d02c..272164cd0ccc8a6cd32222789cb63afeff1222cf 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -21,7 +21,12 @@ class Groups::GroupMembersController < Groups::ApplicationController
end
def create
- @group.add_users(params[:user_ids].split(','), params[:access_level], current_user)
+ @group.add_users(
+ params[:user_ids].split(','),
+ params[:access_level],
+ current_user: current_user,
+ expires_at: params[:expires_at]
+ )
redirect_to group_group_members_path(@group), notice: 'Users were successfully added.'
end
@@ -63,7 +68,7 @@ class Groups::GroupMembersController < Groups::ApplicationController
protected
def member_params
- params.require(:group_member).permit(:access_level, :user_id)
+ params.require(:group_member).permit(:access_level, :user_id, :expires_at)
end
# MembershipActions concern
diff --git a/app/controllers/koding_controller.rb b/app/controllers/koding_controller.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f3759b4c0ea2b77696e0342d5186b703288fc455
--- /dev/null
+++ b/app/controllers/koding_controller.rb
@@ -0,0 +1,15 @@
+class KodingController < ApplicationController
+ before_action :check_integration!, :authenticate_user!, :reject_blocked!
+ layout 'koding'
+
+ def index
+ path = File.join(Rails.root, 'doc/user/project/koding.md')
+ @markdown = File.read(path)
+ end
+
+ private
+
+ def check_integration!
+ render_404 unless current_application_settings.koding_enabled?
+ end
+end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index e37e9e136dbb363cf18a60fc41937249072cdf7f..9eb75bb389185e5e4442882681ca46749434151f 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 0000000000000000000000000000000000000000..c02fe85c3cc2d7c791e8536f684dadd565f0e0b9
--- /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 996909a28c6805156e19f26705843fad413b39a1..91315a07debcff03bba0db8f7e0578847fac0dd0 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/board_lists_controller.rb b/app/controllers/projects/board_lists_controller.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3cfb08d5822ee44eb5089ee7a39c52e1d1246955
--- /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 0000000000000000000000000000000000000000..dad38fff6b9854d41e264c79e4e4c6471481d214
--- /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 0000000000000000000000000000000000000000..1a4f6b50e8f4b2fe72f2354d65c47be8711acb0a
--- /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], methods: [:text_color] }
+ })
+ 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 0000000000000000000000000000000000000000..b995f58673710cbd6ca8ae86a806df93d9ba623a
--- /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 0000000000000000000000000000000000000000..3320671708983bc8c6d11c64abf96444c355836f
--- /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 f44e9bb3fd7d1c3324c549c6126a5b1ae013bfe5..02fb3f568905626aa5f97bc5ae8ca335cfcd21db 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 0000000000000000000000000000000000000000..b2e8733ccb741c1411d8a424238c06285b594993
--- /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 7c21bd181dc04d579ac8798e6cbee14d49380ef2..a5b4031c30f8e2e09551570881cff71acd9d282a 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/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 606552fa85322b5486c8aa930fdf34c459125c3c..d0c4550733c6ddae4629ab0804e40e0f98897809 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -11,7 +11,9 @@ class Projects::GroupLinksController < Projects::ApplicationController
return render_404 unless can?(current_user, :read_group, group)
project.project_group_links.create(
- group: group, group_access: params[:link_group_access]
+ group: group,
+ group_access: params[:link_group_access],
+ expires_at: params[:expires_at]
)
redirect_to namespace_project_group_links_path(project.namespace, project)
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index a60027ff4779f25bb3d36bcfd6383830a1b1e005..b56240463879485eaeccfd8e040f7052c882e9c5 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 e9fb11e8f94860a866f05193fb8427c8dba4a433..c5197720c8eb0f96bfc680d0ee37cfe9eb7e6f22 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -177,11 +177,7 @@ 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
@@ -212,7 +208,7 @@ class Projects::IssuesController < Projects::ApplicationController
if action_name == 'new'
redirect_to external.new_issue_path
else
- redirect_to external.issues_url
+ redirect_to external.project_path
end
end
@@ -226,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 139680d2df9e08b82a6aa700599006305dfdb39e..d3fe441c4d20a7361bd7593cdb0c8dece121e1a7 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
@@ -130,6 +132,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
@@ -141,7 +184,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
@@ -158,7 +216,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
@@ -324,7 +381,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
@@ -338,6 +395,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
@@ -374,12 +435,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))
@@ -412,8 +470,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 766b7e9cf2228e0c8c03027bc2c9df87a205dc2f..0948ad2164929f8919af7bd4e713da29bf348170 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/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index 3435a1189647e71c6eb2b20890f3caa86e6fffeb..42a7e5a2c30d6fe3e0bfa9ef8a16738cf59f3ca2 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -36,7 +36,12 @@ class Projects::ProjectMembersController < Projects::ApplicationController
end
def create
- @project.team.add_users(params[:user_ids].split(','), params[:access_level], current_user)
+ @project.team.add_users(
+ params[:user_ids].split(','),
+ params[:access_level],
+ expires_at: params[:expires_at],
+ current_user: current_user
+ )
redirect_to namespace_project_project_members_path(@project.namespace, @project)
end
@@ -94,7 +99,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController
protected
def member_params
- params.require(:project_member).permit(:user_id, :access_level)
+ params.require(:project_member).permit(:user_id, :access_level, :expires_at)
end
# MembershipActions concern
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index d28ec6e2eacc06d20f3d73fecf2bc8734743f5ca..9a438d5512c7670c7c77202e6bd30143440c7c9e 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 0000000000000000000000000000000000000000..694b468c8d37c775cc4cfcb19ba0b18966b25017
--- /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 47efbd4a93902efe1be30b1e30e0a5f711dc8113..2a6385c1029514ef981ec8cdbb5776b0ecec6da9 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -5,7 +5,7 @@ class ProjectsController < Projects::ApplicationController
before_action :project, except: [:new, :create]
before_action :repository, except: [:new, :create]
before_action :assign_ref_vars, only: [:show], if: :repo_exists?
- before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?]
+ before_action :assign_tree_vars, only: [:show], if: [:repo_exists?, :project_view_files?]
# Authorize
before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export]
@@ -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|
@@ -319,4 +332,11 @@ class ProjectsController < Projects::ApplicationController
def get_id
project.repository.root_ref
end
+
+ # ExtractsPath will set @id = project.path on the show route, but it has to be the
+ # branch name for the tree view to work correctly.
+ def assign_tree_vars
+ @id = get_id
+ tree
+ end
end
diff --git a/app/finders/move_to_project_finder.rb b/app/finders/move_to_project_finder.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3334b8556df6e74e4521094e81a8bf14635df4ce
--- /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 4fe0070552efd8bc131559f63c20f248cd97db7a..06b3e8a9502372e44dfc1728c2b9c64cab65ed9e 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
@@ -33,7 +33,7 @@ class TodosFinder
# the project IDs yielded by the todos query thus far
items = by_project(items)
- items.reorder(id: :desc)
+ sort(items)
end
private
@@ -106,6 +106,10 @@ class TodosFinder
params[:type]
end
+ def sort(items)
+ params[:sort] ? items.sort(params[:sort]) : items.reorder(id: :desc)
+ end
+
def by_action(items)
if action?
items = items.where(action: to_action_id)
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index e12a10529881930a6921d9470fa76989e086b7b9..de13e7a1fc2d8c2847ddd3ae60c400448efc4db3 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 c3613bc67dd005804aec9d506140ffb7db3b8d51..f3733b0172145ab9e1e252bb8392d0f8a22186c9 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/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 78c0b79d2bd82ca2def8a020f14768bc0c27cb46..6de25bea654a782072e710de71978140ed26a6fa 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -31,6 +31,10 @@ module ApplicationSettingsHelper
current_application_settings.akismet_enabled?
end
+ def koding_enabled?
+ current_application_settings.koding_enabled?
+ end
+
def allowed_protocols_present?
current_application_settings.enabled_git_access_protocol.present?
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 48c2782821905c3782534bfd75638f3332bccd50..e13b7cdd7077da04845d003a990e93d0ec9dc64d 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,50 @@ 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
+
+ def blob_editor_paths
+ {
+ 'relative-url-root' => Rails.application.config.relative_url_root,
+ 'assets-prefix' => Gitlab::Application.config.assets.prefix,
+ 'blob-language' => @blob && @blob.language.try(:ace_mode)
+ }
end
end
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
index ea2f5f9281a84c4ae5be1faadb37e70645f5e6cf..bb285a17baffa033adeeb5c28443dd18327fd950 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'
+ 'icon_play'
+ 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 7a02d0b10d911b80fa97ac9ff8f8a09b4ca6e18e..33dcee49aee4c5d50cea03ae4d389428d46e1874 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/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 47d174361dbfdfd55f786bfe479c7330c13f15db..b9baeb1d6c47f2affdec34ed0e56d097865622d1 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -72,6 +72,15 @@ module IssuablesHelper
end
end
+ def issuable_labels_tooltip(labels, limit: 5)
+ first, last = labels.partition.with_index{ |_, i| i < limit }
+
+ label_names = first.collect(&:name)
+ label_names << "and #{last.size} more" unless last.empty?
+
+ label_names.join(', ')
+ end
+
private
def sidebar_gutter_collapsed?
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 3ff8be5e284cbe4126a16e309d46be7ce071973b..6c1cc6ef072df5a99f6e156ab2dd350db1b1e0e4 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 26bde2230a95b10960d92d9913aa2ba2a5a83ad9..da230f76baedfac413e2ca644a053e7b9d841ae9 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/projects_helper.rb b/app/helpers/projects_helper.rb
index 505545fbabb908d3e2fb686ab9d438f54fe2845c..356f27f2d5dad79bbc5011261f4afb0431223a6b 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -116,6 +116,17 @@ module ProjectsHelper
license.nickname || license.name
end
+ def last_push_event
+ return unless current_user
+
+ project_ids = [@project.id]
+ if fork = current_user.fork_of(@project)
+ project_ids << fork.id
+ end
+
+ current_user.recent_push(project_ids)
+ end
+
private
def get_project_nav_tabs(project, current_user)
@@ -236,6 +247,60 @@ module ProjectsHelper
)
end
+ def add_koding_stack_path(project)
+ namespace_project_new_blob_path(
+ project.namespace,
+ project,
+ project.default_branch || 'master',
+ file_name: '.koding.yml',
+ commit_message: "Add Koding stack script",
+ content: <<-CONTENT.strip_heredoc
+ provider:
+ aws:
+ access_key: '${var.aws_access_key}'
+ secret_key: '${var.aws_secret_key}'
+ resource:
+ aws_instance:
+ #{project.path}-vm:
+ instance_type: t2.nano
+ user_data: |-
+
+ # Created by GitLab UI for :>
+
+ echo _KD_NOTIFY_@Installing Base packages...@
+
+ apt-get update -y
+ apt-get install git -y
+
+ echo _KD_NOTIFY_@Cloning #{project.name}...@
+
+ export KODING_USER=${var.koding_user_username}
+ export REPO_URL=#{root_url}${var.koding_queryString_repo}.git
+ export BRANCH=${var.koding_queryString_branch}
+
+ sudo -i -u $KODING_USER git clone $REPO_URL -b $BRANCH
+
+ echo _KD_NOTIFY_@#{project.name} cloned.@
+ CONTENT
+ )
+ end
+
+ def koding_project_url(project = nil, branch = nil, sha = nil)
+ if project
+ import_path = "/Home/Stacks/import"
+
+ repo = project.path_with_namespace
+ branch ||= project.default_branch
+ sha ||= project.commit.short_id
+
+ path = "#{import_path}?repo=#{repo}&branch=#{branch}&sha=#{sha}"
+
+ return URI.join(current_application_settings.koding_url, path).to_s
+ end
+
+ current_application_settings.koding_url
+ end
+
def contribution_guide_path(project)
if project && contribution_guide = project.repository.contribution_guide
namespace_project_blob_path(
@@ -297,16 +362,6 @@ module ProjectsHelper
namespace_project_new_blob_path(@project.namespace, @project, tree_join(ref), file_name: 'LICENSE')
end
- def last_push_event
- return unless current_user
-
- if fork = current_user.fork_of(@project)
- current_user.recent_push(fork.id)
- else
- current_user.recent_push(@project.id)
- end
- end
-
def readme_cache_key
sha = @project.commit.try(:sha) || 'nil'
[@project.path_with_namespace, sha, "readme"].join('-')
diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb
index 790001222f12e92d47de0c72a201652647b08224..271e839692aab9fef18b6daf667020f40c871170 100644
--- a/app/helpers/time_helper.rb
+++ b/app/helpers/time_helper.rb
@@ -15,20 +15,9 @@ module TimeHelper
"#{from.to_s(:short)} - #{to.to_s(:short)}"
end
- def duration_in_numbers(finished_at, started_at)
- interval = interval_in_seconds(started_at, finished_at)
- time_format = interval < 1.hour ? "%M:%S" : "%H:%M:%S"
+ def duration_in_numbers(duration)
+ time_format = duration < 1.hour ? "%M:%S" : "%H:%M:%S"
- Time.at(interval).utc.strftime(time_format)
- end
-
- private
-
- def interval_in_seconds(started_at, finished_at = nil)
- if started_at && finished_at
- finished_at.to_i - started_at.to_i
- elsif started_at
- Time.now.to_i - started_at.to_i
- end
+ Time.at(duration).utc.strftime(time_format)
end
end
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 6f54c42146c6d09f961ebf904adc1b89077bc596..d64e48f774b92123ceb195424fcce8e7de268154 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 9dd11d20ea6b1129a98b3841108a74d3308a04fa..ec27ac517db361dd62693c29cc68168500168abd 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 d9113ffd99a6a3aa7dfb361734aee8fb4d0b45ad..a49dd7039262df49d81f84d6a7f97837c1458728 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,
@@ -164,38 +166,44 @@ class Ability
end
def project_abilities(user, project)
- rules = []
key = "/user/#{user.id}/project/#{project.id}"
- RequestStore.store[key] ||= begin
- # Push abilities on the users team role
- rules.push(*project_team_rules(project.team, user))
+ if RequestStore.active?
+ RequestStore.store[key] ||= uncached_project_abilities(user, project)
+ else
+ uncached_project_abilities(user, project)
+ end
+ end
- owner = user.admin? ||
- project.owner == user ||
- (project.group && project.group.has_owner?(user))
+ def uncached_project_abilities(user, project)
+ rules = []
+ # Push abilities on the users team role
+ rules.push(*project_team_rules(project.team, user))
- if owner
- rules.push(*project_owner_rules)
- end
+ owner = user.admin? ||
+ project.owner == user ||
+ (project.group && project.group.has_owner?(user))
- if project.public? || (project.internal? && !user.external?)
- rules.push(*public_project_rules)
+ if owner
+ rules.push(*project_owner_rules)
+ end
- # Allow to read builds for internal projects
- rules << :read_build if project.public_builds?
+ if project.public? || (project.internal? && !user.external?)
+ rules.push(*public_project_rules)
- unless owner || project.team.member?(user) || project_group_member?(project, user)
- rules << :request_access if project.request_access_enabled
- end
- end
+ # Allow to read builds for internal projects
+ rules << :read_build if project.public_builds?
- if project.archived?
- rules -= project_archived_rules
+ unless owner || project.team.member?(user) || project_group_member?(project, user)
+ rules << :request_access if project.request_access_enabled
end
+ end
- rules - project_disabled_features_rules(project)
+ if project.archived?
+ rules -= project_archived_rules
end
+
+ (rules - project_disabled_features_rules(project)).uniq
end
def project_team_rules(team, user)
@@ -228,6 +236,8 @@ class Ability
:read_project,
:read_wiki,
:read_issue,
+ :read_board,
+ :read_list,
:read_label,
:read_milestone,
:read_project_snippet,
@@ -249,6 +259,7 @@ class Ability
:update_issue,
:admin_issue,
:admin_label,
+ :admin_list,
:read_commit_status,
:read_build,
:read_container_image,
@@ -271,6 +282,7 @@ class Ability
:create_merge_request,
:create_wiki,
:push_code,
+ :resolve_note,
:create_container_image,
:update_container_image,
:create_environment,
@@ -452,7 +464,8 @@ class Ability
rules += [
:read_note,
:update_note,
- :admin_note
+ :admin_note,
+ :resolve_note
]
end
@@ -460,6 +473,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/application_setting.rb b/app/models/application_setting.rb
index 8c19d9dc9c8c8481ce8d29f7cbc9cca425d32be8..f0bcb2d7cdac7ffa6110450750c73a0378d9392f 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -55,6 +55,10 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
if: :akismet_enabled
+ validates :koding_url,
+ presence: true,
+ if: :koding_enabled
+
validates :max_attachment_size,
presence: true,
numericality: { only_integer: true, greater_than: 0 }
@@ -149,6 +153,8 @@ class ApplicationSetting < ActiveRecord::Base
two_factor_grace_period: 48,
recaptcha_enabled: false,
akismet_enabled: false,
+ koding_enabled: false,
+ koding_url: nil,
repository_checks_enabled: true,
disabled_oauth_sign_in_sources: [],
send_user_confirmation_email: false,
diff --git a/app/models/board.rb b/app/models/board.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3240c4bede3f5b5cab9d61ebcc3c5a31e855a143
--- /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 3d6c6ea3209af6e22f0fa9e17ed275a8ced5036f..096b3b801af1f5fbec964f70438e32a3a8fe00a5 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -62,6 +62,7 @@ module Ci
status_event: 'enqueue'
)
MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build)
+ build.pipeline.mark_as_processable_after_stage(build.stage_idx)
new_build
end
end
@@ -97,7 +98,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 +345,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 8cfba92ae9b33236c84452558309441559fda40d..087abe4cbb1b9f03e27d56a5e41338471b7aee9d 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,14 @@ module Ci
CommitStatus.where(pipeline: pluck(:id)).stages
end
+ def self.total_duration
+ where.not(duration: nil).sum(:duration)
+ end
+
+ def stages_with_latest_statuses
+ statuses.latest.order(:stage_idx).group_by(&:stage)
+ end
+
def project_id
project.id
end
@@ -136,6 +150,10 @@ module Ci
end
end
+ def mark_as_processable_after_stage(stage_idx)
+ builds.skipped.where('stage_idx > ?', stage_idx).find_each(&:process)
+ end
+
def latest?
return false unless ref
commit = project.commit(ref)
@@ -240,11 +258,21 @@ module Ci
end
def update_duration
- self.duration = statuses.latest.duration
+ self.duration = calculate_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/commit.rb b/app/models/commit.rb
index cc413448ce891615492a2546d2967e8aa948da60..817d063e4a2ac88f5eff91cc0112b31ec7a1fe21 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -229,7 +229,7 @@ class Commit
def diff_refs
Gitlab::Diff::DiffRefs.new(
- base_sha: self.parent_id || self.sha,
+ base_sha: self.parent_id || Gitlab::Git::BLANK_SHA,
head_sha: self.sha
)
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 703ca90edb6982bee860ca1825bb55d908eaf170..84ceeac7d3eb29dd005e57514107a834a96b89e8 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -21,6 +21,7 @@ class CommitStatus < ActiveRecord::Base
where(id: max_id.group(:name, :commit_id))
end
+
scope :retried, -> { where.not(id: latest) }
scope :ordered, -> { order(:name) }
scope :ignored, -> { where(allow_failure: true, status: [:failed, :canceled]) }
@@ -30,6 +31,10 @@ class CommitStatus < ActiveRecord::Base
transition [:created, :skipped] => :pending
end
+ event :process do
+ transition skipped: :created
+ end
+
event :run do
transition pending: :running
end
@@ -107,13 +112,7 @@ class CommitStatus < ActiveRecord::Base
end
def duration
- duration =
- if started_at && finished_at
- finished_at - started_at
- elsif started_at
- Time.now - started_at
- end
- duration
+ calculate_duration
end
def stuck?
diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb
new file mode 100644
index 0000000000000000000000000000000000000000..be93435453bfb3ca2eefa3c21e756b90ddbb6607
--- /dev/null
+++ b/app/models/concerns/expirable.rb
@@ -0,0 +1,15 @@
+module Expirable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :expired, -> { where('expires_at <= ?', Time.current) }
+ end
+
+ def expires?
+ expires_at.present?
+ end
+
+ def expires_soon?
+ expires_at < 7.days.from_now
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index cbae1cd439bc3d0ffdd0757679570e35ee62c3ef..afb5ce37c06f632f32cbec67103e72704fe45f0a 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -131,7 +131,10 @@ module Issuable
end
def order_labels_priority(excluded_labels: [])
- select("#{table_name}.*, (#{highest_label_priority(excluded_labels).to_sql}) AS highest_priority").
+ condition_field = "#{table_name}.id"
+ highest_priority = highest_label_priority(name, condition_field, excluded_labels: excluded_labels).to_sql
+
+ select("#{table_name}.*, (#{highest_priority}) AS highest_priority").
group(arel_table[:id]).
reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
end
@@ -159,20 +162,6 @@ module Issuable
grouping_columns
end
-
- private
-
- def highest_label_priority(excluded_labels)
- query = Label.select(Label.arel_table[:priority].minimum).
- joins(:label_links).
- where(label_links: { target_type: name }).
- where("label_links.target_id = #{table_name}.id").
- reorder(nil)
-
- query.where.not(title: excluded_labels) if excluded_labels.present?
-
- query
- end
end
def today?
diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb
index 4be6a2f621b322b5c2b9d582d757454b1108d19a..a881fb83b7f6fee492c63895e008e02942222711 100644
--- a/app/models/concerns/note_on_diff.rb
+++ b/app/models/concerns/note_on_diff.rb
@@ -17,6 +17,10 @@ module NoteOnDiff
raise NotImplementedError
end
+ def original_line_code
+ raise NotImplementedError
+ end
+
def diff_attributes
raise NotImplementedError
end
diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5a7b36070e79e8fe43c3bcddb3ac12cd27efbe81
--- /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/sortable.rb b/app/models/concerns/sortable.rb
index 8b47b9e0abd2879d2b67275b90e8e3c78c81849a..1ebecd86af9b4d961fa1d42e29ccc77d75d1a137 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -35,5 +35,19 @@ module Sortable
all
end
end
+
+ private
+
+ def highest_label_priority(object_types, condition_field, excluded_labels: [])
+ query = Label.select(Label.arel_table[:priority].minimum).
+ joins(:label_links).
+ where(label_links: { target_type: object_types }).
+ where("label_links.target_id = #{condition_field}").
+ reorder(nil)
+
+ query.where.not(title: excluded_labels) if excluded_labels.present?
+
+ query
+ end
end
end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index ce54fe5d3bf8d606223121e3de5088b711e237df..1aa97debe426fcfe3c893603833e6119da366191 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -23,7 +23,7 @@ module Spammable
def submittable_as_spam?
if user_agent_detail
- user_agent_detail.submittable?
+ user_agent_detail.submittable? && current_application_settings.akismet_enabled
else
false
end
diff --git a/app/models/concerns/statuseable.rb b/app/models/concerns/statuseable.rb
index 5d4b0a868998c82032f261ea6982cf665b9fef1e..750f937b72407bbdd9e827c41093c4edc86b5bd9 100644
--- a/app/models/concerns/statuseable.rb
+++ b/app/models/concerns/statuseable.rb
@@ -35,11 +35,6 @@ module Statuseable
all.pluck(self.status_sql).first
end
- def duration
- duration_array = all.map(&:duration).compact
- duration_array.reduce(:+)
- end
-
def started_at
all.minimum(:started_at)
end
@@ -85,4 +80,14 @@ module Statuseable
def complete?
COMPLETED_STATUSES.include?(status)
end
+
+ private
+
+ def calculate_duration
+ if started_at && finished_at
+ finished_at - started_at
+ elsif started_at
+ Time.now - started_at
+ end
+ end
end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index c816deb4e0cb65d2b4f2a19c24dfa78f55381856..c8320ff87fa735cef0e79ea80f91e0242753928b 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -9,11 +9,16 @@ 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
+ # We need to do this again, because it's already in `Note`, but is affected by
+ # `update_position` and needs to run after that.
+ before_validation :set_discussion_id
after_save :keep_around_commits
class << self
@@ -30,14 +35,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
@@ -63,6 +60,10 @@ class DiffNote < Note
diff_file.position(line) == self.original_position
end
+ def original_line_code
+ self.diff_file.line_code(self.diff_line)
+ end
+
def active?(diff_refs = nil)
return false unless supported?
return true if for_commit?
@@ -72,10 +73,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
@@ -94,6 +133,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 e2218a5f02bed4b40bfbd510b85310b4a6f4b5b6..9676bc034708630130e3fe79b8d63f3c47830f74 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,
@@ -12,12 +12,19 @@ class Discussion
:for_merge_request?,
:line_code,
+ :original_line_code,
:diff_file,
:for_line?,
:active?,
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 +37,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 +69,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 +123,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/group.rb b/app/models/group.rb
index 37631b997014c644d467827dcd0986a464f5e125..c48869ae465ea904f7b18b6af8ff120bef03ea12 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -95,34 +95,40 @@ class Group < Namespace
end
end
- def add_users(user_ids, access_level, current_user = nil)
+ def add_users(user_ids, access_level, current_user: nil, expires_at: nil)
user_ids.each do |user_id|
- Member.add_user(self.group_members, user_id, access_level, current_user)
+ Member.add_user(
+ self.group_members,
+ user_id,
+ access_level,
+ current_user: current_user,
+ expires_at: expires_at
+ )
end
end
- def add_user(user, access_level, current_user = nil)
- add_users([user], access_level, current_user)
+ def add_user(user, access_level, current_user: nil, expires_at: nil)
+ add_users([user], access_level, current_user: current_user, expires_at: expires_at)
end
def add_guest(user, current_user = nil)
- add_user(user, Gitlab::Access::GUEST, current_user)
+ add_user(user, Gitlab::Access::GUEST, current_user: current_user)
end
def add_reporter(user, current_user = nil)
- add_user(user, Gitlab::Access::REPORTER, current_user)
+ add_user(user, Gitlab::Access::REPORTER, current_user: current_user)
end
def add_developer(user, current_user = nil)
- add_user(user, Gitlab::Access::DEVELOPER, current_user)
+ add_user(user, Gitlab::Access::DEVELOPER, current_user: current_user)
end
def add_master(user, current_user = nil)
- add_user(user, Gitlab::Access::MASTER, current_user)
+ add_user(user, Gitlab::Access::MASTER, current_user: current_user)
end
def add_owner(user, current_user = nil)
- add_user(user, Gitlab::Access::OWNER, current_user)
+ add_user(user, Gitlab::Access::OWNER, current_user: current_user)
end
def has_owner?(user)
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index ba42a8eeb70bdd30a2bb84bee4535eafea8e1a3d..836a75b0608d11ca3ae693f01fb5bc15e17dede4 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 8b87b6c3d64fc1f95560d5490ae21adf22e1a9a3..f365dee31418c85188ce8c638d258133c62470ac 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/label.rb b/app/models/label.rb
index 35e678001dc6c29765fa987a23d5ae45ce202139..a23140b7d64b45e32432b137b157a1d3f8ca7d69 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 6ed6600151385023f3c816b767bd221dbd2f1afb..40277a9b13963f1d7a44bc83a0bf9d76f2758165 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 }
@@ -53,6 +49,10 @@ class LegacyDiffNote < Note
!line.meta? && diff_file.line_code(line) == self.line_code
end
+ def original_line_code
+ self.line_code
+ end
+
# Check if this note is part of an "active" discussion
#
# This will always return true for anything except MergeRequest noteables,
@@ -119,4 +119,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 0000000000000000000000000000000000000000..eb87decdbc803705671408861990277a76e0355d
--- /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/member.rb b/app/models/member.rb
index 24ab1276ee936f9a81098648802df78c9a8e585a..64e0d33fb208adca2e7c3677adafa93a1347c1de 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -1,6 +1,7 @@
class Member < ActiveRecord::Base
include Sortable
include Importable
+ include Expirable
include Gitlab::Access
attr_accessor :raw_invite_token
@@ -73,7 +74,7 @@ class Member < ActiveRecord::Base
user
end
- def add_user(members, user_id, access_level, current_user = nil)
+ def add_user(members, user_id, access_level, current_user: nil, expires_at: nil)
user = user_for_id(user_id)
# `user` can be either a User object or an email to be invited
@@ -87,6 +88,7 @@ class Member < ActiveRecord::Base
if can_update_member?(current_user, member) || project_creator?(member, access_level)
member.created_by ||= current_user
member.access_level = access_level
+ member.expires_at = expires_at
member.save
end
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 18e97c969d76d9286fa18f6e52be80ed08693ebb..ec2d40eb11c564725d3a8a4890fb2f9a5acf975d 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -34,7 +34,7 @@ class ProjectMember < Member
# :master
# )
#
- def add_users_to_projects(project_ids, user_ids, access, current_user = nil)
+ def add_users_to_projects(project_ids, user_ids, access, current_user: nil, expires_at: nil)
access_level = if roles_hash.has_key?(access)
roles_hash[access]
elsif roles_hash.values.include?(access.to_i)
@@ -50,7 +50,13 @@ class ProjectMember < Member
project = Project.find(project_id)
users.each do |user|
- Member.add_user(project.project_members, user, access_level, current_user)
+ Member.add_user(
+ project.project_members,
+ user,
+ access_level,
+ current_user: current_user,
+ expires_at: expires_at
+ )
end
end
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index fe799382fd0083bc7f44516f5cb1260ff465e992..19a684d1d0cb852f8740dfa0e9b7ce06641317df 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -418,6 +418,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),
@@ -674,10 +700,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
@@ -690,12 +727,12 @@ class MergeRequest < ActiveRecord::Base
merge_commit
end
- def support_new_diff_notes?
+ def has_complete_diff_refs?
diff_sha_refs && diff_sha_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|
@@ -723,4 +760,30 @@ 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
+ # Try to parse each conflict. If the MR's mergeable status hasn't been updated,
+ # ensure that we don't say there are conflicts to resolve when there are no conflict
+ # files.
+ conflicts.files.each(&:lines)
+ @conflicts_can_be_resolved_in_ui = conflicts.files.length > 0
+ rescue Rugged::OdbError, 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 ddcd7f9d034dd2421f7ff4ef53977d180f1ca7eb..f2656df028b2a282573f6b884714a37a7104ae1b 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,28 @@ 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?
+ # Needed in case the SELECT statement doesn't ask for `discussion_id`
+ return unless self.has_attribute?(:discussion_id)
+ 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 e0b28160937ad3bf00415eca59b61b93c2fe8e24..8cf093be4c3bb8ef539783e7597f11a9f4e6d3b8 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
@@ -467,8 +471,6 @@ class Project < ActiveRecord::Base
end
def reset_cache_and_import_attrs
- update(import_error: nil)
-
ProjectCacheWorker.perform_async(self.id)
self.import_data.destroy if self.import_data
@@ -607,7 +609,10 @@ class Project < ActiveRecord::Base
end
def new_issue_address(author)
- if Gitlab::IncomingEmail.enabled? && author
+ # This feature is disabled for the time being.
+ return nil
+
+ if Gitlab::IncomingEmail.enabled? && author # rubocop:disable Lint/UnreachableCode
Gitlab::IncomingEmail.reply_address(
"#{path_with_namespace}+#{author.authentication_token}")
end
@@ -999,8 +1004,8 @@ class Project < ActiveRecord::Base
project_members.find_by(user_id: user)
end
- def add_user(user, access_level, current_user = nil)
- team.add_user(user, access_level, current_user)
+ def add_user(user, access_level, current_user: nil, expires_at: nil)
+ team.add_user(user, access_level, current_user: current_user, expires_at: expires_at)
end
def default_branch
diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb
index e52a6bd7c8473b69054824db5724ae4d12d77954..7613cbdea93751fe78dfe9cbbf99484588f479ae 100644
--- a/app/models/project_group_link.rb
+++ b/app/models/project_group_link.rb
@@ -1,4 +1,6 @@
class ProjectGroupLink < ActiveRecord::Base
+ include Expirable
+
GUEST = 10
REPORTER = 20
DEVELOPER = 30
@@ -26,7 +28,7 @@ class ProjectGroupLink < ActiveRecord::Base
self.class.access_options.key(self.group_access)
end
- private
+ private
def different_group
if self.group && self.project && self.project.group == self.group
diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb
index 5e166471077ca69cf939591f81f74d9d8fca78b4..fa66e5864b8b002efd92d17aa737acc96a947a48 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_team.rb b/app/models/project_team.rb
index d0a714cd6fcbefcd5f00ef0339faa9af620791de..ab6ea2aae36b0e362dc1b5900d4c8846d9188493 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -15,9 +15,9 @@ class ProjectTeam
users, access, current_user = *args
if users.respond_to?(:each)
- add_users(users, access, current_user)
+ add_users(users, access, current_user: current_user)
else
- add_user(users, access, current_user)
+ add_user(users, access, current_user: current_user)
end
end
@@ -33,17 +33,18 @@ class ProjectTeam
member
end
- def add_users(users, access, current_user = nil)
+ def add_users(users, access, current_user: nil, expires_at: nil)
ProjectMember.add_users_to_projects(
[project.id],
users,
access,
- current_user
+ current_user: current_user,
+ expires_at: expires_at
)
end
- def add_user(user, access, current_user = nil)
- add_users([user], access, current_user)
+ def add_user(user, access, current_user: nil, expires_at: nil)
+ add_users([user], access, current_user: current_user, expires_at: expires_at)
end
# Remove all users from project team
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 226b3f54342134d15ef5a96e3c2ea5e67701a0d6..6240912a6e1aff65b4e0daeaeea4d4d92329690a 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 b1112ee737df98da6cac950331dba06aeec03c27..806b3ccd27591421c5718bac60d77c21a1848b43 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 6a5e49cf45394b0176075844c19f33b097a57e8f..92e9c51d883cd50b0ff6828cd94960f2d4fe0f21 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 e56bac509a4ed1e774778c68b998565b8b1218b9..bdc3b9d1c1cb5c47809fdb4c274f47e543c86676 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -277,7 +277,7 @@ class Repository
def cache_keys
%i(size commit_count
readme version contribution_guide changelog
- license_blob license_key gitignore)
+ license_blob license_key gitignore koding_yml)
end
# Keys for data on branch/tag operations.
@@ -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.
@@ -537,6 +553,14 @@ class Repository
end
end
+ def koding_yml
+ return nil unless head_exists?
+
+ cache.fetch(:koding_yml) do
+ file_on_head(/\A\.koding\.yml\z/)
+ end
+ end
+
def gitlab_ci_yml
return nil unless head_exists?
@@ -869,6 +893,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 +1091,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 40cd9b861f0c2e29f0179990122831e9de85ca66..09b4717a523e63456bcfc26a5444d1e78e37020c 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/todo.rb b/app/models/todo.rb
index 8d7a5965aa15f4698fc869c3b39a3edadd39601c..6ae9956ade5f61ea16aa2318a2dbd9614bcb2812 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -1,4 +1,6 @@
class Todo < ActiveRecord::Base
+ include Sortable
+
ASSIGNED = 1
MENTIONED = 2
BUILD_FAILED = 3
@@ -41,6 +43,23 @@ class Todo < ActiveRecord::Base
after_save :keep_around_commit
+ class << self
+ def sort(method)
+ method == "priority" ? order_by_labels_priority : order_by(method)
+ end
+
+ # Order by priority depending on which issue/merge request the Todo belongs to
+ # Todos with highest priority first then oldest todos
+ # Need to order by created_at last because of differences on Mysql and Postgres when joining by type "Merge_request/Issue"
+ def order_by_labels_priority
+ highest_priority = highest_label_priority(["Issue", "MergeRequest"], "todos.target_id").to_sql
+
+ select("#{table_name}.*, (#{highest_priority}) AS highest_priority").
+ order(Gitlab::Database.nulls_last_order('highest_priority', 'ASC')).
+ order('todos.created_at')
+ end
+ end
+
def build_failed?
action == BUILD_FAILED
end
diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb
index 00b19686d482e985c1362f1c4ab9cf258fad754f..808acec098f9e23c40db5c757550d4c233f6f4ea 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 87a2d999843d6721d42e2eec9151b37f33775d5f..ad3cfbc03e4c6a5cfefc31194107164b4fb34b4e 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
@@ -482,10 +489,10 @@ class User < ActiveRecord::Base
(personal_projects.count.to_f / projects_limit) * 100
end
- def recent_push(project_id = nil)
+ def recent_push(project_ids = nil)
# Get push events not earlier than 2 hours ago
events = recent_events.code_push.where("created_at > ?", Time.now - 2.hours)
- events = events.where(project_id: project_id) if project_id
+ events = events.where(project_id: project_ids) if project_ids
# Use the latest event that has not been pushed or merged recently
events.recent.find do |event|
diff --git a/app/services/boards/base_service.rb b/app/services/boards/base_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b2069ca825a3c3370a11c06351ddacd53ccd5401
--- /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 0000000000000000000000000000000000000000..072a07492850863c24e411c6104be0e37de12c6f
--- /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 0000000000000000000000000000000000000000..34efd09ed9f64362d4da4523ff3c4e44d8babb54
--- /dev/null
+++ b/app/services/boards/issues/list_service.rb
@@ -0,0 +1,68 @@
+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] =
+ case list.list_type.to_sym
+ when :backlog then 'opened'
+ when :done then 'closed'
+ else 'all'
+ end
+ 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 0000000000000000000000000000000000000000..84dc3f70e7661021c1fc94e4e61482748cf9aa54
--- /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 0000000000000000000000000000000000000000..5cb408b9d2021ae369ae63b897daa42c2cc6919c
--- /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 0000000000000000000000000000000000000000..25da3bfb56dd7678af2e17dd62164be65e976e89
--- /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 0000000000000000000000000000000000000000..1c48b9786e464a8c7619023b56cf5af2c4d87121
--- /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 0000000000000000000000000000000000000000..020ff69f4a7a0bf6ad1df958b3b7256b6af73840
--- /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/delete_branch_service.rb b/app/services/delete_branch_service.rb
index 87f066edb6f10f58f1c2d72d54e096851156e00a..918eddaa53a906844ab13dfb9f50b8f186e6ce62 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 32e0eed6b63644116f74a0391aa49f5299cafc70..d0cb151a010cde5cd721514a3068972c49888afb 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/git_push_service.rb b/app/services/git_push_service.rb
index 6f521462cf33cbf12386994403211b04b33f8b3f..78feb37aa2a653ceac90a046fe32e12ddc95ddfb 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 d2b52f16fa8dc00de7e7246f76e05b3292513475..e6002b03b933a269592590c97d864eee51506b48 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/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 2d96efe1042c955fc984e0c895ae6ae9e84a6100..e06c37c323ed3bc4e47a67cf64e028f5945e9948 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 update_issuable(issuable, attributes)
+ 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
- add_label_ids = attributes.delete(:add_label_ids)
- remove_label_ids = attributes.delete(:remove_label_ids)
+ if issuable.save
+ issuable.update_attributes(label_ids: label_ids)
+ end
+ end
+ end
- issuable.label_ids |= add_label_ids if add_label_ids
- issuable.label_ids -= remove_label_ids if remove_label_ids
+ 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
- issuable.assign_attributes(attributes.merge(updated_by: current_user))
+ def before_create(issuable)
+ # To be overridden by subclasses
+ end
+
+ def after_create(issuable)
+ # To be overridden by subclasses
+ end
- issuable.save
+ def update_issuable(issuable, attributes)
+ issuable.with_transaction_returning_status do
+ 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 859c934ea3bf0493195fb6d106bb9c858b4baf13..45cca216ccce68016b13a423295f3854c9c1f020 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 65550ab8ec6cd5160fd5d88be71a9fb3c7f42640..ea1690f3e381b630425225059dfcb1074a77a5a5 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -1,26 +1,23 @@
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
- @issue.spam = spam_service.check(@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)
- user_agent_detail_service.create
- @issue.create_cross_references!(current_user)
- execute_hooks(@issue, 'open')
- end
+ create(@issue)
+ end
+
+ def before_create(issuable)
+ issuable.spam = spam_service.check(@api)
+ end
- @issue
+ 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
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index e48ca359f4f9905fbcb169792611b885f0529f81..40fbe354492842ecc67d26710c3b717f0e7c946c 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 c7d406cc3317ba8e0a904a99ff5dca41843dfb3d..a2111b3806ba7fbc710315feb762dbe68e389d76 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/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ca9db59cac706762c7586ffb5f6fb16308ab6d4c
--- /dev/null
+++ b/app/services/members/authorized_destroy_service.rb
@@ -0,0 +1,19 @@
+module Members
+ class AuthorizedDestroyService < BaseService
+ attr_accessor :member, :user
+
+ def initialize(member, user = nil)
+ @member, @user = member, user
+ end
+
+ def execute
+ return false if member.is_a?(GroupMember) && member.source.last_owner?(member.user)
+
+ member.destroy
+
+ if member.request? && member.user != user
+ notification_service.decline_access_request(member)
+ end
+ end
+ end
+end
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index 9e3f6af628daa49565ac9e56fd8b9d215906b4dd..9a2bf82ef516e2ac9b3f1fa6510eb634e8d3ba45 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -11,12 +11,7 @@ module Members
unless member && can?(current_user, "destroy_#{member.type.underscore}".to_sym, member)
raise Gitlab::Access::AccessDeniedError
end
-
- member.destroy
-
- if member.request? && member.user != current_user
- notification_service.decline_access_request(member)
- end
+ AuthorizedDestroyService.new(member, current_user).execute
end
end
end
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index 27ee81fe3e753d8fe572b8c12b4fa61e6e8f4aed..f2053bda83aadbb4dbcb8766999f404d02b6c7bc 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 96a25330af100447755e284febaa6ffdf5028ad4..73247e62421b3576f5068d52d97bf6b5023e0032 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 eb88ae9d11c842e536c3a2e8605f4ae13d3629aa..fadcce5d9b6e13f27e68d8a3433cc471fc1a6eea 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 0000000000000000000000000000000000000000..adc71b0c2bcfb47ff7e5cda058af6197a06e2ace
--- /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 0000000000000000000000000000000000000000..3a09350c84704f0065c9c025f675fc429f8bd153
--- /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 026a37997d44376f66bc02c14a3b4643f894dbf9..30c5f24988c8d379c25e03b8a953ebd33f78ffb4 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 18971bd0be3eec0572bbf2b23b8a9a5570f52012..a36008c3ef565033e8d50ccc82aef05d0df6a54f 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 534c48aefffed7499805cfa50ac082c2cc01a6cb..e4cd3fc7833867763cff187fac320dbc37c55f8d 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 0000000000000000000000000000000000000000..4a9a8a646531450202565fe71598fe0ab3d3c50c
--- /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 ab6e51209eeae9828bcd47d43bc26b017aa451b8..6139ed56e25559c4f499e0fa1b162bbf97ed3e32 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
@@ -206,7 +242,6 @@ class NotificationService
project_member.real_source_type,
project_member.project.id,
project_member.invite_email,
- project_member.access_level,
project_member.created_by_id
).deliver_later
end
@@ -233,7 +268,6 @@ class NotificationService
group_member.real_source_type,
group_member.group.id,
group_member.invite_email,
- group_member.access_level,
group_member.created_by_id
).deliver_later
end
@@ -471,6 +505,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 23b6668e0d1c9de43a4fec73ddd5c8486ab56458..f578f8dbea23b3d3de2c67aed2179c69cb047d64 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 02c4eee3d02b2eed9a22ea12a4f81254ce475f8f..d38328403c1641a1a5559b7f37eeb085ff7a4ef4 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 6150a2a83c93fa0d8aef395c7ec062bc4f3934b7..a84e335340d2f3194639a5c7ea5e866dd645f7a1 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 0000000000000000000000000000000000000000..9ac1124abc15f6ff4e32f8d1695014f98de0d85f
--- /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/system_note_service.rb b/app/services/system_note_service.rb
index e13dc9265b83359d122f99aa06357284ff7bbca4..546a8f11330e14c23bc0bc429c150816a447c214 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 e85e58751e781ced2603aa0ea0310531b861d76b..280c81f7d2dc1b2f8d535629c5198c2b98715afe 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 eb833dd82ac1c69cc7f57128c92b084f4bc8a32a..e0ccb65459004da1543048b0f2a3f9d0464f9942 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/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index c7fd344eea2d4712341880de1d576db4428a1adb..d929364fc965348a34a612519272a347cdacc30d 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -388,6 +388,25 @@
.help-block
If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.
+ %fieldset
+ %legend Koding
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :koding_enabled do
+ = f.check_box :koding_enabled
+ Enable Koding
+ .form-group
+ = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090'
+ .help-block
+ Koding has integration enabled out of the box for the
+ %strong gitlab
+ team, and you need to provide that team's URL here. Learn more in the
+ = succeed "." do
+ = link_to "Koding administration documentation", help_page_path("administration/integration/koding")
+
.form-actions
= f.submit 'Save', class: 'btn btn-save'
diff --git a/app/views/admin/builds/_build.html.haml b/app/views/admin/builds/_build.html.haml
index 352adbedee4e0a88401c8aed59bac7751537f215..f29d9c94441a96ce91f27cb17830e52f3fc2ef9f 100644
--- a/app/views/admin/builds/_build.html.haml
+++ b/app/views/admin/builds/_build.html.haml
@@ -51,7 +51,7 @@
- if build.duration
%p.duration
= custom_icon("icon_timer")
- = duration_in_numbers(build.finished_at, build.started_at)
+ = duration_in_numbers(build.duration)
- if build.finished_at
%p.finished-at
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index 6956e5ab7958a003ae5eead0e8789b5a4daf0e20..bfc6142067a53bf82b91a5256e782ec999997a95 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -9,12 +9,20 @@
.light-well
%h4 CPU
.data
- %h1= "#{@cpus} cores"
+ - if @cpus
+ %h1= "#{@cpus.length} cores"
+ - else
+ = icon('warning', class: 'text-warning')
+ Unable to collect CPU info
.col-sm-4
.light-well
%h4 Memory
.data
- %h1= "#{number_to_human_size(@mem_used)} / #{number_to_human_size(@mem_total)}"
+ - if @memory
+ %h1= "#{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)}"
+ - else
+ = icon('warning', class: 'text-warning')
+ Unable to collect memory info
.col-sm-4
.light-well
%h4 Disks
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 4e340b6ec16f3dad72ee06eb91ab93c8a717af5b..d320d3bcc1e31ffec36a9f09b11733ed93de2d84 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -43,6 +43,25 @@
class: 'select2 trigger-submit', include_blank: true,
data: {placeholder: 'Action'})
+ .pull-right
+ .dropdown.inline.prepend-left-10
+ %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'}
+ %span.light
+ - if @sort.present?
+ = sort_options_hash[@sort]
+ - else
+ = sort_title_recently_created
+ %b.caret
+ %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-sort
+ %li
+ = link_to todos_filter_path(sort: sort_value_priority) do
+ = sort_title_priority
+ = link_to todos_filter_path(sort: sort_value_recently_created) do
+ = sort_title_recently_created
+ = link_to todos_filter_path(sort: sort_value_oldest_created) do
+ = sort_title_oldest_created
+
+
.prepend-top-default
- if @todos.any?
.js-todos-options{ data: {per_page: @todos.limit_value, current_page: @todos.current_page, total_pages: @todos.total_pages} }
diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml
index fa1ad9efa73c1ea9baef59536faa74a591b16df3..1411daeb4a69187730403a621f021bbd00d18541 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 02b159ffd454b4f2b19bfc407d08723c03dce167..3a95a65281008c7d838a78207e665185df7b9095 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.original_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 49702e048aa717fffac79c383e4ed626c48251aa..077e8e64e5fbaa328982d5a15e6da71c841ef342 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 0000000000000000000000000000000000000000..c1dabeed3871a3cb549e513bd9ed0bc432c09c9f
--- /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 0000000000000000000000000000000000000000..69bd416c4de8d753f92baa86d806422f5e8b0556
--- /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 a2642b839f6cacb61cd64f72afd0e62bae9ac7fc..fbe470bed2c7af30394531ff77d96a4440ef8050 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 a798c438ea0e9c26dfe764ff7bc887529069db93..f1072ce0febaefdac9514ed35559a2bc27a21512 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 0000000000000000000000000000000000000000..7a8767ddba09ec30d707cb7bccdbc1524635d765
--- /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/groups/group_members/_new_group_member.html.haml b/app/views/groups/group_members/_new_group_member.html.haml
index 9bb9f96217770455302e3e0b5a0022f442ccaf27..2fb3190ab11e304162f349f77feb95c75caaafc7 100644
--- a/app/views/groups/group_members/_new_group_member.html.haml
+++ b/app/views/groups/group_members/_new_group_member.html.haml
@@ -14,5 +14,14 @@
Read more about role permissions
%strong= link_to "here", help_page_path("user/permissions"), class: "vlink"
+ .form-group
+ = f.label :expires_at, 'Access expiration date', class: 'control-label'
+ .col-sm-10
+ .clearable-input
+ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date'
+ %i.clear-icon.js-clear-input
+ .help-block
+ On this date, the user(s) will automatically lose access to this group and all of its projects.
+
.form-actions
= f.submit 'Add users to group', class: "btn btn-create"
diff --git a/app/views/groups/group_members/update.js.haml b/app/views/groups/group_members/update.js.haml
index da71de4cd1e52ec2e6e940404770e62d4238905d..742f9d7a433edd00e623c4d5c3d317405244ca1b 100644
--- a/app/views/groups/group_members/update.js.haml
+++ b/app/views/groups/group_members/update.js.haml
@@ -1,2 +1,3 @@
:plain
$("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render('shared/members/member', member: @group_member))}');
+ new MemberExpirationDate();
diff --git a/app/views/koding/index.html.haml b/app/views/koding/index.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..65887aacbafb8ee20c1c6dcc3c60c993a895e623
--- /dev/null
+++ b/app/views/koding/index.html.haml
@@ -0,0 +1,6 @@
+.row-content-block.second-block.center
+ %p
+ = icon('circle', class: 'cgreen')
+ Integration is active for
+ = link_to koding_project_url, target: '_blank' do
+ #{current_application_settings.koding_url}
diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml
index 351100f3523383a3a723f261f6ab5fe96b6fcab0..67ff4b272b9886a30016c469d71cb7ca760b7255 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 a1a71c2fb3392929b669075ece43dc6174168d51..bf50633af244025eaf404f88f3537bbd46370655 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 33cedaaf2eee64e17ae15780b35309b275cb9f83..15a94ac23c56675e974b7d2b732a32a7a77554f6 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/layouts/koding.html.haml b/app/views/layouts/koding.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..22319bba745f84a8c597eab946aca8a455c2a1f9
--- /dev/null
+++ b/app/views/layouts/koding.html.haml
@@ -0,0 +1,5 @@
+- page_title "Koding"
+- page_description "Koding Dashboard"
+- header_title "Koding", koding_path
+
+= render template: "layouts/application"
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 3a14751ea8ebd9708f62eb5064d3c6eaab5b4938..67f558c854b1e1ac65a99765bf721ebe95490aad 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -12,6 +12,11 @@
= link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
%span
Activity
+ - if koding_enabled?
+ = nav_link(controller: :koding) do
+ = link_to koding_path, title: 'Koding' do
+ %span
+ Koding
= nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
= link_to dashboard_groups_path, title: 'Groups' do
%span
diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml
index 1d3b8fc36833a3dc198c3d52f7d294dece871c10..f7012595a5a72729b298ac3e9773560c9f3dce3d 100644
--- a/app/views/layouts/nav/_project.html.haml
+++ b/app/views/layouts/nav/_project.html.haml
@@ -65,7 +65,7 @@
Graphs
- if project_nav_tab? :issues
- = nav_link(controller: [:issues, :labels, :milestones]) do
+ = nav_link(controller: [:issues, :labels, :milestones, :boards]) do
= link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do
%span
Issues
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 0000000000000000000000000000000000000000..4f3d36bd9cada54e93286fab68177e0953ac7060
--- /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 0000000000000000000000000000000000000000..457e94b48001b3fd85b39d1095e2a00f6b4935ec
--- /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 0000000000000000000000000000000000000000..32aedb9e6b91a3259be0c1a6af41b370888569ed
--- /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 0000000000000000000000000000000000000000..5bf0282e0974e41d55f2626b3564ef2089fa7474
--- /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 c161ecc3463a262fcca6dee0e5e0637b48b2d04c..c0c07d65daa49a256f8235057ccd714bb7270e66 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 0000000000000000000000000000000000000000..522421b7cc351d449faa60d0fbd91dea1cce9930
--- /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 0000000000000000000000000000000000000000..b0d380af8fcb07fa745080c709c53856ec1b7587
--- /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 71ac367830d7a913e1abd36c4a3b3830e330d6ef..05a2ea67aa2189c8327fe45aa409c21f70ced497 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 366f1fed35bb44e15eb1d662d1fa0eaed4cfa39d..03ac739ade51517de77757c31335e0f6445d2918 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 413477a2d3a0082b846aeec6f4b8d330d1f6f1d5..3978fa60d663f2396c2950c962b86985dc8ffc53 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/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 7b0621f94013c52a50c052e0fa176754ce48f9ab..680e95ac6b5bb7015f4bfe92986cdd9e2a3b7700 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -1,4 +1,7 @@
- page_title "Edit", @blob.path, @ref
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
+ = page_specific_javascript_tag('blob_edit/blob_edit_bundle.js')
- if @conflict
.alert.alert-danger
@@ -16,14 +19,10 @@
= link_to '#preview', 'data-preview-url' => namespace_project_preview_blob_path(@project.namespace, @project, @id) do
= editing_preview_title(@blob.name)
- = 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
+ = 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', data: blob_editor_paths) 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_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)
-
-:javascript
- blob = new EditBlob(gon.relative_url_root + "#{Gitlab::Application.config.assets.prefix}", "#{@blob.language.try(:ace_mode)}")
- new NewCommitForm($('.js-edit-blob-form'))
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index c952bc7e5dbcf73de34502ab225769e10ffbd0d2..b6ed9518c489a353a8727de73ff1e2918eff6d33 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -1,17 +1,16 @@
- page_title "New File", @path.presence, @ref
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
+ = page_specific_javascript_tag('blob_edit/blob_edit_bundle.js')
%h3.page-title
New File
.file-editor
- = form_tag(namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post, class: 'form-horizontal js-new-blob-form js-quick-submit js-requires-input') do
+ = form_tag(namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post, class: 'form-horizontal js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths) do
= render 'projects/blob/editor', ref: @ref
= render 'shared/new_commit_form', placeholder: "Add new file"
= hidden_field_tag 'content', '', id: 'file-content'
= render 'projects/commit_button', ref: @ref,
cancel_path: namespace_project_tree_path(@project.namespace, @project, @id)
-
-:javascript
- blob = new EditBlob(gon.relative_url_root + "#{Gitlab::Application.config.assets.prefix}")
- new NewCommitForm($('.js-new-blob-form'))
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 0000000000000000000000000000000000000000..97eb952eff1dffb00c201ca88010fcf75cfe5fcf
--- /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 0000000000000000000000000000000000000000..de53a298f8480f1b98aa074d85ea3e0f9b9487f2
--- /dev/null
+++ b/app/views/projects/boards/components/_board.html.haml
@@ -0,0 +1,43 @@
+%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) }" }
+ {{ 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 0000000000000000000000000000000000000000..e8b60b54d8028bb3e7078618caad2d5d709346be
--- /dev/null
+++ b/app/views/projects/boards/components/_card.html.haml
@@ -0,0 +1,33 @@
+%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" }
+ %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 0000000000000000000000000000000000000000..edbbd3f3d2a01c90f1ed151aacebd1c1a9f0af4f
--- /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 a8bc53c284995b93a4466d95707e49e122ef84b2..5b0b58e087be7dc0c895a516f446ba07f6750fad 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) }
- .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
+ - 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
- - 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
+ .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
- = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
- Download
+ - if @build.retried?
+ %li.active
+ %a
+ Build ##{@build.id}
+ ·
+ %i.fa.fa-warning
+ This build was retried.
- - if @build.artifacts_metadata?
- = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
- Browse
+ .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
- .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
- %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
+ - 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.trigger_request
- .build-widget
- %h4.title
- Trigger
+ - if @build.artifacts_metadata?
+ = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
+ Browse
- %p
- %span.build-light-text Token:
- #{@build.trigger_request.trigger.short_token}
+ .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
+ %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
+
+ - if @build.trigger_request
+ .build-widget
+ %h4.title
+ Trigger
- - 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}
- .block
- .title
- Commit title
- %p.build-light-text.append-bottom-0
- #{@build.pipeline.git_commit_title}
+ - @build.trigger_request.variables.each do |key, value|
+ %code
+ #{key}=#{value}
- - 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 4421f3b9562e3cb40e0cc87252f1d8c5e3d390a0..e4d41288aa6f34aef495e01eef019acc8e9c0cc2 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/_koding.html.haml b/app/views/projects/buttons/_koding.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..fdc80d44253bd049a71cddce5c432b082d7c5778
--- /dev/null
+++ b/app/views/projects/buttons/_koding.html.haml
@@ -0,0 +1,7 @@
+- if koding_enabled? && current_user && can_push_branch?(@project, @project.default_branch)
+ - if @repository.koding_yml
+ = link_to koding_project_url(@project), class: 'btn', target: '_blank' do
+ Run in IDE (Koding)
+ - else
+ = link_to add_koding_stack_path(@project), class: 'btn' do
+ Set Up Koding
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 91081435220cab25fb66d81b13ca2477c6df1345..1fdf32466f234ac7c99e032935fa185d0be9b59a 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -63,7 +63,7 @@
- if build.duration
%p.duration
= custom_icon("icon_timer")
- = duration_in_numbers(build.finished_at, build.started_at)
+ = duration_in_numbers(build.duration)
- if build.finished_at
%p.finished-at
= icon("calendar")
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 0000000000000000000000000000000000000000..04cbd0c3591b04a800f8333c8a3860845057ecd8
--- /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 78709a92aedf405fbf7cd787b05a05aa0634fe9d..b119f6edf14909ca5711c58e230453fc91a2fadb 100644
--- a/app/views/projects/ci/pipelines/_pipeline.html.haml
+++ b/app/views/projects/ci/pipelines/_pipeline.html.haml
@@ -1,21 +1,23 @@
- status = pipeline.status
%tr.commit
%td.commit-link
- = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do
- = ci_status_with_icon(status)
-
-
+ = link_to namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id) do
+ - 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
+ = link_to namespace_project_pipeline_path(pipeline.project.namespace, pipeline.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")
- = link_to pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, pipeline.sha), class: "commit-id monospace"
+ - unless defined?(hide_branch) && hide_branch
+ .icon-container
+ = pipeline.tag? ? icon('tag') : icon('code-fork')
+ = link_to pipeline.ref, namespace_project_commits_path(pipeline.project.namespace, pipeline.project, pipeline.ref), class: "monospace branch-name"
+ .icon-container
+ = custom_icon("icon_commit")
+ = link_to pipeline.short_sha, namespace_project_commit_path(pipeline.project.namespace, pipeline.project, pipeline.sha), class: "commit-id monospace"
- if pipeline.latest?
%span.label.label-success.has-tooltip{ title: 'Latest build for this branch' } latest
- if pipeline.triggered?
@@ -28,7 +30,7 @@
%p.commit-title
- if commit = pipeline.commit
= author_avatar(commit, size: 20)
- = link_to_gfm truncate(commit.title, length: 60), namespace_project_commit_path(@project.namespace, @project, commit.id), class: "commit-row-message"
+ = link_to_gfm truncate(commit.title, length: 60), namespace_project_commit_path(pipeline.project.namespace, pipeline.project, commit.id), class: "commit-row-message"
- else
Cant find HEAD commit for this branch
@@ -39,21 +41,21 @@
- status = stages_status[stage]
- tooltip = "#{stage.titleize}: #{status || 'not found'}"
- if status
- = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id, anchor: stage), class: "has-tooltip ci-status-icon-#{status}", title: tooltip do
+ = link_to namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id, anchor: stage), class: "has-tooltip ci-status-icon-#{status}", title: tooltip do
= ci_icon_for_status(status)
- else
.light.has-tooltip{ title: tooltip }
\-
%td
- - if pipeline.started_at && pipeline.finished_at
+ - if pipeline.duration
%p.duration
= custom_icon("icon_timer")
- = duration_in_numbers(pipeline.finished_at, pipeline.started_at)
+ = duration_in_numbers(pipeline.duration)
- 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
@@ -69,7 +71,7 @@
%ul.dropdown-menu.dropdown-menu-align-right
- actions.each do |build|
%li
- = link_to play_namespace_project_build_path(@project.namespace, @project, build), method: :post, rel: 'nofollow' do
+ = link_to play_namespace_project_build_path(pipeline.project.namespace, pipeline.project, build), method: :post, rel: 'nofollow' do
= icon("play")
%span= build.name.humanize
- if artifacts.present?
@@ -80,15 +82,15 @@
%ul.dropdown-menu.dropdown-menu-align-right
- artifacts.each do |build|
%li
- = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, build), rel: 'nofollow' do
+ = link_to download_namespace_project_build_artifacts_path(pipeline.project.namespace, pipeline.project, build), rel: 'nofollow' do
= icon("download")
%span Download '#{build.name}' artifacts
- - if can?(current_user, :update_pipeline, @project)
+ - if can?(current_user, :update_pipeline, pipeline.project)
.cancel-retry-btns.inline
- if pipeline.retryable?
- = link_to retry_namespace_project_pipeline_path(@project.namespace, @project, pipeline.id), class: 'btn has-tooltip', title: "Retry", method: :post do
+ = link_to retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn has-tooltip', title: "Retry", method: :post do
= icon("repeat")
- if pipeline.cancelable?
- = link_to cancel_namespace_project_pipeline_path(@project.namespace, @project, pipeline.id), class: 'btn btn-remove has-tooltip', title: "Cancel", method: :post do
+ = link_to cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-remove has-tooltip', title: "Cancel", method: :post do
= icon("remove")
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 3ad866bb2f1a6fa1a913580df00fb0d3e804300a..29d767e776967b2b01d960b1edb512bea203f64e 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -56,10 +56,10 @@
= pluralize(@commit.pipelines.count, 'pipeline')
= link_to builds_namespace_project_commit_path(@project.namespace, @project, @commit.id), class: "ci-status-link ci-status-icon-#{@commit.status}" do
= ci_icon_for_status(@commit.status)
- = ci_label_for_status(@commit.status)
- - if @commit.pipelines.duration
- in
- = time_interval_in_words @commit.pipelines.duration
+ %span.ci-status-label
+ = ci_label_for_status(@commit.status)
+ in
+ = time_interval_in_words @commit.pipelines.total_duration
.commit-box.content-block
%h3.commit-title
diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml
index 640abdb993fa09c441c23f63c7e6a5a37cb1d6ad..20a85148ab5cdf06b20bf641867a6f3fc5c68fef 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 0000000000000000000000000000000000000000..29f4ef8f49e10edf3be8f17959eeafd62a300590
--- /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 8fbd89100ca5e23c1bd42d4dde9f5f92e5957972..ad2eb3e504f50f8f651be4ab2022dd9c199acb09 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 2d6a370b8482b4a1e1b916f3c10934c5278776c8..7042e9f1fc97310885b8e8c0cdc7b9b1ea5cd034 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 ab5463ba89d791e65acb4ca135c3fe8da4bb19d1..f1d2d4bf2689d3cb3245ca9d4a68d14e2addeaf3 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 0000000000000000000000000000000000000000..584c0fa18ae4eb8468980d7a7a19d3ac302cb4d2
--- /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/group_links/index.html.haml b/app/views/projects/group_links/index.html.haml
index 2b904544f28fab5c5eb35323768e9529ae79d11a..ca700cb3a3b90b807c37dc2db2191a54c7425bea 100644
--- a/app/views/projects/group_links/index.html.haml
+++ b/app/views/projects/group_links/index.html.haml
@@ -17,6 +17,13 @@
.select-wrapper
= select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
%span.caret
+ .form-group
+ = label_tag :expires_at, 'Access expiration date', class: 'label-light'
+ .clearable-input
+ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date'
+ %i.clear-icon.js-clear-input
+ .help-block
+ On this date, all users in the group will automatically lose access to this project.
= submit_tag "Share", class: "btn btn-create"
.col-lg-9.col-lg-offset-3
%hr
@@ -35,6 +42,10 @@
= group.name
%br
up to #{group_link.human_access}
+ - if group_link.expires?
+ ·
+ %span{ class: ('text-warning' if group_link.expires_soon?) }
+ expires in #{distance_of_time_in_words_to_now(group_link.expires_at)}
.pull-right
= link_to namespace_project_group_link_path(@project.namespace, @project, group_link), method: :delete, class: "btn btn-transparent" do
%span.sr-only disable sharing
diff --git a/app/views/projects/hooks/_project_hook.html.haml b/app/views/projects/hooks/_project_hook.html.haml
index 8151187d49961334912a881214bff77bfb32af86..3fcf1692e09e9068d1e1f50a39a0c297b23bfaec 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 60b45115b73bc69746ba914d9d7cd7ca25431d5b..b6cb559afcbaad942cac010634729b137162efab 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/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml
index 53dd300c35c081945b6b4e2097bd04ba1fdfae08..d070979bcfe6bd06d59d866f7511c1f6fbbbbc2e 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 598bd743676eb211dec31d0f3da2e0a54fc10f08..00bd4e143dff1ad3d0a1a6a944867815c5e433a1 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 269198adf913cacc43c9d92deaa78cff51ab1128..9d8b4cc56be7403ee4236e7ecea98da0badaec8a 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
@@ -14,6 +16,9 @@
- if @merge_request.open?
.pull-right
- if @merge_request.source_branch_exists?
+ - if koding_enabled? && @repository.koding_yml
+ = link_to koding_project_url(@merge_request.source_project, @merge_request.source_branch, @merge_request.commits.first.short_id), class: "btn inline btn-grouped btn-sm", target: '_blank' do
+ Run in IDE (Koding)
= link_to "#modal_merge_info", class: "btn inline btn-grouped btn-sm", "data-toggle" => "modal" do
Check out branch
@@ -45,24 +50,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 +95,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 0000000000000000000000000000000000000000..a524936f73cb3179a97516ef01dfb006e7d7991e
--- /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 0000000000000000000000000000000000000000..457c467fba9825e161cccbad4fd94686b241eb7c
--- /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 0000000000000000000000000000000000000000..19c7da4b5e39a6a9c1ccbfd2151b76e88ef5e286
--- /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 0000000000000000000000000000000000000000..2e6f67c2eaf867c0d5aa94e255fd0dfb5458c1e1
--- /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 0000000000000000000000000000000000000000..78bd4133ea292416bf3b683b3bce89220be47b28
--- /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 81de60f116c2cb7a3b641d00432d547a17adf104..808ef7fed27d876911458b94ec8c8a2562f0abec 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 0000000000000000000000000000000000000000..afe3f3430c689f22f332274135cdb5be7e3e19f7
--- /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/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml
index 19b5d0ff0664f5b64a8acb04e372fe0ecf725e4b..7794d6d7df2ffadd052c924af87fed2626663b9d 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 dc18f715f25b25b0b6c9d9d07be1e859262c345c..6f5ee5f16c5a5e70921bd18759853873c4e0e9e5 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 d9efe81701f222fb81d25b86c972414b420a5198..ea618263a4a83dadc15ff23ce2ff5415623eaa22 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 f000cc38a653a3b34dfa875f42e0fcc27926eac4..af3096f04d97d4c43d93d09b7d437bdc811a87fd 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/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml
index 7c61ba750fe5e0e0fe14587e0f6c025fcfe84c41..402f5b52f5e5d68da8771dcdf735ff396c896ee9 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 25466e7562e04e8bf19d92e1190f6c3fc516e12b..6c14f48d41bcf4265a4c322ea4edeeaaaf6d2c25 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
+ = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1
+ - if supports_slash_commands
+ and
+ = link_to 'slash commands', help_page_path('user/project/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 71da8ac9d7c1e582cb4ac456c099564dd47678ff..d2ac1ce2b9a9c001581d0e1834f9fb69a0140f02 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/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 8289aefcde755c31ecf5bdef79e988a19bae03fb..063e83a407aca98225f8d9a52f2a7cb463305ee4 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -9,7 +9,7 @@
= link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace"
- if @pipeline.duration
in
- = time_interval_in_words @pipeline.duration
+ = time_interval_in_words(@pipeline.duration)
.pull-right
= link_to namespace_project_pipeline_path(@project.namespace, @project, @pipeline), class: "ci-status ci-#{@pipeline.status}" do
diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml
index 978c4dfc5ec92af04008bf2241d51b32875d4a9b..fa8cbf717337b1c63a79f5ffc3ead340aff05e6c 100644
--- a/app/views/projects/project_members/_new_project_member.html.haml
+++ b/app/views/projects/project_members/_new_project_member.html.haml
@@ -14,5 +14,14 @@
Read more about role permissions
%strong= link_to "here", help_page_path("user/permissions"), class: "vlink"
+ .form-group
+ = f.label :expires_at, 'Access expiration date', class: 'control-label'
+ .col-sm-10
+ .clearable-input
+ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date'
+ %i.clear-icon.js-clear-input
+ .help-block
+ On this date, the user(s) will automatically lose access to this project.
+
.form-actions
= f.submit 'Add users to project', class: "btn btn-create"
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 9031f01b496d51c94e62ee659843436b4fc572d3..9d063b3081f878a40c176954ac8ac18ce51cb892 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -1,6 +1,6 @@
- page_title "Members"
-.project-members-page.prepend-top-default
+.project-members-page.js-project-members-page.prepend-top-default
- if can?(current_user, :admin_project_member, @project)
.panel.panel-default
.panel-heading
diff --git a/app/views/projects/project_members/update.js.haml b/app/views/projects/project_members/update.js.haml
index 45f8ef890606e95ce6e022b3b00664d4b2921351..833954bc0391636933a77a979e25566a018b00db 100644
--- a/app/views/projects/project_members/update.js.haml
+++ b/app/views/projects/project_members/update.js.haml
@@ -1,2 +1,3 @@
:plain
$("##{dom_id(@project_member)}").replaceWith('#{escape_javascript(render('shared/members/member', member: @project_member))}');
+ new MemberExpirationDate();
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 85d0c494ba81463846bddaa7452ba4e85c21c030..e95a3b1b4c368810e70e0197d3620826df36e0c9 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,23 @@
%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' }})
+ .merge_access_levels-container
+ = dropdown_tag('Select',
+ options: { toggle_class: 'js-allowed-to-merge wide',
+ dropdown_class: 'dropdown-menu-selectable',
+ 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' }})
+ .push_access_levels-container
+ = dropdown_tag('Select',
+ options: { toggle_class: 'js-allowed-to-push wide',
+ dropdown_class: 'dropdown-menu-selectable',
+ 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 e2e01ee78f8ee5f3623c67d7e0aba46ca019edd9..0628134b1bb781058db0f243019071a4b6d21fc0 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 0000000000000000000000000000000000000000..d6044aacaec851bb06cbb5f921898e0dbdaccd83
--- /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/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml
index 835398b6f9895ca0ad4251664ecc67807955d5a9..33d5cbff42069036094b23d9ef39901ee25b3700 100644
--- a/app/views/projects/releases/edit.html.haml
+++ b/app/views/projects/releases/edit.html.haml
@@ -1,18 +1,20 @@
+- @no_container = true
- page_title "Edit", @tag.name, "Tags"
= render "projects/commits/head"
-.row-content-block
- .oneline
- .title
- Release notes for tag
- %strong #{@tag.name}
+%div{ class: container_class }
+ .sub-header-block.no-bottom-space
+ .oneline
+ .title
+ Release notes for tag
+ %strong #{@tag.name}
+
-.prepend-top-default
= form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f|
= 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 your release notes or drag files here..."
= render 'projects/notes/hints'
.error-alert
- .form-actions.prepend-top-default
+ .prepend-top-default
= f.submit 'Save changes', class: 'btn btn-save'
= link_to "Cancel", namespace_project_tag_path(@project.namespace, @project, @tag.name), class: "btn btn-default btn-cancel"
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index a666d07e9ebac91030631e50cc616623b4c95481..340e159c87412d2330cb728889e653798a8d30f9 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -64,10 +64,12 @@
%li.missing
= link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml') do
Set Up CI
+
%li.project-repo-buttons-right
.project-repo-buttons.project-right-buttons
- if current_user
= render 'shared/members/access_request_buttons', source: @project
+ = render "projects/buttons/koding"
.btn-group.project-repo-btn-group
= render "projects/buttons/download"
@@ -86,4 +88,4 @@
Archived project! Repository is read-only
%div{class: "project-show-#{default_project_view}"}
- = render default_project_view
\ No newline at end of file
+ = render default_project_view
diff --git a/app/views/shared/icons/_icon_play.svg b/app/views/shared/icons/_icon_play.svg
new file mode 100644
index 0000000000000000000000000000000000000000..80a6d41dbf651c6295f1e62e5e44220e7e6d457f
--- /dev/null
+++ b/app/views/shared/icons/_icon_play.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/views/shared/icons/_next_discussion.svg b/app/views/shared/icons/_next_discussion.svg
new file mode 100644
index 0000000000000000000000000000000000000000..43559a60cb0f7ca43c92321edcb518e3fb141cdd
--- /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 0b7fa8c7d06d34a5cd7aa36683aedd4aae4d6248..4f8ea7e7cef830dd668b60b79e9d35b2a4026587 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 c30bdb0ae913279c1ecfa1c027b4d111b1fcc6c5..5ea020467ee92234995b13282de9ab8f5ab81630 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'), tabindex: -1
+ 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 4e280c371acc253702982ad989cf8860e59b2377..c0dc63be2bfb1a73cc92b4515beee2ae1cb35468 100644
--- a/app/views/shared/issuable/_label_page_default.html.haml
+++ b/app/views/shared/issuable/_label_page_default.html.haml
@@ -2,8 +2,16 @@
- 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 +20,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/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 8e2fcbdfab8c24090ace207c8a4e9c8c53d63415..c1b50e65af5f094ecbe34559083bc7c3aea8ecc6 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -109,7 +109,7 @@
- if issuable.project.labels.any?
.block.labels
- .sidebar-collapsed-icon
+ .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(issuable.labels_array), data: { placement: "left", container: "body" } }
= icon('tags')
%span
= issuable.labels_array.size
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index fc6e206d0821c01ddedc088c0669601cc29d2a34..5f20e4bd42af1dc32ec28128e48f311c250e97fc 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -16,7 +16,7 @@
= button_tag icon('pencil'),
type: 'button',
class: 'btn inline js-toggle-button',
- title: 'Edit access level'
+ title: 'Edit'
- if member.request?
= link_to icon('check inverse'), polymorphic_path([:approve_access_request, member]),
@@ -59,6 +59,10 @@
= time_ago_with_tooltip(member.requested_at)
- else
Joined #{time_ago_with_tooltip(member.created_at)}
+ - if member.expires?
+ ·
+ %span{ class: ('text-warning' if member.expires_soon?) }
+ Expires in #{distance_of_time_in_words_to_now(member.expires_at)}
- else
= image_tag avatar_icon(member.invite_email, 40), class: "avatar s40", alt: ''
@@ -73,8 +77,16 @@
- if show_roles
.edit-member.hide.js-toggle-content
%br
- = form_for member, remote: true do |f|
- .prepend-top-10
- = f.select :access_level, options_for_select(member.class.access_level_roles, member.access_level), {}, class: 'form-control'
+ = form_for member, remote: true, html: { class: 'form-horizontal' } do |f|
+ .form-group
+ = label_tag "member_access_level_#{member.id}", 'Project access', class: 'control-label'
+ .col-sm-10
+ = f.select :access_level, options_for_select(member.class.access_level_roles, member.access_level), {}, class: 'form-control', id: "member_access_level_#{member.id}"
+ .form-group
+ = label_tag "member_expires_at_#{member.id}", 'Access expiration date', class: 'control-label'
+ .col-sm-10
+ .clearable-input
+ = f.text_field :expires_at, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date', id: "member_expires_at_#{member.id}"
+ %i.clear-icon.js-clear-input
.prepend-top-10
= f.submit 'Save', class: 'btn btn-save btn-sm'
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 47ec09f62c604262456d2267470f7f916f8861a8..0c7880320208f9bda6bf6f5a6344e6632314480e 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -1,3 +1,7 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
+ = page_specific_javascript_tag('snippet/snippet_bundle.js')
+
.snippet-form-holder
= form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input" } do |f|
= form_errors(@snippet)
@@ -31,8 +35,3 @@
- else
= link_to "Cancel", snippets_path(@project), class: "btn btn-cancel"
-:javascript
- var editor = ace.edit("editor");
- $(".snippet-form-holder form").submit(function(){
- $(".snippet-file-content").val(editor.getValue());
- });
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index 470dac6d75ba987621b13192d44469ca7a61765e..d2ec6c3ddef72ad2cf0c86bc553063401410d4db 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 cbb8dfb78296b940b85c6d0f5de63e0b11c97284..8f7b42eb351f4e34aee018029474bac896dc1792 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/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb
index c6a5af2809a1232a276d80e37c9529c388eb3e02..1dc7e0adef7bd41773d7e72ebeace9c91fc99424 100644
--- a/app/workers/emails_on_push_worker.rb
+++ b/app/workers/emails_on_push_worker.rb
@@ -33,13 +33,13 @@ class EmailsOnPushWorker
reverse_compare = false
if action == :push
- compare = CompareService.new.execute(project, before_sha, project, after_sha)
+ compare = CompareService.new.execute(project, after_sha, project, before_sha)
diff_refs = compare.diff_refs
return false if compare.same
if compare.commits.empty?
- compare = CompareService.new.execute(project, after_sha, project, before_sha)
+ compare = CompareService.new.execute(project, before_sha, project, after_sha)
diff_refs = compare.diff_refs
reverse_compare = true
diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb
new file mode 100644
index 0000000000000000000000000000000000000000..246c8b6650a07a79111703b463466544f23757f8
--- /dev/null
+++ b/app/workers/remove_expired_group_links_worker.rb
@@ -0,0 +1,7 @@
+class RemoveExpiredGroupLinksWorker
+ include Sidekiq::Worker
+
+ def perform
+ ProjectGroupLink.expired.destroy_all
+ end
+end
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cf765af97ce39e33a0df92f9d9d69a113f33b386
--- /dev/null
+++ b/app/workers/remove_expired_members_worker.rb
@@ -0,0 +1,13 @@
+class RemoveExpiredMembersWorker
+ include Sidekiq::Worker
+
+ def perform
+ Member.expired.find_each do |member|
+ begin
+ Members::AuthorizedDestroyService.new(member).execute
+ rescue => ex
+ logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}")
+ end
+ end
+ end
+end
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index d69d6037053d3da556ab1b80fde4a75a529706b9..61ed1c38ac427f20cbb3a71bdbe49e768c6687c8 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 7d819fe78f83fc24699af26b4806b46b4dda19b0..d2ca8813ab9db2a955b037e3af95df7449f788bf 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -10,6 +10,12 @@ 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)
+
+ project.update_column(:import_error, nil)
+
result = Projects::ImportService.new(project, current_user).execute
if result[:status] == :error
diff --git a/config/application.rb b/config/application.rb
index 4a9ed41cbf88a751b92552933b42f8ff1a761974..4792f6670a817636c44692f78633ed3d2bea39bd 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -85,6 +85,11 @@ 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 << "blob_edit/blob_edit_bundle.js"
+ config.assets.precompile << "snippet/snippet_bundle.js"
config.assets.precompile << "lib/utils/*.js"
config.assets.precompile << "lib/*.js"
config.assets.precompile << "u2f.js"
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index deac3b0f0f97b539dc3d05aadb68e3da711f20d7..7a9376def02634dbb11aa5689b9f9c2a8cb8d9f5 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -293,6 +293,12 @@ Settings.cron_jobs['import_export_project_cleanup_worker']['job_class'] = 'Impor
Settings.cron_jobs['requests_profiles_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['requests_profiles_worker']['cron'] ||= '0 0 * * *'
Settings.cron_jobs['requests_profiles_worker']['job_class'] = 'RequestsProfilesWorker'
+Settings.cron_jobs['remove_expired_members_worker'] ||= Settingslogic.new({})
+Settings.cron_jobs['remove_expired_members_worker']['cron'] ||= '10 0 * * *'
+Settings.cron_jobs['remove_expired_members_worker']['job_class'] = 'RemoveExpiredMembersWorker'
+Settings.cron_jobs['remove_expired_group_links_worker'] ||= Settingslogic.new({})
+Settings.cron_jobs['remove_expired_group_links_worker']['cron'] ||= '10 0 * * *'
+Settings.cron_jobs['remove_expired_group_links_worker']['job_class'] = 'RemoveExpiredGroupLinksWorker'
#
# GitLab Shell
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
index 618dba74151b52275cfdaba7698f82c3ba496710..fc4b0a72addf4190b96ce3e38a2dfc143dcff239 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 1d2db91344f29fc05cd068282e4e38bbc56c831e..e93b640fbc0958a413da4af9ddfc125bfb19a0a9 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -90,6 +90,11 @@ Rails.application.routes.draw do
get 'help/ui' => 'help#ui'
get 'help/*path' => 'help#show', as: :help_page
+ #
+ # Koding route
+ #
+ get 'koding' => 'koding#index'
+
#
# Global snippets
#
@@ -375,6 +380,8 @@ Rails.application.routes.draw do
patch :skip
end
end
+
+ resources :u2f_registrations, only: [:destroy]
end
end
@@ -528,6 +535,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',
@@ -722,7 +734,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
@@ -731,6 +745,7 @@ Rails.application.routes.draw do
post :toggle_award_emoji
post :remove_wip
get :diff_for_path
+ post :resolve_conflicts
end
collection do
@@ -739,6 +754,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 }
@@ -848,6 +870,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
diff --git a/db/fixtures/development/14_builds.rb b/db/fixtures/development/14_builds.rb
index 6441a036e75edf0b69abf3c1f4cb999404c86c13..069d9dd62263af5dbb86c53f5993bc5b0b49f1cc 100644
--- a/db/fixtures/development/14_builds.rb
+++ b/db/fixtures/development/14_builds.rb
@@ -1,9 +1,8 @@
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: 'slack post build', stage: 'notify_build', status: :success },
{ name: 'rspec:linux', stage: 'test', status: :success },
{ name: 'rspec:windows', stage: 'test', status: :success },
{ name: 'rspec:windows', stage: 'test', status: :success },
@@ -12,9 +11,9 @@ class Gitlab::Seeder::Builds
{ name: 'spinach:osx', stage: 'test', status: :canceled },
{ name: 'cucumber:linux', stage: 'test', status: :running },
{ name: 'cucumber:osx', stage: 'test', status: :failed },
- { name: 'slack post test', stage: 'notify_test', status: :success },
{ name: 'staging', stage: 'deploy', environment: 'staging', status: :success },
- { name: 'production', stage: 'deploy', environment: 'production', when: 'manual', status: :success },
+ { name: 'production', stage: 'deploy', environment: 'production', when: 'manual', status: :skipped },
+ { name: 'slack', stage: 'notify', when: 'manual', status: :created },
]
def initialize(project)
@@ -25,25 +24,45 @@ class Gitlab::Seeder::Builds
pipelines.each do |pipeline|
begin
BUILDS.each { |opts| build_create!(pipeline, opts) }
- commit_status_create!(pipeline, name: 'jenkins', status: :success)
-
+ 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)
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 0000000000000000000000000000000000000000..b8ebcdbd156019255d771a1097601d8b90a8698f
--- /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/20160727191041_create_boards.rb b/db/migrate/20160727191041_create_boards.rb
new file mode 100644
index 0000000000000000000000000000000000000000..56afbd4e030004fc0910add68f10b06d9ccaf0e1
--- /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 0000000000000000000000000000000000000000..61d501215f21472717867c75d38e23dab1507a6e
--- /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 0000000000000000000000000000000000000000..b800e6d7283c548e676092463d7ee1a2e2d451ab
--- /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 0000000000000000000000000000000000000000..bcd24fe1566b3e6d5befa78886583ce579d56277
--- /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/20160801163421_add_expires_at_to_member.rb b/db/migrate/20160801163421_add_expires_at_to_member.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8db0fc60c4b687f0730cb4fa154c4691b9c14358
--- /dev/null
+++ b/db/migrate/20160801163421_add_expires_at_to_member.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 AddExpiresAtToMember < 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 :members, :expires_at, :date
+ 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 0000000000000000000000000000000000000000..baf2e70b12769dcd2e07d4b0fb9ca0c1ba7f9c2d
--- /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 0000000000000000000000000000000000000000..7152bd04331a9ee29716a2dd85cfd993d23c5c67
--- /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/20160817133006_add_koding_to_application_settings.rb b/db/migrate/20160817133006_add_koding_to_application_settings.rb
new file mode 100644
index 0000000000000000000000000000000000000000..915d3d78e40ebe9ed09c616a0e4fd605f9fbc850
--- /dev/null
+++ b/db/migrate/20160817133006_add_koding_to_application_settings.rb
@@ -0,0 +1,10 @@
+class AddKodingToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings, :koding_enabled, :boolean
+ add_column :application_settings, :koding_url, :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 0000000000000000000000000000000000000000..61facce665a4e3f9fe114fa8bb721cf547d2a3e4
--- /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/migrate/20160818205718_add_expires_at_to_project_group_links.rb b/db/migrate/20160818205718_add_expires_at_to_project_group_links.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0ed538b0df8303cf67e760b24b92f0f6ce537330
--- /dev/null
+++ b/db/migrate/20160818205718_add_expires_at_to_project_group_links.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 AddExpiresAtToProjectGroupLinks < 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 :project_group_links, :expires_at, :date
+ end
+end
diff --git a/db/migrate/20160819221631_add_index_to_note_discussion_id.rb b/db/migrate/20160819221631_add_index_to_note_discussion_id.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b6e8bb18e7bf098c17d015323e37e4031457de2b
--- /dev/null
+++ b/db/migrate/20160819221631_add_index_to_note_discussion_id.rb
@@ -0,0 +1,14 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddIndexToNoteDiscussionId < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def change
+ add_concurrent_index :notes, :discussion_id
+ end
+end
diff --git a/db/migrate/20160819221833_reset_diff_note_discussion_id_because_it_was_calculated_wrongly.rb b/db/migrate/20160819221833_reset_diff_note_discussion_id_because_it_was_calculated_wrongly.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0c68cf01900f6a76b625fc0e1a324efe8d398d57
--- /dev/null
+++ b/db/migrate/20160819221833_reset_diff_note_discussion_id_because_it_was_calculated_wrongly.rb
@@ -0,0 +1,12 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class ResetDiffNoteDiscussionIdBecauseItWasCalculatedWrongly < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ execute "UPDATE notes SET discussion_id = NULL WHERE discussion_id IS NOT NULL AND type = 'DiffNote'"
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 52ba60ace11da8c81c8b42111668033d1250aa50..6cbc766831b63053fea21075f59085971062854b 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: 20160819221833) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -90,6 +90,8 @@ ActiveRecord::Schema.define(version: 20160810142633) do
t.string "enabled_git_access_protocol"
t.boolean "domain_blacklist_enabled", default: false
t.text "domain_blacklist"
+ t.boolean "koding_enabled"
+ t.string "koding_url"
end
create_table "audit_events", force: :cascade do |t|
@@ -117,6 +119,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"
@@ -533,6 +543,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
@@ -547,6 +570,7 @@ ActiveRecord::Schema.define(version: 20160810142633) do
t.string "invite_token"
t.datetime "invite_accepted_at"
t.datetime "requested_at"
+ t.date "expires_at"
end
add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree
@@ -663,17 +687,22 @@ 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
add_index "notes", ["commit_id"], name: "index_notes_on_commit_id", using: :btree
add_index "notes", ["created_at"], name: "index_notes_on_created_at", using: :btree
+ add_index "notes", ["discussion_id"], name: "index_notes_on_discussion_id", using: :btree
add_index "notes", ["line_code"], name: "index_notes_on_line_code", using: :btree
add_index "notes", ["note"], name: "index_notes_on_note_trigram", using: :gin, opclasses: {"note"=>"gin_trgm_ops"}
add_index "notes", ["noteable_id", "noteable_type"], name: "index_notes_on_noteable_id_and_noteable_type", using: :btree
@@ -758,6 +787,7 @@ ActiveRecord::Schema.define(version: 20160810142633) do
t.datetime "created_at"
t.datetime "updated_at"
t.integer "group_access", default: 30, null: false
+ t.date "expires_at"
end
create_table "project_import_data", force: :cascade do |t|
@@ -897,6 +927,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
@@ -994,6 +1025,7 @@ 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
@@ -1110,12 +1142,16 @@ 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"
add_foreign_key "u2f_registrations", "users"
-end
+end
\ No newline at end of file
diff --git a/doc/README.md b/doc/README.md
index fc51ea911b987c914e052fdc1d0a13c388e987e4..452cf1c50c2ffb571c807c20713bab0560f16f56 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -29,6 +29,7 @@
- [Restart GitLab](administration/restart_gitlab.md) Learn how to restart GitLab and its components.
- [Integration](integration/README.md) How to integrate with systems such as JIRA, Redmine, Twitter.
- [Issue closing](customization/issue_closing.md) Customize how to close an issue from commit messages.
+- [Koding](administration/integration/koding.md) Set up Koding to use with GitLab.
- [Libravatar](customization/libravatar.md) Use Libravatar for user avatars.
- [Log system](administration/logs.md) Log system.
- [Environment Variables](administration/environment_variables.md) to configure GitLab.
diff --git a/doc/administration/integration/koding.md b/doc/administration/integration/koding.md
new file mode 100644
index 0000000000000000000000000000000000000000..a2c358af095254b5a31c49c93cb2903fbc7f6050
--- /dev/null
+++ b/doc/administration/integration/koding.md
@@ -0,0 +1,242 @@
+# Koding & GitLab
+
+> [Introduced][ce-5909] in GitLab 8.11.
+
+This document will guide you through installing and configuring Koding with
+GitLab.
+
+First of all, to be able to use Koding and GitLab together you will need public
+access to your server. This allows you to use single sign-on from GitLab to
+Koding and using vms from cloud providers like AWS. Koding has a registry for
+VMs, called Kontrol and it runs on the same server as Koding itself, VMs from
+cloud providers register themselves to Kontrol via the agent that we put into
+provisioned VMs. This agent is called Klient and it provides Koding to access
+and manage the target machine.
+
+Kontrol and Klient are based on another technology called
+[Kite](https://github.com/koding/kite), that we have written at Koding. Which is a
+microservice framework that allows you to develop microservices easily.
+
+## Requirements
+
+### Hardware
+
+Minimum requirements are;
+
+ - 2 cores CPU
+ - 3G RAM
+ - 10G Storage
+
+If you plan to use AWS to install Koding it is recommended that you use at
+least a `c3.xlarge` instance.
+
+### Software
+
+ - [Git](https://git-scm.com)
+ - [Docker](https://www.docker.com)
+ - [docker-compose](https://www.docker.com/products/docker-compose)
+
+Koding can run on most of the UNIX based operating systems, since it's shipped
+as containerized with Docker support, it can work on any operating system that
+supports Docker.
+
+Required services are:
+
+- **PostgreSQL** - Kontrol and Service DB provider
+- **MongoDB** - Main DB provider the application
+- **Redis** - In memory DB used by both application and services
+- **RabbitMQ** - Message Queue for both application and services
+
+which are also provided as a Docker container by Koding.
+
+
+## Getting Started with Development Versions
+
+
+### Koding
+
+You can run `docker-compose` environment for developing koding by
+executing commands in the following snippet.
+
+```bash
+git clone https://github.com/koding/koding.git
+cd koding
+docker-compose up
+```
+
+This should start koding on `localhost:8090`.
+
+By default there is no team exists in Koding DB. You'll need to create a team
+called `gitlab` which is the default team name for GitLab integration in the
+configuration. To make things in order it's recommended to create the `gitlab`
+team first thing after setting up Koding.
+
+
+### GitLab
+
+To install GitLab to your environment for development purposes it's recommended
+to use GitLab Development Kit which you can get it from
+[here](https://gitlab.com/gitlab-org/gitlab-development-kit).
+
+After all those steps, gitlab should be running on `localhost:3000`
+
+
+## Integration
+
+Integration includes following components;
+
+ - Single Sign On with OAuth from GitLab to Koding
+ - System Hook integration for handling GitLab events on Koding
+ (`project_created`, `user_joined` etc.)
+ - Service endpoints for importing/executing stacks from GitLab to Koding
+ (`Run/Try on IDE (Koding)` buttons on GitLab Projects, Issues, MRs)
+
+As it's pointed out before, you will need public access to this machine that
+you've installed Koding and GitLab on. Better to use a domain but a static IP
+is also fine.
+
+For IP based installation you can use [xip.io](https://xip.io) service which is
+free and provides DNS resolution to IP based requests like following;
+
+ - 127.0.0.1.xip.io -> resolves to 127.0.0.1
+ - foo.bar.baz.127.0.0.1.xip.io -> resolves to 127.0.0.1
+ - and so on...
+
+As Koding needs subdomains for team names; `foo.127.0.0.1.xip.io` requests for
+a running koding instance on `127.0.0.1` server will be handled as `foo` team
+requests.
+
+
+### GitLab Side
+
+You need to enable Koding integration from Settings under Admin Area. To do
+that login with an Admin account and do followings;
+
+ - open [http://127.0.0.1:3000/admin/application_settings](http://127.0.0.1:3000/admin/application_settings)
+ - scroll to bottom of the page until Koding section
+ - check `Enable Koding` checkbox
+ - provide GitLab team page for running Koding instance as `Koding URL`*
+
+* For `Koding URL` you need to provide the gitlab integration enabled team on
+your Koding installation. Team called `gitlab` has integration on Koding out
+of the box, so if you didn't change anything your team on Koding should be
+`gitlab`.
+
+So, if your Koding is running on `http://1.2.3.4.xip.io:8090` your URL needs
+to be `http://gitlab.1.2.3.4.xip.io:8090`. You need to provide the same host
+with your Koding installation here.
+
+
+#### Registering Koding for OAuth integration
+
+We need `Application ID` and `Secret` to enable login to Koding via GitLab
+feature and to do that you need to register running Koding as a new application
+to your running GitLab application. Follow
+[these](http://docs.gitlab.com/ce/integration/oauth_provider.html) steps to
+enable this integration.
+
+Redirect URI should be `http://gitlab.127.0.0.1:8090/-/oauth/gitlab/callback`
+which again you need to _replace `127.0.0.1` with your instance public IP._
+
+Take a copy of `Application ID` and `Secret` that is generated by the GitLab
+application, we will need those on _Koding Part_ of this guide.
+
+
+#### Registering system hooks to Koding (optional)
+
+Koding can take actions based on the events generated by GitLab application.
+This feature is still in progress and only following events are processed by
+Koding at the moment;
+
+ - user_create
+ - user_destroy
+
+All system events are handled but not implemented on Koding side.
+
+To enable this feature you need to provide a `URL` and a `Secret Token` to your
+GitLab application. Open your admin area on your GitLab app from
+[http://127.0.0.1:3000/admin/hooks](http://127.0.0.1:3000/admin/hooks)
+and provide `URL` as `http://gitlab.127.0.0.1:8090/-/api/gitlab` which is the
+endpoint to handle GitLab events on Koding side. Provide a `Secret Token` and
+keep a copy of it, we will need it on _Koding Part_ of this guide.
+
+_(replace `127.0.0.1` with your instance public IP)_
+
+
+### Koding Part
+
+If you followed the steps in GitLab part we should have followings to enable
+Koding part integrations;
+
+ - `Application ID` and `Secret` for OAuth integration
+ - `Secret Token` for system hook integration
+ - Public address of running GitLab instance
+
+
+#### Start Koding with GitLab URL
+
+Now we need to configure Koding with all this information to get things ready.
+If it's already running please stop koding first.
+
+##### From command-line
+
+Replace followings with the ones you got from GitLab part of this guide;
+
+```bash
+cd koding
+docker-compose run \
+ --service-ports backend \
+ /opt/koding/scripts/bootstrap-container build \
+ --host=**YOUR_IP**.xip.io \
+ --gitlabHost=**GITLAB_IP** \
+ --gitlabPort=**GITLAB_PORT** \
+ --gitlabToken=**SECRET_TOKEN** \
+ --gitlabAppId=**APPLICATION_ID** \
+ --gitlabAppSecret=**SECRET**
+```
+
+##### By updating configuration
+
+Alternatively you can update `gitlab` section on
+`config/credentials.default.coffee` like following;
+
+```
+gitlab =
+ host: '**GITLAB_IP**'
+ port: '**GITLAB_PORT**'
+ applicationId: '**APPLICATION_ID**'
+ applicationSecret: '**SECRET**'
+ team: 'gitlab'
+ redirectUri: ''
+ systemHookToken: '**SECRET_TOKEN**'
+ hooksEnabled: yes
+```
+
+and start by only providing the `host`;
+
+```bash
+cd koding
+docker-compose run \
+ --service-ports backend \
+ /opt/koding/scripts/bootstrap-container build \
+ --host=**YOUR_IP**.xip.io \
+```
+
+#### Enable Single Sign On
+
+Once you restarted your Koding and logged in with your username and password
+you need to activate oauth authentication for your user. To do that
+
+ - Navigate to Dashboard on Koding from;
+ `http://gitlab.**YOUR_IP**.xip.io:8090/Home/my-account`
+ - Scroll down to Integrations section
+ - Click on toggle to turn On integration in GitLab integration section
+
+This will redirect you to your GitLab instance and will ask your permission (
+if you are not logged in to GitLab at this point you will be redirected after
+login) once you accept you will be redirected to your Koding instance.
+
+From now on you can login by using `SIGN IN WITH GITLAB` button on your Login
+screen in your Koding instance.
+
+[ce-5909]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5909
diff --git a/doc/api/README.md b/doc/api/README.md
index f3117815c7cce97387b1970bec66c3f20e6846c3..3e79cce0120115522ce604b45867a95d13aeb2a1 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 8864df03c98c4efcecd2ee8e2fcc82c811478899..dce666445d0911f441cffbac74b8e28ba9ce08ea 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 0000000000000000000000000000000000000000..417962de82d42e1d7a0c99523692d1f98d12c222
--- /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/members.md b/doc/api/members.md
index d002e6eaf89ec27803ea01cb912ea77c1f8a0192..fd6d728dad24b083b08a7461860cbed8419ef7b2 100644
--- a/doc/api/members.md
+++ b/doc/api/members.md
@@ -86,7 +86,8 @@ Example response:
"name": "Raymond Smith",
"state": "active",
"created_at": "2012-10-22T14:13:35Z",
- "access_level": 30
+ "access_level": 30,
+ "expires_at": null
}
```
@@ -106,6 +107,7 @@ POST /projects/:id/members
| `id` | integer/string | yes | The group/project ID or path |
| `user_id` | integer | yes | The user ID of the new member |
| `access_level` | integer | yes | A valid access level |
+| `expires_at` | string | no | A date string in the format YEAR-MONTH-DAY |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/members/:user_id?access_level=30
@@ -141,6 +143,7 @@ PUT /projects/:id/members/:user_id
| `id` | integer/string | yes | The group/project ID or path |
| `user_id` | integer | yes | The user ID of the member |
| `access_level` | integer | yes | A valid access level |
+| `expires_at` | string | no | A date string in the format YEAR-MONTH-DAY |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/members/:user_id?access_level=40
diff --git a/doc/api/oauth2.md b/doc/api/oauth2.md
index 16ef79617c04c0ab974d7ec3ce6dd9181b3b16e0..0b0fc39ec7e22c37bd9762353da7a2f733f63143 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 0000000000000000000000000000000000000000..847408a7f617dec115b5f08de5534f6b43427032
--- /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 9076c48b899decf0a1e1fa54ceeb30c677f1d668..f776424023e8eacebc9fcfd9c36914ececdcc447 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 d90d7aca4fd75f17e85e029beec15d813d190b8e..20cd88c8d20c957d123b9e5184069092012d4fff 100644
--- a/doc/ci/pipelines.md
+++ b/doc/ci/pipelines.md
@@ -67,6 +67,8 @@ use following Markdown code to embed the est coverage report into `README.md`:

```
+The latest successful pipeline will be used to read the test coverage value.
+
[builds]: #builds
[jobs]: yaml/README.md#jobs
[stages]: yaml/README.md#stages
diff --git a/doc/install/installation.md b/doc/install/installation.md
index eb9606934cd87312c383222373d5695d81aed6dc..d4b89fa834562e9c8974bb7bb4e7a29db44a89e5 100644
--- a/doc/install/installation.md
+++ b/doc/install/installation.md
@@ -397,7 +397,7 @@ If you are not using Linux you may have to run `gmake` instead of
cd /home/git
sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-workhorse.git
cd gitlab-workhorse
- sudo -u git -H git checkout v0.7.8
+ sudo -u git -H git checkout v0.7.11
sudo -u git -H make
### Initialize Database and Activate Advanced Features
diff --git a/doc/integration/README.md b/doc/integration/README.md
index ddbd570ac6c9ed37b61e303be07adc7c66b26849..c2fd299db07a1240770433265f83ba42f2e73cca 100644
--- a/doc/integration/README.md
+++ b/doc/integration/README.md
@@ -15,6 +15,7 @@ See the documentation below for details on how to configure these services.
- [Gmail actions buttons](gmail_action_buttons_for_gitlab.md) Adds GitLab actions to messages
- [reCAPTCHA](recaptcha.md) Configure GitLab to use Google reCAPTCHA for new users
- [Akismet](akismet.md) Configure Akismet to stop spam
+- [Koding](../administration/integration/koding.md) Configure Koding to use IDE integration
GitLab Enterprise Edition contains [advanced Jenkins support][jenkins].
diff --git a/doc/integration/akismet.md b/doc/integration/akismet.md
index 06c787cfcc7af63fbb667ef62979011b851ac9ba..a6436b5f9260a4bdee644076b7c7b4ae2d6477be 100644
--- a/doc/integration/akismet.md
+++ b/doc/integration/akismet.md
@@ -22,11 +22,11 @@ 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.
@@ -44,13 +44,13 @@ 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
+spam, you can use the **Submit as ham** button to tell Akismet that it falsely
recognized an entry as spam.

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 also submit this to Akismet. The **Submit as spam** button will only appear
to admin users.

diff --git a/doc/integration/img/submit_issue.png b/doc/integration/img/submit_issue.png
index 9fd9c20f6b36d9845944835e14079efa8d647dc0..5c7896a7eec31ee81297c1b8c1612cdca351fad7 100644
Binary files a/doc/integration/img/submit_issue.png and b/doc/integration/img/submit_issue.png differ
diff --git a/doc/monitoring/performance/influxdb_schema.md b/doc/monitoring/performance/influxdb_schema.md
index 41861860b6d2d23ed9a5ec54fdefdcce18c6fcc0..eff0e29f58d5e856d762d6cc815b73b09b5806c3 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/update/8.10-to-8.11.md b/doc/update/8.10-to-8.11.md
index 84c624cbcb7006c94d7249e9007710cb37fc9d34..9872176356628837b3ab146596e26319020be801 100644
--- a/doc/update/8.10-to-8.11.md
+++ b/doc/update/8.10-to-8.11.md
@@ -20,7 +20,31 @@ cd /home/git/gitlab
sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production
```
-### 3. Get latest code
+### 3. Update Ruby
+
+If you are you running Ruby 2.1.x, you do not _need_ to upgrade Ruby yet, but you should note that support for 2.1.x is deprecated and we will require 2.3.x in 8.13. It's strongly recommended that you upgrade as soon as possible.
+
+You can check which version you are running with `ruby -v`.
+
+Download and compile Ruby:
+
+```bash
+mkdir /tmp/ruby && cd /tmp/ruby
+curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.1.tar.gz
+echo 'c39b4001f7acb4e334cb60a0f4df72d434bef711 ruby-2.3.1.tar.gz' | shasum --check - && tar xzf ruby-2.3.1.tar.gz
+cd ruby-2.3.1
+./configure --disable-install-rdoc
+make
+sudo make install
+```
+
+Install Bundler:
+
+```bash
+sudo gem install bundler --no-ri --no-rdoc
+```
+
+### 4. Get latest code
```bash
sudo -u git -H git fetch --all
@@ -41,15 +65,15 @@ For GitLab Enterprise Edition:
sudo -u git -H git checkout 8-11-stable-ee
```
-### 4. Update gitlab-shell
+### 5. Update gitlab-shell
```bash
cd /home/git/gitlab-shell
sudo -u git -H git fetch --all --tags
-sudo -u git -H git checkout v3.3.3
+sudo -u git -H git checkout v3.4.0
```
-### 5. Update gitlab-workhorse
+### 6. Update gitlab-workhorse
Install and compile gitlab-workhorse. This requires
[Go 1.5](https://golang.org/dl) which should already be on your system from
@@ -62,7 +86,7 @@ sudo -u git -H git checkout v0.7.8
sudo -u git -H make
```
-### 6. Install libs, migrations, etc.
+### 7. Install libs, migrations, etc.
```bash
cd /home/git/gitlab
@@ -84,7 +108,7 @@ sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS
```
-### 7. Update configuration files
+### 8. Update configuration files
#### New configuration options for `gitlab.yml`
@@ -133,12 +157,12 @@ Ensure you're still up-to-date with the latest init script changes:
sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab
-### 8. Start application
+### 9. Start application
sudo service gitlab start
sudo service nginx restart
-### 9. Check application status
+### 10. Check application status
Check if GitLab and its environment are configured correctly:
diff --git a/doc/user/project/description_templates.md b/doc/user/project/description_templates.md
new file mode 100644
index 0000000000000000000000000000000000000000..ea7496af089f5cfcd1e4a41aea702e1e2c6d0ca6
--- /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.
+
+
+
+[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 0000000000000000000000000000000000000000..c41cc77a94c0e888f97e376603f45e6c3feb24ad
Binary files /dev/null and b/doc/user/project/img/description_templates.png differ
diff --git a/doc/user/project/img/issue_board.png b/doc/user/project/img/issue_board.png
new file mode 100644
index 0000000000000000000000000000000000000000..63c269f6dbc41a8f9d8db31f485ae95f012473c5
Binary files /dev/null and b/doc/user/project/img/issue_board.png differ
diff --git a/doc/user/project/img/issue_board_add_list.png b/doc/user/project/img/issue_board_add_list.png
new file mode 100644
index 0000000000000000000000000000000000000000..2b8c10eaa0a4d9c8b58614ff233dcd5c552eea75
Binary files /dev/null and b/doc/user/project/img/issue_board_add_list.png differ
diff --git a/doc/user/project/img/issue_board_search_backlog.png b/doc/user/project/img/issue_board_search_backlog.png
new file mode 100644
index 0000000000000000000000000000000000000000..112ea17153908c157028c255c5adcb854069c51b
Binary files /dev/null and b/doc/user/project/img/issue_board_search_backlog.png differ
diff --git a/doc/user/project/img/issue_board_system_notes.png b/doc/user/project/img/issue_board_system_notes.png
new file mode 100644
index 0000000000000000000000000000000000000000..b69ef034954effa4d2f921e633fbe733fb290ee5
Binary files /dev/null and b/doc/user/project/img/issue_board_system_notes.png differ
diff --git a/doc/user/project/img/issue_board_welcome_message.png b/doc/user/project/img/issue_board_welcome_message.png
new file mode 100644
index 0000000000000000000000000000000000000000..b757faeb230ea8e1ec12bf546834e249fbc85617
Binary files /dev/null and b/doc/user/project/img/issue_board_welcome_message.png differ
diff --git a/doc/user/project/img/koding_build-in-progress.png b/doc/user/project/img/koding_build-in-progress.png
new file mode 100644
index 0000000000000000000000000000000000000000..f8cc81834c4efffa0c2f8db212c931aa3b8b48fd
Binary files /dev/null and b/doc/user/project/img/koding_build-in-progress.png differ
diff --git a/doc/user/project/img/koding_build-logs.png b/doc/user/project/img/koding_build-logs.png
new file mode 100644
index 0000000000000000000000000000000000000000..a04cd5aff9926d86d8d36cc4345d59765a91240c
Binary files /dev/null and b/doc/user/project/img/koding_build-logs.png differ
diff --git a/doc/user/project/img/koding_build-success.png b/doc/user/project/img/koding_build-success.png
new file mode 100644
index 0000000000000000000000000000000000000000..2a0dd296480b7f81b297cc79936873ded03e6d60
Binary files /dev/null and b/doc/user/project/img/koding_build-success.png differ
diff --git a/doc/user/project/img/koding_commit-koding.yml.png b/doc/user/project/img/koding_commit-koding.yml.png
new file mode 100644
index 0000000000000000000000000000000000000000..3e133c50327cd620ae9738d51fc159812d0fb49c
Binary files /dev/null and b/doc/user/project/img/koding_commit-koding.yml.png differ
diff --git a/doc/user/project/img/koding_different-stack-on-mr-try.png b/doc/user/project/img/koding_different-stack-on-mr-try.png
new file mode 100644
index 0000000000000000000000000000000000000000..fd25e32f64847e94c7efe9a4210bb03564e4bf6b
Binary files /dev/null and b/doc/user/project/img/koding_different-stack-on-mr-try.png differ
diff --git a/doc/user/project/img/koding_edit-on-ide.png b/doc/user/project/img/koding_edit-on-ide.png
new file mode 100644
index 0000000000000000000000000000000000000000..fd5aaff75f504a257090ac2d3f7e948bc7aea12e
Binary files /dev/null and b/doc/user/project/img/koding_edit-on-ide.png differ
diff --git a/doc/user/project/img/koding_enable-koding.png b/doc/user/project/img/koding_enable-koding.png
new file mode 100644
index 0000000000000000000000000000000000000000..c0ae0ee99186029119f9025be23d3d34099b7404
Binary files /dev/null and b/doc/user/project/img/koding_enable-koding.png differ
diff --git a/doc/user/project/img/koding_landing.png b/doc/user/project/img/koding_landing.png
new file mode 100644
index 0000000000000000000000000000000000000000..7c629d9b05e6ba1a2f0e6959508d6f0f391a1c26
Binary files /dev/null and b/doc/user/project/img/koding_landing.png differ
diff --git a/doc/user/project/img/koding_open-gitlab-from-koding.png b/doc/user/project/img/koding_open-gitlab-from-koding.png
new file mode 100644
index 0000000000000000000000000000000000000000..c958cf8f22423c9a5044f6bba39f22f844046d00
Binary files /dev/null and b/doc/user/project/img/koding_open-gitlab-from-koding.png differ
diff --git a/doc/user/project/img/koding_run-in-ide.png b/doc/user/project/img/koding_run-in-ide.png
new file mode 100644
index 0000000000000000000000000000000000000000..f91ee0f74cc1d875c300c8f209deae437b96af77
Binary files /dev/null and b/doc/user/project/img/koding_run-in-ide.png differ
diff --git a/doc/user/project/img/koding_run-mr-in-ide.png b/doc/user/project/img/koding_run-mr-in-ide.png
new file mode 100644
index 0000000000000000000000000000000000000000..502817a2a46528752ab391761305316859beffb9
Binary files /dev/null and b/doc/user/project/img/koding_run-mr-in-ide.png differ
diff --git a/doc/user/project/img/koding_set-up-ide.png b/doc/user/project/img/koding_set-up-ide.png
new file mode 100644
index 0000000000000000000000000000000000000000..7f408c980b51b3340561e7aff24fb21671e540a6
Binary files /dev/null and b/doc/user/project/img/koding_set-up-ide.png differ
diff --git a/doc/user/project/img/koding_stack-import.png b/doc/user/project/img/koding_stack-import.png
new file mode 100644
index 0000000000000000000000000000000000000000..2a4e3c87fc855fd33e761b0e1ed7d57098d21ffe
Binary files /dev/null and b/doc/user/project/img/koding_stack-import.png differ
diff --git a/doc/user/project/img/koding_start-build.png b/doc/user/project/img/koding_start-build.png
new file mode 100644
index 0000000000000000000000000000000000000000..52159440f62883217f430394b2d0113d3f4dee8f
Binary files /dev/null and b/doc/user/project/img/koding_start-build.png differ
diff --git a/doc/user/project/img/protected_branches_devs_can_push.png b/doc/user/project/img/protected_branches_devs_can_push.png
index 9c33db36586752a18cf9575fcf6c5385c3d2d1bd..812cc8767b7262f06d76e86509a23c6d0d31a3c1 100644
Binary files a/doc/user/project/img/protected_branches_devs_can_push.png and b/doc/user/project/img/protected_branches_devs_can_push.png differ
diff --git a/doc/user/project/img/protected_branches_list.png b/doc/user/project/img/protected_branches_list.png
index 9f070f7a208d94b6890227d7801f18c944d5f2e6..f33f1b2bdb618c2172bcc3a01010933bf34182d7 100644
Binary files a/doc/user/project/img/protected_branches_list.png and b/doc/user/project/img/protected_branches_list.png differ
diff --git a/doc/user/project/img/protected_branches_page.png b/doc/user/project/img/protected_branches_page.png
new file mode 100644
index 0000000000000000000000000000000000000000..1585dde5b29596cf24d0c34aa506b1c40410b28e
Binary files /dev/null and b/doc/user/project/img/protected_branches_page.png differ
diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md
new file mode 100644
index 0000000000000000000000000000000000000000..cac926b3e28fc055747881809ffb467e8198cf28
--- /dev/null
+++ b/doc/user/project/issue_board.md
@@ -0,0 +1,187 @@
+# Issue board
+
+> [Introduced][ce-5554] in GitLab 8.11.
+
+The GitLab Issue Board is a software project management tool used to plan,
+organize, and visualize a workflow for a feature or product release.
+It can be seen like a light version of a [Kanban] or a [Scrum] board.
+
+Other interesting links:
+
+- [GitLab Issue Board landing page on about.gitlab.com][landing]
+- [YouTube video introduction to Issue Boards][youtube]
+
+## Overview
+
+The Issue Board builds on GitLab's existing issue tracking functionality and
+leverages the power of [labels] by utilizing them as lists of the scrum board.
+
+With the Issue Board you can have a different view of your issues while also
+maintaining the same filtering and sorting abilities you see across the
+issue tracker.
+
+Below is a table of the definitions used for GitLab's Issue Board.
+
+| What we call it | What it means |
+| -------------- | ------------- |
+| **Issue Board** | It represents a different view for your issues. It can have multiple lists with each list consisting of issues represented by cards. |
+| **List** | Each label that exists in the issue tracker can have its own dedicated list. Every list is named after the label it is based on and is represented by a column which contains all the issues associated with that label. You can think of a list like the results you get when you filter the issues by a label in your issue tracker. |
+| **Card** | Every card represents an issue and it is shown under the list for which it has a label. The information you can see on a card consists of the issue number, the issue title, the assignee and the labels associated with it. You can drag cards around from one list to another. Issues inside lists are [ordered by priority](labels.md#prioritize-labels). |
+
+There are three types of lists, the ones you create based on your labels, and
+two default:
+
+- **Backlog** (default): shows all opened issues that do not fall in one of the other lists. Always appears on the very left.
+- **Done** (default): shows all closed issues that do not fall in one of the other lists. Always appears on the very right.
+- Label list: a list based on a label. It shows all opened or closed issues with that label.
+
+
+
+---
+
+In short, here's a list of actions you can take in an Issue Board:
+
+- [Create a new list](#creating-a-new-list).
+- [Delete an existing list](#deleting-a-list).
+- Drag issues between lists.
+- Drag and reorder the lists themselves.
+- Change issue labels on-the-fly while dragging issues between lists.
+- Close an issue if you drag it to the **Done** list.
+- Create a new list from a non-existing label by [creating the label on-the-fly](#creating-a-new-list)
+ within the Issue Board.
+- [Filter issues](#filtering-issues) that appear across your Issue Board.
+
+If you are not able to perform one or more of the things above, make sure you
+have the right [permissions](#permissions).
+
+## First time using the Issue Board
+
+The first time you navigate to your Issue Board, you will be presented with the
+two default lists (**Backlog** and **Done**) and a welcoming message that gives
+you two options. You can either create a predefined set of labels and create
+their corresponding lists to the Issue Board or opt-out and use your own lists.
+
+
+
+If you choose to use and create the predefined lists, they will appear as empty
+because the labels associated to them will not exist up until that moment,
+which means the system has no way of populating them automatically. That's of
+course if the predefined labels don't already exist. If any of them does exist,
+the list will be created and filled with the issues that have that label.
+
+## Creating a new list
+
+Create a new list by clicking on the **Create new list** button at the upper
+right corner of the Issue Board.
+
+
+
+Simply choose the label to create the list from. The new list will be inserted
+at the end of the lists, before **Done**. Moving and reordering lists is as
+easy as dragging them around.
+
+To create a list for a label that doesn't yet exist, simply create the label by
+choosing **Create new label**. The label will be created on-the-fly and it will
+be immediately added to the dropdown. You can now choose it to create a list.
+
+## Deleting a list
+
+To delete a list from the Issue Board use the small trash icon that is present
+in the list's heading. A confirmation dialog will appear for you to confirm.
+
+Deleting a list doesn't have any effect in issues and labels, it's just the
+list view that is removed. You can always add it back later if you need.
+
+## Searching issues in the Backlog list
+
+The very first time you start using the Issue Board, it is very likely your
+issue tracker is already populated with labels and issues. In that case,
+**Backlog** will have all the issues that don't belong to another list, and
+**Done** will have all the closed ones.
+
+For performance and visibility reasons, each list shows the first 20 issues
+by default. If you have more than 20, you have to start scrolling down for the
+next 20 issues to appear. This can be cumbersome if your issue tracker hosts
+hundreds of issues, and for that reason it is easier to search for issues to
+move from **Backlog** to another list.
+
+Start typing in the search bar under the **Backlog** list and the relevant
+issues will appear.
+
+
+
+## Filtering issues
+
+You should be able to use the filters on top of your Issue Board to show only
+the results you want. This is similar to the filtering used in the issue tracker
+since the metadata from the issues and labels are re-used in the Issue Board.
+
+You can filter by author, assignee, milestone and label.
+
+## Creating workflows
+
+By reordering your lists, you can create workflows. As lists in Issue Boards are
+based on labels, it works out of the box with your existing issues. So if you've
+already labeled things with 'Backend' and 'Frontend', the issue will appear in
+the lists as you create them. In addition, this means you can easily move
+something between lists by changing a label.
+
+A typical workflow of using the Issue Board would be:
+
+1. You have [created][create-labels] and [prioritized][label-priority] labels
+ so that you can easily categorize your issues.
+1. You have a bunch of issues (ideally labeled).
+1. You visit the Issue Board and start [creating lists](#creating-a-new-list) to
+ create a workflow.
+1. You move issues around in lists so that your team knows who should be working
+ on what issue.
+1. When the work by one team is done, the issue can be dragged to the next list
+ so someone else can pick up.
+1. When the issue is finally resolved, the issue is moved to the **Done** list
+ and gets automatically closed.
+
+For instance you can create a list based on the label of 'Frontend' and one for
+'Backend'. A designer can start working on an issue by dragging it from
+**Backlog** to 'Frontend'. That way, everyone knows that this issue is now being
+worked on by the designers. Then, once they're done, all they have to do is
+drag it over to the next list, 'Backend', where a backend developer can
+eventually pick it up. Once they’re done, they move it to **Done**, to close the
+issue.
+
+This process can be seen clearly when visiting an issue since with every move
+to another list the label changes and a system not is recorded.
+
+
+
+## Permissions
+
+[Developers and up](../permissions.md) can use all the functionality of the
+Issue Board, that is create/delete lists and drag issues around.
+
+## Tips
+
+A few things to remember:
+
+- The label that corresponds to a list is hidden for issues under that list.
+- Moving an issue between lists removes the label from the list it came from
+ and adds the label from the list it goes to.
+- When moving a card to **Done**, the label of the list it came from is removed
+ and the issue gets closed.
+- An issue can exist in multiple lists if it has more than one label.
+- Lists are populated with issues automatically if the issues are labeled.
+- Clicking on the issue title inside a card will take you to that issue.
+- Clicking on a label inside a card will quickly filter the entire Issue Board
+ and show only the issues from all lists that have that label.
+- Issues inside lists are [ordered by priority][label-priority].
+- For performance and visibility reasons, each list shows the first 20 issues
+ by default. If you have more than 20 issues start scrolling down and the next
+ 20 will appear.
+
+[ce-5554]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5554
+[labels]: ./labels.md
+[scrum]: https://en.wikipedia.org/wiki/Scrum_(software_development)
+[kanban]: https://en.wikipedia.org/wiki/Kanban_(development)
+[create-labels]: ./labels.md#create-new-labels
+[label-priority]: ./labels.md#prioritize-labels
+[landing]: https://about.gitlab.com/solutions/issueboard
+[youtube]: https://www.youtube.com/watch?v=UWsJ8tkHAa8
diff --git a/doc/user/project/koding.md b/doc/user/project/koding.md
new file mode 100644
index 0000000000000000000000000000000000000000..e54587fab682a4f720b34a7283ea92b727a2604a
--- /dev/null
+++ b/doc/user/project/koding.md
@@ -0,0 +1,128 @@
+# Koding & GitLab
+
+> [Introduced][ce-5909] in GitLab 8.11.
+
+This document will guide you through using Koding integration on GitLab in
+detail. For configuring and installing please follow the
+[administrator guide](../../administration/integration/koding.md).
+
+You can use Koding integration to run and develop your projects on GitLab. This
+will allow you and the users to test your project without leaving the browser.
+Koding handles projects as stacks which are basic recipes to define your
+environment for your project. With this integration you can automatically
+create a proper stack template for your projects. Currently auto-generated
+stack templates are designed to work with AWS which requires a valid AWS
+credential to be able to use these stacks. You can find more information about
+stacks and the other providers that you can use on Koding following the
+[Koding documentation][koding-docs].
+
+## Enable Integration
+
+You can enable Koding integration by providing the running Koding instance URL
+in Application Settings under **Admin area > Settings** (`/admin/application_settings`).
+
+
+
+Once enabled you will see `Koding` link on your sidebar which leads you to
+Koding Landing page.
+
+
+
+You can navigate to running Koding instance from here. For more information and
+details about configuring the integration, please follow the
+[administrator guide](../../administration/integration/koding.md).
+
+## Set up Koding on Projects
+
+Once it's enabled, you will see some integration buttons on Project pages,
+Merge Requests etc. To get started working on a specific project you first need
+to create a `.koding.yml` file under your project root. You can easily do that
+by using `Set Up Koding` button which will be visible on every project's
+landing page;
+
+
+
+Once you click this will open a New File page on GitLab with auto-generated
+`.koding.yml` content based on your server and repository configuration.
+
+
+
+
+## Run a project on Koding
+
+If there is `.koding.yml` exists in your project root, you will see
+`Run in IDE (Koding)` button in your project landing page. You can initiate the
+process from here.
+
+
+
+This will open Koding defined in the settings in a new window and will start
+importing the project's stack file.
+
+
+
+You should see the details of your repository imported into your Koding
+instance. Once it's completed it will lead you to the Stack Editor and from
+there you can start using your new stack integrated with your project on your
+GitLab instance. For details about what's next you can follow
+[this guide](https://www.koding.com/docs/creating-an-aws-stack) from step 8.
+
+Once stack initialized you will see the `README.md` content from your project
+in `Stack Build` wizard, this wizard will let you to build the stack and import
+your project into it. **Once it's completed it will automatically open the
+related vm instead of importing from scratch**.
+
+
+
+This will take time depending on the required environment.
+
+
+
+It usually takes ~4 min. to make it ready with a `t2.nano` instance on given
+AWS region. (`t2.nano` is default vm type on auto-generated stack template
+which can be manually changed).
+
+
+
+You can check out the `Build Logs` from this success modal as well.
+
+
+
+You can now `Start Coding`!
+
+
+
+## Try a Merge Request on IDE
+
+It's also possible to try a change on IDE before merging it. This flow only
+enabled if the target project has `.koding.yml` in it's target branch. You
+should see the alternative version of `Run in IDE (Koding)` button in merge
+request pages as well;
+
+
+
+This will again take you to Koding with proper arguments passed, which will
+allow Koding to modify the stack template provided by target branch. You can
+see the difference;
+
+
+
+The flow for the branch stack is also same with the regular project flow.
+
+## Open GitLab from Koding
+
+Since stacks generated with import flow defined in previous steps, they have
+information about the repository they are belonging to. By using this
+information you can access to related GitLab page from stacks on your sidebar
+on Koding.
+
+
+
+## Other links
+
+- [YouTube video on GitLab + Koding workflow][youtube]
+- [Koding documentation][koding-docs]
+
+[ce-5909]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5909
+[youtube]: https://youtu.be/3wei5yv_Ye8
+[koding-docs]: https://www.koding.com/docs
diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md
index 0f7e9eede19ab54a785ce62cb519a06385dd9b57..cf1d9cbe69cc7cd429081802d4edc9411844cf55 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 0000000000000000000000000000000000000000..842e50b14b2c6646139e7d92ccb68b39a7610787
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 0000000000000000000000000000000000000000..83bb60acce2f947730e3add6f0b0a801dcbf40de
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 0000000000000000000000000000000000000000..85428129ac8858fef27d082b39bec420dceae02f
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 0000000000000000000000000000000000000000..ffb96b17b0740373838cea9d446435977d443147
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 0000000000000000000000000000000000000000..2c4ab2f5d531faed671ba8dee2980430e12b739f
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 0000000000000000000000000000000000000000..73f265bb1019dc39257b5ebdda7c352df23b4ec1
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 0000000000000000000000000000000000000000..2559f5f5250e4174e049aaa956e6d972a1350e6d
--- /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 0000000000000000000000000000000000000000..4d7225bd820dd7ac334cd40d7425b30b2ddc2cc6
--- /dev/null
+++ b/doc/user/project/merge_requests/resolve_conflicts.md
@@ -0,0 +1,42 @@
+# 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:
+
+
+
+Clicking this will show a list of files with conflicts, with conflict sections
+highlighted:
+
+
+
+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 is in a UTF-8 compatible encoding
+- 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/protected_branches.md b/doc/user/project/protected_branches.md
index 96d9bdc1b29354b5109511a555b5af5f656cc1c6..f7a686d2ccf4dd7eb3c4b3bde50d31976709b825 100644
--- a/doc/user/project/protected_branches.md
+++ b/doc/user/project/protected_branches.md
@@ -5,6 +5,8 @@ idea of having read or write permission to the repository and branches. To
prevent people from messing with history or pushing code without review, we've
created protected branches.
+## Overview
+
By default, a protected branch does four simple things:
- it prevents its creation, if not already created, from everybody except users
@@ -15,6 +17,11 @@ By default, a protected branch does four simple things:
See the [Changelog](#changelog) section for changes over time.
+>
+>Additional functionality for GitLab Enterprise Edition:
+>
+>- Restrict push and merge access to [certain users][ee-restrict]
+
## Configuring protected branches
To protect a branch, you need to have at least Master permission level. Note
@@ -28,22 +35,41 @@ that the `master` branch is protected by default.
1. From the **Branch** dropdown menu, select the branch you want to protect and
click **Protect**. In the screenshot below, we chose the `develop` branch.
- 
+ 
-1. Once done, the protected branch will appear in the "Already protected" list.
+1. Once done, the protected branch will appear in the "Protected branches" list.

+## Using the Allowed to merge and Allowed to push settings
+
+> [Introduced][ce-5081] in GitLab 8.11.
+
+Since GitLab 8.11, we added another layer of branch protection which provides
+more granular management of protected branches. The "Developers can push"
+option was replaced by an "Allowed to push" setting which can be set to
+allow/prohibit Masters and/or Developers to push to a protected branch.
+
+Using the "Allowed to push" and "Allowed to merge" settings, you can control
+the actions that different roles can perform with the protected branch.
+For example, you could set "Allowed to push" to "No one", and "Allowed to merge"
+to "Developers + Masters", to require _everyone_ to submit a merge request for
+changes going into the protected branch. This is compatible with workflows like
+the [GitLab workflow](../../workflow/gitlab_flow.md).
+
+However, there are workflows where that is not needed, and only protecting from
+force pushes and branch removal is useful. For those workflows, you can allow
+everyone with write access to push to a protected branch by setting
+"Allowed to push" to "Developers + Masters".
+
+You can set the "Allowed to push" and "Allowed to merge" options while creating
+a protected branch or afterwards by selecting the option you want from the
+dropdown list in the "Already protected" area.
-Since GitLab 8.10, we added another layer of branch protection which provides
-more granular management of protected branches. You can now choose the option
-"Developers can merge" so that Developer users can merge a merge request but
-not directly push. In that case, your branches are protected from direct pushes,
-yet Developers don't need elevated permissions or wait for someone with a higher
-permission level to press merge.
+
-You can set this option while creating the protected branch or after its
-creation.
+If you don't choose any of those options while creating a protected branch,
+they are set to "Masters" by default.
## Wildcard protected branches
@@ -66,40 +92,25 @@ Two different wildcards can potentially match the same branch. For example,
In that case, if _any_ of these protected branches have a setting like
"Allowed to push", then `production-stable` will also inherit this setting.
-If you click on a protected branch's name that is created using a wildcard,
-you will be presented with a list of all matching branches:
+If you click on a protected branch's name, you will be presented with a list of
+all matching branches:

-## Restrict the creation of protected branches
-
-Creating a protected branch or a list of protected branches using the wildcard
-feature, not only you are restricting pushes to those branches, but also their
-creation if not already created.
-
-## Error messages when pushing to a protected branch
-
-A user with insufficient permissions will be presented with an error when
-creating or pushing to a branch that's prohibited, either through GitLab's UI:
-
-
-
-or using Git from their terminal:
+## Changelog
-```bash
-remote: GitLab: You are not allowed to push code to protected branches on this project.
-To https://gitlab.example.com/thedude/bowling.git
- ! [remote rejected] staging-stable -> staging-stable (pre-receive hook declined)
-error: failed to push some refs to 'https://gitlab.example.com/thedude/bowling.git'
-```
+**8.11**
-## Changelog
+- Allow creating protected branches that can't be pushed to [gitlab-org/gitlab-ce!5081][ce-5081]
-**8.10.0**
+**8.10**
-- Allow specifying protected branches using wildcards [gitlab-org/gitlab-ce!5081][ce-4665]
+- Allow developers to merge into a protected branch without having push access [gitlab-org/gitlab-ce!4892][ce-4892]
+- Allow specifying protected branches using wildcards [gitlab-org/gitlab-ce!4665][ce-4665]
---
[ce-4665]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4665 "Allow specifying protected branches using wildcards"
+[ce-4892]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4892 "Allow developers to merge into a protected branch without having push access"
[ce-5081]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5081 "Allow creating protected branches that can't be pushed to"
+[ee-restrict]: http://docs.gitlab.com/ee/user/project/protected_branches.html#restricting-push-and-merge-access-to-certain-users
diff --git a/doc/user/project/slash_commands.md b/doc/user/project/slash_commands.md
new file mode 100644
index 0000000000000000000000000000000000000000..11e1574f7724df92a7bda282f3725337500f1356
--- /dev/null
+++ b/doc/user/project/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/doc/web_hooks/web_hooks.md b/doc/web_hooks/web_hooks.md
index d4b28d875cd6d7366cc9af0845145e75d40a492f..33c1a79d59cb4e671478968f296d1eeffda423a7 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 49dec613716562bb6108952fcd3aaaad5d2ab27d..0cf56449de230e7774cfaa82572c0e1f33edeb90 100644
--- a/doc/workflow/README.md
+++ b/doc/workflow/README.md
@@ -2,9 +2,11 @@
- [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)
+- [Issue Board](../user/project/issue_board.md)
- [Keyboard shortcuts](shortcuts.md)
- [File finder](file_finder.md)
- [Labels](../user/project/labels.md)
@@ -13,6 +15,7 @@
- [Project forking workflow](forking_workflow.md)
- [Project users](add-user/add-user.md)
- [Protected branches](../user/project/protected_branches.md)
+- [Slash commands](../user/project/slash_commands.md)
- [Sharing a project with a group](share_with_group.md)
- [Share projects with other groups](share_projects_with_other_groups.md)
- [Web Editor](web_editor.md)
diff --git a/doc/workflow/notifications.md b/doc/workflow/notifications.md
index b4a9c2f3d3e622a837cdc943607261656efecd64..1b49a5c385fa8fba633bf1a2271329b80088c1bf 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/share_projects_with_other_groups.md b/doc/workflow/share_projects_with_other_groups.md
index 4c59f59c5871b094614386a86a99ffd12fe21ad4..8e50cb03e638f49ec9c44d9419b28a8e56d7be1f 100644
--- a/doc/workflow/share_projects_with_other_groups.md
+++ b/doc/workflow/share_projects_with_other_groups.md
@@ -1,22 +1,24 @@
# Share Projects with other Groups
-In GitLab Enterprise Edition you can share projects with other groups.
-This makes it possible to add a group of users to a project with a single action.
+You can share projects with other groups. This makes it possible to add a group of users
+to a project with a single action.
## Groups as collections of users
-In GitLab Community Edition groups are used primarily to [create collections of projects](groups.md).
-In GitLab Enterprise Edition you can also take advantage of the fact that groups define collections of _users_, namely the group members.
+Groups are used primarily to [create collections of projects](groups.md), but you can also
+take advantage of the fact that groups define collections of _users_, namely the group
+members.
## Sharing a project with a group of users
-The primary mechanism to give a group of users, say 'Engineering', access to a project, say 'Project Acme', in GitLab is to make the 'Engineering' group the owner of 'Project Acme'.
-But what if 'Project Acme' already belongs to another group, say 'Open Source'?
-This is where the (Enterprise Edition only) group sharing feature can be of use.
+The primary mechanism to give a group of users, say 'Engineering', access to a project,
+say 'Project Acme', in GitLab is to make the 'Engineering' group the owner of 'Project
+Acme'. But what if 'Project Acme' already belongs to another group, say 'Open Source'?
+This is where the group sharing feature can be of use.
To share 'Project Acme' with the 'Engineering' group, go to the project settings page for 'Project Acme' and use the left navigation menu to go to the 'Groups' section.
-
+
Now you can add the 'Engineering' group with the maximum access level of your choice.
After sharing 'Project Acme' with 'Engineering', the project is listed on the group dashboard.
diff --git a/features/project/commits/branches.feature b/features/project/commits/branches.feature
index 2c17d32154a9788bcb168213358aa305e506c154..88fef674c0cf7c67450a36335556b130f3f70f6e 100644
--- a/features/project/commits/branches.feature
+++ b/features/project/commits/branches.feature
@@ -22,6 +22,7 @@ Feature: Project Commits Branches
@javascript
Scenario: I delete a branch
Given I visit project branches page
+ And I filter for branch improve/awesome
And I click branch 'improve/awesome' delete link
Then I should not see branch 'improve/awesome'
diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb
index dfa2fa75def097b4a4fdf0c2b62827a33c8dc810..e9b45823c67a72e95476541fe638bb7962b8a532 100644
--- a/features/steps/group/members.rb
+++ b/features/steps/group/members.rb
@@ -116,8 +116,8 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps
member = mary_jane_member
page.within "#group_member_#{member.id}" do
- click_button "Edit access level"
- select 'Developer', from: 'group_member_access_level'
+ click_button 'Edit'
+ select 'Developer', from: "member_access_level_#{member.id}"
click_on 'Save'
end
end
diff --git a/features/steps/project/commits/branches.rb b/features/steps/project/commits/branches.rb
index 4bfb7e92e99657f01c22b45170ca1a2ce3a2c185..5f9b9e0445e85970786f10a770eb363eeb5543d7 100644
--- a/features/steps/project/commits/branches.rb
+++ b/features/steps/project/commits/branches.rb
@@ -73,6 +73,11 @@ class Spinach::Features::ProjectCommitsBranches < Spinach::FeatureSteps
expect(page).to have_content 'Branch already exists'
end
+ step 'I filter for branch improve/awesome' do
+ fill_in 'branch-search', with: 'improve/awesome'
+ find('#branch-search').native.send_keys(:enter)
+ end
+
step "I click branch 'improve/awesome' delete link" do
page.within '.js-branch-improve\/awesome' do
find('.btn-remove').click
diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb
index daee90b3767d8689d573916e6614ed081b748e6e..e21f76d00d9aae388e4c14cd6f62403bcc853a9e 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 53d1aedf27f22e2305995564db83cb8b332bed2c..9778ff4a6c7d89e5266a31807198df25519686ac 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/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb
index 841d191d55b77244ad7c357eb22b643eef47206a..bb79424ee08aa30e17afaeeeaaf077ad41b7fb38 100644
--- a/features/steps/project/source/browse_files.rb
+++ b/features/steps/project/source/browse_files.rb
@@ -44,7 +44,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
end
step 'I should see its content with new lines preserved at end of file' do
- expect(evaluate_script('blob.editor.getValue()')).to eq "Sample\n\n\n"
+ expect(evaluate_script('ace.edit("editor").getValue()')).to eq "Sample\n\n\n"
end
step 'I click link "Raw"' do
@@ -65,7 +65,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
step 'I can edit code' do
set_new_content
- expect(evaluate_script('blob.editor.getValue()')).to eq new_gitignore_content
+ expect(evaluate_script('ace.edit("editor").getValue()')).to eq new_gitignore_content
end
step 'I edit code' do
@@ -74,7 +74,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
end
step 'I edit code with new lines at end of file' do
- execute_script('blob.editor.setValue("Sample\n\n\n")')
+ execute_script('ace.edit("editor").setValue("Sample\n\n\n")')
end
step 'I fill the new file name' do
@@ -378,7 +378,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
private
def set_new_content
- execute_script("blob.editor.setValue('#{new_gitignore_content}')")
+ execute_script("ace.edit('editor').setValue('#{new_gitignore_content}')")
end
# Content of the gitignore file on the seed repository.
diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb
index f32576d2cb1b6dcbd33acb87adc56eebf0c5ae44..e920f5a706ba44d60cfb5e78bcbab4f82cda9878 100644
--- a/features/steps/project/team_management.rb
+++ b/features/steps/project/team_management.rb
@@ -65,8 +65,8 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps
user = User.find_by(name: 'Dmitriy')
project_member = project.project_members.find_by(user_id: user.id)
page.within "#project_member_#{project_member.id}" do
- click_button "Edit access level"
- select "Reporter", from: "project_member_access_level"
+ click_button 'Edit'
+ select "Reporter", from: "member_access_level_#{project_member.id}"
click_button "Save"
end
end
diff --git a/lib/api/api.rb b/lib/api/api.rb
index d43af3f24e9586edea1c1cbe793297cb59f4d40f..6b8bfbbdae6110e5fa5067653d10987d18b77d67 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 a77afe634f626d96737d9fbba5be9ede41400fbe..b615703df936c11a2f757dfc2ebbd5253284c7e4 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 be5a3484ec8a8143f8fef3a64fb90a312567863c..52bdbcae5a8e68c9e182de535e58ca249cdb2af9 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 0000000000000000000000000000000000000000..f782bcaf7e91cadcd03947271cdaa9c0af8cb154
--- /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 ae74d14a4bbfada5458733e971969e204c9f0b5d..54ce2dcfa578b3100bbff55699354a847530c0f9 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
@@ -96,6 +97,10 @@ module API
member = options[:member] || options[:members].find { |m| m.user_id == user.id }
member.access_level
end
+ expose :expires_at do |user, options|
+ member = options[:member] || options[:members].find { |m| m.user_id == user.id }
+ member.expires_at
+ end
end
class AccessRequester < UserBasic
@@ -129,12 +134,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
@@ -344,7 +351,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.
@@ -498,8 +506,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/members.rb b/lib/api/members.rb
index 2fae83f60b278aace40315c85f401c41789ce776..94c16710d9a5b5cf0fa1ef89c90836ba1a11ebc9 100644
--- a/lib/api/members.rb
+++ b/lib/api/members.rb
@@ -49,6 +49,7 @@ module API
# id (required) - The group/project ID
# user_id (required) - The user ID of the new member
# access_level (required) - A valid access level
+ # expires_at (optional) - Date string in the format YEAR-MONTH-DAY
#
# Example Request:
# POST /groups/:id/members
@@ -72,7 +73,7 @@ module API
conflict!('Member already exists') if source_type == 'group' && member
unless member
- source.add_user(params[:user_id], params[:access_level], current_user)
+ source.add_user(params[:user_id], params[:access_level], current_user: current_user, expires_at: params[:expires_at])
member = source.members.find_by(user_id: params[:user_id])
end
@@ -81,7 +82,7 @@ module API
else
# Since `source.add_user` doesn't return a member object, we have to
# build a new one and populate its errors in order to render them.
- member = source.members.build(attributes_for_keys([:user_id, :access_level]))
+ member = source.members.build(attributes_for_keys([:user_id, :access_level, :expires_at]))
member.valid? # populate the errors
# This is to ensure back-compatibility but 400 behavior should be used
@@ -97,6 +98,7 @@ module API
# id (required) - The group/project ID
# user_id (required) - The user ID of the member
# access_level (required) - A valid access level
+ # expires_at (optional) - Date string in the format YEAR-MONTH-DAY
#
# Example Request:
# PUT /groups/:id/members/:user_id
@@ -107,8 +109,9 @@ module API
required_attributes! [:user_id, :access_level]
member = source.members.find_by!(user_id: params[:user_id])
+ attrs = attributes_for_keys [:access_level, :expires_at]
- if member.update_attributes(access_level: params[:access_level])
+ if member.update_attributes(attrs)
present member.user, with: Entities::Member, member: member
else
# This is to ensure back-compatibility but 400 behavior should be used
diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2aae75c471d337c6919e4506f5511800c1e8c5be
--- /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 6bb70bc8bc39dc19c299000ba6adb69124de97fb..3f63cd678e8f3ae2fa5295911af695f6db7aeeba 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 56c202f129435eedad105f015b8a845afb4f5994..55ec66a6d674a3a7faeed2df452f7459bf0b30e7 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 1840879775666751c702f2bb3b2e0a076ed70e86..b9e718147e10d1e9285c96e0ed582b0f1a023357 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 260ac81f5fab8ea822a250a664d8426f2ab8b7a0..9f3b582a263c0a9d5aa35f1570bf787778742184 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/extracts_path.rb b/lib/extracts_path.rb
index 84688f6646eb849c0e84ee097491404249d63c42..a293fa2752f8ab7878d4cfb7c358049f2b649247 100644
--- a/lib/extracts_path.rb
+++ b/lib/extracts_path.rb
@@ -94,7 +94,9 @@ module ExtractsPath
@options = params.select {|key, value| allowed_options.include?(key) && !value.blank? }
@options = HashWithIndifferentAccess.new(@options)
- @id = Addressable::URI.normalize_component(get_id)
+ @id = params[:id] || params[:ref]
+ @id += "/" + params[:path] unless params[:path].blank?
+
@ref, @path = extract_ref(@id)
@repo = @project.repository
if @options[:extended_sha1].blank?
@@ -116,12 +118,4 @@ module ExtractsPath
def tree
@tree ||= @repo.tree(@commit.id, @path)
end
-
- private
-
- def get_id
- id = params[:id] || params[:ref]
- id += "/" + params[:path] unless params[:path].blank?
- id
- end
end
diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb
index db1704af75ebb26077ce156ea508f78520f8b423..91f0270818a3f2568dbcb2edcf9a810f74ab4f33 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/coverage/report.rb b/lib/gitlab/badge/coverage/report.rb
index 3d56ea3e47a390b6b579121ecf3da12756650582..95d925dc7f3eccb01165f0cd6f1ba2a92b655faa 100644
--- a/lib/gitlab/badge/coverage/report.rb
+++ b/lib/gitlab/badge/coverage/report.rb
@@ -13,8 +13,7 @@ module Gitlab
@job = job
@pipeline = @project.pipelines
- .where(ref: @ref)
- .where(sha: @project.commit(@ref).try(:sha))
+ .latest_successful_for(@ref)
.first
end
diff --git a/lib/gitlab/conflict/file.rb b/lib/gitlab/conflict/file.rb
new file mode 100644
index 0000000000000000000000000000000000000000..dff9e29c6a5f8fdf0562c6ab0616405e802de26d
--- /dev/null
+++ b/lib/gitlab/conflict/file.rb
@@ -0,0 +1,197 @@
+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
+
+ # Don't try to print merge_request or repository.
+ def inspect
+ instance_variables = [:merge_file_result, :their_path, :our_path, :our_mode].map do |instance_variable|
+ value = instance_variable_get("@#{instance_variable}")
+
+ "#{instance_variable}=\"#{value}\""
+ end
+
+ "#<#{self.class} #{instance_variables.join(' ')}>"
+ 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 0000000000000000000000000000000000000000..bbd0427a2c82766707c6079447915148c065930e
--- /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
+
+ begin
+ text.to_json
+ rescue Encoding::UndefinedConversionError
+ raise UnsupportedEncoding
+ end
+
+ 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/current_settings.rb b/lib/gitlab/current_settings.rb
index 735331df66cf4c72acfb0bc0b4c5a3e23c143a0a..27acd817e516511ee304fefd3f465a8d79ad28a1 100644
--- a/lib/gitlab/current_settings.rb
+++ b/lib/gitlab/current_settings.rb
@@ -30,6 +30,7 @@ module Gitlab
signup_enabled: Settings.gitlab['signup_enabled'],
signin_enabled: Settings.gitlab['signin_enabled'],
gravatar_enabled: Settings.gravatar['enabled'],
+ koding_enabled: false,
sign_in_text: nil,
after_sign_up_text: nil,
help_page_text: nil,
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 9f45aefda0f6937eaa1d8e52689e429b4a61e849..6548e6475c60fcd5da5aca3a92fc225f10b5d2e1 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 8bdc89a7751f3f77a8a001b51b57dcf2641e9dd1..50fea1232af72daa78b7226bfdff5d8bdafba414 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 0000000000000000000000000000000000000000..06a783ebc1c2636a9ed63512342b601d4dae3210
--- /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 c8f12577112eaed1562bb49cc7b31b0a0b477090..4f81863da35a917b6976991bb14b9aed87887090 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 cf097e0d0dec32f6f782efeca98be06e30328638..80a146b4a5a96f490b0252b5404232486ad8a4aa 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/diff/position.rb b/lib/gitlab/diff/position.rb
index 2fdcf8d7838d7e572066228abaa5cdd2d976d70a..ecf62dead350fd54c4f45079829a75201e62ee5f 100644
--- a/lib/gitlab/diff/position.rb
+++ b/lib/gitlab/diff/position.rb
@@ -139,13 +139,19 @@ module Gitlab
private
def find_diff_file(repository)
- diffs = Gitlab::Git::Compare.new(
- repository.raw_repository,
- start_sha,
- head_sha
- ).diffs(paths: paths)
+ # We're at the initial commit, so just get that as we can't compare to anything.
+ if Gitlab::Git.blank_ref?(start_sha)
+ compare = Gitlab::Git::Commit.find(repository.raw_repository, head_sha)
+ else
+ compare = Gitlab::Git::Compare.new(
+ repository.raw_repository,
+ start_sha,
+ head_sha
+ )
+ end
+
+ diff = compare.diffs(paths: paths).first
- diff = diffs.first
return unless diff
Gitlab::Diff::File.new(diff, repository: repository, diff_refs: diff_refs)
diff --git a/lib/gitlab/downtime_check/message.rb b/lib/gitlab/downtime_check/message.rb
index 4446e921e0df989d2cd0b7a54dafd84388becfe9..40a4815a9a02ba320d8ab71c4e075ffe96be9f20 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.rb b/lib/gitlab/email/handler.rb
index bd3267e2a80ba54f31753d732b664b19e8838583..5cf9d5ebe28d67be964a8fc4cd9beac8e106dac6 100644
--- a/lib/gitlab/email/handler.rb
+++ b/lib/gitlab/email/handler.rb
@@ -4,7 +4,8 @@ require 'gitlab/email/handler/create_issue_handler'
module Gitlab
module Email
module Handler
- HANDLERS = [CreateNoteHandler, CreateIssueHandler]
+ # The `CreateIssueHandler` feature is disabled for the time being.
+ HANDLERS = [CreateNoteHandler]
def self.for(mail, mail_key)
HANDLERS.find do |klass|
diff --git a/lib/gitlab/email/handler/base_handler.rb b/lib/gitlab/email/handler/base_handler.rb
index b7ed11cb6389e06cdbeccd81a4b0134534d5636e..7cccf465334f5ceacfc68b2809ca704e73ac7aab 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/github_import/importer.rb b/lib/gitlab/github_import/importer.rb
index 9ddc8905bd6149f32bf58fbb896afc45787613c5..02ffb43d89bd42e3c4da8ca049cd02d9e5cc7bfb 100644
--- a/lib/gitlab/github_import/importer.rb
+++ b/lib/gitlab/github_import/importer.rb
@@ -3,12 +3,13 @@ module Gitlab
class Importer
include Gitlab::ShellAdapter
- attr_reader :client, :project, :repo, :repo_url
+ attr_reader :client, :errors, :project, :repo, :repo_url
def initialize(project)
@project = project
@repo = project.import_source
@repo_url = project.import_url
+ @errors = []
if credentials
@client = Client.new(credentials[:user])
@@ -18,8 +19,14 @@ module Gitlab
end
def execute
- import_labels && import_milestones && import_issues &&
- import_pull_requests && import_wiki
+ import_labels
+ import_milestones
+ import_issues
+ import_pull_requests
+ import_wiki
+ handle_errors
+
+ true
end
private
@@ -28,22 +35,37 @@ module Gitlab
@credentials ||= project.import_data.credentials if project.import_data
end
+ def handle_errors
+ return unless errors.any?
+
+ project.update_column(:import_error, {
+ message: 'The remote data could not be fully imported.',
+ errors: errors
+ }.to_json)
+ end
+
def import_labels
labels = client.labels(repo, per_page: 100)
- labels.each { |raw| LabelFormatter.new(project, raw).create! }
- true
- rescue ActiveRecord::RecordInvalid => e
- raise Projects::ImportService::Error, e.message
+ labels.each do |raw|
+ begin
+ LabelFormatter.new(project, raw).create!
+ rescue => e
+ errors << { type: :label, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message }
+ end
+ end
end
def import_milestones
milestones = client.milestones(repo, state: :all, per_page: 100)
- milestones.each { |raw| MilestoneFormatter.new(project, raw).create! }
- true
- rescue ActiveRecord::RecordInvalid => e
- raise Projects::ImportService::Error, e.message
+ milestones.each do |raw|
+ begin
+ MilestoneFormatter.new(project, raw).create!
+ rescue => e
+ errors << { type: :milestone, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message }
+ end
+ end
end
def import_issues
@@ -53,15 +75,15 @@ module Gitlab
gh_issue = IssueFormatter.new(project, raw)
if gh_issue.valid?
- issue = gh_issue.create!
- apply_labels(issue)
- import_comments(issue) if gh_issue.has_comments?
+ begin
+ issue = gh_issue.create!
+ apply_labels(issue)
+ import_comments(issue) if gh_issue.has_comments?
+ rescue => e
+ errors << { type: :issue, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message }
+ end
end
end
-
- true
- rescue ActiveRecord::RecordInvalid => e
- raise Projects::ImportService::Error, e.message
end
def import_pull_requests
@@ -77,14 +99,12 @@ module Gitlab
apply_labels(merge_request)
import_comments(merge_request)
import_comments_on_diff(merge_request)
- rescue ActiveRecord::RecordInvalid => e
- raise Projects::ImportService::Error, e.message
+ rescue => e
+ errors << { type: :pull_request, url: Gitlab::UrlSanitizer.sanitize(pull_request.url), errors: e.message }
ensure
clean_up_restored_branches(pull_request)
end
end
-
- true
end
def restore_source_branch(pull_request)
@@ -98,7 +118,7 @@ module Gitlab
def remove_branch(name)
project.repository.delete_branch(name)
rescue Rugged::ReferenceError
- nil
+ errors << { type: :remove_branch, name: name }
end
def clean_up_restored_branches(pull_request)
@@ -112,9 +132,10 @@ module Gitlab
issue = client.issue(repo, issuable.iid)
if issue.labels.count > 0
- label_ids = issue.labels.map do |raw|
- Label.find_by(LabelFormatter.new(project, raw).attributes).try(:id)
- end
+ label_ids = issue.labels
+ .map { |raw| LabelFormatter.new(project, raw).attributes }
+ .map { |attrs| Label.find_by(attrs).try(:id) }
+ .compact
issuable.update_attribute(:label_ids, label_ids)
end
@@ -132,8 +153,12 @@ module Gitlab
def create_comments(issuable, comments)
comments.each do |raw|
- comment = CommentFormatter.new(project, raw)
- issuable.notes.create!(comment.attributes)
+ begin
+ comment = CommentFormatter.new(project, raw)
+ issuable.notes.create!(comment.attributes)
+ rescue => e
+ errors << { type: :comment, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message }
+ end
end
end
@@ -143,16 +168,12 @@ module Gitlab
gitlab_shell.import_repository(project.repository_storage_path, wiki.path_with_namespace, wiki.import_url)
project.update_attribute(:wiki_enabled, true)
end
-
- true
rescue Gitlab::Shell::Error => e
# GitHub error message when the wiki repo has not been created,
# this means that repo has wiki enabled, but have no pages. So,
# we can skip the import.
if e.message !~ /repository not exported/
- raise Projects::ImportService::Error, e.message
- else
- true
+ errors << { type: :wiki, errors: e.message }
end
end
end
diff --git a/lib/gitlab/github_import/pull_request_formatter.rb b/lib/gitlab/github_import/pull_request_formatter.rb
index b84538a090a268a0e97af332dd2528579941ecb1..04aa3664f640388dfa0de539512496f60048f594 100644
--- a/lib/gitlab/github_import/pull_request_formatter.rb
+++ b/lib/gitlab/github_import/pull_request_formatter.rb
@@ -56,6 +56,10 @@ module Gitlab
end
end
+ def url
+ raw_data.url
+ end
+
private
def assigned?
diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb
index 41fcd971c228c1341ba807c24e389d86d2a2c3c9..3d1ba33ec68d69378450449758b211fe1b2a7509 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 f23d67e1e3849f4ab512c2d390110605009988d9..bd0afe53c5166da5abc2515a57f0a8d82d64fb8d 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 e61670f491cfd5d8b58f1366fa834b3680aefe99..b4493bf44d28eacde9d5828ab30db6598aa48ed7 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 a1240fd33eee739d2e62990f72e161f5cb6a0c13..f9dd8e419128e1330bed49c974779cb29775fb4c 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 968f32189505326c02c3c32a5a08498b83e46875..7bc16181be624cac25ed051d8b27b94a20a04440 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/middleware/rails_queue_duration.rb b/lib/gitlab/middleware/rails_queue_duration.rb
index 56608b1b2765dd18b9c480d88d2b9689b503dd39..5d2d7d0026cd6273c31fbfb6bf48b3f4555e7bbf 100644
--- a/lib/gitlab/middleware/rails_queue_duration.rb
+++ b/lib/gitlab/middleware/rails_queue_duration.rb
@@ -11,7 +11,7 @@ module Gitlab
def call(env)
trans = Gitlab::Metrics.current_transaction
- proxy_start = env['HTTP_GITLAB_WORHORSE_PROXY_START'].presence
+ proxy_start = env['HTTP_GITLAB_WORKHORSE_PROXY_START'].presence
if trans && proxy_start
# Time in milliseconds since gitlab-workhorse started the request
trans.set(:rails_queue_duration, Time.now.to_f * 1_000 - proxy_start.to_f / 1_000_000)
diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb
new file mode 100644
index 0000000000000000000000000000000000000000..60d35be259912e34e905c337e0ed321796e9ac2a
--- /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 0000000000000000000000000000000000000000..50b0937d267cdf870bb24cf42871155979fb22e2
--- /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 0000000000000000000000000000000000000000..a672e5e485528a58a1d1f562f95f80b4b4fa3ea3
--- /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 760ff3e614a699b6055fb7573add67df79fca1e5..7ebec8e2cff245ee8e12b6512225243d6e3a246b 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,29 +47,40 @@ 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 category_directory(category)
- File.join(base_dir, categories[category])
+ def by_category(category, project = nil)
+ directory = category_directory(category)
+ files = finder(project).list_files_for(directory)
+
+ files.map { |f| new(f, project) }
end
- private
+ def category_directory(category)
+ return base_dir unless category.present?
- def select_directory(file_name)
- categories.keys.find do |category|
- File.exist?(File.join(category_directory(category), file_name))
- end
+ File.join(base_dir, categories[category])
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
+ # 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 filter_regex
- @filter_reges ||= /#{Regexp.escape(extension)}\z/
+ 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
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 0000000000000000000000000000000000000000..473b05257c6256cdeeebdf40aeeeaadb1f4320a7
--- /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 0000000000000000000000000000000000000000..831da45191f80dc1f4ebd1b05db48421f034551c
--- /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 0000000000000000000000000000000000000000..22c39436cb2465dc290a0b85d6717f31b92eea26
--- /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 964fbfd4de330ef82019a615d67be438dcb2eedf..8d2a9d2305ca7f24a427cae8ff677761a29efe96 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 7f480fe33c0f51aeb653313517c2829035f542bd..8d1a1ed54c9db399cc4c64f7a608baf9bf1ab36a 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 0000000000000000000000000000000000000000..c6fa8d3eafcc913003b17f7dad0e60fdaf1b6d16
--- /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 0000000000000000000000000000000000000000..f826c02f3b53ad9f07e20277929a8d8dca9c0391
--- /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 c55a7fc4d3d371e1a4bba1fa823b5f0be1e26975..9858d2e7d83b64404a5a8de49a7b74fab0a54821 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/lib/gitlab/utils.rb b/lib/gitlab/utils.rb
index d13fe0ef8a9df2c8ac823caa97d0e5d363ae0f9d..e59ead5d76c6c45cdb91a4c1719073b157d1a9c5 100644
--- a/lib/gitlab/utils.rb
+++ b/lib/gitlab/utils.rb
@@ -7,7 +7,7 @@ module Gitlab
# @param cmd [Array]
# @return [Boolean]
def system_silent(cmd)
- Popen::popen(cmd).last.zero?
+ Popen.popen(cmd).last.zero?
end
def force_utf8(str)
diff --git a/spec/controllers/admin/impersonations_controller_spec.rb b/spec/controllers/admin/impersonations_controller_spec.rb
index d5f0b289b5b64993b7b4bec4f58bacfc5e48d015..8be662974a0702b9ef244320d5da582a5a3ad653 100644
--- a/spec/controllers/admin/impersonations_controller_spec.rb
+++ b/spec/controllers/admin/impersonations_controller_spec.rb
@@ -77,6 +77,8 @@ describe Admin::ImpersonationsController do
context "when the impersonator is not blocked" do
it "redirects to the impersonated user's page" do
+ expect(Gitlab::AppLogger).to receive(:info).with("User #{impersonator.username} has stopped impersonating #{user.username}").and_call_original
+
delete :destroy
expect(response).to redirect_to(admin_user_path(user))
diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb
index ed0b7f9e240d07f8f6f4e446e12cb641cb0dd1ec..44128a4336234d99fd98bf6a835b8eb747f62f54 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]
- end
+ context 'users and members' do
+ let!(:user2) { create(:user) }
+ let!(:non_member) { create(:user) }
- describe 'GET #users with project ID' do
+ context 'project members' do
before do
- get(:users, project_id: project.id)
+ sign_in(user)
+ 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)
+ end
- 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
+ let(:body) { JSON.parse(response.body) }
- describe 'GET #users with unknown project' do
- before do
- get(:users, project_id: 'unknown')
+ 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
- it { expect(response).to have_http_status(404) }
- end
- end
-
- context 'group members' do
- let(:group) { create(:group) }
+ describe 'GET #users with unknown project' do
+ before do
+ get(:users, project_id: 'unknown')
+ end
- before do
- sign_in(user)
- group.add_owner(user)
+ it { expect(response).to have_http_status(404) }
+ end
end
- let(:body) { JSON.parse(response.body) }
+ context 'group members' do
+ let(:group) { create(:group) }
- describe 'GET #users with group ID' do
before do
- get(:users, group_id: group.id)
+ sign_in(user)
+ group.add_owner(user)
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 }
+ 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
- describe 'GET #users with unknown group ID' do
+ context 'non-member login for public project' do
+ let!(:project) { create(:project, :public) }
+
before do
- get(:users, group_id: 'unknown')
+ sign_in(non_member)
+ project.team << [user, :master]
end
- it { expect(response).to have_http_status(404) }
- end
- end
+ let(:body) { JSON.parse(response.body) }
- context 'non-member login for public project' do
- let!(:project) { create(:project, :public) }
+ describe 'GET #users with project ID' do
+ before do
+ get(:users, project_id: project.id, current_user: true)
+ end
- before do
- sign_in(non_member)
- project.team << [user, :master]
+ 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
- let(:body) { JSON.parse(response.body) }
-
- describe 'GET #users with project ID' do
+ context 'all users' do
before do
- get(:users, project_id: project.id, current_user: true)
+ 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 2 }
- it { expect(body.map { |u| u['username'] }).to match_array([user.username, non_member.username]) }
+ it { expect(body.size).to eq User.count }
end
- end
- context 'all users' do
- before do
- sign_in(user)
- get(:users)
- end
+ context 'unauthenticated user' do
+ let(:public_project) { create(:project, :public) }
+ let(:body) { JSON.parse(response.body) }
- 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 User.count }
- end
+ it { expect(body).to be_kind_of(Array) }
+ it { expect(body.size).to eq 1 }
+ end
- context 'unauthenticated user' do
- let(:public_project) { create(:project, :public) }
- let(:body) { JSON.parse(response.body) }
+ describe 'GET #users with project' do
+ before do
+ get(:users, project_id: project.id)
+ end
- describe 'GET #users with public project' do
- before do
- public_project.team << [user, :guest]
- get(:users, project_id: public_project.id)
+ it { expect(response).to have_http_status(404) }
end
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 1 }
- end
+ describe 'GET #users with unknown project' do
+ before do
+ get(:users, project_id: 'unknown')
+ end
- describe 'GET #users with project' do
- before do
- get(:users, project_id: project.id)
+ it { expect(response).to have_http_status(404) }
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
- describe 'GET #users with unknown project' do
- before do
- get(:users, project_id: 'unknown')
+ it { expect(response).to have_http_status(404) }
end
- it { expect(response).to have_http_status(404) }
+ 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
- describe 'GET #users with inaccessible group' do
+ context 'author of issuable included' do
before do
- project.team << [user, :guest]
- get(:users, group_id: user.namespace.id)
+ sign_in(user)
end
- it { expect(response).to have_http_status(404) }
- end
+ let(:body) { JSON.parse(response.body) }
- describe 'GET #users with no project' do
- before do
- get(:users)
+ it 'includes the author' do
+ get(:users, author_id: non_member.id)
+
+ expect(body.first["username"]).to eq non_member.username
end
- it { expect(body).to be_kind_of(Array) }
- it { expect(body.size).to eq 0 }
+ 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 'author of issuable included' do
+ context 'projects' do
+ let(:authorized_project) { create(:project) }
+ let(:authorized_search_project) { create(:project, name: 'rugged') }
+
before do
sign_in(user)
+ project.team << [user, :master]
end
- let(:body) { JSON.parse(response.body) }
+ context 'authorized projects' do
+ before do
+ authorized_project.team << [user, :master]
+ end
+
+ describe 'GET #projects with project ID' do
+ before do
+ get(:projects, project_id: project.id)
+ end
+
+ let(:body) { JSON.parse(response.body) }
+
+ it do
+ expect(body).to be_kind_of(Array)
+ expect(body.size).to eq 2
- it 'includes the author' do
- get(:users, author_id: non_member.id)
+ expect(body.first['id']).to eq 0
+ expect(body.first['name_with_namespace']).to eq 'No project'
- expect(body.first["username"]).to eq non_member.username
+ 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
- it 'rejects non existent user ids' do
- get(:users, author_id: 99999)
+ context 'authorized projects and search' do
+ before do
+ authorized_project.team << [user, :master]
+ authorized_search_project.team << [user, :master]
+ end
+
+ 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) }
- expect(body.collect { |u| u['id'] }).not_to include(99999)
+ 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
- end
- context 'skip_users parameter included' do
- before { sign_in(user) }
+ 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 'skips the user IDs passed' do
- get(:users, skip_users: [user, user2].map(&:id))
+ describe 'GET #projects with project ID' do
+ before do
+ get(:projects, project_id: project.id)
+ end
- other_user_ids = [non_member, project.owner, project.creator].map(&:id)
- response_user_ids = JSON.parse(response.body).map { |user| user['id'] }
+ let(:body) { JSON.parse(response.body) }
- expect(response_user_ids).to contain_exactly(*other_user_ids)
+ 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
end
end
end
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 0000000000000000000000000000000000000000..d0ad5e26dbd077725cfc8bf28d546a9483191998
--- /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 0000000000000000000000000000000000000000..9496636e3cc4c58d77d4b09aa4882c33bd1f5f71
--- /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 0000000000000000000000000000000000000000..75a6d39e82c1a84652e750d76dbdfe858e88d99b
--- /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 0000000000000000000000000000000000000000..2bec67375ee50dc01b1deb576e57c4aeb756470f
--- /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
+
+ xcontext "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 0836b71056c7a7d0e44266b3dbb61ba991e494f0..16929767ddf7250f6f70e79d6ef4edd187357d38 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -8,13 +8,13 @@ describe Projects::IssuesController do
describe "GET #index" do
context 'external issue tracker' do
it 'redirects to the external issue tracker' do
- external = double(issues_url: 'https://example.com/issues')
+ external = double(project_path: 'https://example.com/project')
allow(project).to receive(:external_issue_tracker).and_return(external)
controller.instance_variable_set(:@project, project)
get :index, namespace_id: project.namespace.path, project_id: project
- expect(response).to redirect_to('https://example.com/issues')
+ expect(response).to redirect_to('https://example.com/project')
end
end
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 69758494543085e94dd493877311407b40b3fec1..c64c2b075c53c81adac2617da089badcb47953ca 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 75590c1ed4ff97b5ec296c2dd8d2ef2a0f04eab7..92e38b02615f7fd75c590cd36ace370db025d46a 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 0000000000000000000000000000000000000000..7b3a26d7ca773695830c09c0d26a323d954d799a
--- /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 0000000000000000000000000000000000000000..35c4a0b6f080502011c34605eac3f2386a6211eb
--- /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 04d66020c87b74a8359c47ca14bd97e77a65e1a8..ac2a1ba5dffb66bb1f1bffb1d359e5e4377272c8 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 0000000000000000000000000000000000000000..9e3f06c682cdc75ef6465e55614ee71279ec0340
--- /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 3195fb3ddcc90332facafc6ccb5bfd7b80a6d7f1..4fd51a2349060aa4ccc53f96720dbc66deba525e 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 b682ced75acf77977d07a662fdc41b76dcb0c063..f82d68a1816eba8380a1029aeb96c6ca883c6dfa 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 5575852c2d77af2f5105580147d0ff43d9a402b5..b2695e0482a02860dc76f999a377dc0422ab7515 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/features/admin/admin_system_info_spec.rb b/spec/features/admin/admin_system_info_spec.rb
index f4e5c26b51918579b76041fd35ae40d7f8d973e6..1df972843e2e9c3e678b943b48ec1f006d2bc98c 100644
--- a/spec/features/admin/admin_system_info_spec.rb
+++ b/spec/features/admin/admin_system_info_spec.rb
@@ -6,12 +6,49 @@ describe 'Admin System Info' do
end
describe 'GET /admin/system_info' do
- it 'shows system info page' do
- visit admin_system_info_path
+ let(:cpu) { double(:cpu, length: 2) }
+ let(:memory) { double(:memory, active_bytes: 4294967296, total_bytes: 17179869184) }
- expect(page).to have_content 'CPU'
- expect(page).to have_content 'Memory'
- expect(page).to have_content 'Disks'
+ context 'when all info is available' do
+ before do
+ allow(Vmstat).to receive(:cpu).and_return(cpu)
+ allow(Vmstat).to receive(:memory).and_return(memory)
+ visit admin_system_info_path
+ end
+
+ it 'shows system info page' do
+ expect(page).to have_content 'CPU 2 cores'
+ expect(page).to have_content 'Memory 4 GB / 16 GB'
+ expect(page).to have_content 'Disks'
+ end
+ end
+
+ context 'when CPU info is not available' do
+ before do
+ allow(Vmstat).to receive(:cpu).and_raise(Errno::ENOENT)
+ allow(Vmstat).to receive(:memory).and_return(memory)
+ visit admin_system_info_path
+ end
+
+ it 'shows system info page with no CPU info' do
+ expect(page).to have_content 'CPU Unable to collect CPU info'
+ expect(page).to have_content 'Memory 4 GB / 16 GB'
+ expect(page).to have_content 'Disks'
+ end
+ end
+
+ context 'when memory info is not available' do
+ before do
+ allow(Vmstat).to receive(:cpu).and_return(cpu)
+ allow(Vmstat).to receive(:memory).and_raise(Errno::ENOENT)
+ visit admin_system_info_path
+ end
+
+ it 'shows system info page with no CPU info' do
+ expect(page).to have_content 'CPU 2 cores'
+ expect(page).to have_content 'Memory Unable to collect memory info'
+ expect(page).to have_content 'Disks'
+ end
end
end
end
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5d777895542cc40b0dd7ba464024f76aca56a78d
--- /dev/null
+++ b/spec/features/boards/boards_spec.rb
@@ -0,0 +1,634 @@
+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 'keyboard shortcuts' do
+ before do
+ visit namespace_project_board_path(project.namespace, project)
+ wait_for_vue_resource
+ end
+
+ it 'allows user to use keyboard shortcuts' do
+ find('.boards-list').native.send_keys('i')
+ expect(page).to have_content('New Issue')
+ 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 9114f751b5518eb95fed9855969f53cb7870ebc3..9a2b879e789a97f19bf2b2ef240016428be6b774 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 0000000000000000000000000000000000000000..2883e3926940aef814ab89057fad03a8778b6f76
--- /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/issues_spec.rb b/spec/features/issues_spec.rb
index cb445e22af0a5aeb20bb200bba8205ae826705dd..2e595959f04fbd3a590956eccd2b8297950785bb 100644
--- a/spec/features/issues_spec.rb
+++ b/spec/features/issues_spec.rb
@@ -525,7 +525,7 @@ describe 'Issues', feature: true do
end
end
- describe 'new issue by email' do
+ xdescribe 'new issue by email' do
shared_examples 'show the email in the modal' do
before do
stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab")
diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..759edf8ec80c5bb968604701df27ce9e3a987504
--- /dev/null
+++ b/spec/features/merge_requests/conflicts_spec.rb
@@ -0,0 +1,73 @@
+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',
+ 'conflict-non-utf8' => 'when the conflicts contain a non-UTF-8 file',
+ }
+
+ 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 11c9de3c4bf29617f2ed326c578ada3aca7df097..b963d1305b5315314ff50e6a1f2f869f776bbc6e 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 0000000000000000000000000000000000000000..c6adf7e4c567799b507a852dd0ea0fdc8837df39
--- /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 0000000000000000000000000000000000000000..9c4c05252672139120cc9abe777cf02c4ecf6f8e
--- /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 0000000000000000000000000000000000000000..d9ef0d180742e5ae0797f6e35f33c1d4455dde76
--- /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
index af86d3c338a8e9db156affb048b89cb0bb411823..5972e7f31c2acc92fc979630aa53b1e5aca358fa 100644
--- a/spec/features/projects/badges/coverage_spec.rb
+++ b/spec/features/projects/badges/coverage_spec.rb
@@ -4,12 +4,6 @@ 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]
@@ -17,8 +11,10 @@ feature 'test coverage badge' do
end
scenario 'user requests coverage badge image for pipeline' do
- create_job(coverage: 100, name: 'test:1')
- create_job(coverage: 90, name: 'test:2')
+ create_pipeline do |pipeline|
+ create_build(pipeline, coverage: 100, name: 'test:1')
+ create_build(pipeline, coverage: 90, name: 'test:2')
+ end
show_test_coverage_badge
@@ -26,9 +22,11 @@ feature 'test coverage badge' do
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')
+ create_pipeline do |pipeline|
+ create_build(pipeline, coverage: 50, name: 'test:1')
+ create_build(pipeline, coverage: 50, name: 'test:2')
+ create_build(pipeline, coverage: 85, name: 'coverage')
+ end
show_test_coverage_badge(job: 'coverage')
@@ -36,7 +34,9 @@ feature 'test coverage badge' do
end
scenario 'user requests coverage badge for pipeline without coverage' do
- create_job(coverage: nil, name: 'test')
+ create_pipeline do |pipeline|
+ create_build(pipeline, coverage: nil, name: 'test')
+ end
show_test_coverage_badge
@@ -54,10 +54,19 @@ feature 'test coverage badge' do
end
end
- def create_job(coverage:, name:)
- create(:ci_build, name: name,
- coverage: coverage,
- pipeline: pipeline)
+ def create_pipeline
+ opts = { project: project, ref: 'master', sha: project.commit.id }
+
+ create(:ci_pipeline, opts).tap do |pipeline|
+ yield pipeline
+ pipeline.build_updated
+ end
+ end
+
+ def create_build(pipeline, coverage:, name:)
+ opts = { pipeline: pipeline, coverage: coverage, name: name }
+
+ create(:ci_build, :success, opts)
end
def show_test_coverage_badge(job: nil)
diff --git a/spec/features/projects/group_links_spec.rb b/spec/features/projects/group_links_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1a71a03fbd9fc0f03736af9a7471c716ba9c666e
--- /dev/null
+++ b/spec/features/projects/group_links_spec.rb
@@ -0,0 +1,32 @@
+require 'spec_helper'
+
+feature 'Project group links', feature: true, js: true do
+ include Select2Helper
+
+ let(:master) { create(:user) }
+ let(:project) { create(:project) }
+ let!(:group) { create(:group) }
+
+ background do
+ project.team << [master, :master]
+ login_as(master)
+ end
+
+ context 'setting an expiration date for a group link' do
+ before do
+ visit namespace_project_group_links_path(project.namespace, project)
+
+ select2 group.id, from: '#link_group_id'
+ fill_in 'expires_at', with: (Time.current + 4.5.days).strftime('%Y-%m-%d')
+ page.find('body').click
+ click_on 'Share'
+ end
+
+ it 'shows the expiration time with a warning class' do
+ page.within('.enabled-groups') do
+ expect(page).to have_content('expires in 4 days')
+ expect(page).to have_selector('.text-warning')
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4a83740621a698129f07e2e1f8e7c947c0bebe4c
--- /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 0000000000000000000000000000000000000000..3137af074cac5e791d83bdaffe10682025a2a54b
--- /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/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..430c384ac2ee824412a7051d55d80b927af3e6f5
--- /dev/null
+++ b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
@@ -0,0 +1,45 @@
+require 'spec_helper'
+
+feature 'Projects > Members > Master adds member with expiration date', feature: true, js: true do
+ include Select2Helper
+ include ActiveSupport::Testing::TimeHelpers
+
+ let(:master) { create(:user) }
+ let(:project) { create(:project) }
+ let!(:new_member) { create(:user) }
+
+ background do
+ project.team << [master, :master]
+ login_as(master)
+ end
+
+ scenario 'expiration date is displayed in the members list' do
+ travel_to Time.zone.parse('2016-08-06 08:00') do
+ visit namespace_project_project_members_path(project.namespace, project)
+
+ page.within '.users-project-form' do
+ select2(new_member.id, from: '#user_ids', multiple: true)
+ fill_in 'expires_at', with: '2016-08-10'
+ click_on 'Add users to project'
+ end
+
+ page.within '.project_member:first-child' do
+ expect(page).to have_content('Expires in 4 days')
+ end
+ end
+ end
+
+ scenario 'change expiration date' do
+ travel_to Time.zone.parse('2016-08-06 08:00') do
+ project.team.add_users([new_member.id], :developer, expires_at: '2016-09-06')
+ visit namespace_project_project_members_path(project.namespace, project)
+
+ page.within '.project_member:first-child' do
+ click_on 'Edit'
+ fill_in 'Access expiration date', with: '2016-08-09'
+ click_on 'Save'
+ expect(page).to have_content('Expires in 3 days')
+ end
+ end
+ 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 0000000000000000000000000000000000000000..5dd58ad66a768ba16d3c34c83b6fa43a3549150b
--- /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 29d150bc5971be934b218e04c53774e7fa6c6a55..47482bc3cc95e294bea9977ec788f997a0cf7125 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/projects_spec.rb b/spec/features/projects_spec.rb
index 1b14c66fe286ec30fa059e7773f2d6c34667235e..e00d85904d5da844cd41b7c5730f22ad1293404d 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -115,6 +115,35 @@ feature 'Project', feature: true do
end
end
+ describe 'tree view (default view is set to Files)' do
+ let(:user) { create(:user, project_view: 'files') }
+ let(:project) { create(:forked_project_with_submodules) }
+
+ before do
+ project.team << [user, :master]
+ login_as user
+ visit namespace_project_path(project.namespace, project)
+ end
+
+ it 'has working links to files' do
+ click_link('PROCESS.md')
+
+ expect(page.status_code).to eq(200)
+ end
+
+ it 'has working links to directories' do
+ click_link('encoding')
+
+ expect(page.status_code).to eq(200)
+ end
+
+ it 'has working links to submodules' do
+ click_link('645f6c4c')
+
+ expect(page.status_code).to eq(200)
+ end
+ end
+
def remove_with_confirm(button_text, confirm_with)
click_button button_text
fill_in 'confirm_name_input', with: confirm_with
diff --git a/spec/features/protected_branches/access_control_ce_spec.rb b/spec/features/protected_branches/access_control_ce_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..395c61a47438e9b33e0f659445d0449a6963650f
--- /dev/null
+++ b/spec/features/protected_branches/access_control_ce_spec.rb
@@ -0,0 +1,71 @@
+RSpec.shared_examples "protected branches > access control > CE" do
+ ProtectedBranch::PushAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)|
+ it "allows creating protected branches that #{access_type_name} can push to" do
+ visit namespace_project_protected_branches_path(project.namespace, project)
+ set_protected_branch_name('master')
+ within('.new_protected_branch') do
+ 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_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
+ visit namespace_project_protected_branches_path(project.namespace, project)
+ set_protected_branch_name('master')
+ click_on "Protect"
+
+ expect(ProtectedBranch.count).to eq(1)
+
+ within(".protected-branches-list") do
+ find(".js-allowed-to-push").click
+ within('.js-allowed-to-push-container') { click_on access_type_name }
+ end
+
+ wait_for_ajax
+ expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id)
+ end
+ end
+
+ ProtectedBranch::MergeAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)|
+ it "allows creating protected branches that #{access_type_name} can merge to" do
+ visit namespace_project_protected_branches_path(project.namespace, project)
+ set_protected_branch_name('master')
+ within('.new_protected_branch') do
+ 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_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
+ visit namespace_project_protected_branches_path(project.namespace, project)
+ set_protected_branch_name('master')
+ click_on "Protect"
+
+ expect(ProtectedBranch.count).to eq(1)
+
+ within(".protected-branches-list") do
+ find(".js-allowed-to-merge").click
+ within('.js-allowed-to-merge-container') { click_on access_type_name }
+ end
+
+ wait_for_ajax
+ expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to include(access_type_id)
+ end
+ end
+end
diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb
index 3499460c84dc4320c06e5b3c0b1961be166efd1d..1a3f7b970f6c81881e73bd833693e97937fe990e 100644
--- a/spec/features/protected_branches_spec.rb
+++ b/spec/features/protected_branches_spec.rb
@@ -1,4 +1,5 @@
require 'spec_helper'
+Dir["./spec/features/protected_branches/*.rb"].sort.each { |f| require f }
feature 'Projected Branches', feature: true, js: true do
include WaitForAjax
@@ -71,7 +72,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"
@@ -85,66 +89,6 @@ feature 'Projected Branches', feature: true, js: true do
end
describe "access control" do
- ProtectedBranch::PushAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)|
- it "allows creating protected branches that #{access_type_name} can push to" 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 }
- end
- click_on "Protect"
-
- expect(ProtectedBranch.count).to eq(1)
- expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id)
- end
-
- it "allows updating protected branches so that #{access_type_name} can push to them" do
- visit namespace_project_protected_branches_path(project.namespace, project)
- set_protected_branch_name('master')
- click_on "Protect"
-
- expect(ProtectedBranch.count).to eq(1)
-
- within(".protected-branches-list") do
- find(".js-allowed-to-push").click
- within('.js-allowed-to-push-container') { click_on access_type_name }
- end
-
- wait_for_ajax
- expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id)
- end
- end
-
- ProtectedBranch::MergeAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)|
- it "allows creating protected branches that #{access_type_name} can merge to" 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 }
- end
- click_on "Protect"
-
- expect(ProtectedBranch.count).to eq(1)
- expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id)
- end
-
- it "allows updating protected branches so that #{access_type_name} can merge to them" do
- visit namespace_project_protected_branches_path(project.namespace, project)
- set_protected_branch_name('master')
- click_on "Protect"
-
- expect(ProtectedBranch.count).to eq(1)
-
- within(".protected-branches-list") do
- find(".js-allowed-to-merge").click
- within('.js-allowed-to-merge-container') { click_on access_type_name }
- end
-
- wait_for_ajax
- expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id)
- end
- end
+ include_examples "protected branches > access control > CE"
end
end
diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb
index b7a25d80fec90a01565e7fd328a58d45706fa530..dcd3a2f17b048b5c0ca66cdc88eaa20df376335c 100644
--- a/spec/features/search_spec.rb
+++ b/spec/features/search_spec.rb
@@ -71,6 +71,16 @@ describe "Search", feature: true do
end
describe 'Right header search field', feature: true do
+ it 'allows enter key to search', js: true do
+ visit namespace_project_path(project.namespace, project)
+ fill_in 'search', with: 'gitlab'
+ find('#search').native.send_keys(:enter)
+
+ page.within '.title' do
+ expect(page).to have_content 'Search'
+ end
+ end
+
describe 'Search in project page' do
before do
visit namespace_project_path(project.namespace, project)
diff --git a/spec/features/security/dashboard_access_spec.rb b/spec/features/security/dashboard_access_spec.rb
index 788581a26cb924925e3111d1ec422fdfeb263970..40f773956d198606e35e917ff0eae75295757225 100644
--- a/spec/features/security/dashboard_access_spec.rb
+++ b/spec/features/security/dashboard_access_spec.rb
@@ -43,6 +43,20 @@ describe "Dashboard access", feature: true do
it { is_expected.to be_allowed_for :visitor }
end
+ describe "GET /koding" do
+ subject { koding_path }
+
+ context 'with Koding enabled' do
+ before do
+ stub_application_setting(koding_enabled?: true)
+ end
+
+ it { is_expected.to be_allowed_for :admin }
+ it { is_expected.to be_allowed_for :user }
+ it { is_expected.to be_denied_for :visitor }
+ end
+ end
+
describe "GET /projects/new" do
it { expect(new_project_path).to be_allowed_for :admin }
it { expect(new_project_path).to be_allowed_for :user }
diff --git a/spec/features/todos/todos_sorting_spec.rb b/spec/features/todos/todos_sorting_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e74a51acede8b52e189aab15020a3533e27183d9
--- /dev/null
+++ b/spec/features/todos/todos_sorting_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+describe "Dashboard > User sorts todos", feature: true do
+ let(:user) { create(:user) }
+ let(:project) { create(:empty_project) }
+
+ let(:label_1) { create(:label, title: 'label_1', project: project, priority: 1) }
+ let(:label_2) { create(:label, title: 'label_2', project: project, priority: 2) }
+ let(:label_3) { create(:label, title: 'label_3', project: project, priority: 3) }
+
+ let(:issue_1) { create(:issue, title: 'issue_1', project: project) }
+ let(:issue_2) { create(:issue, title: 'issue_2', project: project) }
+ let(:issue_3) { create(:issue, title: 'issue_3', project: project) }
+ let(:issue_4) { create(:issue, title: 'issue_4', project: project) }
+
+ let!(:merge_request_1) { create(:merge_request, source_project: project, title: "merge_request_1") }
+
+ before do
+ create(:todo, user: user, project: project, target: issue_4, created_at: 5.hours.ago)
+ create(:todo, user: user, project: project, target: issue_2, created_at: 4.hours.ago)
+ create(:todo, user: user, project: project, target: issue_3, created_at: 3.hours.ago)
+ create(:todo, user: user, project: project, target: issue_1, created_at: 2.hours.ago)
+ create(:todo, user: user, project: project, target: merge_request_1, created_at: 1.hour.ago)
+
+ merge_request_1.labels << label_1
+ issue_3.labels << label_1
+ issue_2.labels << label_3
+ issue_1.labels << label_2
+
+ project.team << [user, :developer]
+ login_as(user)
+ visit dashboard_todos_path
+ end
+
+ it "sorts with oldest created todos first" do
+ click_link "Last created"
+
+ results_list = page.find('.todos-list')
+ expect(results_list.all('p')[0]).to have_content("merge_request_1")
+ expect(results_list.all('p')[1]).to have_content("issue_1")
+ expect(results_list.all('p')[2]).to have_content("issue_3")
+ expect(results_list.all('p')[3]).to have_content("issue_2")
+ expect(results_list.all('p')[4]).to have_content("issue_4")
+ end
+
+ it "sorts with newest created todos first" do
+ click_link "Oldest created"
+
+ results_list = page.find('.todos-list')
+ expect(results_list.all('p')[0]).to have_content("issue_4")
+ expect(results_list.all('p')[1]).to have_content("issue_2")
+ expect(results_list.all('p')[2]).to have_content("issue_3")
+ expect(results_list.all('p')[3]).to have_content("issue_1")
+ expect(results_list.all('p')[4]).to have_content("merge_request_1")
+ end
+
+ it "sorts by priority" do
+ click_link "Priority"
+
+ results_list = page.find('.todos-list')
+ expect(results_list.all('p')[0]).to have_content("issue_3")
+ expect(results_list.all('p')[1]).to have_content("merge_request_1")
+ expect(results_list.all('p')[2]).to have_content("issue_1")
+ expect(results_list.all('p')[3]).to have_content("issue_2")
+ expect(results_list.all('p')[4]).to have_content("issue_4")
+ end
+end
diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb
index d370f90f7d97b6d14fdc8f8e6faf7352d3805534..a46e48c76ed6eb69bf122edd06cb5492a31c2edb 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 0000000000000000000000000000000000000000..4f3304f7b6d9fa3e4cbf6cb706c0310527e63ea3
--- /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/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f7e7e733cf71f6a8fab2117697a989e3d8baf70d
--- /dev/null
+++ b/spec/finders/todos_finder_spec.rb
@@ -0,0 +1,70 @@
+require 'spec_helper'
+
+describe TodosFinder do
+ describe '#execute' do
+ let(:user) { create(:user) }
+ let(:project) { create(:empty_project) }
+ let(:finder) { described_class }
+
+ before { project.team << [user, :developer] }
+
+ describe '#sort' do
+ context 'by date' do
+ let!(:todo1) { create(:todo, user: user, project: project) }
+ let!(:todo2) { create(:todo, user: user, project: project) }
+ let!(:todo3) { create(:todo, user: user, project: project) }
+
+ it 'sorts with oldest created first' do
+ todos = finder.new(user, { sort: 'id_asc' }).execute
+
+ expect(todos.first).to eq(todo1)
+ expect(todos.second).to eq(todo2)
+ expect(todos.third).to eq(todo3)
+ end
+
+ it 'sorts with newest created first' do
+ todos = finder.new(user, { sort: 'id_desc' }).execute
+
+ expect(todos.first).to eq(todo3)
+ expect(todos.second).to eq(todo2)
+ expect(todos.third).to eq(todo1)
+ end
+ end
+
+ it "sorts by priority" do
+ label_1 = create(:label, title: 'label_1', project: project, priority: 1)
+ label_2 = create(:label, title: 'label_2', project: project, priority: 2)
+ label_3 = create(:label, title: 'label_3', project: project, priority: 3)
+
+ issue_1 = create(:issue, title: 'issue_1', project: project)
+ issue_2 = create(:issue, title: 'issue_2', project: project)
+ issue_3 = create(:issue, title: 'issue_3', project: project)
+ issue_4 = create(:issue, title: 'issue_4', project: project)
+ merge_request_1 = create(:merge_request, source_project: project)
+
+ merge_request_1.labels << label_1
+
+ # Covers the case where Todo has more than one label
+ issue_3.labels << label_1
+ issue_3.labels << label_3
+
+ issue_2.labels << label_3
+ issue_1.labels << label_2
+
+ todo_1 = create(:todo, user: user, project: project, target: issue_4)
+ todo_2 = create(:todo, user: user, project: project, target: issue_2)
+ todo_3 = create(:todo, user: user, project: project, target: issue_3, created_at: 2.hours.ago)
+ todo_4 = create(:todo, user: user, project: project, target: issue_1)
+ todo_5 = create(:todo, user: user, project: project, target: merge_request_1, created_at: 1.hour.ago)
+
+ todos = finder.new(user, { sort: 'priority' }).execute
+
+ expect(todos.first).to eq(todo_3)
+ expect(todos.second).to eq(todo_5)
+ expect(todos.third).to eq(todo_4)
+ expect(todos.fourth).to eq(todo_2)
+ expect(todos.fifth).to eq(todo_1)
+ 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 0000000000000000000000000000000000000000..532ebb9640e86aa0e6cc9b47d1ae6676fe6225a7
--- /dev/null
+++ b/spec/fixtures/api/schemas/issue.json
@@ -0,0 +1,48 @@
+{
+ "type": "object",
+ "required" : [
+ "iid",
+ "title",
+ "confidential"
+ ],
+ "properties" : {
+ "iid": { "type": "integer" },
+ "title": { "type": "string" },
+ "confidential": { "type": "boolean" },
+ "labels": {
+ "type": "array",
+ "items": {
+ "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"] },
+ "text_color": {
+ "type": "string",
+ "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
+ },
+ "title": { "type": "string" },
+ "priority": { "type": ["integer", "null"] }
+ },
+ "additionalProperties": false
+ }
+ },
+ "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 0000000000000000000000000000000000000000..0d2067f704a1877ac765d2fc273e0c9c8a7089c2
--- /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 0000000000000000000000000000000000000000..f070fa3b254f150095e6e58361bf02a5c9a8ad3c
--- /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 0000000000000000000000000000000000000000..9f618aa9de5cb8014f5ec5b5ee8a0eb99b32740e
--- /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 0000000000000000000000000000000000000000..06bf60ab734823074dc90bd071e132fc8a5b9e5a
--- /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 0000000000000000000000000000000000000000..aed64224b06e61c95a88a1b57026bc1ab3e08a6b
--- /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 94972eed945b274f61a72a9694b4bd003cb4d2d0..a43a7238c708b1ac9a5ba87d02e6e9ba5f61602a 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 cf632f594c74e37dcf7164174ea267da3a18b63c..dc07657e101354d98fd46db4c59776f18656dfb9 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/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 604204cca0a4c3a6ce760694bd4101d50990fde0..284b58d8d5cad0ee1ecc1c919ba022c7e6071659 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -136,4 +136,42 @@ describe ProjectsHelper do
expect(sanitize_repo_path(project, import_error)).to eq('Could not clone [REPOS PATH]/namespace/test.git')
end
end
+
+ describe '#last_push_event' do
+ let(:user) { double(:user, fork_of: nil) }
+ let(:project) { double(:project, id: 1) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ helper.instance_variable_set(:@project, project)
+ end
+
+ context 'when there is no current_user' do
+ let(:user) { nil }
+
+ it 'returns nil' do
+ expect(helper.last_push_event).to eq(nil)
+ end
+ end
+
+ it 'returns recent push on the current project' do
+ event = double(:event)
+ expect(user).to receive(:recent_push).with([project.id]).and_return(event)
+
+ expect(helper.last_push_event).to eq(event)
+ end
+
+ context 'when current user has a fork of the current project' do
+ let(:fork) { double(:fork, id: 2) }
+
+ it 'returns recent push considering fork events' do
+ expect(user).to receive(:fork_of).with(project).and_return(fork)
+
+ event_on_fork = double(:event)
+ expect(user).to receive(:recent_push).with([project.id, fork.id]).and_return(event_on_fork)
+
+ expect(helper.last_push_event).to eq(event_on_fork)
+ end
+ end
+ end
end
diff --git a/spec/helpers/time_helper_spec.rb b/spec/helpers/time_helper_spec.rb
index bf3ed5c094c3779398955743fce81302455a0a04..21f355853672844661ac90c156333833b6274928 100644
--- a/spec/helpers/time_helper_spec.rb
+++ b/spec/helpers/time_helper_spec.rb
@@ -19,16 +19,16 @@ describe TimeHelper do
describe "#duration_in_numbers" do
it "returns minutes and seconds" do
- duration_in_numbers = {
- [100, 0] => "01:40",
- [121, 0] => "02:01",
- [3721, 0] => "01:02:01",
- [0, 0] => "00:00",
- [nil, Time.now.to_i - 42] => "00:42"
+ durations_and_expectations = {
+ 100 => "01:40",
+ 121 => "02:01",
+ 3721 => "01:02:01",
+ 0 => "00:00",
+ 42 => "00:42"
}
- duration_in_numbers.each do |interval, expectation|
- expect(duration_in_numbers(*interval)).to eq(expectation)
+ durations_and_expectations.each do |duration, expectation|
+ expect(duration_in_numbers(duration)).to eq(expectation)
end
end
end
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 0000000000000000000000000000000000000000..078e4b00023c2574509948a6806eb617b33c70e3
--- /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 0000000000000000000000000000000000000000..3569d1b98bd12f0b3ab042d8b82b281113044d82
--- /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 0000000000000000000000000000000000000000..c206b794442f913c71a96290381e839cd0730ea8
--- /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 0000000000000000000000000000000000000000..0c37ec8354f3efe9385e703b1225bee8240beb3b
--- /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 0000000000000000000000000000000000000000..22293d4de878d992d341ca32d8b880f8503256f9
--- /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/gl_dropdown.html.haml b/spec/javascripts/fixtures/gl_dropdown.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..a20390c08ee39f4c177fee3472f662c59b2ec1e1
--- /dev/null
+++ b/spec/javascripts/fixtures/gl_dropdown.html.haml
@@ -0,0 +1,16 @@
+%div
+ .dropdown.inline
+ %button#js-project-dropdown.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}}
+ Projects
+ %i.fa.fa-chevron-down.dropdown-toggle-caret.js-projects-dropdown-toggle
+ .dropdown-menu.dropdown-select.dropdown-menu-selectable
+ .dropdown-title
+ %span Go to project
+ %button.dropdown-title-button.dropdown-menu-close{aria: {label: 'Close'}}
+ %i.fa.fa-times.dropdown-menu-close-icon
+ .dropdown-input
+ %input.dropdown-input-field{type: 'search', placeholder: 'Filter results'}
+ %i.fa.fa-search.dropdown-input-search
+ .dropdown-content
+ .dropdown-loading
+ %i.fa.fa-spinner.fa-spin
diff --git a/spec/javascripts/fixtures/issue_sidebar_label.html.haml b/spec/javascripts/fixtures/issue_sidebar_label.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..397bdc85c67d4e2695190eb931c15743f3ebb61f
--- /dev/null
+++ b/spec/javascripts/fixtures/issue_sidebar_label.html.haml
@@ -0,0 +1,16 @@
+.block.labels
+ .sidebar-collapsed-icon.js-sidebar-labels-tooltip
+ .title.hide-collapsed
+ %a.edit-link.pull-right{ href: "#" }
+ Edit
+ .selectbox.hide-collapsed{ style: "display: none;" }
+ .dropdown
+ %button.dropdown-menu-toggle.js-label-select.js-multiselect{ type: "button", data: { ability_name: "issue", field_name: "issue[label_names][]", issue_update: "/root/test/issues/2.json", labels: "/root/test/labels.json", project_id: "12", show_any: "true", show_no: "true", toggle: "dropdown" } }
+ %span.dropdown-toggle-text
+ Label
+ %i.fa.fa-chevron-down
+ .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
+ .dropdown-page-one
+ .dropdown-content
+ .dropdown-loading
+ %i.fa.fa-spinner.fa-spin
diff --git a/spec/javascripts/gl_dropdown_spec.js.es6 b/spec/javascripts/gl_dropdown_spec.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..b529ea6458d9a6663e9d74b270ddf8abd6f75695
--- /dev/null
+++ b/spec/javascripts/gl_dropdown_spec.js.es6
@@ -0,0 +1,119 @@
+/*= require jquery */
+/*= require gl_dropdown */
+/*= require turbolinks */
+/*= require lib/utils/common_utils */
+/*= require lib/utils/type_utility */
+
+(() => {
+ const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link';
+ const ITEM_SELECTOR = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`;
+ const FOCUSED_ITEM_SELECTOR = `${ITEM_SELECTOR} a.is-focused`;
+
+ const ARROW_KEYS = {
+ DOWN: 40,
+ UP: 38,
+ ENTER: 13,
+ ESC: 27
+ };
+
+ let navigateWithKeys = function navigateWithKeys(direction, steps, cb, i) {
+ i = i || 0;
+ if (!i) direction = direction.toUpperCase();
+ $('body').trigger({
+ type: 'keydown',
+ which: ARROW_KEYS[direction],
+ keyCode: ARROW_KEYS[direction]
+ });
+ i++;
+ if (i <= steps) {
+ navigateWithKeys(direction, steps, cb, i);
+ } else {
+ cb();
+ }
+ };
+
+ describe('Dropdown', function describeDropdown() {
+ fixture.preload('gl_dropdown.html');
+ fixture.preload('projects.json');
+
+ beforeEach(() => {
+ fixture.load('gl_dropdown.html');
+ this.dropdownContainerElement = $('.dropdown.inline');
+ this.dropdownMenuElement = $('.dropdown-menu', this.dropdownContainerElement);
+ this.projectsData = fixture.load('projects.json')[0];
+ this.dropdownButtonElement = $('#js-project-dropdown', this.dropdownContainerElement).glDropdown({
+ selectable: true,
+ data: this.projectsData,
+ text: (project) => {
+ (project.name_with_namespace || project.name);
+ },
+ id: (project) => {
+ project.id;
+ }
+ });
+ });
+
+ afterEach(() => {
+ $('body').unbind('keydown');
+ this.dropdownContainerElement.unbind('keyup');
+ });
+
+ it('should open on click', () => {
+ expect(this.dropdownContainerElement).not.toHaveClass('open');
+ this.dropdownButtonElement.click();
+ expect(this.dropdownContainerElement).toHaveClass('open');
+ });
+
+ describe('that is open', () => {
+ beforeEach(() => {
+ this.dropdownButtonElement.click();
+ });
+
+ it('should select a following item on DOWN keypress', () => {
+ expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(0);
+ let randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 1)) + 0);
+ navigateWithKeys('down', randomIndex, () => {
+ expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1);
+ expect($(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.dropdownMenuElement)).toHaveClass('is-focused');
+ });
+ });
+
+ it('should select a previous item on UP keypress', () => {
+ expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(0);
+ navigateWithKeys('down', (this.projectsData.length - 1), () => {
+ expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1);
+ let randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 2)) + 0);
+ navigateWithKeys('up', randomIndex, () => {
+ expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1);
+ expect($(`${ITEM_SELECTOR}:eq(${((this.projectsData.length - 2) - randomIndex)}) a`, this.dropdownMenuElement)).toHaveClass('is-focused');
+ });
+ });
+ });
+
+ it('should click the selected item on ENTER keypress', () => {
+ expect(this.dropdownContainerElement).toHaveClass('open')
+ let randomIndex = Math.floor(Math.random() * (this.projectsData.length - 1)) + 0
+ navigateWithKeys('down', randomIndex, () => {
+ spyOn(Turbolinks, 'visit').and.stub();
+ navigateWithKeys('enter', null, () => {
+ expect(this.dropdownContainerElement).not.toHaveClass('open');
+ let link = $(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.dropdownMenuElement);
+ expect(link).toHaveClass('is-active');
+ let linkedLocation = link.attr('href');
+ if (linkedLocation && linkedLocation !== '#') expect(Turbolinks.visit).toHaveBeenCalledWith(linkedLocation);
+ });
+ });
+ });
+
+ it('should close on ESC keypress', () => {
+ expect(this.dropdownContainerElement).toHaveClass('open');
+ this.dropdownContainerElement.trigger({
+ type: 'keyup',
+ which: ARROW_KEYS.ESC,
+ keyCode: ARROW_KEYS.ESC
+ });
+ expect(this.dropdownContainerElement).not.toHaveClass('open');
+ });
+ });
+ });
+})();
diff --git a/spec/javascripts/labels_issue_sidebar_spec.js.es6 b/spec/javascripts/labels_issue_sidebar_spec.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..840c7b6d015e1379eb7a333b14da1c5da9ae0edd
--- /dev/null
+++ b/spec/javascripts/labels_issue_sidebar_spec.js.es6
@@ -0,0 +1,89 @@
+//= require lib/utils/type_utility
+//= require jquery
+//= require bootstrap
+//= require gl_dropdown
+//= require select2
+//= require jquery.nicescroll
+//= require api
+//= require create_label
+//= require issuable_context
+//= require users_select
+//= require labels_select
+
+(() => {
+ let saveLabelCount = 0;
+ describe('Issue dropdown sidebar', () => {
+ fixture.preload('issue_sidebar_label.html');
+
+ beforeEach(() => {
+ fixture.load('issue_sidebar_label.html');
+ new IssuableContext('{"id":1,"name":"Administrator","username":"root"}');
+ new LabelsSelect();
+
+ spyOn(jQuery, 'ajax').and.callFake((req) => {
+ const d = $.Deferred();
+ let LABELS_DATA = []
+
+ if (req.url === '/root/test/labels.json') {
+ for (let i = 0; i < 10; i++) {
+ LABELS_DATA.push({id: i, title: `test ${i}`, color: '#5CB85C'});
+ }
+ } else if (req.url === '/root/test/issues/2.json') {
+ let tmp = []
+ for (let i = 0; i < saveLabelCount; i++) {
+ tmp.push({id: i, title: `test ${i}`, color: '#5CB85C'});
+ }
+ LABELS_DATA = {labels: tmp};
+ }
+
+ d.resolve(LABELS_DATA);
+ return d.promise();
+ });
+ });
+
+ it('changes collapsed tooltip when changing labels when less than 5', (done) => {
+ saveLabelCount = 5;
+ $('.edit-link').get(0).click();
+
+ setTimeout(() => {
+ expect($('.dropdown-content a').length).toBe(10);
+
+ $('.dropdow-content a').each((i, $link) => {
+ if (i < 5) {
+ $link.get(0).click();
+ }
+ });
+
+ $('.edit-link').get(0).click();
+
+ setTimeout(() => {
+ expect($('.sidebar-collapsed-icon').attr('data-original-title')).toBe('test 0, test 1, test 2, test 3, test 4');
+ done();
+ }, 0);
+ }, 0);
+ });
+
+ it('changes collapsed tooltip when changing labels when more than 5', (done) => {
+ saveLabelCount = 6;
+ $('.edit-link').get(0).click();
+
+ setTimeout(() => {
+ expect($('.dropdown-content a').length).toBe(10);
+
+ $('.dropdow-content a').each((i, $link) => {
+ if (i < 5) {
+ $link.get(0).click();
+ }
+ });
+
+ $('.edit-link').get(0).click();
+
+ setTimeout(() => {
+ expect($('.sidebar-collapsed-icon').attr('data-original-title')).toBe('test 0, test 1, test 2, test 3, test 4, and 1 more');
+ done();
+ }, 0);
+ }, 0);
+ });
+ });
+})();
+
diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js
index 68d64483d6777ce04521116b00e07785abdf80d1..324f5152780af71348dd786b931e8e60e6da5b8f 100644
--- a/spec/javascripts/search_autocomplete_spec.js
+++ b/spec/javascripts/search_autocomplete_spec.js
@@ -105,13 +105,13 @@
a3 = "a[href='" + mrsAssignedToMeLink + "']";
a4 = "a[href='" + mrsIHaveCreatedLink + "']";
expect(list.find(a1).length).toBe(1);
- expect(list.find(a1).text()).toBe(' Issues assigned to me ');
+ expect(list.find(a1).text()).toBe('Issues assigned to me');
expect(list.find(a2).length).toBe(1);
- expect(list.find(a2).text()).toBe(" Issues I've created ");
+ expect(list.find(a2).text()).toBe("Issues I've created");
expect(list.find(a3).length).toBe(1);
- expect(list.find(a3).text()).toBe(' Merge requests assigned to me ');
+ expect(list.find(a3).text()).toBe('Merge requests assigned to me');
expect(list.find(a4).length).toBe(1);
- return expect(list.find(a4).text()).toBe(" Merge requests I've created ");
+ return expect(list.find(a4).text()).toBe("Merge requests I've created");
};
describe('Search autocomplete dropdown', function() {
diff --git a/spec/lib/extracts_path_spec.rb b/spec/lib/extracts_path_spec.rb
index 36c77206a3f935255113d977ddd8074ad7bc4acc..86d04ecfa36b0777b2ec5da99ecabdf53ba7381e 100644
--- a/spec/lib/extracts_path_spec.rb
+++ b/spec/lib/extracts_path_spec.rb
@@ -30,17 +30,6 @@ describe ExtractsPath, lib: true do
expect(@logs_path).to eq("/#{@project.path_with_namespace}/refs/#{ref}/logs_tree/files/ruby/popen.rb")
end
- context 'escaped slash character in ref' do
- let(:ref) { 'improve%2Fawesome' }
-
- it 'has no escape sequences in @ref or @logs_path' do
- assign_ref_vars
-
- expect(@ref).to eq('improve/awesome')
- expect(@logs_path).to eq("/#{@project.path_with_namespace}/refs/#{ref}/logs_tree/files/ruby/popen.rb")
- end
- end
-
context 'ref contains %20' do
let(:ref) { 'foo%20bar' }
@@ -52,6 +41,16 @@ describe ExtractsPath, lib: true do
expect(@id).to start_with('foo%20bar/')
end
end
+
+ context 'path contains space' do
+ let(:params) { { path: 'with space', ref: '38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e' } }
+
+ it 'is not converted to %20 in @path' do
+ assign_ref_vars
+
+ expect(@path).to eq(params[:path])
+ end
+ end
end
describe '#extract_ref' do
diff --git a/spec/lib/gitlab/badge/coverage/report_spec.rb b/spec/lib/gitlab/badge/coverage/report_spec.rb
index 1ff496024864e8b476d135ca9a6c25bd90e2c7a7..ab0cce6e09104a8a6de11e105f00cf1fa415014b 100644
--- a/spec/lib/gitlab/badge/coverage/report_spec.rb
+++ b/spec/lib/gitlab/badge/coverage/report_spec.rb
@@ -44,45 +44,49 @@ describe Gitlab::Badge::Coverage::Report do
end
end
- context 'pipeline exists' do
- let!(:pipeline) do
- create(:ci_pipeline, project: project,
- sha: project.commit.id,
- ref: 'master')
- end
+ context 'when latest successful pipeline exists' do
+ before do
+ create_pipeline do |pipeline|
+ create(:ci_build, :success, pipeline: pipeline, name: 'first', coverage: 40)
+ create(:ci_build, :success, pipeline: pipeline, coverage: 60)
+ end
- context 'builds exist' do
- before do
- create(:ci_build, name: 'first', pipeline: pipeline, coverage: 40)
- create(:ci_build, pipeline: pipeline, coverage: 60)
+ create_pipeline do |pipeline|
+ create(:ci_build, :failed, pipeline: pipeline, coverage: 10)
end
+ end
- context 'particular job specified' do
- let(:job_name) { 'first' }
+ context 'when particular job specified' do
+ let(:job_name) { 'first' }
- it 'returns coverage for the particular job' do
- expect(badge.status).to eq 40
- end
+ 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) { '' }
+ context 'when 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
- it 'returns arithemetic mean for the pipeline' do
- expect(badge.status).to eq 50
- end
+ context 'when only failed pipeline exists' do
+ before do
+ create_pipeline do |pipeline|
+ create(:ci_build, :failed, pipeline: pipeline, coverage: 10)
end
end
- context 'builds do not exist' do
- it_behaves_like 'unknown coverage report'
+ it_behaves_like 'unknown coverage report'
- context 'particular job specified' do
- let(:job_name) { 'nonexistent' }
+ context 'particular job specified' do
+ let(:job_name) { 'nonexistent' }
- it 'retruns nil' do
- expect(badge.status).to be_nil
- end
+ it 'retruns nil' do
+ expect(badge.status).to be_nil
end
end
end
@@ -90,4 +94,13 @@ describe Gitlab::Badge::Coverage::Report do
context 'pipeline does not exist' do
it_behaves_like 'unknown coverage report'
end
+
+ def create_pipeline
+ opts = { project: project, sha: project.commit.id, ref: 'master' }
+
+ create(:ci_pipeline, opts).tap do |pipeline|
+ yield pipeline
+ pipeline.build_updated
+ end
+ 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 0000000000000000000000000000000000000000..39d892c18c033812e90e892973d6fb6d9f77bea3
--- /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 0000000000000000000000000000000000000000..a1d2ca1e27263080b4c883818e36447e1993c191
--- /dev/null
+++ b/spec/lib/gitlab/conflict/parser_spec.rb
@@ -0,0 +1,193 @@
+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
+
+ it 'raises UnsupportedEncoding when the file contains non-UTF-8 characters' do
+ expect { parse_text("a\xC4\xFC".force_encoding(Encoding::ASCII_8BIT)) }.
+ to raise_error(Gitlab::Conflict::Parser::UnsupportedEncoding)
+ 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 23ae5cfacc4c0e256cb8d449b01679ff06a1513c..6c71e98066bf0b90487fa8a8144269e6594d63d9 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 3d6bcdfd873821e90176f937b8f3251d96455395..9a4dec91e56389a837c0aebfcb05391da8da64fd 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 0000000000000000000000000000000000000000..a68f5943a6a91015338a69a24789480f6d259363
--- /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 6bd7393aaa7bd85ae7539583229a493fc176a938..b73434e8dd787626cf95c50cf6b5080674de4c1a 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/diff/position_spec.rb b/spec/lib/gitlab/diff/position_spec.rb
index 10537bea00830e3d641998277be1be7080d49d5f..6e8fff6f5163c43589a580ca4a2440cbea0df8cb 100644
--- a/spec/lib/gitlab/diff/position_spec.rb
+++ b/spec/lib/gitlab/diff/position_spec.rb
@@ -339,6 +339,48 @@ describe Gitlab::Diff::Position, lib: true do
end
end
+ describe "position for a file in the initial commit" do
+ let(:commit) { project.commit("1a0b36b3cdad1d2ee32457c102a8c0b7056fa863") }
+
+ subject do
+ described_class.new(
+ old_path: "README.md",
+ new_path: "README.md",
+ old_line: nil,
+ new_line: 1,
+ diff_refs: commit.diff_refs
+ )
+ end
+
+ describe "#diff_file" do
+ it "returns the correct diff file" do
+ diff_file = subject.diff_file(project.repository)
+
+ expect(diff_file.new_file).to be true
+ expect(diff_file.new_path).to eq(subject.new_path)
+ expect(diff_file.diff_refs).to eq(subject.diff_refs)
+ end
+ end
+
+ describe "#diff_line" do
+ it "returns the correct diff line" do
+ diff_line = subject.diff_line(project.repository)
+
+ expect(diff_line.added?).to be true
+ expect(diff_line.new_line).to eq(subject.new_line)
+ expect(diff_line.text).to eq("+testme")
+ end
+ end
+
+ describe "#line_code" do
+ it "returns the correct line code" do
+ line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, 0)
+
+ expect(subject.line_code(project.repository)).to eq(line_code)
+ end
+ end
+ end
+
describe "#to_json" do
let(:hash) do
{
diff --git a/spec/lib/gitlab/downtime_check/message_spec.rb b/spec/lib/gitlab/downtime_check/message_spec.rb
index 93094cda776f03f3e7b31a629c8b59f20192fc5d..a5a398abf78b73d8b0d7fd62a470958cfc791553 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_issue_handler_spec.rb b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb
index e115315477822703498e05027098ed46dd17747e..a5cc7b02936f339b2fc5d6bd9789b9d8989af8dc 100644
--- a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require_relative '../email_shared_blocks'
-describe Gitlab::Email::Handler::CreateIssueHandler, lib: true do
+xdescribe Gitlab::Email::Handler::CreateIssueHandler, lib: true do
include_context :email_shared_context
it_behaves_like :email_shared_examples
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 a2119b0dadfb1619434cc6ffe1c60d33593c3a53..4909fed6b774a82eea3518aad56a65a549576cd6 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/github_import/importer_spec.rb b/spec/lib/gitlab/github_import/importer_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b7c3bc4e1a74983c75c024262d4659597d61d1cf
--- /dev/null
+++ b/spec/lib/gitlab/github_import/importer_spec.rb
@@ -0,0 +1,132 @@
+require 'spec_helper'
+
+describe Gitlab::GithubImport::Importer, lib: true do
+ describe '#execute' do
+ context 'when an error occurs' do
+ let(:project) { create(:project, import_url: 'https://github.com/octocat/Hello-World.git', wiki_enabled: false) }
+ let(:octocat) { double(id: 123456, login: 'octocat') }
+ let(:created_at) { DateTime.strptime('2011-01-26T19:01:12Z') }
+ let(:updated_at) { DateTime.strptime('2011-01-27T19:01:12Z') }
+ let(:repository) { double(id: 1, fork: false) }
+ let(:source_sha) { create(:commit, project: project).id }
+ let(:source_branch) { double(ref: 'feature', repo: repository, sha: source_sha) }
+ let(:target_sha) { create(:commit, project: project, git_commit: RepoHelpers.another_sample_commit).id }
+ let(:target_branch) { double(ref: 'master', repo: repository, sha: target_sha) }
+
+ let(:label) do
+ double(
+ name: 'Bug',
+ color: 'ff0000',
+ url: 'https://api.github.com/repos/octocat/Hello-World/labels/bug'
+ )
+ end
+
+ let(:milestone) do
+ double(
+ number: 1347,
+ state: 'open',
+ title: '1.0',
+ description: 'Version 1.0',
+ due_on: nil,
+ created_at: created_at,
+ updated_at: updated_at,
+ closed_at: nil,
+ url: 'https://api.github.com/repos/octocat/Hello-World/milestones/1'
+ )
+ end
+
+ let(:issue1) do
+ double(
+ number: 1347,
+ milestone: nil,
+ state: 'open',
+ title: 'Found a bug',
+ body: "I'm having a problem with this.",
+ assignee: nil,
+ user: octocat,
+ comments: 0,
+ pull_request: nil,
+ created_at: created_at,
+ updated_at: updated_at,
+ closed_at: nil,
+ url: 'https://api.github.com/repos/octocat/Hello-World/issues/1347'
+ )
+ end
+
+ let(:issue2) do
+ double(
+ number: 1348,
+ milestone: nil,
+ state: 'open',
+ title: nil,
+ body: "I'm having a problem with this.",
+ assignee: nil,
+ user: octocat,
+ comments: 0,
+ pull_request: nil,
+ created_at: created_at,
+ updated_at: updated_at,
+ closed_at: nil,
+ url: 'https://api.github.com/repos/octocat/Hello-World/issues/1348'
+ )
+ end
+
+ let(:pull_request) do
+ double(
+ number: 1347,
+ milestone: nil,
+ state: 'open',
+ title: 'New feature',
+ body: 'Please pull these awesome changes',
+ head: source_branch,
+ base: target_branch,
+ assignee: nil,
+ user: octocat,
+ created_at: created_at,
+ updated_at: updated_at,
+ closed_at: nil,
+ merged_at: nil,
+ url: 'https://api.github.com/repos/octocat/Hello-World/pulls/1347'
+ )
+ end
+
+ before do
+ allow(project).to receive(:import_data).and_return(double.as_null_object)
+ allow_any_instance_of(Octokit::Client).to receive(:rate_limit!).and_raise(Octokit::NotFound)
+ allow_any_instance_of(Octokit::Client).to receive(:labels).and_return([label, label])
+ allow_any_instance_of(Octokit::Client).to receive(:milestones).and_return([milestone, milestone])
+ allow_any_instance_of(Octokit::Client).to receive(:issues).and_return([issue1, issue2])
+ allow_any_instance_of(Octokit::Client).to receive(:pull_requests).and_return([pull_request, pull_request])
+ allow_any_instance_of(Octokit::Client).to receive(:last_response).and_return(double(rels: { next: nil }))
+ allow_any_instance_of(Gitlab::Shell).to receive(:import_repository).and_raise(Gitlab::Shell::Error)
+ end
+
+ it 'returns true' do
+ expect(described_class.new(project).execute).to eq true
+ end
+
+ it 'does not raise an error' do
+ expect { described_class.new(project).execute }.not_to raise_error
+ end
+
+ it 'stores error messages' do
+ error = {
+ message: 'The remote data could not be fully imported.',
+ errors: [
+ { type: :label, url: "https://api.github.com/repos/octocat/Hello-World/labels/bug", errors: "Validation failed: Title has already been taken" },
+ { type: :milestone, url: "https://api.github.com/repos/octocat/Hello-World/milestones/1", errors: "Validation failed: Title has already been taken" },
+ { type: :issue, url: "https://api.github.com/repos/octocat/Hello-World/issues/1347", errors: "Invalid Repository. Use user/repo format." },
+ { type: :issue, url: "https://api.github.com/repos/octocat/Hello-World/issues/1348", errors: "Validation failed: Title can't be blank, Title is too short (minimum is 0 characters)" },
+ { type: :pull_request, url: "https://api.github.com/repos/octocat/Hello-World/pulls/1347", errors: "Invalid Repository. Use user/repo format." },
+ { type: :pull_request, url: "https://api.github.com/repos/octocat/Hello-World/pulls/1347", errors: "Validation failed: Validate branches Cannot Create: This merge request already exists: [\"New feature\"]" },
+ { type: :wiki, errors: "Gitlab::Shell::Error" }
+ ]
+ }
+
+ described_class.new(project).execute
+
+ expect(project.import_error).to eq error.to_json
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb b/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb
index aa28e360993434ba264a3362a65a4d5f5ac07e75..b667abf063d65e088307602081637dd8a7c13878 100644
--- a/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb
+++ b/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb
@@ -27,7 +27,8 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do
created_at: created_at,
updated_at: updated_at,
closed_at: nil,
- merged_at: nil
+ merged_at: nil,
+ url: 'https://api.github.com/repos/octocat/Hello-World/pulls/1347'
}
end
@@ -229,4 +230,12 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do
end
end
end
+
+ describe '#url' do
+ let(:raw_data) { double(base_data) }
+
+ it 'return raw url' do
+ expect(pull_request.url).to eq 'https://api.github.com/repos/octocat/Hello-World/pulls/1347'
+ end
+ end
end
diff --git a/spec/lib/gitlab/metrics/metric_spec.rb b/spec/lib/gitlab/metrics/metric_spec.rb
index f718d536130a35e09f2948129461067c1f9b570c..f26fca52c5067a77d0d7679fc6fabc802ee462a0 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 f264ed64029a532763611948e0f0b03f7e3e7392..a30cb2a5e38220c41e8683062781542507cd15de 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 4d2aa03e722cbac5d5d2c8eef9cc39980ccf28a1..acaba785606e13e9b43348a3fa4247eb3ac56d91 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 f1a191d94100889466524e3a8a81e9e052a6e590..3887c04c83214b19f531523afd6e646e0c3104f1 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 84f9475a0f85fbe66d58c8adce93d0eb7edd04f7..ab6e311b1e80b4034febc97db91c69f4393a8bfd 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/middleware/rails_queue_duration_spec.rb b/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb
index fd6f684db0c17bf1244fd9a4573ce4e8231bb045..168090d5b5c3d34d3daa4dc3f2c295874505753e 100644
--- a/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb
+++ b/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb
@@ -22,7 +22,7 @@ describe Gitlab::Middleware::RailsQueueDuration do
end
it 'sets proxy_flight_time and calls the app when the header is present' do
- env['HTTP_GITLAB_WORHORSE_PROXY_START'] = '123'
+ env['HTTP_GITLAB_WORKHORSE_PROXY_START'] = '123'
expect(transaction).to receive(:set).with(:rails_queue_duration, an_instance_of(Float))
expect(middleware.call(env)).to eq('yay')
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 0000000000000000000000000000000000000000..c9c2f314e576df9f2e0359fd7e8022882302f97c
--- /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 0000000000000000000000000000000000000000..26217a0e3b2c6d29ece057cc64e5b8166553eb4d
--- /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 0000000000000000000000000000000000000000..1e4954c4af8dcb9cbb427c6ee3aa2dccb6fe16c6
--- /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 bc0ec9325cc106368d866f62a4d49f49d5cce8bb..9750a012e22dc877a07426187c3e11d417b2077e 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 0000000000000000000000000000000000000000..e3b8321eda3907f61eeea3fdb25cfc5263cb40c7
--- /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 0000000000000000000000000000000000000000..f770857e958817206b6687da1a5f1cd5ef66c73f
--- /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 0000000000000000000000000000000000000000..bb0f68043fa0ef7ae0f5b8c6b4c419dc87b6684c
--- /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 0000000000000000000000000000000000000000..4d3811af254acd2bf5aeb1184a6a86581218a09d
--- /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/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index fa241867858744f5eb48e18745bad22059e4230e..eae9c060c38eeeecf68a6b75a2f2eed5659eb359 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -493,7 +493,12 @@ describe Notify do
end
def invite_to_project(project:, email:, inviter:)
- ProjectMember.add_user(project.project_members, 'toto@example.com', Gitlab::Access::DEVELOPER, inviter)
+ Member.add_user(
+ project.project_members,
+ 'toto@example.com',
+ Gitlab::Access::DEVELOPER,
+ current_user: inviter
+ )
project.project_members.invite.last
end
@@ -740,7 +745,12 @@ describe Notify do
end
def invite_to_group(group:, email:, inviter:)
- GroupMember.add_user(group.group_members, 'toto@example.com', Gitlab::Access::DEVELOPER, inviter)
+ Member.add_user(
+ group.group_members,
+ 'toto@example.com',
+ Gitlab::Access::DEVELOPER,
+ current_user: inviter
+ )
group.group_members.invite.last
end
diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb
index 853f6943cef87546ed3c7099b83501b49b8774a8..aa3b2bbf47140e7f1e4895eae53ca92487205b02 100644
--- a/spec/models/ability_spec.rb
+++ b/spec/models/ability_spec.rb
@@ -171,6 +171,70 @@ describe Ability, lib: true do
end
end
+ shared_examples_for ".project_abilities" do |enable_request_store|
+ before do
+ RequestStore.begin! if enable_request_store
+ end
+
+ after do
+ if enable_request_store
+ RequestStore.end!
+ RequestStore.clear!
+ end
+ end
+
+ describe '.project_abilities' do
+ let!(:project) { create(:empty_project, :public) }
+ let!(:user) { create(:user) }
+
+ it 'returns permissions for admin user' do
+ admin = create(:admin)
+
+ results = described_class.project_abilities(admin, project)
+
+ expect(results.count).to eq(68)
+ end
+
+ it 'returns permissions for an owner' do
+ results = described_class.project_abilities(project.owner, project)
+
+ expect(results.count).to eq(68)
+ end
+
+ it 'returns permissions for a master' do
+ project.team << [user, :master]
+
+ results = described_class.project_abilities(user, project)
+
+ expect(results.count).to eq(60)
+ end
+
+ it 'returns permissions for a developer' do
+ project.team << [user, :developer]
+
+ results = described_class.project_abilities(user, project)
+
+ expect(results.count).to eq(44)
+ end
+
+ it 'returns permissions for a guest' do
+ project.team << [user, :guest]
+
+ results = described_class.project_abilities(user, project)
+
+ expect(results.count).to eq(21)
+ end
+ end
+ end
+
+ describe '.project_abilities with RequestStore' do
+ it_behaves_like ".project_abilities", true
+ end
+
+ describe '.project_abilities without RequestStore' do
+ it_behaves_like ".project_abilities", false
+ end
+
describe '.issues_readable_by_user' do
context 'with an admin user' do
it 'returns all given issues' do
diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..12d29540137c41dbf643f2a3b24562a61c0afce9
--- /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/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb
index 72688137f08f531857a9e6c16cd54683170fec9a..02d6263094aa0d784e3268deef207cd1ffd88880 100644
--- a/spec/models/broadcast_message_spec.rb
+++ b/spec/models/broadcast_message_spec.rb
@@ -1,8 +1,6 @@
require 'spec_helper'
describe BroadcastMessage, models: true do
- include ActiveSupport::Testing::TimeHelpers
-
subject { create(:broadcast_message) }
it { is_expected.to be_valid }
diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb
index 5980f6ddc32b1a21e9c399af4808e11360c490ba..ee2c3d049843bc5d1f17eee86b6c3ebe4a3cdc73 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
- before do
- build.status = state
- end
-
- it { is_expected.to be_truthy }
+ context "when commit_status.status is pending" do
+ before do
+ build.status = 'pending'
+ end
- context "and there are specific runner" do
- let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) }
+ it { is_expected.to be_truthy }
- before do
- build.project.runners << runner
- runner.save
- end
+ context "and there are specific runner" do
+ let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) }
- it { is_expected.to be_falsey }
+ before do
+ build.project.runners << runner
+ runner.save
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 950833cb219a2476b819420559cc5fdb5b9b2146..721b20e0cb28d79c62446c6184e140c4e9bc9a26 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
@@ -122,17 +124,21 @@ describe Ci::Pipeline, models: true do
describe 'state machine' do
let(:current) { Time.now.change(usec: 0) }
- let(:build) { create :ci_build, name: 'build1', pipeline: pipeline, started_at: current - 60, finished_at: current }
- let(:build2) { create :ci_build, name: 'build2', pipeline: pipeline, started_at: current - 60, finished_at: current }
+ let(:build) { create :ci_build, name: 'build1', pipeline: pipeline }
describe '#duration' do
before do
- build.skip
- build2.skip
+ travel_to(current - 120) do
+ pipeline.run
+ end
+
+ travel_to(current) do
+ pipeline.succeed
+ end
end
it 'matches sum of builds duration' do
- expect(pipeline.reload.duration).to eq(build.duration + build2.duration)
+ expect(pipeline.reload.duration).to eq(120)
end
end
@@ -310,4 +316,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/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index 1fa96eb1f158cf5653fe1b96acc48c0e6712d647..6a640474cfe04f6b057af2f4c91e514b359db77e 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 0000000000000000000000000000000000000000..179f2e7366247a1b684035e0ff9045f730d3f6b8
--- /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/label_spec.rb b/spec/models/label_spec.rb
index 2a09063f85779ed0fe92a69faffda2a5dd2f066d..5a5d1a5d60c23183eb2948a03fdd5f913a635726 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 2cfd26419ca0869f18a6b16d316523e3e86fb0b1..81517a18b748604cfaefcaca70ac6c5b9a226cab 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 0000000000000000000000000000000000000000..9e1a52011c3789702a0061327dfb519be864b748
--- /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/member_spec.rb b/spec/models/member_spec.rb
index 2277f4e13bfd84877275758fd325e3bebce6869d..fef90d9b5cb8368c1d31a04c448cc8ddc0b442fe 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -65,11 +65,21 @@ describe Member, models: true do
@master_user = create(:user).tap { |u| project.team << [u, :master] }
@master = project.members.find_by(user_id: @master_user.id)
- ProjectMember.add_user(project.members, 'toto1@example.com', Gitlab::Access::DEVELOPER, @master_user)
+ Member.add_user(
+ project.members,
+ 'toto1@example.com',
+ Gitlab::Access::DEVELOPER,
+ current_user: @master_user
+ )
@invited_member = project.members.invite.find_by_invite_email('toto1@example.com')
accepted_invite_user = build(:user)
- ProjectMember.add_user(project.members, 'toto2@example.com', Gitlab::Access::DEVELOPER, @master_user)
+ Member.add_user(
+ project.members,
+ 'toto2@example.com',
+ Gitlab::Access::DEVELOPER,
+ current_user: @master_user
+ )
@accepted_invite_member = project.members.invite.find_by_invite_email('toto2@example.com').tap { |u| u.accept_invite!(accepted_invite_user) }
requested_user = create(:user).tap { |u| project.request_access(u) }
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 35a4418ebb3682dc91febc474f445e51692633b1..c4138c4f3f3abb9399bf88cabea1e2a426acc3f2 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) }
@@ -756,4 +783,161 @@ 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 is marked as having conflicts, but has none' do
+ merge_request = create_merge_request('master')
+
+ expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the MR has a missing ref after a force push' do
+ merge_request = create_merge_request('conflict-resolvable')
+ allow(merge_request.conflicts).to receive(:merge_index).and_raise(Rugged::OdbError)
+
+ 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/network/graph_spec.rb b/spec/models/network/graph_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b76513d2a3c7cb040c1cec1a95149f5a8f1bd0c8
--- /dev/null
+++ b/spec/models/network/graph_spec.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+
+describe Network::Graph, models: true do
+ let(:project) { create(:project) }
+ let!(:note_on_commit) { create(:note_on_commit, project: project) }
+
+ it '#initialize' do
+ graph = described_class.new(project, 'refs/heads/master', project.repository.commit, nil)
+
+ expect(graph.notes).to eq( { note_on_commit.commit_id => 1 } )
+ end
+end
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index 53733d253f7f86c2861fe67c278850877ea52512..ef2747046b96131c35e9f8fd9d402f5417cd70b0 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 00c4e0fb64c912c19fa8111281ef720851af367c..d672d80156c91090c32a84dc7df732f44a406465 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 ca2cd8aa551f7727cb045a1a83ca5da0275fe6a7..0194f9e256306bf63ae140b48029eea97259eccd 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 1adf93258f3512d5bff2aae38708ec156673ad89..c76ae21421b3cbd58b3bb4e1f614bd8596aa3733 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 3a8e67438fc308f98b4a03b5f5aac9757a48907a..8ef892259f260b8692b1b6f3319911d753371fe0 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 6518098ceea9238478f64d033b6e28b8f850c069..d25570197567cadb8e05481741503fbc5a05f65a 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 2c5583bdaa23fd54665ad9cb7a1badf641703dde..3d0b6c9816bdc45beed5a12d426767db5d7d8b61 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 1b383219eb9c852bcfe0f1e637457ad10208df09..34eafbe555d7234d75af619d96689db5bd991031 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 ea7182322552722bc05ffb42300551c2c3edacc6..ffb17fd3259d3bbb4f0810d1619db131a3a5dead 100644
--- a/spec/models/project_services/irker_service_spec.rb
+++ b/spec/models/project_services/irker_service_spec.rb
@@ -46,7 +46,9 @@ describe IrkerService, models: true do
let(:irker) { IrkerService.new }
let(:user) { create(:user) }
let(:project) { create(:project) }
- let(:sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) }
+ let(:sample_data) do
+ Gitlab::DataBuilder::Push.build_sample(project, user)
+ end
let(:recipients) { '#commits irc://test.net/#test ftp://bad' }
let(:colorize_messages) { '1' }
diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb
index 342403f63544b7f733c2c8c4518d15fbc3c4a9b2..9037ca5cc2026513c9794247d51f8bd6c320a61b 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 19c0270a4938d696878401793e1e8b9d8c2181bd..5959c81577d93a6a8ec4ccfd3b1f73a56fccac6f 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 45a5f4ef12ada008e34cc2a1f0dc530706f21852..28af68d13b49b64ff9fbb0076e528012f5e5d1a2 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 9c3b4712cab653e0ab4e1087fc17dc5d9315b64c..9a3660012f995320f35f3ff3f657d7cbcf20786d 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) }
@@ -246,7 +247,7 @@ describe Project, models: true do
end
end
- describe "#new_issue_address" do
+ xdescribe "#new_issue_address" do
let(:project) { create(:empty_project, path: "somewhere") }
let(:user) { create(:user) }
@@ -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/repository_spec.rb b/spec/models/repository_spec.rb
index f7dbfd712cc4525a7a87afb14aa82375226f3077..1fea50ad42c549df96069ce8c2367749dee3c7ff 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -719,6 +719,14 @@ describe Repository, models: true do
expect(merge_commit).to be_present
expect(repository.blob_at(merge_commit.id, 'files/ruby/feature.rb')).to be_present
end
+
+ it 'sets the `in_progress_merge_commit_sha` flag for the given merge request' do
+ merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project)
+ merge_commit_id = repository.merge(user, merge_request, commit_options)
+ repository.commit(merge_commit_id)
+
+ expect(merge_request.in_progress_merge_commit_sha).to eq(merge_commit_id)
+ end
end
describe '#revert' do
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index f67acbbef375b9ea1dfff692abb6177251daf574..8eb0c5033c977a08f78fec0c178708a15d6b7541 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
@@ -918,6 +920,16 @@ describe User, models: true do
expect(subject.recent_push).to eq(nil)
end
+
+ it "includes push events on any of the provided projects" do
+ expect(subject.recent_push(project1)).to eq(nil)
+ expect(subject.recent_push(project2)).to eq(push_event)
+
+ push_data1 = Gitlab::DataBuilder::Push.build_sample(project1, subject)
+ push_event1 = create(:event, action: Event::PUSHED, project: project1, target: project1, author: subject, data: push_data1)
+
+ expect(subject.recent_push([project1, project2])).to eq(push_event1) # Newest
+ end
end
describe '#authorized_groups' do
@@ -955,6 +967,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 9444138f93d7dc2b50b72ece0c970c40e1401e17..3fd989dd7a612ecd2d9773b171f2f240ddf94805 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 41503885dd94547e7a85b9409f165e344b287a22..9a17a705b1e18af4a91abb3c7c7dc161824949fd 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 0000000000000000000000000000000000000000..8fa8c66db6ce117981e30e88ea6fafafb51e7890
--- /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 05e57905343bcfea499a17ceb987cf9b23ed8bde..1898b07835d68f0fb66ea01c4095e9c89bd22414 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/members_spec.rb b/spec/requests/api/members_spec.rb
index a56ee30f7b15eb0bc8b9000cdb14491e568eff35..1e365bf353a9e133de342456237ef9a65e379021 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -122,12 +122,13 @@ describe API::Members, api: true do
it 'creates a new member' do
expect do
post api("/#{source_type.pluralize}/#{source.id}/members", master),
- user_id: stranger.id, access_level: Member::DEVELOPER
+ user_id: stranger.id, access_level: Member::DEVELOPER, expires_at: '2016-08-05'
expect(response).to have_http_status(201)
end.to change { source.members.count }.by(1)
expect(json_response['id']).to eq(stranger.id)
expect(json_response['access_level']).to eq(Member::DEVELOPER)
+ expect(json_response['expires_at']).to eq('2016-08-05')
end
end
@@ -183,11 +184,12 @@ describe API::Members, api: true do
context 'when authenticated as a master/owner' do
it 'updates the member' do
put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master),
- access_level: Member::MASTER
+ access_level: Member::MASTER, expires_at: '2016-08-05'
expect(response).to have_http_status(200)
expect(json_response['id']).to eq(developer.id)
expect(json_response['access_level']).to eq(Member::MASTER)
+ expect(json_response['expires_at']).to eq('2016-08-05')
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 0000000000000000000000000000000000000000..7e2cc50e5917b63fefee049fda8b2b4e1fac4662
--- /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 0000000000000000000000000000000000000000..7011bdc9ec00a488156e33d33e6f4e052fa1662e
--- /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 34fac297923e878eb2f62e239622112e77b49839..914e88c94878d0cfa3b55b52f1496a51f2501a4b 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 519e7ce12ad18760247d0b135bec73e7e11c29a2..acad1365ace3539167bcb1e8f5907a7bb912d314 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 68d0f41b489b239ffe4cf1d70e8b1a214180407f..5bd5b861792da3b92f187bbcf4688547d4e17d95 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 8537c252b589fbdf99bd23c3a27a5ef09865e96f..afaf4b7cefbe0578daa2097e7475dddcfc495f86 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/routing/routing_spec.rb b/spec/routing/routing_spec.rb
index 1d4df9197f68824fc2aac271d1f76f9276b5b230..d65648dd0b29b5c813908fb1977efbd0245c77c9 100644
--- a/spec/routing/routing_spec.rb
+++ b/spec/routing/routing_spec.rb
@@ -116,12 +116,19 @@ describe HelpController, "routing" do
expect(get(path)).to route_to('help#show',
path: 'workflow/protected_branches/protected_branches1',
format: 'png')
-
+
path = '/help/ui'
expect(get(path)).to route_to('help#ui')
end
end
+# koding GET /koding(.:format) koding#index
+describe KodingController, "routing" do
+ it "to #index" do
+ expect(get("/koding")).to route_to('koding#index')
+ end
+end
+
# profile_account GET /profile/account(.:format) profile#account
# profile_history GET /profile/history(.:format) profile#history
# profile_password PUT /profile/password(.:format) profile#password_update
diff --git a/spec/services/boards/create_service_spec.rb b/spec/services/boards/create_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a1a4dd4c57c991b5a468ec9494bd7686bf525ed6
--- /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 0000000000000000000000000000000000000000..cf4c5f13635aa810e0e4972865656147e4190bff
--- /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, development]) }
+
+ 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_issue2, closed_issue3, closed_issue1]
+ end
+
+ it 'returns opened/closed 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 [closed_issue4, 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 0000000000000000000000000000000000000000..0122159cab8066218a47a52673679a7401ee52bc
--- /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 0000000000000000000000000000000000000000..5e7e145065ed43dd99dd37bc015f9f71a13b655c
--- /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 0000000000000000000000000000000000000000..6eff445feeef6353afcb91d0327e605541a79842
--- /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 0000000000000000000000000000000000000000..9fd39122737088d4ceeb5b4815ecc99df685698d
--- /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 0000000000000000000000000000000000000000..3e9b7d07fc68464e939b766353b00af1c1be029d
--- /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/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb
index ad8c2485888d0cf6955662737bbb710ca5c544d9..8326e5cd3139f07a959b4b97de72651f5bbdd264 100644
--- a/spec/services/ci/process_pipeline_service_spec.rb
+++ b/spec/services/ci/process_pipeline_service_spec.rb
@@ -3,8 +3,6 @@ require 'spec_helper'
describe Ci::ProcessPipelineService, services: true do
let(:pipeline) { create(:ci_pipeline, ref: 'master') }
let(:user) { create(:user) }
- let(:all_builds) { pipeline.builds }
- let(:builds) { all_builds.where.not(status: [:created, :skipped]) }
let(:config) { nil }
before do
@@ -12,6 +10,14 @@ describe Ci::ProcessPipelineService, services: true do
end
describe '#execute' do
+ def all_builds
+ pipeline.builds
+ end
+
+ def builds
+ all_builds.where.not(status: [:created, :skipped])
+ end
+
def create_builds
described_class.new(pipeline.project, user).execute(pipeline)
end
@@ -48,7 +54,7 @@ describe Ci::ProcessPipelineService, services: true do
it 'does not process pipeline if existing stage is running' do
expect(create_builds).to be_truthy
expect(builds.pending.count).to eq(2)
-
+
expect(create_builds).to be_falsey
expect(builds.pending.count).to eq(2)
end
@@ -224,6 +230,40 @@ describe Ci::ProcessPipelineService, services: true do
end
end
+ context 'when failed build in the middle stage is retried' do
+ context 'when failed build is the only unsuccessful build in the stage' do
+ before do
+ create(:ci_build, :created, pipeline: pipeline, name: 'build:1', stage_idx: 0)
+ create(:ci_build, :created, pipeline: pipeline, name: 'build:2', stage_idx: 0)
+ create(:ci_build, :created, pipeline: pipeline, name: 'test:1', stage_idx: 1)
+ create(:ci_build, :created, pipeline: pipeline, name: 'test:2', stage_idx: 1)
+ create(:ci_build, :created, pipeline: pipeline, name: 'deploy:1', stage_idx: 2)
+ create(:ci_build, :created, pipeline: pipeline, name: 'deploy:2', stage_idx: 2)
+ end
+
+ it 'does trigger builds in the next stage' do
+ expect(create_builds).to be_truthy
+ expect(builds.pluck(:name)).to contain_exactly('build:1', 'build:2')
+
+ pipeline.builds.running_or_pending.each(&:success)
+
+ expect(builds.pluck(:name))
+ .to contain_exactly('build:1', 'build:2', 'test:1', 'test:2')
+
+ pipeline.builds.find_by(name: 'test:1').success
+ pipeline.builds.find_by(name: 'test:2').drop
+
+ expect(builds.pluck(:name))
+ .to contain_exactly('build:1', 'build:2', 'test:1', 'test:2')
+
+ Ci::Build.retry(pipeline.builds.find_by(name: 'test:2')).success
+
+ expect(builds.pluck(:name)).to contain_exactly(
+ 'build:1', 'build:2', 'test:1', 'test:2', 'test:2', 'deploy:1', 'deploy:2')
+ end
+ end
+ end
+
context 'creates a builds from .gitlab-ci.yml' do
let(:config) do
YAML.dump({
diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb
index 80f6ebac86c5889ae856dbaaf433fe2931c2f7d1..6ac1fa8f18295373330e271f14183acc7ac90c6f 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 1318607a388015b0612c2f5dc0c9bb6cb209a62b..aff022a573e03a4c9d9428f4ba6ad065e31b71d5 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 1ee9f3aae4dcb17aaf754121e33e75c4381df444..fcc3c0a00bd08e946d7409e5f26930e115c1cbd1 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 0000000000000000000000000000000000000000..34a89fcd4e1869d01bcd3bb3a81ed9bd6f97efaa
--- /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 088c3d48bf76d2cc74c0fab7bd04c97bcd16b5fa..0313f4244639b3a45eed461c879fae52d8f3363b 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 403533be5d9bd4e487f8971e64607faffe01c275..24c25e4350f9b3913a9e6a8025f069abd5c8a697 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 b84a580967ad92c95ea0499aa3645e9033e197cf..c1e4f8bd96b019b8bb09c6b9445d2cc390ec9d54 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 3419b8bf5e6acaec140c0064cdff360d53281fd1..af7424a76a98c2c7231d07edacc7fd0afb40c83b 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 0000000000000000000000000000000000000000..7ddd812e513ad5d9439e77da1406972772437527
--- /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 283a336afd9346ceed2f6e772b966baae5214115..6dfeb581975e13c85586c66bbd1c370b7996924f 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 32753e84b314c7e7391e54ea9fc9846c79ca6c46..93885c84dc3b9dcc0fa53d71a433a65f357179ae 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 0000000000000000000000000000000000000000..4f231aab161c7c97bf2449a23d8f8b9894fa04d8
--- /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 92b441c28caa4dc8b4556f5b01ce6330f2528114..f81a58899fd819d5a987169afb0bf6f028b7f153 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
@@ -1029,6 +1113,46 @@ describe NotificationService, services: true do
end
end
+ describe 'GroupMember' do
+ describe '#decline_group_invite' do
+ let(:creator) { create(:user) }
+ let(:group) { create(:group) }
+ let(:member) { create(:user) }
+
+ before(:each) do
+ group.add_owner(creator)
+ group.add_developer(member, creator)
+ end
+
+ it do
+ group_member = group.members.first
+
+ expect do
+ notification.decline_group_invite(group_member)
+ end.to change { ActionMailer::Base.deliveries.size }.by(1)
+ end
+ end
+ end
+
+ describe 'ProjectMember' do
+ describe '#decline_group_invite' do
+ let(:project) { create(:project) }
+ let(:member) { create(:user) }
+
+ before(:each) do
+ project.team << [member, :developer, project.owner]
+ end
+
+ it do
+ project_member = project.members.first
+
+ expect do
+ notification.decline_project_invite(project_member)
+ end.to change { ActionMailer::Base.deliveries.size }.by(1)
+ end
+ end
+ end
+
def build_team(project)
@u_watcher = create_global_setting_for(create(:user), :watch)
@u_participating = create_global_setting_for(create(:user), :participating)
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 0000000000000000000000000000000000000000..a616275e8834c30342b69ce6cce5cd233890a009
--- /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 6c3cbeae13c8d6948fe4b328e8fe8858a0288fd7..296fd1bd5a4339b3d72511011d38dd39a5df8a6a 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/simplecov_env.rb b/spec/simplecov_env.rb
index 6f8f7109e143245241dd88b69a402c97918ff3e5..b507d38f472c719e50595ca1db74a464312bb2ae 100644
--- a/spec/simplecov_env.rb
+++ b/spec/simplecov_env.rb
@@ -1,4 +1,5 @@
require 'simplecov'
+require 'active_support/core_ext/numeric/time'
module SimpleCovEnv
extend self
@@ -48,7 +49,7 @@ module SimpleCovEnv
add_group 'Uploaders', 'app/uploaders'
add_group 'Validators', 'app/validators'
- merge_timeout 7200
+ merge_timeout 365.days
end
end
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 2e2aa7c4fc04e7681ce6d37d50a0055b23e4064a..c144cd85487f250be31227711bf6be927f853baf 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -33,6 +33,7 @@ RSpec.configure do |config|
config.include EmailHelpers
config.include TestEnv
config.include ActiveJob::TestHelper
+ config.include ActiveSupport::Testing::TimeHelpers
config.include StubGitlabCalls
config.include StubGitlabData
diff --git a/spec/support/api/schema_matcher.rb b/spec/support/api/schema_matcher.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e42d727672be009d12e71c0428e28aca0bd91ad7
--- /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 a85ab22ce36fc814a2f5603d8d5f528a484f46ca..0bfc4685532f576c184eb0393b26ee94a41b494e 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 f550e9a0160f27c7568084146c168ec7276e097c..8c407b867fef5683ed5df7945a3880772307b3c2 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/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/issuable_create_service_slash_commands_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5f9645ed44f4c59aa0c98e90f5f060faa42d8498
--- /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 0000000000000000000000000000000000000000..d2a49ea5c5edc787d8e9997dd1144a101d816d18
--- /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 1c0c66969e3bece8bd82183029bc4857a449c60f..c7a45fc4ff9a62681bd9b548e5bfc70f3f9fafcf 100644
--- a/spec/support/test_env.rb
+++ b/spec/support/test_env.rb
@@ -5,25 +5,32 @@ 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' => '75284c7',
+ 'conflict-resolvable' => '1450cd6',
+ 'conflict-binary-file' => '259a6fb',
+ 'conflict-contains-conflict-markers' => '5e0964c',
+ 'conflict-missing-side' => 'eb227b3',
+ 'conflict-non-utf8' => 'd0a293c',
+ '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 0000000000000000000000000000000000000000..e0c59a5c280e3c2bba2b9b24be7d17ac11c8145e
--- /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 0000000000000000000000000000000000000000..3fddfb3b62f491b46f82fa30e03ca1809fe9bb80
--- /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/workers/build_email_worker_spec.rb b/spec/workers/build_email_worker_spec.rb
index 98deae0a588366b27eb5f4ce16e87c7a0be66c85..788b92c1b84e6e36a71e3416fb21797706713297 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 796751efe8dcf9a1411df00aea63e7d12371fed1..7ca2c29da1c3e26acd7e96257a85d7deb857e9e9 100644
--- a/spec/workers/emails_on_push_worker_spec.rb
+++ b/spec/workers/emails_on_push_worker_spec.rb
@@ -2,19 +2,19 @@ require 'spec_helper'
describe EmailsOnPushWorker do
include RepoHelpers
+ include EmailSpec::Matchers
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) }
+ let(:email) { ActionMailer::Base.deliveries.last }
subject { EmailsOnPushWorker.new }
describe "#perform" do
context "when push is a new branch" do
- let(:email) { ActionMailer::Base.deliveries.last }
-
before do
data_new_branch = data.stringify_keys.merge("before" => Gitlab::Git::BLANK_SHA)
@@ -31,8 +31,6 @@ describe EmailsOnPushWorker do
end
context "when push is a deleted branch" do
- let(:email) { ActionMailer::Base.deliveries.last }
-
before do
data_deleted_branch = data.stringify_keys.merge("after" => Gitlab::Git::BLANK_SHA)
@@ -48,15 +46,40 @@ describe EmailsOnPushWorker do
end
end
- context "when there are no errors in sending" do
- let(:email) { ActionMailer::Base.deliveries.last }
+ context "when push is a force push to delete commits" do
+ before do
+ data_force_push = data.stringify_keys.merge(
+ "after" => data[:before],
+ "before" => data[:after]
+ )
+
+ subject.perform(project.id, recipients, data_force_push)
+ end
+
+ it "sends a mail with the correct subject" do
+ expect(email.subject).to include('Change some files')
+ end
+ it "mentions force pushing in the body" do
+ expect(email).to have_body_text("force push")
+ end
+
+ it "sends the mail to the correct recipient" do
+ expect(email.to).to eq([user.email])
+ end
+ end
+
+ context "when there are no errors in sending" do
before { perform }
it "sends a mail with the correct subject" do
expect(email.subject).to include('Change some files')
end
+ it "does not mention force pushing in the body" do
+ expect(email).not_to have_body_text("force push")
+ end
+
it "sends the mail to the correct recipient" do
expect(email.to).to eq([user.email])
end
@@ -66,6 +89,7 @@ describe EmailsOnPushWorker do
before do
ActionMailer::Base.deliveries.clear
allow(Notify).to receive(:repository_push_email).and_raise(Net::SMTPFatalError)
+ allow(subject).to receive_message_chain(:logger, :info)
perform
end
diff --git a/spec/workers/remove_expired_group_links_worker_spec.rb b/spec/workers/remove_expired_group_links_worker_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..689bc3d27b4c46e8166478cd99c971bf31cd88d8
--- /dev/null
+++ b/spec/workers/remove_expired_group_links_worker_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+describe RemoveExpiredGroupLinksWorker do
+ describe '#perform' do
+ let!(:expired_project_group_link) { create(:project_group_link, expires_at: 1.hour.ago) }
+ let!(:project_group_link_expiring_in_future) { create(:project_group_link, expires_at: 10.days.from_now) }
+ let!(:non_expiring_project_group_link) { create(:project_group_link, expires_at: nil) }
+
+ it 'removes expired group links' do
+ expect { subject.perform }.to change { ProjectGroupLink.count }.by(-1)
+ expect(ProjectGroupLink.find_by(id: expired_project_group_link.id)).to be_nil
+ end
+
+ it 'leaves group links that expire in the future' do
+ subject.perform
+ expect(project_group_link_expiring_in_future.reload).to be_present
+ end
+
+ it 'leaves group links that do not expire at all' do
+ subject.perform
+ expect(non_expiring_project_group_link.reload).to be_present
+ end
+ end
+end
diff --git a/spec/workers/remove_expired_members_worker_spec.rb b/spec/workers/remove_expired_members_worker_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..402aa1e714e44557a7a1f44266a70eb9d95280c2
--- /dev/null
+++ b/spec/workers/remove_expired_members_worker_spec.rb
@@ -0,0 +1,58 @@
+require 'spec_helper'
+
+describe RemoveExpiredMembersWorker do
+ let(:worker) { RemoveExpiredMembersWorker.new }
+
+ describe '#perform' do
+ context 'project members' do
+ let!(:expired_project_member) { create(:project_member, expires_at: 1.hour.ago, access_level: GroupMember::DEVELOPER) }
+ let!(:project_member_expiring_in_future) { create(:project_member, expires_at: 10.days.from_now, access_level: GroupMember::DEVELOPER) }
+ let!(:non_expiring_project_member) { create(:project_member, expires_at: nil, access_level: GroupMember::DEVELOPER) }
+
+ it 'removes expired members' do
+ expect { worker.perform }.to change { Member.count }.by(-1)
+ expect(Member.find_by(id: expired_project_member.id)).to be_nil
+ end
+
+ it 'leaves members that expire in the future' do
+ worker.perform
+ expect(project_member_expiring_in_future.reload).to be_present
+ end
+
+ it 'leaves members that do not expire at all' do
+ worker.perform
+ expect(non_expiring_project_member.reload).to be_present
+ end
+ end
+
+ context 'group members' do
+ let!(:expired_group_member) { create(:group_member, expires_at: 1.hour.ago, access_level: GroupMember::DEVELOPER) }
+ let!(:group_member_expiring_in_future) { create(:group_member, expires_at: 10.days.from_now, access_level: GroupMember::DEVELOPER) }
+ let!(:non_expiring_group_member) { create(:group_member, expires_at: nil, access_level: GroupMember::DEVELOPER) }
+
+ it 'removes expired members' do
+ expect { worker.perform }.to change { Member.count }.by(-1)
+ expect(Member.find_by(id: expired_group_member.id)).to be_nil
+ end
+
+ it 'leaves members that expire in the future' do
+ worker.perform
+ expect(group_member_expiring_in_future.reload).to be_present
+ end
+
+ it 'leaves members that do not expire at all' do
+ worker.perform
+ expect(non_expiring_group_member.reload).to be_present
+ end
+ end
+
+ context 'when the last group owner expires' do
+ let!(:expired_group_owner) { create(:group_member, expires_at: 1.hour.ago, access_level: GroupMember::OWNER) }
+
+ it 'does not delete the owner' do
+ worker.perform
+ expect(expired_group_owner.reload).to be_present
+ end
+ end
+ end
+end
diff --git a/vendor/assets/javascripts/Sortable.js b/vendor/assets/javascripts/Sortable.js
new file mode 100644
index 0000000000000000000000000000000000000000..eca7c5012b2b1178f4fd942e95676555344ea208
--- /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 1b1f4f0bd63e9f38ea635607ab0aa9971e5c650d..39d7d2306f8eca30429df7e7af6c9f2b54151037 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 0000000000000000000000000000000000000000..d7981dbec7e100b8799b278a343bf9ea8dfb798a
--- /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 0000000000000000000000000000000000000000..8001775ce981f7546f9cb372e52e9a5ebe18b07a
--- /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 0000000000000000000000000000000000000000..6bff73a2a675ab5c5e346bcf151642052ee949fb
--- /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 0000000000000000000000000000000000000000..7ae95897a016afe6110f7f70e1c3ff9524cffed1
--- /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 = /