Commit 9c9259cc authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge remote-tracking branch 'upstream/master' into artifacts-from-ref-and-build-name

* upstream/master: (195 commits)
  Fix expansion of discussions in diff
  Improve performance of MR show page
  Fix jumping between discussions on changes tab
  Update doorkeeper to 4.2.0
  Fix MR note discussion ID
  Handle legacy sort order values
  Refactor `find_for_git_client` and its related methods.
  Remove right margin on Jump button icon
  Fix bug causing “Jump to discussion” button not to show
  Small refactor and syntax fixes.
  Removed unnecessary service for user retrieval and improved API error message.
  Added documentation and CHANGELOG item
  Added checks for 2FA to the API `/sessions` endpoint and the Resource Owner Password Credentials flow.
  Fix behavior around commands with optional arguments
  Fix behavior of label_ids and add/remove_label_ids
  Remove unneeded aliases
  Do not expose projects on deployments
  Incorporate feedback
  Docs for API endpoints
  Expose project for environments
  ...
parents 62127dc9 12fe6a6f
...@@ -11,6 +11,7 @@ v 8.11.0 (unreleased) ...@@ -11,6 +11,7 @@ v 8.11.0 (unreleased)
- Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz) - Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz)
- Update to Ruby 2.3.1. !4948 - Update to Ruby 2.3.1. !4948
- Add Issues Board !5548 - Add Issues Board !5548
- Allow resolving merge conflicts in the UI !5479
- Improve diff performance by eliminating redundant checks for text blobs - 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) - Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi)
- Convert switch icon into icon font (ClemMakesApps) - Convert switch icon into icon font (ClemMakesApps)
...@@ -20,6 +21,7 @@ v 8.11.0 (unreleased) ...@@ -20,6 +21,7 @@ v 8.11.0 (unreleased)
- Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell) - Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell)
- GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository - 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) - 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) - Ignore URLs starting with // in Markdown links !5677 (winniehell)
- Fix CI status icon link underline (ClemMakesApps) - Fix CI status icon link underline (ClemMakesApps)
- The Repository class is now instrumented - The Repository class is now instrumented
...@@ -29,6 +31,9 @@ v 8.11.0 (unreleased) ...@@ -29,6 +31,9 @@ v 8.11.0 (unreleased)
- Expand commit message width in repo view (ClemMakesApps) - Expand commit message width in repo view (ClemMakesApps)
- Cache highlighted diff lines for merge requests - Cache highlighted diff lines for merge requests
- Pre-create all builds for a Pipeline when the new Pipeline is created !5295 - 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' - Fix of 'Commits being passed to custom hooks are already reachable when using the UI'
- Show member roles to all users on members page - Show member roles to all users on members page
- Project.visible_to_user is instrumented again - Project.visible_to_user is instrumented again
...@@ -48,12 +53,15 @@ v 8.11.0 (unreleased) ...@@ -48,12 +53,15 @@ v 8.11.0 (unreleased)
- Get issue and merge request description templates from repositories - Get issue and merge request description templates from repositories
- Add hover state to todos !5361 (winniehell) - Add hover state to todos !5361 (winniehell)
- Fix icon alignment of star and fork buttons !5451 (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 - Limit git rev-list output count to one in forced push check
- Show deployment status on merge requests with external URLs - Show deployment status on merge requests with external URLs
- Clean up unused routes (Josef Strzibny) - Clean up unused routes (Josef Strzibny)
- Fix issue on empty project to allow developers to only push to protected branches if given permission - 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) - Add green outline to New Branch button. !5447 (winniehell)
- Optimize generating of cache keys for issues and notes - Optimize generating of cache keys for issues and notes
- Fix repository push email formatting in Outlook
- Improve performance of syntax highlighting Markdown code blocks - Improve performance of syntax highlighting Markdown code blocks
- Update to gitlab_git 10.4.1 and take advantage of preserved Ref objects - 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 - Remove delay when hitting "Reply..." button on page with a lot of discussions
...@@ -62,9 +70,12 @@ v 8.11.0 (unreleased) ...@@ -62,9 +70,12 @@ v 8.11.0 (unreleased)
- Upgrade Grape from 0.13.0 to 0.15.0. !4601 - 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 - Trigram indexes for the "ci_runners" table have been removed to speed up UPDATE queries
- Fix devise deprecation warnings. - 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 - 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 - 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 - 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 - Nokogiri's various parsing methods are now instrumented
- Add archived badge to project list !5798 - Add archived badge to project list !5798
- Add simple identifier to public SSH keys (muteor) - Add simple identifier to public SSH keys (muteor)
...@@ -82,6 +93,8 @@ v 8.11.0 (unreleased) ...@@ -82,6 +93,8 @@ v 8.11.0 (unreleased)
- Remove `search_id` of labels dropdown filter to fix 'Missleading URI for labels in Merge Requests and Issues view'. !5368 (Scott Le) - 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` - Load project invited groups and members eagerly in `ProjectTeam#fetch_members`
- Add pipeline events hook - Add pipeline events hook
- Award emoji tooltips containing more than 10 usernames are now truncated !4780 (jlogandavison)
- Fix duplicate "me" in award emoji tooltip !5218 (jlogandavison)
- Bump gitlab_git to speedup DiffCollection iterations - Bump gitlab_git to speedup DiffCollection iterations
- Rewrite description of a blocked user in admin settings. (Elias Werberich) - Rewrite description of a blocked user in admin settings. (Elias Werberich)
- Make branches sortable without push permission !5462 (winniehell) - Make branches sortable without push permission !5462 (winniehell)
...@@ -104,6 +117,7 @@ v 8.11.0 (unreleased) ...@@ -104,6 +117,7 @@ v 8.11.0 (unreleased)
- Add commit stats in commit api. !5517 (dixpac) - Add commit stats in commit api. !5517 (dixpac)
- Add CI configuration button on project page - Add CI configuration button on project page
- Fix merge request new view not changing code view rendering style - 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) - Make error pages responsive (Takuya Noguchi)
- The performance of the project dropdown used for moving issues has been improved - The performance of the project dropdown used for moving issues has been improved
- Fix skip_repo parameter being ignored when destroying a namespace - Fix skip_repo parameter being ignored when destroying a namespace
...@@ -127,10 +141,13 @@ v 8.11.0 (unreleased) ...@@ -127,10 +141,13 @@ v 8.11.0 (unreleased)
- Sort folders with submodules in Files view !5521 - Sort folders with submodules in Files view !5521
- Each `File::exists?` replaced to `File::exist?` because of deprecate since ruby version 2.2.0 - 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 auto-completition in pipeline (Katarzyna Kobierska Ula Budziszewska)
- Add pipelines tab to merge requests
- Fix a memory leak caused by Banzai::Filter::SanitizationFilter - Fix a memory leak caused by Banzai::Filter::SanitizationFilter
- Speed up todos queries by limiting the projects set we join with - 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 - 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 - 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 v 8.10.6
- Upgrade Rails to 4.2.7.1 for security fixes. !5781 - Upgrade Rails to 4.2.7.1 for security fixes. !5781
......
...@@ -20,7 +20,7 @@ gem 'pg', '~> 0.18.2', group: :postgres ...@@ -20,7 +20,7 @@ gem 'pg', '~> 0.18.2', group: :postgres
# Authentication libraries # Authentication libraries
gem 'devise', '~> 4.0' gem 'devise', '~> 4.0'
gem 'doorkeeper', '~> 4.0' gem 'doorkeeper', '~> 4.2.0'
gem 'omniauth', '~> 1.3.1' gem 'omniauth', '~> 1.3.1'
gem 'omniauth-auth0', '~> 1.4.1' gem 'omniauth-auth0', '~> 1.4.1'
gem 'omniauth-azure-oauth2', '~> 0.0.6' gem 'omniauth-azure-oauth2', '~> 0.0.6'
...@@ -53,7 +53,7 @@ gem 'browser', '~> 2.2' ...@@ -53,7 +53,7 @@ gem 'browser', '~> 2.2'
# Extracting information from a git repository # Extracting information from a git repository
# Provide access to Gitlab::Git library # Provide access to Gitlab::Git library
gem 'gitlab_git', '~> 10.4.5' gem 'gitlab_git', '~> 10.4.7'
# LDAP Auth # LDAP Auth
# GitLab fork with several improvements to original library. For full list of changes # 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' ...@@ -77,7 +77,7 @@ gem 'rack-cors', '~> 0.4.0', require: 'rack/cors'
gem 'kaminari', '~> 0.17.0' gem 'kaminari', '~> 0.17.0'
# HAML # HAML
gem 'hamlit', '~> 2.5' gem 'hamlit', '~> 2.6.1'
# Files attachments # Files attachments
gem 'carrierwave', '~> 0.10.0' gem 'carrierwave', '~> 0.10.0'
...@@ -201,7 +201,7 @@ gem 'licensee', '~> 8.0.0' ...@@ -201,7 +201,7 @@ gem 'licensee', '~> 8.0.0'
gem 'rack-attack', '~> 4.3.1' gem 'rack-attack', '~> 4.3.1'
# Ace editor # Ace editor
gem 'ace-rails-ap', '~> 4.0.2' gem 'ace-rails-ap', '~> 4.1.0'
# Keyboard shortcuts # Keyboard shortcuts
gem 'mousetrap-rails', '~> 1.4.6' gem 'mousetrap-rails', '~> 1.4.6'
...@@ -209,7 +209,8 @@ gem 'mousetrap-rails', '~> 1.4.6' ...@@ -209,7 +209,8 @@ gem 'mousetrap-rails', '~> 1.4.6'
# Detect and convert string character encoding # Detect and convert string character encoding
gem 'charlock_holmes', '~> 0.7.3' gem 'charlock_holmes', '~> 0.7.3'
# Parse duration # Parse time & duration
gem 'chronic', '~> 0.10.2'
gem 'chronic_duration', '~> 0.10.6' gem 'chronic_duration', '~> 0.10.6'
gem 'sass-rails', '~> 5.0.0' gem 'sass-rails', '~> 5.0.0'
......
...@@ -2,7 +2,7 @@ GEM ...@@ -2,7 +2,7 @@ GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
RedCloth (4.3.2) RedCloth (4.3.2)
ace-rails-ap (4.0.2) ace-rails-ap (4.1.0)
actionmailer (4.2.7.1) actionmailer (4.2.7.1)
actionpack (= 4.2.7.1) actionpack (= 4.2.7.1)
actionview (= 4.2.7.1) actionview (= 4.2.7.1)
...@@ -128,6 +128,7 @@ GEM ...@@ -128,6 +128,7 @@ GEM
mime-types (>= 1.16) mime-types (>= 1.16)
cause (0.1) cause (0.1)
charlock_holmes (0.7.3) charlock_holmes (0.7.3)
chronic (0.10.2)
chronic_duration (0.10.6) chronic_duration (0.10.6)
numerizer (~> 0.1.1) numerizer (~> 0.1.1)
chunky_png (1.3.5) chunky_png (1.3.5)
...@@ -175,7 +176,7 @@ GEM ...@@ -175,7 +176,7 @@ GEM
diff-lcs (1.2.5) diff-lcs (1.2.5)
diffy (3.0.7) diffy (3.0.7)
docile (1.1.5) docile (1.1.5)
doorkeeper (4.0.0) doorkeeper (4.2.0)
railties (>= 4.2) railties (>= 4.2)
dropzonejs-rails (0.7.2) dropzonejs-rails (0.7.2)
rails (> 3.1) rails (> 3.1)
...@@ -278,7 +279,7 @@ GEM ...@@ -278,7 +279,7 @@ GEM
diff-lcs (~> 1.1) diff-lcs (~> 1.1)
mime-types (>= 1.16, < 3) mime-types (>= 1.16, < 3)
posix-spawn (~> 0.3) posix-spawn (~> 0.3)
gitlab_git (10.4.5) gitlab_git (10.4.7)
activesupport (~> 4.0) activesupport (~> 4.0)
charlock_holmes (~> 0.7.3) charlock_holmes (~> 0.7.3)
github-linguist (~> 4.7.0) github-linguist (~> 4.7.0)
...@@ -321,7 +322,7 @@ GEM ...@@ -321,7 +322,7 @@ GEM
grape-entity (0.4.8) grape-entity (0.4.8)
activesupport activesupport
multi_json (>= 1.3.2) multi_json (>= 1.3.2)
hamlit (2.5.0) hamlit (2.6.1)
temple (~> 0.7.6) temple (~> 0.7.6)
thor thor
tilt tilt
...@@ -798,7 +799,7 @@ PLATFORMS ...@@ -798,7 +799,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
RedCloth (~> 4.3.2) RedCloth (~> 4.3.2)
ace-rails-ap (~> 4.0.2) ace-rails-ap (~> 4.1.0)
activerecord-session_store (~> 1.0.0) activerecord-session_store (~> 1.0.0)
acts-as-taggable-on (~> 3.4) acts-as-taggable-on (~> 3.4)
addressable (~> 2.3.8) addressable (~> 2.3.8)
...@@ -824,6 +825,7 @@ DEPENDENCIES ...@@ -824,6 +825,7 @@ DEPENDENCIES
capybara-screenshot (~> 1.0.0) capybara-screenshot (~> 1.0.0)
carrierwave (~> 0.10.0) carrierwave (~> 0.10.0)
charlock_holmes (~> 0.7.3) charlock_holmes (~> 0.7.3)
chronic (~> 0.10.2)
chronic_duration (~> 0.10.6) chronic_duration (~> 0.10.6)
coffee-rails (~> 4.1.0) coffee-rails (~> 4.1.0)
connection_pool (~> 2.0) connection_pool (~> 2.0)
...@@ -834,7 +836,7 @@ DEPENDENCIES ...@@ -834,7 +836,7 @@ DEPENDENCIES
devise (~> 4.0) devise (~> 4.0)
devise-two-factor (~> 3.0.0) devise-two-factor (~> 3.0.0)
diffy (~> 3.0.3) diffy (~> 3.0.3)
doorkeeper (~> 4.0) doorkeeper (~> 4.2.0)
dropzonejs-rails (~> 0.7.1) dropzonejs-rails (~> 0.7.1)
email_reply_parser (~> 0.5.8) email_reply_parser (~> 0.5.8)
email_spec (~> 1.6.0) email_spec (~> 1.6.0)
...@@ -857,7 +859,7 @@ DEPENDENCIES ...@@ -857,7 +859,7 @@ DEPENDENCIES
github-linguist (~> 4.7.0) github-linguist (~> 4.7.0)
github-markup (~> 1.4) github-markup (~> 1.4)
gitlab-flowdock-git-hook (~> 1.0.1) gitlab-flowdock-git-hook (~> 1.0.1)
gitlab_git (~> 10.4.5) gitlab_git (~> 10.4.7)
gitlab_meta (= 7.0) gitlab_meta (= 7.0)
gitlab_omniauth-ldap (~> 1.2.1) gitlab_omniauth-ldap (~> 1.2.1)
gollum-lib (~> 4.2) gollum-lib (~> 4.2)
...@@ -865,7 +867,7 @@ DEPENDENCIES ...@@ -865,7 +867,7 @@ DEPENDENCIES
gon (~> 6.1.0) gon (~> 6.1.0)
grape (~> 0.15.0) grape (~> 0.15.0)
grape-entity (~> 0.4.2) grape-entity (~> 0.4.2)
hamlit (~> 2.5) hamlit (~> 2.6.1)
health_check (~> 2.1.0) health_check (~> 2.1.0)
hipchat (~> 1.5.0) hipchat (~> 1.5.0)
html-pipeline (~> 1.11.0) html-pipeline (~> 1.11.0)
......
window.gl = window.gl || {};
((global) => {
const MAX_MESSAGE_LENGTH = 500;
const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
class AbuseReports {
constructor() {
$(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage);
$(document)
.off('click', MESSAGE_CELL_SELECTOR)
.on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation);
}
truncateLongMessage() {
const $messageCellElement = $(this);
const reportMessage = $messageCellElement.text();
if (reportMessage.length > MAX_MESSAGE_LENGTH) {
$messageCellElement.data('original-message', reportMessage);
$messageCellElement.data('message-truncated', 'true');
$messageCellElement.text(global.text.truncate(reportMessage, MAX_MESSAGE_LENGTH));
}
}
toggleMessageTruncation() {
const $messageCellElement = $(this);
const originalMessage = $messageCellElement.data('original-message');
if (!originalMessage) return;
if ($messageCellElement.data('message-truncated') === 'true') {
$messageCellElement.data('message-truncated', 'false');
$messageCellElement.text(originalMessage);
} else {
$messageCellElement.data('message-truncated', 'true');
$messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`);
}
}
}
global.AbuseReports = AbuseReports;
})(window.gl);
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
/*= require bootstrap/tooltip */ /*= require bootstrap/tooltip */
/*= require bootstrap/popover */ /*= require bootstrap/popover */
/*= require select2 */ /*= require select2 */
/*= require ace/ace */ /*= require ace-rails-ap */
/*= require ace/ext-searchbox */ /*= require ace/ext-searchbox */
/*= require underscore */ /*= require underscore */
/*= require dropzone */ /*= require dropzone */
...@@ -225,10 +225,13 @@ ...@@ -225,10 +225,13 @@
}); });
$body.on("click", ".js-toggle-diff-comments", function(e) { $body.on("click", ".js-toggle-diff-comments", function(e) {
var $this = $(this); var $this = $(this);
var showComments = $this.hasClass('active');
$this.toggleClass('active'); $this.toggleClass('active');
$this.closest(".diff-file").find(".notes_holder").toggle(showComments); var notesHolders = $this.closest('.diff-file').find('.notes_holder');
if ($this.hasClass('active')) {
notesHolders.show();
} else {
notesHolders.hide();
}
return e.preventDefault(); return e.preventDefault();
}); });
$document.off("click", '.js-confirm-danger'); $document.off("click", '.js-confirm-danger');
......
(function() { (function() {
this.AwardsHandler = (function() { this.AwardsHandler = (function() {
const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; //For separating lists produced by ruby's Array#toSentence
function AwardsHandler() { function AwardsHandler() {
this.aliases = gl.emojiAliases(); this.aliases = gl.emojiAliases();
$(document).off('click', '.js-add-award').on('click', '.js-add-award', (function(_this) { $(document).off('click', '.js-add-award').on('click', '.js-add-award', (function(_this) {
...@@ -130,7 +131,7 @@ ...@@ -130,7 +131,7 @@
counter = $emojiButton.find('.js-counter'); counter = $emojiButton.find('.js-counter');
counter.text(parseInt(counter.text()) + 1); counter.text(parseInt(counter.text()) + 1);
$emojiButton.addClass('active'); $emojiButton.addClass('active');
this.addMeToUserList(votesBlock, emoji); this.addYouToUserList(votesBlock, emoji);
return this.animateEmoji($emojiButton); return this.animateEmoji($emojiButton);
} }
} else { } else {
...@@ -176,11 +177,11 @@ ...@@ -176,11 +177,11 @@
counterNumber = parseInt(counter.text(), 10); counterNumber = parseInt(counter.text(), 10);
if (counterNumber > 1) { if (counterNumber > 1) {
counter.text(counterNumber - 1); counter.text(counterNumber - 1);
this.removeMeFromUserList($emojiButton, emoji); this.removeYouFromUserList($emojiButton, emoji);
} else if (emoji === 'thumbsup' || emoji === 'thumbsdown') { } else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
$emojiButton.tooltip('destroy'); $emojiButton.tooltip('destroy');
counter.text('0'); counter.text('0');
this.removeMeFromUserList($emojiButton, emoji); this.removeYouFromUserList($emojiButton, emoji);
if ($emojiButton.parents('.note').length) { if ($emojiButton.parents('.note').length) {
this.removeEmoji($emojiButton); this.removeEmoji($emojiButton);
} }
...@@ -204,43 +205,48 @@ ...@@ -204,43 +205,48 @@
return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || ''; return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
}; };
AwardsHandler.prototype.removeMeFromUserList = function($emojiButton, emoji) { AwardsHandler.prototype.toSentence = function(list) {
if(list.length <= 2){
return list.join(' and ');
}
else{
return list.slice(0, -1).join(', ') + ', and ' + list[list.length - 1];
}
};
AwardsHandler.prototype.removeYouFromUserList = function($emojiButton, emoji) {
var authors, awardBlock, newAuthors, originalTitle; var authors, awardBlock, newAuthors, originalTitle;
awardBlock = $emojiButton; awardBlock = $emojiButton;
originalTitle = this.getAwardTooltip(awardBlock); originalTitle = this.getAwardTooltip(awardBlock);
authors = originalTitle.split(', '); authors = originalTitle.split(FROM_SENTENCE_REGEX);
authors.splice(authors.indexOf('me'), 1); authors.splice(authors.indexOf('You'), 1);
newAuthors = authors.join(', '); return awardBlock
awardBlock.closest('.js-emoji-btn').removeData('original-title').attr('data-original-title', newAuthors); .closest('.js-emoji-btn')
return this.resetTooltip(awardBlock); .removeData('title')
.removeAttr('data-title')
.removeAttr('data-original-title')
.attr('title', this.toSentence(authors))
.tooltip('fixTitle');
}; };
AwardsHandler.prototype.addMeToUserList = function(votesBlock, emoji) { AwardsHandler.prototype.addYouToUserList = function(votesBlock, emoji) {
var awardBlock, origTitle, users; var awardBlock, origTitle, users;
awardBlock = this.findEmojiIcon(votesBlock, emoji).parent(); awardBlock = this.findEmojiIcon(votesBlock, emoji).parent();
origTitle = this.getAwardTooltip(awardBlock); origTitle = this.getAwardTooltip(awardBlock);
users = []; users = [];
if (origTitle) { if (origTitle) {
users = origTitle.trim().split(', '); users = origTitle.trim().split(FROM_SENTENCE_REGEX);
} }
users.push('me'); users.unshift('You');
awardBlock.attr('title', users.join(', ')); return awardBlock
return this.resetTooltip(awardBlock); .attr('title', this.toSentence(users))
}; .tooltip('fixTitle');
AwardsHandler.prototype.resetTooltip = function(award) {
var cb;
award.tooltip('destroy');
cb = function() {
return award.tooltip();
};
return setTimeout(cb, 200);
}; };
AwardsHandler.prototype.createEmoji_ = function(votesBlock, emoji) { AwardsHandler.prototype.createEmoji_ = function(votesBlock, emoji) {
var $emojiButton, buttonHtml, emojiCssClass; var $emojiButton, buttonHtml, emojiCssClass;
emojiCssClass = this.resolveNameToCssClass(emoji); emojiCssClass = this.resolveNameToCssClass(emoji);
buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='me' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>"; buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='You' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>";
$emojiButton = $(buttonHtml); $emojiButton = $(buttonHtml);
$emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('emoji', emoji); $emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('emoji', emoji);
this.animateEmoji($emojiButton); this.animateEmoji($emojiButton);
......
Vue.http.interceptors.push((request, next) => { Vue.http.interceptors.push((request, next) => {
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
setTimeout(() => { Vue.nextTick(() => {
Vue.activeResources--; setTimeout(() => {
}, 500); Vue.activeResources--;
}, 500);
});
next(); next();
}); });
((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);
(() => {
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);
})();
((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);
((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);
((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);
//= 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
}
});
});
((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);
((w) => {
w.ButtonMixins = {
computed: {
namespace: function () {
return `${this.namespacePath}/${this.projectPath}`;
}
}
};
})(window);
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;
}
}
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;
}
}
((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);
((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);
...@@ -196,6 +196,9 @@ ...@@ -196,6 +196,9 @@
case 'edit': case 'edit':
new Labels(); new Labels();
} }
case 'abuse_reports':
new gl.AbuseReports();
break;
} }
break; break;
case 'dashboard': case 'dashboard':
......
...@@ -223,7 +223,7 @@ ...@@ -223,7 +223,7 @@
} }
} }
}); });
return this.input.atwho({ this.input.atwho({
at: '~', at: '~',
alias: 'labels', alias: 'labels',
searchKey: 'search', searchKey: 'search',
...@@ -249,6 +249,68 @@ ...@@ -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 = '<li>/${name}';
if (value.aliases.length > 0) {
tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
}
if (value.params.length > 0) {
tpl += ' <small><%- params.join(" ") %></small>';
}
if (value.description !== '') {
tpl += '<small class="description"><i><%- description %></i></small>';
}
tpl += '</li>';
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() { destroyAtWho: function() {
return this.input.atwho('destroy'); return this.input.atwho('destroy');
...@@ -265,6 +327,7 @@ ...@@ -265,6 +327,7 @@
this.input.atwho('load', 'mergerequests', data.mergerequests); this.input.atwho('load', 'mergerequests', data.mergerequests);
this.input.atwho('load', ':', data.emojis); this.input.atwho('load', ':', data.emojis);
this.input.atwho('load', '~', data.labels); this.input.atwho('load', '~', data.labels);
this.input.atwho('load', '/', data.commands);
return $(':focus').trigger('keyup'); return $(':focus').trigger('keyup');
} }
}; };
......
...@@ -104,9 +104,12 @@ ...@@ -104,9 +104,12 @@
return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend'));
}); });
}; };
return gl.text.removeListeners = function(form) { gl.text.removeListeners = function(form) {
return $('.js-md', form).off(); return $('.js-md', form).off();
}; };
return gl.text.truncate = function(string, maxLength) {
return string.substr(0, (maxLength - 3)) + '...';
};
})(window); })(window);
}).call(this); }).call(this);
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
MergeRequest.prototype.initTabs = function() { MergeRequest.prototype.initTabs = function() {
if (this.opts.action !== 'new') { if (this.opts.action !== 'new') {
return new MergeRequestTabs(this.opts); window.mrTabs = new MergeRequestTabs(this.opts);
} else { } else {
return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show'); return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show');
} }
......
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
function MergeRequestTabs(opts) { function MergeRequestTabs(opts) {
this.opts = opts != null ? opts : {}; this.opts = opts != null ? opts : {};
this.opts.setUrl = this.opts.setUrl !== undefined ? this.opts.setUrl : true;
this.setCurrentAction = bind(this.setCurrentAction, this); this.setCurrentAction = bind(this.setCurrentAction, this);
this.tabShown = bind(this.tabShown, this); this.tabShown = bind(this.tabShown, this);
this.showTab = bind(this.showTab, this); this.showTab = bind(this.showTab, this);
...@@ -58,7 +59,9 @@ ...@@ -58,7 +59,9 @@
} else { } else {
this.expandView(); this.expandView();
} }
return this.setCurrentAction(action); if (this.opts.setUrl) {
this.setCurrentAction(action);
}
}; };
MergeRequestTabs.prototype.scrollToElement = function(container) { MergeRequestTabs.prototype.scrollToElement = function(container) {
...@@ -86,6 +89,7 @@ ...@@ -86,6 +89,7 @@
if (action === 'show') { if (action === 'show') {
action = 'notes'; action = 'notes';
} }
this.currentAction = action;
new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, ''); new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, '');
if (action !== 'notes') { if (action !== 'notes') {
new_state += "/" + action; new_state += "/" + action;
...@@ -124,6 +128,11 @@ ...@@ -124,6 +128,11 @@
success: (function(_this) { success: (function(_this) {
return function(data) { return function(data) {
$('#diffs').html(data.html); $('#diffs').html(data.html);
if (typeof DiffNotesApp !== 'undefined') {
DiffNotesApp.compileComponents();
}
gl.utils.localTimeAgo($('.js-timeago', 'div#diffs')); gl.utils.localTimeAgo($('.js-timeago', 'div#diffs'));
$('#diffs .js-syntax-highlight').syntaxHighlight(); $('#diffs .js-syntax-highlight').syntaxHighlight();
$('#diffs .diff-file').singleFileDiff(); $('#diffs .diff-file').singleFileDiff();
......
This diff is collapsed.
...@@ -35,10 +35,16 @@ ...@@ -35,10 +35,16 @@
this.isOpen = !this.isOpen; this.isOpen = !this.isOpen;
if (!this.isOpen && !this.hasError) { if (!this.isOpen && !this.hasError) {
this.content.hide(); this.content.hide();
return this.collapsedContent.show(); this.collapsedContent.show();
if (typeof DiffNotesApp !== 'undefined') {
DiffNotesApp.compileComponents();
}
} else if (this.content) { } else if (this.content) {
this.collapsedContent.hide(); this.collapsedContent.hide();
return this.content.show(); this.content.show();
if (typeof DiffNotesApp !== 'undefined') {
DiffNotesApp.compileComponents();
}
} else { } else {
return this.getContentHTML(); return this.getContentHTML();
} }
...@@ -57,7 +63,11 @@ ...@@ -57,7 +63,11 @@
_this.hasError = true; _this.hasError = true;
_this.content = $(ERROR_HTML); _this.content = $(ERROR_HTML);
} }
return _this.collapsedContent.after(_this.content); _this.collapsedContent.after(_this.content);
if (typeof DiffNotesApp !== 'undefined') {
DiffNotesApp.compileComponents();
}
}; };
})(this)); })(this));
}; };
......
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
} }
} }
// Hide element if Vue is still working on rendering it fully.
[v-cloak] { [v-cloak="true"] {
display: none; display: none !important;
} }
...@@ -147,3 +147,8 @@ ...@@ -147,3 +147,8 @@
color: $gl-link-color; color: $gl-link-color;
} }
} }
.atwho-view small.description {
float: right;
padding: 3px 5px;
}
...@@ -45,7 +45,6 @@ ...@@ -45,7 +45,6 @@
.line_content { .line_content {
padding-left: 0.5em; padding-left: 0.5em;
padding-right: 0.5em; padding-right: 0.5em;
white-space: pre;
&.old { &.old {
background-color: $line-removed; background-color: $line-removed;
...@@ -71,6 +70,10 @@ ...@@ -71,6 +70,10 @@
} }
} }
pre {
margin: 0;
}
span.highlight_word { span.highlight_word {
background-color: #fafe3d !important; background-color: #fafe3d !important;
} }
......
...@@ -72,7 +72,6 @@ ...@@ -72,7 +72,6 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
// Users List // Users List
.users-list { .users-list {
...@@ -98,3 +97,44 @@ ...@@ -98,3 +97,44 @@
} }
} }
} }
.abuse-reports {
.table {
table-layout: fixed;
}
.subheading {
padding-bottom: $gl-padding;
}
.message {
word-wrap: break-word;
}
.btn {
white-space: normal;
padding: $gl-btn-padding;
}
th {
width: 15%;
&.wide {
width: 55%;
}
}
@media (max-width: $screen-sm-max) {
th {
width: 100%;
}
td {
width: 100%;
float: left;
}
}
.no-reports {
.emoji-icon {
margin-left: $btn-side-margin;
margin-top: 3px;
}
span {
font-size: 19px;
}
}
}
...@@ -159,6 +159,32 @@ ...@@ -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 { .discussion-notes-count {
font-size: 16px; font-size: 16px;
} }
......
...@@ -383,3 +383,80 @@ ul.notes { ...@@ -383,3 +383,80 @@ ul.notes {
color: $gl-link-color; 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;
}
}
}
...@@ -228,3 +228,9 @@ ...@@ -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
...@@ -66,6 +66,11 @@ module IssuableCollections ...@@ -66,6 +66,11 @@ module IssuableCollections
key = 'issuable_sort' key = 'issuable_sort'
cookies[key] = params[:sort] if params[:sort].present? 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] params[:sort] = cookies[key]
end end
......
...@@ -6,7 +6,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController ...@@ -6,7 +6,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end end
def destroy 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| respond_to do |format|
format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' } format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' }
...@@ -27,10 +27,6 @@ class Dashboard::TodosController < Dashboard::ApplicationController ...@@ -27,10 +27,6 @@ class Dashboard::TodosController < Dashboard::ApplicationController
private private
def todo
@todo ||= find_todos.find(params[:id])
end
def find_todos def find_todos
@todos ||= TodosFinder.new(current_user, params).execute @todos ||= TodosFinder.new(current_user, params).execute
end end
......
...@@ -43,11 +43,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -43,11 +43,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
# A U2F (universal 2nd factor) device's information is stored after successful # A U2F (universal 2nd factor) device's information is stored after successful
# registration, which is then used while 2FA authentication is taking place. # registration, which is then used while 2FA authentication is taking place.
def create_u2f 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? if @u2f_registration.persisted?
session.delete(:challenges) 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 else
@qr_code = build_qr_code @qr_code = build_qr_code
setup_u2f_registration setup_u2f_registration
...@@ -91,15 +91,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -91,15 +91,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
# Actual communication is performed using a Javascript API # Actual communication is performed using a Javascript API
def setup_u2f_registration def setup_u2f_registration
@u2f_registration ||= U2fRegistration.new @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) u2f = U2F::U2F.new(u2f_app_id)
registration_requests = u2f.registration_requests 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) session[:challenges] = registration_requests.map(&:challenge)
gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id, gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id,
register_requests: registration_requests, register_requests: registration_requests,
sign_requests: sign_requests }) sign_requests: sign_requests })
end end
def u2f_registration_params
params.require(:u2f_registration).permit(:device_response, :name)
end
end end
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
...@@ -83,6 +83,7 @@ class Projects::ApplicationController < ApplicationController ...@@ -83,6 +83,7 @@ class Projects::ApplicationController < ApplicationController
end end
def apply_diff_view_cookie! def apply_diff_view_cookie!
@show_changes_tab = params[:view].present?
cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present? cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present?
end end
......
...@@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController ...@@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController
end end
def commit def commit
@commit ||= @project.commit(params[:id]) @noteable = @commit ||= @project.commit(params[:id])
end end
def pipelines def pipelines
......
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
...@@ -27,6 +27,9 @@ class Projects::GitHttpClientController < Projects::ApplicationController ...@@ -27,6 +27,9 @@ class Projects::GitHttpClientController < Projects::ApplicationController
@ci = true @ci = true
elsif auth_result.type == :oauth && !download_request? elsif auth_result.type == :oauth && !download_request?
# Not allowed # Not allowed
elsif auth_result.type == :missing_personal_token
render_missing_personal_token
return # Render above denied access, nothing left to do
else else
@user = auth_result.user @user = auth_result.user
end end
...@@ -91,6 +94,13 @@ class Projects::GitHttpClientController < Projects::ApplicationController ...@@ -91,6 +94,13 @@ class Projects::GitHttpClientController < Projects::ApplicationController
[nil, nil] [nil, nil]
end 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 def repository
_, suffix = project_id_with_suffix _, suffix = project_id_with_suffix
if suffix == '.wiki.git' if suffix == '.wiki.git'
......
...@@ -177,11 +177,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -177,11 +177,7 @@ class Projects::IssuesController < Projects::ApplicationController
protected protected
def issue def issue
@issue ||= begin @noteable = @issue ||= @project.issues.find_by(iid: params[:id]) || redirect_old
@project.issues.find_by!(iid: params[:id])
rescue ActiveRecord::RecordNotFound
redirect_old
end
end end
alias_method :subscribable_resource, :issue alias_method :subscribable_resource, :issue
alias_method :issuable, :issue alias_method :issuable, :issue
...@@ -226,7 +222,6 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -226,7 +222,6 @@ class Projects::IssuesController < Projects::ApplicationController
if issue if issue
redirect_to issue_path(issue) redirect_to issue_path(issue)
return
else else
raise ActiveRecord::RecordNotFound.new raise ActiveRecord::RecordNotFound.new
end end
......
...@@ -216,7 +216,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -216,7 +216,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@base_commit = @merge_request.diff_base_commit @base_commit = @merge_request.diff_base_commit
@diffs = @merge_request.diffs(diff_options) if @merge_request.compare @diffs = @merge_request.diffs(diff_options) if @merge_request.compare
@diff_notes_disabled = true @diff_notes_disabled = true
@pipeline = @merge_request.pipeline @pipeline = @merge_request.pipeline
@statuses = @pipeline.statuses.relevant if @pipeline @statuses = @pipeline.statuses.relevant if @pipeline
...@@ -382,7 +381,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -382,7 +381,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end end
def merge_request 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 end
alias_method :subscribable_resource, :merge_request alias_method :subscribable_resource, :merge_request
alias_method :issuable, :merge_request alias_method :issuable, :merge_request
...@@ -436,12 +435,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -436,12 +435,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController
# :show, :diff, :commits, :builds. but not when request the data through AJAX # :show, :diff, :commits, :builds. but not when request the data through AJAX
def define_discussion_vars def define_discussion_vars
# Build a note object for comment form # 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. @discussions = @merge_request.discussions
inc_author_project_award_emoji.
fresh.
discussions
preload_noteable_for_regular_notes(@discussions.flat_map(&:notes)) preload_noteable_for_regular_notes(@discussions.flat_map(&:notes))
...@@ -475,7 +471,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -475,7 +471,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
} }
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
@grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions @grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions
Banzai::NoteRenderer.render( Banzai::NoteRenderer.render(
@grouped_diff_discussions.values.flat_map(&:notes), @grouped_diff_discussions.values.flat_map(&:notes),
......
...@@ -5,6 +5,7 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -5,6 +5,7 @@ class Projects::NotesController < Projects::ApplicationController
before_action :authorize_read_note! before_action :authorize_read_note!
before_action :authorize_create_note!, only: [:create] before_action :authorize_create_note!, only: [:create]
before_action :authorize_admin_note!, only: [:update, :destroy] before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
before_action :find_current_user_notes, only: [:index] before_action :find_current_user_notes, only: [:index]
def index def index
...@@ -66,6 +67,33 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -66,6 +67,33 @@ class Projects::NotesController < Projects::ApplicationController
end end
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 private
def note def note
...@@ -125,7 +153,7 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -125,7 +153,7 @@ class Projects::NotesController < Projects::ApplicationController
id: note.id, id: note.id,
name: note.name name: note.name
} }
elsif note.valid? elsif note.persisted?
Banzai::NoteRenderer.render([note], @project, current_user) Banzai::NoteRenderer.render([note], @project, current_user)
attrs = { attrs = {
...@@ -138,7 +166,7 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -138,7 +166,7 @@ class Projects::NotesController < Projects::ApplicationController
} }
if note.diff_note? if note.diff_note?
discussion = Discussion.new([note]) discussion = note.to_discussion
attrs.merge!( attrs.merge!(
diff_discussion_html: diff_discussion_html(discussion), diff_discussion_html: diff_discussion_html(discussion),
...@@ -175,6 +203,10 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -175,6 +203,10 @@ class Projects::NotesController < Projects::ApplicationController
return access_denied! unless can?(current_user, :admin_note, note) return access_denied! unless can?(current_user, :admin_note, note)
end end
def authorize_resolve_note!
return access_denied! unless can?(current_user, :resolve_note, note)
end
def note_params def note_params
params.require(:note).permit( params.require(:note).permit(
:note, :noteable, :noteable_id, :noteable_type, :project_id, :note, :noteable, :noteable_id, :noteable_type, :project_id,
......
...@@ -134,10 +134,22 @@ class ProjectsController < Projects::ApplicationController ...@@ -134,10 +134,22 @@ class ProjectsController < Projects::ApplicationController
end end
def autocomplete_sources def autocomplete_sources
note_type = params['type'] noteable =
note_id = params['type_id'] 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) 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 = { @suggestions = {
emojis: Gitlab::AwardEmoji.urls, emojis: Gitlab::AwardEmoji.urls,
...@@ -145,7 +157,8 @@ class ProjectsController < Projects::ApplicationController ...@@ -145,7 +157,8 @@ class ProjectsController < Projects::ApplicationController
milestones: autocomplete.milestones, milestones: autocomplete.milestones,
mergerequests: autocomplete.merge_requests, mergerequests: autocomplete.merge_requests,
labels: autocomplete.labels, labels: autocomplete.labels,
members: participants members: participants,
commands: autocomplete.commands(noteable, params[:type])
} }
respond_to do |format| respond_to do |format|
......
...@@ -17,7 +17,7 @@ class TodosFinder ...@@ -17,7 +17,7 @@ class TodosFinder
attr_accessor :current_user, :params attr_accessor :current_user, :params
def initialize(current_user, params) def initialize(current_user, params = {})
@current_user = current_user @current_user = current_user
@params = params @params = params
end end
......
...@@ -32,6 +32,8 @@ module AppearancesHelper ...@@ -32,6 +32,8 @@ module AppearancesHelper
end end
def custom_icon(icon_name, size: 16) 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 render "shared/icons/#{icon_name}.svg", size: size
end end
end end
...@@ -11,17 +11,14 @@ module BlobHelper ...@@ -11,17 +11,14 @@ module BlobHelper
def edit_blob_link(project = @project, ref = @ref, path = @path, options = {}) def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
return unless current_user 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 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, edit_path = namespace_project_edit_blob_path(project.namespace, project,
tree_join(ref, path), tree_join(ref, path),
link_opts) options[:link_opts])
if !on_top_of_branch?(project, ref) 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' } 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' }
......
...@@ -114,9 +114,17 @@ module IssuesHelper ...@@ -114,9 +114,17 @@ module IssuesHelper
end end
def award_user_list(awards, current_user) def award_user_list(awards, current_user)
awards.map do |award| names = awards.map do |award|
award.user == current_user ? 'me' : award.user.name award.user == current_user ? 'You' : award.user.name
end.join(', ') end
# Take first 9 OR current user + first 9
current_user_name = names.delete('You')
names = names.first(9).insert(0, current_user_name).compact
names << "#{awards.size - names.size} more." if awards.size > names.size
names.to_sentence
end end
def award_active_class(awards, current_user) def award_active_class(awards, current_user)
......
...@@ -49,7 +49,7 @@ module NotesHelper ...@@ -49,7 +49,7 @@ module NotesHelper
} }
if use_legacy_diff_note if use_legacy_diff_note
discussion_id = LegacyDiffNote.build_discussion_id( discussion_id = LegacyDiffNote.discussion_id(
@comments_target[:noteable_type], @comments_target[:noteable_type],
@comments_target[:noteable_id] || @comments_target[:commit_id], @comments_target[:noteable_id] || @comments_target[:commit_id],
line_code line_code
...@@ -60,7 +60,7 @@ module NotesHelper ...@@ -60,7 +60,7 @@ module NotesHelper
discussion_id: discussion_id discussion_id: discussion_id
) )
else else
discussion_id = DiffNote.build_discussion_id( discussion_id = DiffNote.discussion_id(
@comments_target[:noteable_type], @comments_target[:noteable_type],
@comments_target[:noteable_id] || @comments_target[:commit_id], @comments_target[:noteable_id] || @comments_target[:commit_id],
position position
...@@ -81,10 +81,8 @@ module NotesHelper ...@@ -81,10 +81,8 @@ module NotesHelper
data = discussion.reply_attributes.merge(line_type: line_type) 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',
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', data: data, title: 'Add a reply'
data: data, title: 'Add a reply'
end
end end
def preload_max_access_for_authors(notes, project) def preload_max_access_for_authors(notes, project)
......
...@@ -47,6 +47,13 @@ module Emails ...@@ -47,6 +47,13 @@ module Emails
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
end 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 private
def setup_merge_request_mail(merge_request_id, recipient_id) def setup_merge_request_mail(merge_request_id, recipient_id)
......
...@@ -276,6 +276,7 @@ class Ability ...@@ -276,6 +276,7 @@ class Ability
:create_merge_request, :create_merge_request,
:create_wiki, :create_wiki,
:push_code, :push_code,
:resolve_note,
:create_container_image, :create_container_image,
:update_container_image, :update_container_image,
:create_environment, :create_environment,
...@@ -457,7 +458,8 @@ class Ability ...@@ -457,7 +458,8 @@ class Ability
rules += [ rules += [
:read_note, :read_note,
:update_note, :update_note,
:admin_note :admin_note,
:resolve_note
] ]
end end
...@@ -465,6 +467,10 @@ class Ability ...@@ -465,6 +467,10 @@ class Ability
rules += project_abilities(user, note.project) rules += project_abilities(user, note.project)
end end
if note.for_merge_request? && note.noteable.author == user
rules << :resolve_note
end
rules rules
end end
......
...@@ -9,11 +9,13 @@ class DiffNote < Note ...@@ -9,11 +9,13 @@ class DiffNote < Note
validates :diff_line, presence: true validates :diff_line, presence: true
validates :line_code, presence: true, line_code: true validates :line_code, presence: true, line_code: true
validates :noteable_type, inclusion: { in: ['Commit', 'MergeRequest'] } validates :noteable_type, inclusion: { in: ['Commit', 'MergeRequest'] }
validates :resolved_by, presence: true, if: :resolved?
validate :positions_complete validate :positions_complete
validate :verify_supported validate :verify_supported
after_initialize :ensure_original_discussion_id
before_validation :set_original_position, :update_position, on: :create before_validation :set_original_position, :update_position, on: :create
before_validation :set_line_code before_validation :set_line_code, :set_original_discussion_id
after_save :keep_around_commits after_save :keep_around_commits
class << self class << self
...@@ -30,14 +32,6 @@ class DiffNote < Note ...@@ -30,14 +32,6 @@ class DiffNote < Note
{ position: position.to_json } { position: position.to_json }
end 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) def position=(new_position)
if new_position.is_a?(String) if new_position.is_a?(String)
new_position = JSON.parse(new_position) rescue nil new_position = JSON.parse(new_position) rescue nil
...@@ -72,10 +66,48 @@ class DiffNote < Note ...@@ -72,10 +66,48 @@ class DiffNote < Note
self.position.diff_refs == diff_refs self.position.diff_refs == diff_refs
end 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 private
def supported? def supported?
!self.for_merge_request? || self.noteable.has_complete_diff_refs? for_commit? || self.noteable.has_complete_diff_refs?
end end
def noteable_diff_refs def noteable_diff_refs
...@@ -94,6 +126,26 @@ class DiffNote < Note ...@@ -94,6 +126,26 @@ class DiffNote < Note
self.line_code = self.position.line_code(self.project.repository) self.line_code = self.position.line_code(self.project.repository)
end 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 def update_position
return unless supported? return unless supported?
return if for_commit? return if for_commit?
......
class Discussion class Discussion
NUMBER_OF_TRUNCATED_DIFF_LINES = 16 NUMBER_OF_TRUNCATED_DIFF_LINES = 16
attr_reader :first_note, :notes attr_reader :first_note, :last_note, :notes
delegate :created_at, delegate :created_at,
:project, :project,
...@@ -18,6 +18,12 @@ class Discussion ...@@ -18,6 +18,12 @@ class Discussion
to: :first_note 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 delegate :blob, :highlighted_diff_lines, to: :diff_file, allow_nil: true
def self.for_notes(notes) def self.for_notes(notes)
...@@ -30,13 +36,30 @@ class Discussion ...@@ -30,13 +36,30 @@ class Discussion
def initialize(notes) def initialize(notes)
@first_note = notes.first @first_note = notes.first
@last_note = notes.last
@notes = notes @notes = notes
end 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 def id
first_note.discussion_id first_note.discussion_id
end end
alias_method :to_param, :id
def diff_discussion? def diff_discussion?
first_note.diff_note? first_note.diff_note?
end end
...@@ -45,6 +68,50 @@ class Discussion ...@@ -45,6 +68,50 @@ class Discussion
notes.any?(&:legacy_diff_note?) notes.any?(&:legacy_diff_note?)
end 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) def for_target?(target)
self.noteable == target && !diff_discussion? self.noteable == target && !diff_discussion?
end end
...@@ -55,8 +122,20 @@ class Discussion ...@@ -55,8 +122,20 @@ class Discussion
@active = first_note.active? @active = first_note.active?
end 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? def expanded?
!diff_discussion? || active? !collapsed?
end end
def reply_attributes def reply_attributes
......
...@@ -8,8 +8,8 @@ class LegacyDiffNote < Note ...@@ -8,8 +8,8 @@ class LegacyDiffNote < Note
before_create :set_diff before_create :set_diff
class << self class << self
def build_discussion_id(noteable_type, noteable_id, line_code, active = true) def build_discussion_id(noteable_type, noteable_id, line_code)
[super(noteable_type, noteable_id), line_code, active].join("-") [super(noteable_type, noteable_id), line_code].join("-")
end end
end end
...@@ -21,10 +21,6 @@ class LegacyDiffNote < Note ...@@ -21,10 +21,6 @@ class LegacyDiffNote < Note
{ line_code: line_code } { line_code: line_code }
end end
def discussion_id
@discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
end
def project_repository def project_repository
if RequestStore.active? if RequestStore.active?
RequestStore.fetch("project:#{project_id}:repository") { self.project.repository } RequestStore.fetch("project:#{project_id}:repository") { self.project.repository }
...@@ -119,4 +115,8 @@ class LegacyDiffNote < Note ...@@ -119,4 +115,8 @@ class LegacyDiffNote < Note
diffs = noteable.raw_diffs(Commit.max_diff_options) diffs = noteable.raw_diffs(Commit.max_diff_options)
diffs.find { |d| d.new_path == self.diff.new_path } diffs.find { |d| d.new_path == self.diff.new_path }
end end
def build_discussion_id
self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
end
end end
...@@ -418,6 +418,32 @@ class MergeRequest < ActiveRecord::Base ...@@ -418,6 +418,32 @@ class MergeRequest < ActiveRecord::Base
) )
end 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 def hook_attrs
attrs = { attrs = {
source: source_project.try(:hook_attrs), source: source_project.try(:hook_attrs),
......
...@@ -25,6 +25,9 @@ class Note < ActiveRecord::Base ...@@ -25,6 +25,9 @@ class Note < ActiveRecord::Base
belongs_to :author, class_name: "User" belongs_to :author, class_name: "User"
belongs_to :updated_by, 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 :todos, dependent: :destroy
has_many :events, as: :target, dependent: :destroy has_many :events, as: :target, dependent: :destroy
...@@ -59,7 +62,7 @@ class Note < ActiveRecord::Base ...@@ -59,7 +62,7 @@ class Note < ActiveRecord::Base
scope :fresh, ->{ order(created_at: :asc, id: :asc) } scope :fresh, ->{ order(created_at: :asc, id: :asc) }
scope :inc_author_project, ->{ includes(:project, :author) } scope :inc_author_project, ->{ includes(:project, :author) }
scope :inc_author, ->{ includes(: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 :diff_notes, ->{ where(type: ['LegacyDiffNote', 'DiffNote']) }
scope :non_diff_notes, ->{ where(type: ['Note', nil]) } scope :non_diff_notes, ->{ where(type: ['Note', nil]) }
...@@ -70,7 +73,9 @@ class Note < ActiveRecord::Base ...@@ -70,7 +73,9 @@ class Note < ActiveRecord::Base
project: [:project_members, { group: [:group_members] }]) project: [:project_members, { group: [:group_members] }])
end end
after_initialize :ensure_discussion_id
before_validation :nullify_blank_type, :nullify_blank_line_code before_validation :nullify_blank_type, :nullify_blank_line_code
before_validation :set_discussion_id
after_save :keep_around_commit after_save :keep_around_commit
class << self class << self
...@@ -82,13 +87,18 @@ class Note < ActiveRecord::Base ...@@ -82,13 +87,18 @@ class Note < ActiveRecord::Base
[:discussion, noteable_type.try(:underscore), noteable_id].join("-") [:discussion, noteable_type.try(:underscore), noteable_id].join("-")
end end
def discussion_id(*args)
Digest::SHA1.hexdigest(build_discussion_id(*args))
end
def discussions def discussions
Discussion.for_notes(all) Discussion.for_notes(all)
end end
def grouped_diff_discussions def grouped_diff_discussions
notes = diff_notes.fresh.select(&:active?) active_notes = diff_notes.fresh.select(&:active?)
Discussion.for_diff_notes(notes).map { |d| [d.line_code, d] }.to_h Discussion.for_diff_notes(active_notes).
map { |d| [d.line_code, d] }.to_h
end end
# Searches for notes matching the given query. # Searches for notes matching the given query.
...@@ -129,13 +139,16 @@ class Note < ActiveRecord::Base ...@@ -129,13 +139,16 @@ class Note < ActiveRecord::Base
true true
end end
def discussion_id def resolvable?
@discussion_id ||= false
if for_merge_request? end
[:discussion, :note, id].join("-")
else def resolved?
self.class.build_discussion_id(noteable_type, noteable_id || commit_id) false
end end
def to_be_resolved?
resolvable? && !resolved?
end end
def max_attachment_size def max_attachment_size
...@@ -243,4 +256,26 @@ class Note < ActiveRecord::Base ...@@ -243,4 +256,26 @@ class Note < ActiveRecord::Base
def nullify_blank_line_code def nullify_blank_line_code
self.line_code = nil if self.line_code.blank? self.line_code = nil if self.line_code.blank?
end end
def ensure_discussion_id
return unless self.persisted?
return if self.discussion_id
set_discussion_id
update_column(:discussion_id, self.discussion_id)
end
def set_discussion_id
self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id)
end
def build_discussion_id
if for_merge_request?
# Notes on merge requests are always in a discussion of their own,
# so we generate a unique discussion ID.
[:discussion, :note, SecureRandom.hex].join("-")
else
self.class.build_discussion_id(noteable_type, noteable_id || commit_id)
end
end
end end
...@@ -3,18 +3,19 @@ ...@@ -3,18 +3,19 @@
class U2fRegistration < ActiveRecord::Base class U2fRegistration < ActiveRecord::Base
belongs_to :user 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) u2f = U2F::U2F.new(app_id)
registration = self.new registration = self.new
begin 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_data = u2f.register!(challenges, response)
registration.update(certificate: registration_data.certificate, registration.update(certificate: registration_data.certificate,
key_handle: registration_data.key_handle, key_handle: registration_data.key_handle,
public_key: registration_data.public_key, public_key: registration_data.public_key,
counter: registration_data.counter, counter: registration_data.counter,
user: user) user: user,
name: params[:name])
rescue JSON::ParserError, NoMethodError, ArgumentError rescue JSON::ParserError, NoMethodError, ArgumentError
registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.') registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.')
rescue U2F::Error => e rescue U2F::Error => e
......
...@@ -69,14 +69,9 @@ class IssuableBaseService < BaseService ...@@ -69,14 +69,9 @@ class IssuableBaseService < BaseService
end end
def filter_labels def filter_labels
if params[:add_label_ids].present? || params[:remove_label_ids].present? filter_labels_in_param(:add_label_ids)
params.delete(:label_ids) filter_labels_in_param(:remove_label_ids)
filter_labels_in_param(:label_ids)
filter_labels_in_param(:add_label_ids)
filter_labels_in_param(:remove_label_ids)
else
filter_labels_in_param(:label_ids)
end
end end
def filter_labels_in_param(key) def filter_labels_in_param(key)
...@@ -85,27 +80,86 @@ class IssuableBaseService < BaseService ...@@ -85,27 +80,86 @@ class IssuableBaseService < BaseService
params[key] = project.labels.where(id: params[key]).pluck(:id) params[key] = project.labels.where(id: params[key]).pluck(:id)
end 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 issuable.with_transaction_returning_status do
add_label_ids = attributes.delete(:add_label_ids) if issuable.save
remove_label_ids = attributes.delete(:remove_label_ids) issuable.update_attributes(label_ids: label_ids)
end
end
end
issuable.label_ids |= add_label_ids if add_label_ids def create(issuable)
issuable.label_ids -= remove_label_ids if remove_label_ids 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
end end
def update(issuable) def update(issuable)
change_state(issuable) change_state(issuable)
change_subscription(issuable) change_subscription(issuable)
change_todo(issuable)
filter_params filter_params
old_labels = issuable.labels.to_a old_labels = issuable.labels.to_a
old_mentioned_users = issuable.mentioned_users.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) if params.present? && update_issuable(issuable, params)
issuable.reset_events_cache issuable.reset_events_cache
handle_common_system_notes(issuable, old_labels: old_labels) handle_common_system_notes(issuable, old_labels: old_labels)
...@@ -135,6 +189,16 @@ class IssuableBaseService < BaseService ...@@ -135,6 +189,16 @@ class IssuableBaseService < BaseService
end end
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: []) def has_changes?(issuable, old_labels: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
......
module Issues module Issues
class CloseService < Issues::BaseService class CloseService < Issues::BaseService
def execute(issue, commit: nil, notifications: true, system_note: true) 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 if project.jira_tracker? && project.jira_service.active
project.jira_service.execute(commit, issue) project.jira_service.execute(commit, issue)
todo_service.close_issue(issue, current_user) todo_service.close_issue(issue, current_user)
......
module Issues module Issues
class CreateService < Issues::BaseService class CreateService < Issues::BaseService
def execute def execute
filter_params
label_params = params.delete(:label_ids)
@request = params.delete(:request) @request = params.delete(:request)
@api = params.delete(:api) @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 create(@issue)
@issue.update_attributes(label_ids: label_params) end
notification_service.new_issue(@issue, current_user)
todo_service.new_issue(@issue, current_user) def before_create(issuable)
event_service.open_issue(@issue, current_user) issuable.spam = spam_service.check(@api)
user_agent_detail_service.create end
@issue.create_cross_references!(current_user)
execute_hooks(@issue, 'open')
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 end
private private
......
module Issues module Issues
class ReopenService < Issues::BaseService class ReopenService < Issues::BaseService
def execute(issue) def execute(issue)
return issue unless can?(current_user, :update_issue, issue)
if issue.reopen if issue.reopen
event_service.reopen_issue(issue, current_user) event_service.reopen_issue(issue, current_user)
create_note(issue) create_note(issue)
......
module MergeRequests module MergeRequests
class CloseService < MergeRequests::BaseService class CloseService < MergeRequests::BaseService
def execute(merge_request, commit = nil) 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 # If we close MergeRequest we want to ignore validation
# so we can close broken one (Ex. fork project removed) # so we can close broken one (Ex. fork project removed)
merge_request.allow_broken = true merge_request.allow_broken = true
......
...@@ -7,26 +7,19 @@ module MergeRequests ...@@ -7,26 +7,19 @@ module MergeRequests
source_project = @project source_project = @project
@project = Project.find(params[:target_project_id]) if params[:target_project_id] @project = Project.find(params[:target_project_id]) if params[:target_project_id]
filter_params params[:target_project_id] ||= source_project.id
label_params = params.delete(:label_ids)
force_remove_source_branch = params.delete(:force_remove_source_branch)
merge_request = MergeRequest.new(params) merge_request = MergeRequest.new
merge_request.source_project = source_project merge_request.source_project = source_project
merge_request.target_project ||= source_project merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch)
merge_request.author = current_user
merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch
if merge_request.save create(merge_request)
merge_request.update_attributes(label_ids: label_params) end
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
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 end
end end
module MergeRequests module MergeRequests
class ReopenService < MergeRequests::BaseService class ReopenService < MergeRequests::BaseService
def execute(merge_request) def execute(merge_request)
return merge_request unless can?(current_user, :update_merge_request, merge_request)
if merge_request.reopen if merge_request.reopen
event_service.reopen_mr(merge_request, current_user) event_service.reopen_mr(merge_request, current_user)
create_note(merge_request) create_note(merge_request)
......
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
...@@ -11,10 +11,33 @@ module Notes ...@@ -11,10 +11,33 @@ module Notes
return noteable.create_award_emoji(note.award_emoji_name, current_user) return noteable.create_award_emoji(note.award_emoji_name, current_user)
end 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 # Finish the harder work in the background
NewNoteWorker.perform_in(2.seconds, note.id, params) 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 end
note note
......
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
...@@ -148,6 +148,14 @@ class NotificationService ...@@ -148,6 +148,14 @@ class NotificationService
) )
end 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 # Notify new user with email after creation
def new_user(user, token = nil) def new_user(user, token = nil)
# Don't email omniauth created users # Don't email omniauth created users
......
module Projects module Projects
class AutocompleteService < BaseService class AutocompleteService < BaseService
def issues 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 end
def milestones def milestones
...@@ -9,11 +9,34 @@ module Projects ...@@ -9,11 +9,34 @@ module Projects
end end
def merge_requests 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 end
def labels def labels
@project.labels.select([:title, :color]) @project.labels.select([:title, :color])
end 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
end end
module Projects module Projects
class ParticipantsService < BaseService class ParticipantsService < BaseService
def execute(noteable_type, noteable_id) attr_reader :noteable
@noteable_type = noteable_type
@noteable_id = noteable_id def execute(noteable)
@noteable = noteable
project_members = sorted(project.team.members) 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 participants.uniq
end end
def target def noteable_owner
@target ||= return [] unless noteable && noteable.author.present?
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?
[{ [{
name: target.author.name, name: noteable.author.name,
username: target.author.username username: noteable.author.username
}] }]
end end
def participants_in_target def participants_in_noteable
return [] unless target return [] unless noteable
users = target.participants(current_user) users = noteable.participants(current_user)
sorted(users) sorted(users)
end end
......
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 '<New title>'
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 '<in 2 days | this Friday | December 31st>'
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
...@@ -158,6 +158,12 @@ module SystemNoteService ...@@ -158,6 +158,12 @@ module SystemNoteService
create_note(noteable: noteable, project: project, author: author, note: body) create_note(noteable: noteable, project: project, author: author, note: body)
end 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 # Called when the title of a Noteable is changed
# #
# noteable - Noteable object that responds to `title` # noteable - Noteable object that responds to `title`
......
...@@ -142,7 +142,11 @@ class TodoService ...@@ -142,7 +142,11 @@ class TodoService
# When user marks some todos as done # When user marks some todos as done
def mark_todos_as_done(todos, current_user) 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) marked_todos = todos.update_all(state: :done)
current_user.update_todos_count_cache current_user.update_todos_count_cache
...@@ -155,6 +159,10 @@ class TodoService ...@@ -155,6 +159,10 @@ class TodoService
create_todos(current_user, attributes) create_todos(current_user, attributes)
end end
def todo_exist?(issuable, current_user)
TodosFinder.new(current_user).execute.exists?(target: issuable)
end
private private
def create_todos(users, attributes) def create_todos(users, attributes)
......
- reporter = abuse_report.reporter - reporter = abuse_report.reporter
- user = abuse_report.user - user = abuse_report.user
%tr %tr
%th.visible-xs-block.visible-sm-block
%strong User
%td %td
- if user - if user
= link_to user.name, user = link_to user.name, user
...@@ -9,6 +11,7 @@ ...@@ -9,6 +11,7 @@
- else - else
(removed) (removed)
%td %td
%strong.subheading.visible-xs-block.visible-sm-block Reported by
- if reporter - if reporter
= link_to reporter.name, reporter = link_to reporter.name, reporter
- else - else
...@@ -16,16 +19,16 @@ ...@@ -16,16 +19,16 @@
.light.small .light.small
= time_ago_with_tooltip(abuse_report.created_at) = time_ago_with_tooltip(abuse_report.created_at)
%td %td
= markdown(abuse_report.message.squish!, pipeline: :single_line, author: reporter) %strong.subheading.visible-xs-block.visible-sm-block Message
.message
= markdown(abuse_report.message.squish!, pipeline: :single_line, author: reporter)
%td %td
- if user - if user
= link_to 'Remove user & report', admin_abuse_report_path(abuse_report, remove_user: true), = link_to 'Remove user & report', admin_abuse_report_path(abuse_report, remove_user: true),
data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-xs btn-remove js-remove-tr" data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-sm btn-block btn-remove js-remove-tr"
%td
- if user && !user.blocked? - if user && !user.blocked?
= link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs" = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-sm btn-block"
- else - else
.btn.btn-xs.disabled .btn.btn-sm.disabled.btn-block
Already Blocked Already Blocked
= link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-xs btn-close js-remove-tr" = link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr"
- page_title "Abuse Reports" - page_title 'Abuse Reports'
%h3.page-title Abuse Reports %h3.page-title Abuse Reports
%hr %hr
- if @abuse_reports.present? .abuse-reports
.table-holder - if @abuse_reports.present?
%table.table .table-holder
%thead %table.table
%tr %thead.hidden-sm.hidden-xs
%th User %tr
%th Reported by %th User
%th Message %th Reported by
%th Primary action %th.wide Message
%th %th Action
= render @abuse_reports = render @abuse_reports
= paginate @abuse_reports - else
- else .no-reports
%h4 There are no abuse reports %span.pull-left
There are no abuse reports!
.pull-left
= emoji_icon 'tada'
%tr.notes_holder - expanded = local_assigns.fetch(:expanded, true)
%tr.notes_holder{class: ('hide' unless expanded)}
%td.notes_line{ colspan: 2 } %td.notes_line{ colspan: 2 }
%td.notes_content %td.notes_content
%ul.notes{ data: { discussion_id: discussion.id } } .content
= render partial: "projects/notes/note", collection: discussion.notes, as: :note = render "discussions/notes", discussion: discussion
= link_to_reply_discussion(discussion)
...@@ -7,8 +7,11 @@ ...@@ -7,8 +7,11 @@
.diff-content.code.js-syntax-highlight .diff-content.code.js-syntax-highlight
%table %table
- discussion.truncated_diff_lines.each do |line| - discussions = { discussion.line_code => discussion }
= render "projects/diffs/line", line: line, diff_file: diff_file, plain: true = render partial: "projects/diffs/line",
collection: discussion.truncated_diff_lines,
- if discussion.for_line?(line) as: :line,
= render "discussions/diff_discussion", discussion: discussion locals: { diff_file: diff_file,
discussions: discussions,
discussion_expanded: true,
plain: true }
...@@ -5,8 +5,17 @@ ...@@ -5,8 +5,17 @@
= link_to user_path(discussion.author) do = link_to user_path(discussion.author) do
= image_tag avatar_icon(discussion.author), class: "avatar s40" = image_tag avatar_icon(discussion.author), class: "avatar s40"
.timeline-content .timeline-content
.discussion.js-toggle-container{ class: discussion.id } .discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } }
.discussion-header .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) = link_to_member(@project, discussion.author, avatar: false)
.inline.discussion-headline-light .inline.discussion-headline-light
...@@ -29,17 +38,11 @@ ...@@ -29,17 +38,11 @@
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago") = time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
.discussion-actions = render "discussions/headline", discussion: discussion
= link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do
- if expanded
= icon("chevron-up")
- else
= icon("chevron-down")
Toggle discussion
.discussion-body.js-toggle-content{ class: ("hide" unless expanded) } .discussion-body.js-toggle-content{ class: ("hide" unless expanded) }
- if discussion.diff_discussion? && discussion.diff_file - if discussion.diff_discussion? && discussion.diff_file
= render "discussions/diff_with_notes", discussion: discussion = render "discussions/diff_with_notes", discussion: discussion
- else - else
= render "discussions/notes", discussion: discussion .panel.panel-default
= render "discussions/notes", discussion: discussion
- 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")
- 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")
.panel.panel-default %ul.notes{ data: { discussion_id: discussion.id } }
.notes{ data: { discussion_id: discussion.id } } = render partial: "projects/notes/note", collection: discussion.notes, as: :note
%ul.notes.timeline
= render partial: "projects/notes/note", collection: discussion.notes, as: :note - if current_user
= link_to_reply_discussion(discussion) .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)
%tr.notes_holder - expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?)
%tr.notes_holder{class: ('hide' unless expanded)}
- if discussion_left - if discussion_left
%td.notes_line.old %td.notes_line.old
%td.notes_content.parallel.old %td.notes_content.parallel.old
%ul.notes{ data: { discussion_id: discussion_left.id } } .content{class: ('hide' unless discussion_left.expanded?)}
= render partial: "projects/notes/note", collection: discussion_left.notes, as: :note = render "discussions/notes", discussion: discussion_left, line_type: 'old'
= link_to_reply_discussion(discussion_left, 'old')
- else - else
%td.notes_line.old= "" %td.notes_line.old= ""
%td.notes_content.parallel.old= "" %td.notes_content.parallel.old
.content
- if discussion_right - if discussion_right
%td.notes_line.new %td.notes_line.new
%td.notes_content.parallel.new %td.notes_content.parallel.new
%ul.notes{ data: { discussion_id: discussion_right.id } } .content{class: ('hide' unless discussion_right.expanded?)}
= render partial: "projects/notes/note", collection: discussion_right.notes, as: :note = render "discussions/notes", discussion: discussion_right, line_type: 'new'
= link_to_reply_discussion(discussion_right, 'new')
- else - else
%td.notes_line.new= "" %td.notes_line.new= ""
%td.notes_content.parallel.new= "" %td.notes_content.parallel.new
.content
- 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 }}
- project = @target_project || @project - project = @target_project || @project
- noteable_class = @noteable.class if @noteable.present? - noteable_type = @noteable.class if @noteable.present?
:javascript :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.cachedData = undefined;
GitLab.GfmAutoComplete.setup(); GitLab.GfmAutoComplete.setup();
...@@ -75,8 +75,7 @@ ...@@ -75,8 +75,7 @@
- blob = diff_file.blob - blob = diff_file.blob
- if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob) - if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob)
%table.code.white %table.code.white
- diff_file.highlighted_diff_lines.each do |line| = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true }
= render "projects/diffs/line", line: line, diff_file: diff_file, plain: true
- else - else
No preview for this file type No preview for this file type
%br %br
%p
All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{@resolved_by.name}
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)) %>
...@@ -7,6 +7,10 @@ ...@@ -7,6 +7,10 @@
= page_title = page_title
%p %p
You can generate a personal access token for each application you use that needs access to the GitLab API. 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 .col-lg-9
- if flash[:personal_access_token] - if flash[:personal_access_token]
......
...@@ -60,13 +60,38 @@ ...@@ -60,13 +60,38 @@
two-factor authentication app before a U2F device. That way you'll always be able to 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. log in - even when you're using an unsupported browser.
.col-lg-9 .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? - if @u2f_registration.errors.present?
= form_errors(@u2f_registration) = form_errors(@u2f_registration)
= render "u2f/register" = 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 || "<no name set>"
%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? - if two_factor_skippable?
:javascript :javascript
var button = "<a class='btn btn-xs btn-warning pull-right' data-method='patch' href='#{skip_profile_two_factor_auth_path}'>Configure it later</a>"; var button = "<a class='btn btn-xs btn-warning pull-right' data-method='patch' href='#{skip_profile_two_factor_auth_path}'>Configure it later</a>";
......
- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
.zen-backdrop .zen-backdrop
- classes << ' js-gfm-input js-autosize markdown-area' - classes << ' js-gfm-input js-autosize markdown-area'
- if defined?(f) && f - 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 - else
= text_area_tag attr, nil, class: classes, placeholder: placeholder = text_area_tag attr, nil, class: classes, placeholder: placeholder
%a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" }
......
...@@ -10,10 +10,9 @@ ...@@ -10,10 +10,9 @@
\ \
- if editable_diff?(diff_file) - if editable_diff?(diff_file)
= edit_blob_link(@merge_request.source_project, - link_opts = @merge_request.id ? { from_merge_request_id: @merge_request.id } : {}
@merge_request.source_branch, diff_file.new_path, = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
from_merge_request_id: @merge_request.id, blob: blob, link_opts: link_opts)
skip_visible_check: true)
= view_file_btn(diff_commit.id, diff_file.new_path, project) = view_file_btn(diff_commit.id, diff_file.new_path, project)
......
- email = local_assigns.fetch(:email, false)
- plain = local_assigns.fetch(:plain, false) - plain = local_assigns.fetch(:plain, false)
- type = line.type - 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 } } %tr.line_holder{ plain ? { class: type} : { class: type, id: line_code } }
- case type - case type
- when 'match' - when 'match'
...@@ -22,4 +23,15 @@ ...@@ -22,4 +23,15 @@
= link_text = link_text
- else - else
%a{href: "##{line_code}", data: { linenumber: link_text }} %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
...@@ -5,15 +5,12 @@ ...@@ -5,15 +5,12 @@
%table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' } %table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' }
- last_line = 0 - last_line = 0
- diff_file.highlighted_diff_lines.each do |line| - discussions = @grouped_diff_discussions unless @diff_notes_disabled
- last_line = line.new_pos = render partial: "projects/diffs/line",
= render "projects/diffs/line", line: line, diff_file: diff_file collection: diff_file.highlighted_diff_lines,
as: :line,
- unless @diff_notes_disabled locals: { diff_file: diff_file, discussions: discussions }
- line_code = diff_file.line_code(line)
- discussion = @grouped_diff_discussions[line_code] if line_code
- if discussion
= render "discussions/diff_discussion", discussion: discussion
- last_line = diff_file.highlighted_diff_lines.last.new_pos
- if !diff_file.new_file && last_line > 0 - if !diff_file.new_file && last_line > 0
= diff_match_line last_line, last_line, bottom: true = diff_match_line last_line, last_line, bottom: true
...@@ -4,5 +4,8 @@ ...@@ -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"} = 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? - 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"} = 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" #notes= render "projects/notes/notes_with_form"
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
.mr-compare.merge-request .mr-compare.merge-request
%ul.merge-request-tabs.nav-links.no-top.no-bottom %ul.merge-request-tabs.nav-links.no-top.no-bottom
%li.commits-tab %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 Commits
%span.badge= @commits.size %span.badge= @commits.size
- if @pipeline - if @pipeline
...@@ -52,11 +52,8 @@ ...@@ -52,11 +52,8 @@
$('#merge_request_assignee_id').val("#{current_user.id}").trigger("change"); $('#merge_request_assignee_id').val("#{current_user.id}").trigger("change");
e.preventDefault(); e.preventDefault();
}); });
:javascript :javascript
var merge_request var merge_request = new MergeRequest({
merge_request = new MergeRequest({ action: "#{(@show_changes_tab ? 'diffs' : 'new')}",
action: 'new', setUrl: false
diffs_loaded: true,
commits_loaded: true
}); });
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" - page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
- page_description @merge_request.description - page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes - 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 - if diff_view == :parallel
- fluid_layout true - fluid_layout true
...@@ -65,8 +67,18 @@ ...@@ -65,8 +67,18 @@
= 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 Changes
%span.badge= @merge_request.diff_size %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 #notes.notes.tab-pane.voting_notes
.content-block.content-block-small.oneline-block .content-block.content-block-small.oneline-block
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true = render 'award_emoji/awards_block', awardable: @merge_request, inline: true
......
= 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 :view, diff_view
= hidden_field_tag :line_type = hidden_field_tag :line_type
= note_target_fields(@note) = note_target_fields(@note)
...@@ -10,8 +10,12 @@ ...@@ -10,8 +10,12 @@
= f.hidden_field :position = f.hidden_field :position
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = 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/zen', f: f,
= render 'projects/notes/hints' 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 .error-alert
.note-form-actions.clearfix .note-form-actions.clearfix
......
- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
.comment-toolbar.clearfix .comment-toolbar.clearfix
.toolbar-text .toolbar-text
Styling with Styling with
= link_to 'Markdown', help_page_path('markdown/markdown'), target: '_blank', tabindex: -1 = link_to 'Markdown', help_page_path('markdown/markdown'), target: '_blank', tabindex: -1
is supported - if supports_slash_commands
and
= link_to 'slash commands', help_page_path('workflow/slash_commands'), target: '_blank', tabindex: -1
are
- else
is
supported
%button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' } %button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' }
= icon('file-image-o', class: 'toolbar-button-icon') = icon('file-image-o', class: 'toolbar-button-icon')
Attach a file Attach a file
\ No newline at end of file
- return unless note.author - return unless note.author
- return if note.cross_reference_not_visible_for?(current_user) - return if note.cross_reference_not_visible_for?(current_user)
- can_resolve = can?(current_user, :resolve_note, note)
- note_editable = note_editable?(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} } %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 @@ ...@@ -16,19 +17,48 @@
commented commented
%a{ href: "##{dom_id(note)}" } %a{ href: "##{dom_id(note)}" }
= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
.note-actions - unless note.system?
- access = note_max_access_for_user(note) .note-actions
- if access and not note.system - access = note_max_access_for_user(note)
%span.note-role.hidden-xs= access - if access
- if current_user and not note.system %span.note-role.hidden-xs= access
= 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') - if note.resolvable?
= icon('smile-o') %resolve-btn{ ":namespace-path" => "'#{note.project.namespace.path}'",
- if note_editable ":project-path" => "'#{note.project.path}'",
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do ":discussion-id" => "'#{note.discussion_id}'",
= icon('pencil') ":note-id" => note.id,
= 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 ":resolved" => note.resolved?,
= icon('trash-o') ":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-body{class: note_editable ? 'js-task-list-container' : ''}
.note-text.md .note-text.md
= preserve do = preserve do
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment