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 ...
This commit is contained in:
commit
9c9259cc6a
17
CHANGELOG
17
CHANGELOG
|
|
@ -11,6 +11,7 @@ v 8.11.0 (unreleased)
|
|||
- Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz)
|
||||
- Update to Ruby 2.3.1. !4948
|
||||
- Add Issues Board !5548
|
||||
- Allow resolving merge conflicts in the UI !5479
|
||||
- Improve diff performance by eliminating redundant checks for text blobs
|
||||
- Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi)
|
||||
- Convert switch icon into icon font (ClemMakesApps)
|
||||
|
|
@ -20,6 +21,7 @@ v 8.11.0 (unreleased)
|
|||
- Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell)
|
||||
- GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository
|
||||
- Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell)
|
||||
- Allow naming U2F devices !5833
|
||||
- Ignore URLs starting with // in Markdown links !5677 (winniehell)
|
||||
- Fix CI status icon link underline (ClemMakesApps)
|
||||
- The Repository class is now instrumented
|
||||
|
|
@ -29,6 +31,9 @@ v 8.11.0 (unreleased)
|
|||
- Expand commit message width in repo view (ClemMakesApps)
|
||||
- Cache highlighted diff lines for merge requests
|
||||
- Pre-create all builds for a Pipeline when the new Pipeline is created !5295
|
||||
- Allow merge request diff notes and discussions to be explicitly marked as resolved
|
||||
- API: Add deployment endpoints
|
||||
- API: Add Play endpoint on Builds
|
||||
- Fix of 'Commits being passed to custom hooks are already reachable when using the UI'
|
||||
- Show member roles to all users on members page
|
||||
- Project.visible_to_user is instrumented again
|
||||
|
|
@ -48,12 +53,15 @@ v 8.11.0 (unreleased)
|
|||
- Get issue and merge request description templates from repositories
|
||||
- Add hover state to todos !5361 (winniehell)
|
||||
- Fix icon alignment of star and fork buttons !5451 (winniehell)
|
||||
- Enforce 2FA restrictions on API authentication endpoints !5820
|
||||
- Limit git rev-list output count to one in forced push check
|
||||
- Show deployment status on merge requests with external URLs
|
||||
- Clean up unused routes (Josef Strzibny)
|
||||
- Fix issue on empty project to allow developers to only push to protected branches if given permission
|
||||
- API: Add enpoints for pipelines
|
||||
- Add green outline to New Branch button. !5447 (winniehell)
|
||||
- Optimize generating of cache keys for issues and notes
|
||||
- Fix repository push email formatting in Outlook
|
||||
- Improve performance of syntax highlighting Markdown code blocks
|
||||
- Update to gitlab_git 10.4.1 and take advantage of preserved Ref objects
|
||||
- Remove delay when hitting "Reply..." button on page with a lot of discussions
|
||||
|
|
@ -62,9 +70,12 @@ v 8.11.0 (unreleased)
|
|||
- Upgrade Grape from 0.13.0 to 0.15.0. !4601
|
||||
- Trigram indexes for the "ci_runners" table have been removed to speed up UPDATE queries
|
||||
- Fix devise deprecation warnings.
|
||||
- Check for 2FA when using Git over HTTP and only allow PersonalAccessTokens as password in that case !5764
|
||||
- Update version_sorter and use new interface for faster tag sorting
|
||||
- Optimize checking if a user has read access to a list of issues !5370
|
||||
- Store all DB secrets in secrets.yml, under descriptive names !5274
|
||||
- Fix syntax highlighting in file editor
|
||||
- Support slash commands in issue and merge request descriptions as well as comments. !5021
|
||||
- Nokogiri's various parsing methods are now instrumented
|
||||
- Add archived badge to project list !5798
|
||||
- Add simple identifier to public SSH keys (muteor)
|
||||
|
|
@ -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)
|
||||
- Load project invited groups and members eagerly in `ProjectTeam#fetch_members`
|
||||
- Add pipeline events hook
|
||||
- Award emoji tooltips containing more than 10 usernames are now truncated !4780 (jlogandavison)
|
||||
- Fix duplicate "me" in award emoji tooltip !5218 (jlogandavison)
|
||||
- Bump gitlab_git to speedup DiffCollection iterations
|
||||
- Rewrite description of a blocked user in admin settings. (Elias Werberich)
|
||||
- Make branches sortable without push permission !5462 (winniehell)
|
||||
|
|
@ -104,6 +117,7 @@ v 8.11.0 (unreleased)
|
|||
- Add commit stats in commit api. !5517 (dixpac)
|
||||
- Add CI configuration button on project page
|
||||
- Fix merge request new view not changing code view rendering style
|
||||
- edit_blob_link will use blob passed onto the options parameter
|
||||
- Make error pages responsive (Takuya Noguchi)
|
||||
- The performance of the project dropdown used for moving issues has been improved
|
||||
- Fix skip_repo parameter being ignored when destroying a namespace
|
||||
|
|
@ -127,10 +141,13 @@ v 8.11.0 (unreleased)
|
|||
- Sort folders with submodules in Files view !5521
|
||||
- Each `File::exists?` replaced to `File::exist?` because of deprecate since ruby version 2.2.0
|
||||
- Add auto-completition in pipeline (Katarzyna Kobierska Ula Budziszewska)
|
||||
- Add pipelines tab to merge requests
|
||||
- Fix a memory leak caused by Banzai::Filter::SanitizationFilter
|
||||
- Speed up todos queries by limiting the projects set we join with
|
||||
- Ensure file editing in UI does not overwrite commited changes without warning user
|
||||
- Eliminate unneeded calls to Repository#blob_at when listing commits with no path
|
||||
- Update gitlab_git gem to 10.4.7
|
||||
- Simplify SQL queries of marking a todo as done
|
||||
|
||||
v 8.10.6
|
||||
- Upgrade Rails to 4.2.7.1 for security fixes. !5781
|
||||
|
|
|
|||
11
Gemfile
11
Gemfile
|
|
@ -20,7 +20,7 @@ gem 'pg', '~> 0.18.2', group: :postgres
|
|||
|
||||
# Authentication libraries
|
||||
gem 'devise', '~> 4.0'
|
||||
gem 'doorkeeper', '~> 4.0'
|
||||
gem 'doorkeeper', '~> 4.2.0'
|
||||
gem 'omniauth', '~> 1.3.1'
|
||||
gem 'omniauth-auth0', '~> 1.4.1'
|
||||
gem 'omniauth-azure-oauth2', '~> 0.0.6'
|
||||
|
|
@ -53,7 +53,7 @@ gem 'browser', '~> 2.2'
|
|||
|
||||
# Extracting information from a git repository
|
||||
# Provide access to Gitlab::Git library
|
||||
gem 'gitlab_git', '~> 10.4.5'
|
||||
gem 'gitlab_git', '~> 10.4.7'
|
||||
|
||||
# LDAP Auth
|
||||
# GitLab fork with several improvements to original library. For full list of changes
|
||||
|
|
@ -77,7 +77,7 @@ gem 'rack-cors', '~> 0.4.0', require: 'rack/cors'
|
|||
gem 'kaminari', '~> 0.17.0'
|
||||
|
||||
# HAML
|
||||
gem 'hamlit', '~> 2.5'
|
||||
gem 'hamlit', '~> 2.6.1'
|
||||
|
||||
# Files attachments
|
||||
gem 'carrierwave', '~> 0.10.0'
|
||||
|
|
@ -201,7 +201,7 @@ gem 'licensee', '~> 8.0.0'
|
|||
gem 'rack-attack', '~> 4.3.1'
|
||||
|
||||
# Ace editor
|
||||
gem 'ace-rails-ap', '~> 4.0.2'
|
||||
gem 'ace-rails-ap', '~> 4.1.0'
|
||||
|
||||
# Keyboard shortcuts
|
||||
gem 'mousetrap-rails', '~> 1.4.6'
|
||||
|
|
@ -209,7 +209,8 @@ gem 'mousetrap-rails', '~> 1.4.6'
|
|||
# Detect and convert string character encoding
|
||||
gem 'charlock_holmes', '~> 0.7.3'
|
||||
|
||||
# Parse duration
|
||||
# Parse time & duration
|
||||
gem 'chronic', '~> 0.10.2'
|
||||
gem 'chronic_duration', '~> 0.10.6'
|
||||
|
||||
gem 'sass-rails', '~> 5.0.0'
|
||||
|
|
|
|||
18
Gemfile.lock
18
Gemfile.lock
|
|
@ -2,7 +2,7 @@ GEM
|
|||
remote: https://rubygems.org/
|
||||
specs:
|
||||
RedCloth (4.3.2)
|
||||
ace-rails-ap (4.0.2)
|
||||
ace-rails-ap (4.1.0)
|
||||
actionmailer (4.2.7.1)
|
||||
actionpack (= 4.2.7.1)
|
||||
actionview (= 4.2.7.1)
|
||||
|
|
@ -128,6 +128,7 @@ GEM
|
|||
mime-types (>= 1.16)
|
||||
cause (0.1)
|
||||
charlock_holmes (0.7.3)
|
||||
chronic (0.10.2)
|
||||
chronic_duration (0.10.6)
|
||||
numerizer (~> 0.1.1)
|
||||
chunky_png (1.3.5)
|
||||
|
|
@ -175,7 +176,7 @@ GEM
|
|||
diff-lcs (1.2.5)
|
||||
diffy (3.0.7)
|
||||
docile (1.1.5)
|
||||
doorkeeper (4.0.0)
|
||||
doorkeeper (4.2.0)
|
||||
railties (>= 4.2)
|
||||
dropzonejs-rails (0.7.2)
|
||||
rails (> 3.1)
|
||||
|
|
@ -278,7 +279,7 @@ GEM
|
|||
diff-lcs (~> 1.1)
|
||||
mime-types (>= 1.16, < 3)
|
||||
posix-spawn (~> 0.3)
|
||||
gitlab_git (10.4.5)
|
||||
gitlab_git (10.4.7)
|
||||
activesupport (~> 4.0)
|
||||
charlock_holmes (~> 0.7.3)
|
||||
github-linguist (~> 4.7.0)
|
||||
|
|
@ -321,7 +322,7 @@ GEM
|
|||
grape-entity (0.4.8)
|
||||
activesupport
|
||||
multi_json (>= 1.3.2)
|
||||
hamlit (2.5.0)
|
||||
hamlit (2.6.1)
|
||||
temple (~> 0.7.6)
|
||||
thor
|
||||
tilt
|
||||
|
|
@ -798,7 +799,7 @@ PLATFORMS
|
|||
|
||||
DEPENDENCIES
|
||||
RedCloth (~> 4.3.2)
|
||||
ace-rails-ap (~> 4.0.2)
|
||||
ace-rails-ap (~> 4.1.0)
|
||||
activerecord-session_store (~> 1.0.0)
|
||||
acts-as-taggable-on (~> 3.4)
|
||||
addressable (~> 2.3.8)
|
||||
|
|
@ -824,6 +825,7 @@ DEPENDENCIES
|
|||
capybara-screenshot (~> 1.0.0)
|
||||
carrierwave (~> 0.10.0)
|
||||
charlock_holmes (~> 0.7.3)
|
||||
chronic (~> 0.10.2)
|
||||
chronic_duration (~> 0.10.6)
|
||||
coffee-rails (~> 4.1.0)
|
||||
connection_pool (~> 2.0)
|
||||
|
|
@ -834,7 +836,7 @@ DEPENDENCIES
|
|||
devise (~> 4.0)
|
||||
devise-two-factor (~> 3.0.0)
|
||||
diffy (~> 3.0.3)
|
||||
doorkeeper (~> 4.0)
|
||||
doorkeeper (~> 4.2.0)
|
||||
dropzonejs-rails (~> 0.7.1)
|
||||
email_reply_parser (~> 0.5.8)
|
||||
email_spec (~> 1.6.0)
|
||||
|
|
@ -857,7 +859,7 @@ DEPENDENCIES
|
|||
github-linguist (~> 4.7.0)
|
||||
github-markup (~> 1.4)
|
||||
gitlab-flowdock-git-hook (~> 1.0.1)
|
||||
gitlab_git (~> 10.4.5)
|
||||
gitlab_git (~> 10.4.7)
|
||||
gitlab_meta (= 7.0)
|
||||
gitlab_omniauth-ldap (~> 1.2.1)
|
||||
gollum-lib (~> 4.2)
|
||||
|
|
@ -865,7 +867,7 @@ DEPENDENCIES
|
|||
gon (~> 6.1.0)
|
||||
grape (~> 0.15.0)
|
||||
grape-entity (~> 0.4.2)
|
||||
hamlit (~> 2.5)
|
||||
hamlit (~> 2.6.1)
|
||||
health_check (~> 2.1.0)
|
||||
hipchat (~> 1.5.0)
|
||||
html-pipeline (~> 1.11.0)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
window.gl = window.gl || {};
|
||||
((global) => {
|
||||
const MAX_MESSAGE_LENGTH = 500;
|
||||
const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
|
||||
|
||||
class AbuseReports {
|
||||
constructor() {
|
||||
$(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage);
|
||||
$(document)
|
||||
.off('click', MESSAGE_CELL_SELECTOR)
|
||||
.on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation);
|
||||
}
|
||||
|
||||
truncateLongMessage() {
|
||||
const $messageCellElement = $(this);
|
||||
const reportMessage = $messageCellElement.text();
|
||||
if (reportMessage.length > MAX_MESSAGE_LENGTH) {
|
||||
$messageCellElement.data('original-message', reportMessage);
|
||||
$messageCellElement.data('message-truncated', 'true');
|
||||
$messageCellElement.text(global.text.truncate(reportMessage, MAX_MESSAGE_LENGTH));
|
||||
}
|
||||
}
|
||||
|
||||
toggleMessageTruncation() {
|
||||
const $messageCellElement = $(this);
|
||||
const originalMessage = $messageCellElement.data('original-message');
|
||||
if (!originalMessage) return;
|
||||
if ($messageCellElement.data('message-truncated') === 'true') {
|
||||
$messageCellElement.data('message-truncated', 'false');
|
||||
$messageCellElement.text(originalMessage);
|
||||
} else {
|
||||
$messageCellElement.data('message-truncated', 'true');
|
||||
$messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.AbuseReports = AbuseReports;
|
||||
})(window.gl);
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
/*= require bootstrap/tooltip */
|
||||
/*= require bootstrap/popover */
|
||||
/*= require select2 */
|
||||
/*= require ace/ace */
|
||||
/*= require ace-rails-ap */
|
||||
/*= require ace/ext-searchbox */
|
||||
/*= require underscore */
|
||||
/*= require dropzone */
|
||||
|
|
@ -225,10 +225,13 @@
|
|||
});
|
||||
$body.on("click", ".js-toggle-diff-comments", function(e) {
|
||||
var $this = $(this);
|
||||
var showComments = $this.hasClass('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();
|
||||
});
|
||||
$document.off("click", '.js-confirm-danger');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
(function() {
|
||||
this.AwardsHandler = (function() {
|
||||
const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; //For separating lists produced by ruby's Array#toSentence
|
||||
function AwardsHandler() {
|
||||
this.aliases = gl.emojiAliases();
|
||||
$(document).off('click', '.js-add-award').on('click', '.js-add-award', (function(_this) {
|
||||
|
|
@ -130,7 +131,7 @@
|
|||
counter = $emojiButton.find('.js-counter');
|
||||
counter.text(parseInt(counter.text()) + 1);
|
||||
$emojiButton.addClass('active');
|
||||
this.addMeToUserList(votesBlock, emoji);
|
||||
this.addYouToUserList(votesBlock, emoji);
|
||||
return this.animateEmoji($emojiButton);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -176,11 +177,11 @@
|
|||
counterNumber = parseInt(counter.text(), 10);
|
||||
if (counterNumber > 1) {
|
||||
counter.text(counterNumber - 1);
|
||||
this.removeMeFromUserList($emojiButton, emoji);
|
||||
this.removeYouFromUserList($emojiButton, emoji);
|
||||
} else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
|
||||
$emojiButton.tooltip('destroy');
|
||||
counter.text('0');
|
||||
this.removeMeFromUserList($emojiButton, emoji);
|
||||
this.removeYouFromUserList($emojiButton, emoji);
|
||||
if ($emojiButton.parents('.note').length) {
|
||||
this.removeEmoji($emojiButton);
|
||||
}
|
||||
|
|
@ -204,43 +205,48 @@
|
|||
return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.removeMeFromUserList = function($emojiButton, emoji) {
|
||||
AwardsHandler.prototype.toSentence = function(list) {
|
||||
if(list.length <= 2){
|
||||
return list.join(' and ');
|
||||
}
|
||||
else{
|
||||
return list.slice(0, -1).join(', ') + ', and ' + list[list.length - 1];
|
||||
}
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.removeYouFromUserList = function($emojiButton, emoji) {
|
||||
var authors, awardBlock, newAuthors, originalTitle;
|
||||
awardBlock = $emojiButton;
|
||||
originalTitle = this.getAwardTooltip(awardBlock);
|
||||
authors = originalTitle.split(', ');
|
||||
authors.splice(authors.indexOf('me'), 1);
|
||||
newAuthors = authors.join(', ');
|
||||
awardBlock.closest('.js-emoji-btn').removeData('original-title').attr('data-original-title', newAuthors);
|
||||
return this.resetTooltip(awardBlock);
|
||||
authors = originalTitle.split(FROM_SENTENCE_REGEX);
|
||||
authors.splice(authors.indexOf('You'), 1);
|
||||
return awardBlock
|
||||
.closest('.js-emoji-btn')
|
||||
.removeData('title')
|
||||
.removeAttr('data-title')
|
||||
.removeAttr('data-original-title')
|
||||
.attr('title', this.toSentence(authors))
|
||||
.tooltip('fixTitle');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.addMeToUserList = function(votesBlock, emoji) {
|
||||
AwardsHandler.prototype.addYouToUserList = function(votesBlock, emoji) {
|
||||
var awardBlock, origTitle, users;
|
||||
awardBlock = this.findEmojiIcon(votesBlock, emoji).parent();
|
||||
origTitle = this.getAwardTooltip(awardBlock);
|
||||
users = [];
|
||||
if (origTitle) {
|
||||
users = origTitle.trim().split(', ');
|
||||
users = origTitle.trim().split(FROM_SENTENCE_REGEX);
|
||||
}
|
||||
users.push('me');
|
||||
awardBlock.attr('title', users.join(', '));
|
||||
return this.resetTooltip(awardBlock);
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.resetTooltip = function(award) {
|
||||
var cb;
|
||||
award.tooltip('destroy');
|
||||
cb = function() {
|
||||
return award.tooltip();
|
||||
};
|
||||
return setTimeout(cb, 200);
|
||||
users.unshift('You');
|
||||
return awardBlock
|
||||
.attr('title', this.toSentence(users))
|
||||
.tooltip('fixTitle');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.createEmoji_ = function(votesBlock, emoji) {
|
||||
var $emojiButton, buttonHtml, emojiCssClass;
|
||||
emojiCssClass = this.resolveNameToCssClass(emoji);
|
||||
buttonHtml = "<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.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('emoji', emoji);
|
||||
this.animateEmoji($emojiButton);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
Vue.http.interceptors.push((request, next) => {
|
||||
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
|
||||
|
||||
setTimeout(() => {
|
||||
Vue.activeResources--;
|
||||
}, 500);
|
||||
Vue.nextTick(() => {
|
||||
setTimeout(() => {
|
||||
Vue.activeResources--;
|
||||
}, 500);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
((w) => {
|
||||
w.CommentAndResolveBtn = Vue.extend({
|
||||
props: {
|
||||
discussionId: String,
|
||||
textareaIsEmpty: Boolean
|
||||
},
|
||||
computed: {
|
||||
discussion: function () {
|
||||
return CommentsStore.state[this.discussionId];
|
||||
},
|
||||
showButton: function () {
|
||||
if (this.discussion) {
|
||||
return this.discussion.isResolvable();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
isDiscussionResolved: function () {
|
||||
return this.discussion.isResolved();
|
||||
},
|
||||
buttonText: function () {
|
||||
if (this.isDiscussionResolved) {
|
||||
if (this.textareaIsEmpty) {
|
||||
return "Unresolve discussion";
|
||||
} else {
|
||||
return "Comment & unresolve discussion";
|
||||
}
|
||||
} else {
|
||||
if (this.textareaIsEmpty) {
|
||||
return "Resolve discussion";
|
||||
} else {
|
||||
return "Comment & resolve discussion";
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ready: function () {
|
||||
const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`);
|
||||
this.textareaIsEmpty = $textarea.val() === '';
|
||||
|
||||
$textarea.on('input.comment-and-resolve-btn', () => {
|
||||
this.textareaIsEmpty = $textarea.val() === '';
|
||||
});
|
||||
},
|
||||
destroyed: function () {
|
||||
$(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn');
|
||||
}
|
||||
});
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
(() => {
|
||||
JumpToDiscussion = Vue.extend({
|
||||
mixins: [DiscussionMixins],
|
||||
props: {
|
||||
discussionId: String
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
discussions: CommentsStore.state,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
discussion: function () {
|
||||
return this.discussions[this.discussionId];
|
||||
},
|
||||
allResolved: function () {
|
||||
return this.unresolvedDiscussionCount === 0;
|
||||
},
|
||||
showButton: function () {
|
||||
if (this.discussionId) {
|
||||
if (this.unresolvedDiscussionCount > 1) {
|
||||
return true;
|
||||
} else {
|
||||
return this.discussionId !== this.lastResolvedId;
|
||||
}
|
||||
} else {
|
||||
return this.unresolvedDiscussionCount >= 1;
|
||||
}
|
||||
},
|
||||
lastResolvedId: function () {
|
||||
let lastId;
|
||||
for (const discussionId in this.discussions) {
|
||||
const discussion = this.discussions[discussionId];
|
||||
|
||||
if (!discussion.isResolved()) {
|
||||
lastId = discussion.id;
|
||||
}
|
||||
}
|
||||
return lastId;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
jumpToNextUnresolvedDiscussion: function () {
|
||||
let discussionsSelector,
|
||||
discussionIdsInScope,
|
||||
firstUnresolvedDiscussionId,
|
||||
nextUnresolvedDiscussionId,
|
||||
activeTab = window.mrTabs.currentAction,
|
||||
hasDiscussionsToJumpTo = true,
|
||||
jumpToFirstDiscussion = !this.discussionId;
|
||||
|
||||
const discussionIdsForElements = function(elements) {
|
||||
return elements.map(function() {
|
||||
return $(this).attr('data-discussion-id');
|
||||
}).toArray();
|
||||
};
|
||||
|
||||
const discussions = this.discussions;
|
||||
|
||||
if (activeTab === 'diffs') {
|
||||
discussionsSelector = '.diffs .notes[data-discussion-id]';
|
||||
discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
|
||||
|
||||
let unresolvedDiscussionCount = 0;
|
||||
|
||||
for (let i = 0; i < discussionIdsInScope.length; i++) {
|
||||
const discussionId = discussionIdsInScope[i];
|
||||
const discussion = discussions[discussionId];
|
||||
if (discussion && !discussion.isResolved()) {
|
||||
unresolvedDiscussionCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.discussionId && !this.discussion.isResolved()) {
|
||||
// If this is the last unresolved discussion on the diffs tab,
|
||||
// there are no discussions to jump to.
|
||||
if (unresolvedDiscussionCount === 1) {
|
||||
hasDiscussionsToJumpTo = false;
|
||||
}
|
||||
} else {
|
||||
// If there are no unresolved discussions on the diffs tab at all,
|
||||
// there are no discussions to jump to.
|
||||
if (unresolvedDiscussionCount === 0) {
|
||||
hasDiscussionsToJumpTo = false;
|
||||
}
|
||||
}
|
||||
} else if (activeTab !== 'notes') {
|
||||
// If we are on the commits or builds tabs,
|
||||
// there are no discussions to jump to.
|
||||
hasDiscussionsToJumpTo = false;
|
||||
}
|
||||
|
||||
if (!hasDiscussionsToJumpTo) {
|
||||
// If there are no discussions to jump to on the current page,
|
||||
// switch to the notes tab and jump to the first disucssion there.
|
||||
window.mrTabs.activateTab('notes');
|
||||
activeTab = 'notes';
|
||||
jumpToFirstDiscussion = true;
|
||||
}
|
||||
|
||||
if (activeTab === 'notes') {
|
||||
discussionsSelector = '.discussion[data-discussion-id]';
|
||||
discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
|
||||
}
|
||||
|
||||
let currentDiscussionFound = false;
|
||||
for (let i = 0; i < discussionIdsInScope.length; i++) {
|
||||
const discussionId = discussionIdsInScope[i];
|
||||
const discussion = discussions[discussionId];
|
||||
|
||||
if (!discussion) {
|
||||
// Discussions for comments on commits in this MR don't have a resolved status.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!firstUnresolvedDiscussionId && !discussion.isResolved()) {
|
||||
firstUnresolvedDiscussionId = discussionId;
|
||||
|
||||
if (jumpToFirstDiscussion) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!jumpToFirstDiscussion) {
|
||||
if (currentDiscussionFound) {
|
||||
if (!discussion.isResolved()) {
|
||||
nextUnresolvedDiscussionId = discussionId;
|
||||
break;
|
||||
}
|
||||
else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (discussionId === this.discussionId) {
|
||||
currentDiscussionFound = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId;
|
||||
|
||||
if (!nextUnresolvedDiscussionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`);
|
||||
|
||||
if (activeTab === 'notes') {
|
||||
$target = $target.closest('.note-discussion');
|
||||
|
||||
// If the next discussion is closed, toggle it open.
|
||||
if ($target.find('.js-toggle-content').is(':hidden')) {
|
||||
$target.find('.js-toggle-button i').trigger('click')
|
||||
}
|
||||
} else if (activeTab === 'diffs') {
|
||||
// Resolved discussions are hidden in the diffs tab by default.
|
||||
// If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab.
|
||||
// When jumping between unresolved discussions on the diffs tab, we show them.
|
||||
$target.closest(".content").show();
|
||||
|
||||
$target = $target.closest("tr.notes_holder");
|
||||
$target.show();
|
||||
|
||||
// If we are on the diffs tab, we don't scroll to the discussion itself, but to
|
||||
// 4 diff lines above it: the line the discussion was in response to + 3 context
|
||||
let prevEl;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
prevEl = $target.prev();
|
||||
|
||||
// If the discussion doesn't have 4 lines above it, we'll have to do with fewer.
|
||||
if (!prevEl.hasClass("line_holder")) {
|
||||
break;
|
||||
}
|
||||
|
||||
$target = prevEl;
|
||||
}
|
||||
}
|
||||
|
||||
$.scrollTo($target, {
|
||||
offset: -($('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight())
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Vue.component('jump-to-discussion', JumpToDiscussion);
|
||||
})();
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
((w) => {
|
||||
w.ResolveBtn = Vue.extend({
|
||||
mixins: [
|
||||
ButtonMixins
|
||||
],
|
||||
props: {
|
||||
noteId: Number,
|
||||
discussionId: String,
|
||||
resolved: Boolean,
|
||||
namespacePath: String,
|
||||
projectPath: String,
|
||||
canResolve: Boolean,
|
||||
resolvedBy: String
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
discussions: CommentsStore.state,
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
'discussions': {
|
||||
handler: 'updateTooltip',
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
discussion: function () {
|
||||
return this.discussions[this.discussionId];
|
||||
},
|
||||
note: function () {
|
||||
if (this.discussion) {
|
||||
return this.discussion.getNote(this.noteId);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
buttonText: function () {
|
||||
if (this.isResolved) {
|
||||
return `Resolved by ${this.resolvedByName}`;
|
||||
} else if (this.canResolve) {
|
||||
return 'Mark as resolved';
|
||||
} else {
|
||||
return 'Unable to resolve';
|
||||
}
|
||||
},
|
||||
isResolved: function () {
|
||||
if (this.note) {
|
||||
return this.note.resolved;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
resolvedByName: function () {
|
||||
return this.note.resolved_by;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateTooltip: function () {
|
||||
$(this.$els.button)
|
||||
.tooltip('hide')
|
||||
.tooltip('fixTitle');
|
||||
},
|
||||
resolve: function () {
|
||||
if (!this.canResolve) return;
|
||||
|
||||
let promise;
|
||||
this.loading = true;
|
||||
|
||||
if (this.isResolved) {
|
||||
promise = ResolveService
|
||||
.unresolve(this.namespace, this.noteId);
|
||||
} else {
|
||||
promise = ResolveService
|
||||
.resolve(this.namespace, this.noteId);
|
||||
}
|
||||
|
||||
promise.then((response) => {
|
||||
this.loading = false;
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.json();
|
||||
const resolved_by = data ? data.resolved_by : null;
|
||||
|
||||
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
|
||||
this.discussion.updateHeadline(data);
|
||||
} else {
|
||||
new Flash('An error occurred when trying to resolve a comment. Please try again.', 'alert');
|
||||
}
|
||||
|
||||
this.$nextTick(this.updateTooltip);
|
||||
});
|
||||
}
|
||||
},
|
||||
compiled: function () {
|
||||
$(this.$els.button).tooltip({
|
||||
container: 'body'
|
||||
});
|
||||
},
|
||||
beforeDestroy: function () {
|
||||
CommentsStore.delete(this.discussionId, this.noteId);
|
||||
},
|
||||
created: function () {
|
||||
CommentsStore.create(this.discussionId, this.noteId, this.canResolve, this.resolved, this.resolvedBy);
|
||||
}
|
||||
});
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
((w) => {
|
||||
w.ResolveCount = Vue.extend({
|
||||
mixins: [DiscussionMixins],
|
||||
props: {
|
||||
loggedOut: Boolean
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
discussions: CommentsStore.state
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
allResolved: function () {
|
||||
return this.resolvedDiscussionCount === this.discussionCount;
|
||||
}
|
||||
}
|
||||
});
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
((w) => {
|
||||
w.ResolveDiscussionBtn = Vue.extend({
|
||||
mixins: [
|
||||
ButtonMixins
|
||||
],
|
||||
props: {
|
||||
discussionId: String,
|
||||
mergeRequestId: Number,
|
||||
namespacePath: String,
|
||||
projectPath: String,
|
||||
canResolve: Boolean,
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
discussions: CommentsStore.state
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
discussion: function () {
|
||||
return this.discussions[this.discussionId];
|
||||
},
|
||||
showButton: function () {
|
||||
if (this.discussion) {
|
||||
return this.discussion.isResolvable();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
isDiscussionResolved: function () {
|
||||
if (this.discussion) {
|
||||
return this.discussion.isResolved();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
buttonText: function () {
|
||||
if (this.isDiscussionResolved) {
|
||||
return "Unresolve discussion";
|
||||
} else {
|
||||
return "Resolve discussion";
|
||||
}
|
||||
},
|
||||
loading: function () {
|
||||
if (this.discussion) {
|
||||
return this.discussion.loading;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resolve: function () {
|
||||
ResolveService.toggleResolveForDiscussion(this.namespace, this.mergeRequestId, this.discussionId);
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
CommentsStore.createDiscussion(this.discussionId, this.canResolve);
|
||||
}
|
||||
});
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
//= require vue
|
||||
//= require vue-resource
|
||||
//= require_directory ./models
|
||||
//= require_directory ./stores
|
||||
//= require_directory ./services
|
||||
//= require_directory ./mixins
|
||||
//= require_directory ./components
|
||||
|
||||
$(() => {
|
||||
window.DiffNotesApp = new Vue({
|
||||
el: '#diff-notes-app',
|
||||
components: {
|
||||
'resolve-btn': ResolveBtn,
|
||||
'resolve-discussion-btn': ResolveDiscussionBtn,
|
||||
'comment-and-resolve-btn': CommentAndResolveBtn
|
||||
},
|
||||
methods: {
|
||||
compileComponents: function () {
|
||||
const $components = $('resolve-btn, resolve-discussion-btn, jump-to-discussion');
|
||||
if ($components.length) {
|
||||
$components.each(function () {
|
||||
DiffNotesApp.$compile($(this).get(0));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
new Vue({
|
||||
el: '#resolve-count-app',
|
||||
components: {
|
||||
'resolve-count': ResolveCount
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
((w) => {
|
||||
w.DiscussionMixins = {
|
||||
computed: {
|
||||
discussionCount: function () {
|
||||
return Object.keys(this.discussions).length;
|
||||
},
|
||||
resolvedDiscussionCount: function () {
|
||||
let resolvedCount = 0;
|
||||
|
||||
for (const discussionId in this.discussions) {
|
||||
const discussion = this.discussions[discussionId];
|
||||
|
||||
if (discussion.isResolved()) {
|
||||
resolvedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedCount;
|
||||
},
|
||||
unresolvedDiscussionCount: function () {
|
||||
let unresolvedCount = 0;
|
||||
|
||||
for (const discussionId in this.discussions) {
|
||||
const discussion = this.discussions[discussionId];
|
||||
|
||||
if (!discussion.isResolved()) {
|
||||
unresolvedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return unresolvedCount;
|
||||
}
|
||||
}
|
||||
};
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
((w) => {
|
||||
w.ButtonMixins = {
|
||||
computed: {
|
||||
namespace: function () {
|
||||
return `${this.namespacePath}/${this.projectPath}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
class DiscussionModel {
|
||||
constructor (discussionId) {
|
||||
this.id = discussionId;
|
||||
this.notes = {};
|
||||
this.loading = false;
|
||||
this.canResolve = false;
|
||||
}
|
||||
|
||||
createNote (noteId, canResolve, resolved, resolved_by) {
|
||||
Vue.set(this.notes, noteId, new NoteModel(this.id, noteId, canResolve, resolved, resolved_by));
|
||||
}
|
||||
|
||||
deleteNote (noteId) {
|
||||
Vue.delete(this.notes, noteId);
|
||||
}
|
||||
|
||||
getNote (noteId) {
|
||||
return this.notes[noteId];
|
||||
}
|
||||
|
||||
notesCount() {
|
||||
return Object.keys(this.notes).length;
|
||||
}
|
||||
|
||||
isResolved () {
|
||||
for (const noteId in this.notes) {
|
||||
const note = this.notes[noteId];
|
||||
|
||||
if (!note.resolved) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
resolveAllNotes (resolved_by) {
|
||||
for (const noteId in this.notes) {
|
||||
const note = this.notes[noteId];
|
||||
|
||||
if (!note.resolved) {
|
||||
note.resolved = true;
|
||||
note.resolved_by = resolved_by;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unResolveAllNotes () {
|
||||
for (const noteId in this.notes) {
|
||||
const note = this.notes[noteId];
|
||||
|
||||
if (note.resolved) {
|
||||
note.resolved = false;
|
||||
note.resolved_by = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateHeadline (data) {
|
||||
const $discussionHeadline = $(`.discussion[data-discussion-id="${this.id}"] .js-discussion-headline`);
|
||||
|
||||
if (data.discussion_headline_html) {
|
||||
if ($discussionHeadline.length) {
|
||||
$discussionHeadline.replaceWith(data.discussion_headline_html);
|
||||
} else {
|
||||
$(`.discussion[data-discussion-id="${this.id}"] .discussion-header`).append(data.discussion_headline_html);
|
||||
}
|
||||
} else {
|
||||
$discussionHeadline.remove();
|
||||
}
|
||||
}
|
||||
|
||||
isResolvable () {
|
||||
if (!this.canResolve) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const noteId in this.notes) {
|
||||
const note = this.notes[noteId];
|
||||
|
||||
if (note.canResolve) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
class NoteModel {
|
||||
constructor (discussionId, noteId, canResolve, resolved, resolved_by) {
|
||||
this.discussionId = discussionId;
|
||||
this.id = noteId;
|
||||
this.canResolve = canResolve;
|
||||
this.resolved = resolved;
|
||||
this.resolved_by = resolved_by;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
((w) => {
|
||||
class ResolveServiceClass {
|
||||
constructor() {
|
||||
this.noteResource = Vue.resource('notes{/noteId}/resolve');
|
||||
this.discussionResource = Vue.resource('merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve');
|
||||
}
|
||||
|
||||
setCSRF() {
|
||||
Vue.http.headers.common['X-CSRF-Token'] = $.rails.csrfToken();
|
||||
}
|
||||
|
||||
prepareRequest(namespace) {
|
||||
this.setCSRF();
|
||||
Vue.http.options.root = `/${namespace}`;
|
||||
}
|
||||
|
||||
resolve(namespace, noteId) {
|
||||
this.prepareRequest(namespace);
|
||||
|
||||
return this.noteResource.save({ noteId }, {});
|
||||
}
|
||||
|
||||
unresolve(namespace, noteId) {
|
||||
this.prepareRequest(namespace);
|
||||
|
||||
return this.noteResource.delete({ noteId }, {});
|
||||
}
|
||||
|
||||
toggleResolveForDiscussion(namespace, mergeRequestId, discussionId) {
|
||||
const discussion = CommentsStore.state[discussionId],
|
||||
isResolved = discussion.isResolved();
|
||||
let promise;
|
||||
|
||||
if (isResolved) {
|
||||
promise = this.unResolveAll(namespace, mergeRequestId, discussionId);
|
||||
} else {
|
||||
promise = this.resolveAll(namespace, mergeRequestId, discussionId);
|
||||
}
|
||||
|
||||
promise.then((response) => {
|
||||
discussion.loading = false;
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.json();
|
||||
const resolved_by = data ? data.resolved_by : null;
|
||||
|
||||
if (isResolved) {
|
||||
discussion.unResolveAllNotes();
|
||||
} else {
|
||||
discussion.resolveAllNotes(resolved_by);
|
||||
}
|
||||
|
||||
discussion.updateHeadline(data);
|
||||
} else {
|
||||
new Flash('An error occurred when trying to resolve a discussion. Please try again.', 'alert');
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
resolveAll(namespace, mergeRequestId, discussionId) {
|
||||
const discussion = CommentsStore.state[discussionId];
|
||||
|
||||
this.prepareRequest(namespace);
|
||||
|
||||
discussion.loading = true;
|
||||
|
||||
return this.discussionResource.save({
|
||||
mergeRequestId,
|
||||
discussionId
|
||||
}, {});
|
||||
}
|
||||
|
||||
unResolveAll(namespace, mergeRequestId, discussionId) {
|
||||
const discussion = CommentsStore.state[discussionId];
|
||||
|
||||
this.prepareRequest(namespace);
|
||||
|
||||
discussion.loading = true;
|
||||
|
||||
return this.discussionResource.delete({
|
||||
mergeRequestId,
|
||||
discussionId
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
|
||||
w.ResolveService = new ResolveServiceClass();
|
||||
})(window);
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
((w) => {
|
||||
w.CommentsStore = {
|
||||
state: {},
|
||||
get: function (discussionId, noteId) {
|
||||
return this.state[discussionId].getNote(noteId);
|
||||
},
|
||||
createDiscussion: function (discussionId, canResolve) {
|
||||
let discussion = this.state[discussionId];
|
||||
if (!this.state[discussionId]) {
|
||||
discussion = new DiscussionModel(discussionId);
|
||||
Vue.set(this.state, discussionId, discussion);
|
||||
}
|
||||
|
||||
if (canResolve !== undefined) {
|
||||
discussion.canResolve = canResolve;
|
||||
}
|
||||
|
||||
return discussion;
|
||||
},
|
||||
create: function (discussionId, noteId, canResolve, resolved, resolved_by) {
|
||||
const discussion = this.createDiscussion(discussionId);
|
||||
|
||||
discussion.createNote(noteId, canResolve, resolved, resolved_by);
|
||||
},
|
||||
update: function (discussionId, noteId, resolved, resolved_by) {
|
||||
const discussion = this.state[discussionId];
|
||||
const note = discussion.getNote(noteId);
|
||||
note.resolved = resolved;
|
||||
note.resolved_by = resolved_by;
|
||||
},
|
||||
delete: function (discussionId, noteId) {
|
||||
const discussion = this.state[discussionId];
|
||||
discussion.deleteNote(noteId);
|
||||
|
||||
if (discussion.notesCount() === 0) {
|
||||
Vue.delete(this.state, discussionId);
|
||||
}
|
||||
},
|
||||
unresolvedDiscussionIds: function () {
|
||||
let ids = [];
|
||||
|
||||
for (const discussionId in this.state) {
|
||||
const discussion = this.state[discussionId];
|
||||
|
||||
if (!discussion.isResolved()) {
|
||||
ids.push(discussion.id);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
};
|
||||
})(window);
|
||||
|
|
@ -196,6 +196,9 @@
|
|||
case 'edit':
|
||||
new Labels();
|
||||
}
|
||||
case 'abuse_reports':
|
||||
new gl.AbuseReports();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'dashboard':
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
return this.input.atwho({
|
||||
this.input.atwho({
|
||||
at: '~',
|
||||
alias: 'labels',
|
||||
searchKey: 'search',
|
||||
|
|
@ -249,6 +249,68 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
// We don't instantiate the slash commands autocomplete for note and issue/MR edit forms
|
||||
this.input.filter('[data-supports-slash-commands="true"]').atwho({
|
||||
at: '/',
|
||||
alias: 'commands',
|
||||
searchKey: 'search',
|
||||
displayTpl: function(value) {
|
||||
var tpl = '<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() {
|
||||
return this.input.atwho('destroy');
|
||||
|
|
@ -265,6 +327,7 @@
|
|||
this.input.atwho('load', 'mergerequests', data.mergerequests);
|
||||
this.input.atwho('load', ':', data.emojis);
|
||||
this.input.atwho('load', '~', data.labels);
|
||||
this.input.atwho('load', '/', data.commands);
|
||||
return $(':focus').trigger('keyup');
|
||||
}
|
||||
};
|
||||
|
|
@ -104,9 +104,12 @@
|
|||
return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend'));
|
||||
});
|
||||
};
|
||||
return gl.text.removeListeners = function(form) {
|
||||
gl.text.removeListeners = function(form) {
|
||||
return $('.js-md', form).off();
|
||||
};
|
||||
return gl.text.truncate = function(string, maxLength) {
|
||||
return string.substr(0, (maxLength - 3)) + '...';
|
||||
};
|
||||
})(window);
|
||||
|
||||
}).call(this);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
MergeRequest.prototype.initTabs = function() {
|
||||
if (this.opts.action !== 'new') {
|
||||
return new MergeRequestTabs(this.opts);
|
||||
window.mrTabs = new MergeRequestTabs(this.opts);
|
||||
} else {
|
||||
return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
function MergeRequestTabs(opts) {
|
||||
this.opts = opts != null ? opts : {};
|
||||
this.opts.setUrl = this.opts.setUrl !== undefined ? this.opts.setUrl : true;
|
||||
this.setCurrentAction = bind(this.setCurrentAction, this);
|
||||
this.tabShown = bind(this.tabShown, this);
|
||||
this.showTab = bind(this.showTab, this);
|
||||
|
|
@ -58,7 +59,9 @@
|
|||
} else {
|
||||
this.expandView();
|
||||
}
|
||||
return this.setCurrentAction(action);
|
||||
if (this.opts.setUrl) {
|
||||
this.setCurrentAction(action);
|
||||
}
|
||||
};
|
||||
|
||||
MergeRequestTabs.prototype.scrollToElement = function(container) {
|
||||
|
|
@ -86,6 +89,7 @@
|
|||
if (action === 'show') {
|
||||
action = 'notes';
|
||||
}
|
||||
this.currentAction = action;
|
||||
new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, '');
|
||||
if (action !== 'notes') {
|
||||
new_state += "/" + action;
|
||||
|
|
@ -124,6 +128,11 @@
|
|||
success: (function(_this) {
|
||||
return function(data) {
|
||||
$('#diffs').html(data.html);
|
||||
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
|
||||
gl.utils.localTimeAgo($('.js-timeago', 'div#diffs'));
|
||||
$('#diffs .js-syntax-highlight').syntaxHighlight();
|
||||
$('#diffs .diff-file').singleFileDiff();
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
$(document).on("click", ".note-edit-cancel", this.cancelEdit);
|
||||
$(document).on("click", ".js-comment-button", this.updateCloseButton);
|
||||
$(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
|
||||
$(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion);
|
||||
$(document).on("click", ".js-note-delete", this.removeNote);
|
||||
$(document).on("click", ".js-note-attachment-delete", this.removeAttachment);
|
||||
$(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
|
||||
|
|
@ -100,6 +101,7 @@
|
|||
$(document).off("click", ".js-note-target-close");
|
||||
$(document).off("click", ".js-note-discard");
|
||||
$(document).off("keydown", ".js-note-text");
|
||||
$(document).off('click', '.js-comment-resolve-button');
|
||||
$('.note .js-task-list-container').taskList('disable');
|
||||
return $(document).off('tasklist:changed', '.note .js-task-list-container');
|
||||
};
|
||||
|
|
@ -201,7 +203,7 @@
|
|||
Increase @pollingInterval up to 120 seconds on every function call,
|
||||
if `shouldReset` has a truthy value, 'null' or 'undefined' the variable
|
||||
will reset to @basePollingInterval.
|
||||
|
||||
|
||||
Note: this function is used to gradually increase the polling interval
|
||||
if there aren't new notes coming from the server
|
||||
*/
|
||||
|
|
@ -223,7 +225,7 @@
|
|||
|
||||
/*
|
||||
Render note in main comments area.
|
||||
|
||||
|
||||
Note: for rendering inline notes use renderDiscussionNote
|
||||
*/
|
||||
|
||||
|
|
@ -231,7 +233,13 @@
|
|||
var $notesList, votesBlock;
|
||||
if (!note.valid) {
|
||||
if (note.award) {
|
||||
new Flash('You have already awarded this emoji!', 'alert');
|
||||
new Flash('You have already awarded this emoji!', 'alert', this.parentTimeline);
|
||||
}
|
||||
else {
|
||||
if (note.errors.commands_only) {
|
||||
new Flash(note.errors.commands_only, 'notice', this.parentTimeline);
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -245,6 +253,7 @@
|
|||
$notesList.append(note.html).syntaxHighlight();
|
||||
gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
|
||||
this.initTaskList();
|
||||
this.refresh();
|
||||
return this.updateNotesCount(1);
|
||||
}
|
||||
};
|
||||
|
|
@ -265,7 +274,7 @@
|
|||
|
||||
/*
|
||||
Render note in discussion area.
|
||||
|
||||
|
||||
Note: for rendering inline notes use renderDiscussionNote
|
||||
*/
|
||||
|
||||
|
|
@ -297,6 +306,11 @@
|
|||
} else {
|
||||
discussionContainer.append(note_html);
|
||||
}
|
||||
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
|
||||
gl.utils.localTimeAgo($('.js-timeago', note_html), false);
|
||||
return this.updateNotesCount(1);
|
||||
};
|
||||
|
|
@ -304,7 +318,7 @@
|
|||
|
||||
/*
|
||||
Called in response the main target form has been successfully submitted.
|
||||
|
||||
|
||||
Removes any errors.
|
||||
Resets text and preview.
|
||||
Resets buttons.
|
||||
|
|
@ -329,7 +343,7 @@
|
|||
|
||||
/*
|
||||
Shows the main form and does some setup on it.
|
||||
|
||||
|
||||
Sets some hidden fields in the form.
|
||||
*/
|
||||
|
||||
|
|
@ -343,13 +357,14 @@
|
|||
form.find("#note_line_code").remove();
|
||||
form.find("#note_position").remove();
|
||||
form.find("#note_type").remove();
|
||||
form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
|
||||
return this.parentTimeline = form.parents('.timeline');
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
General note form setup.
|
||||
|
||||
|
||||
deactivates the submit button when text is empty
|
||||
hides the preview button when text is empty
|
||||
setup GFM auto complete
|
||||
|
|
@ -366,7 +381,7 @@
|
|||
|
||||
/*
|
||||
Called in response to the new note form being submitted
|
||||
|
||||
|
||||
Adds new note to list.
|
||||
*/
|
||||
|
||||
|
|
@ -381,19 +396,33 @@
|
|||
|
||||
/*
|
||||
Called in response to the new note form being submitted
|
||||
|
||||
|
||||
Adds new note to list.
|
||||
*/
|
||||
|
||||
Notes.prototype.addDiscussionNote = function(xhr, note, status) {
|
||||
var $form = $(xhr.target);
|
||||
|
||||
if ($form.attr('data-resolve-all') != null) {
|
||||
var namespacePath = $form.attr('data-namespace-path'),
|
||||
projectPath = $form.attr('data-project-path')
|
||||
discussionId = $form.attr('data-discussion-id'),
|
||||
mergeRequestId = $form.attr('data-noteable-iid'),
|
||||
namespace = namespacePath + '/' + projectPath;
|
||||
|
||||
if (ResolveService != null) {
|
||||
ResolveService.toggleResolveForDiscussion(namespace, mergeRequestId, discussionId);
|
||||
}
|
||||
}
|
||||
|
||||
this.renderDiscussionNote(note);
|
||||
return this.removeDiscussionNoteForm($(xhr.target));
|
||||
this.removeDiscussionNoteForm($form);
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
Called in response to the edit note form being submitted
|
||||
|
||||
|
||||
Updates the current note field.
|
||||
*/
|
||||
|
||||
|
|
@ -404,13 +433,18 @@
|
|||
$html.syntaxHighlight();
|
||||
$html.find('.js-task-list-container').taskList('enable');
|
||||
$note_li = $('.note-row-' + note.id);
|
||||
return $note_li.replaceWith($html);
|
||||
|
||||
$note_li.replaceWith($html);
|
||||
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
Called in response to clicking the edit note link
|
||||
|
||||
|
||||
Replaces the note text with the note edit form
|
||||
Adds a data attribute to the form with the original content of the note for cancellations
|
||||
*/
|
||||
|
|
@ -450,7 +484,7 @@
|
|||
|
||||
/*
|
||||
Called in response to clicking the edit note link
|
||||
|
||||
|
||||
Hides edit form and restores the original note text to the editor textarea.
|
||||
*/
|
||||
|
||||
|
|
@ -472,7 +506,7 @@
|
|||
|
||||
/*
|
||||
Called in response to deleting a note of any kind.
|
||||
|
||||
|
||||
Removes the actual note from view.
|
||||
Removes the whole discussion if the last note is being removed.
|
||||
*/
|
||||
|
|
@ -485,6 +519,15 @@
|
|||
var note, notes;
|
||||
note = $(el);
|
||||
notes = note.closest(".notes");
|
||||
|
||||
if (typeof DiffNotesApp !== "undefined" && DiffNotesApp !== null) {
|
||||
ref = DiffNotesApp.$refs[noteId];
|
||||
|
||||
if (ref) {
|
||||
ref.$destroy(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (notes.find(".note").length === 1) {
|
||||
notes.closest(".timeline-entry").remove();
|
||||
notes.closest("tr").remove();
|
||||
|
|
@ -498,7 +541,7 @@
|
|||
|
||||
/*
|
||||
Called in response to clicking the delete attachment link
|
||||
|
||||
|
||||
Removes the attachment wrapper view, including image tag if it exists
|
||||
Resets the note editing form
|
||||
*/
|
||||
|
|
@ -515,7 +558,7 @@
|
|||
|
||||
/*
|
||||
Called when clicking on the "reply" button for a diff line.
|
||||
|
||||
|
||||
Shows the note form below the notes.
|
||||
*/
|
||||
|
||||
|
|
@ -523,17 +566,19 @@
|
|||
var form, replyLink;
|
||||
form = this.formClone.clone();
|
||||
replyLink = $(e.target).closest(".js-discussion-reply-button");
|
||||
replyLink.hide();
|
||||
replyLink.after(form);
|
||||
replyLink
|
||||
.closest('.discussion-reply-holder')
|
||||
.hide()
|
||||
.after(form);
|
||||
return this.setupDiscussionNoteForm(replyLink, form);
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
Shows the diff or discussion form and does some setup on it.
|
||||
|
||||
|
||||
Sets some hidden fields in the form.
|
||||
|
||||
|
||||
Note: dataHolder must have the "discussionId", "lineCode", "noteableType"
|
||||
and "noteableId" data attributes set.
|
||||
*/
|
||||
|
|
@ -549,15 +594,29 @@
|
|||
form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
|
||||
form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
|
||||
form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text'));
|
||||
form.find('.js-note-target-close').remove();
|
||||
this.setupNoteForm(form);
|
||||
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
var $commentBtn = form.find('comment-and-resolve-btn');
|
||||
$commentBtn
|
||||
.attr(':discussion-id', "'" + dataHolder.data('discussionId') + "'");
|
||||
DiffNotesApp.$compile($commentBtn.get(0));
|
||||
}
|
||||
|
||||
form.find(".js-note-text").focus();
|
||||
return form.removeClass('js-main-target-form').addClass("discussion-form js-discussion-note-form");
|
||||
form
|
||||
.find('.js-comment-resolve-button')
|
||||
.attr('data-discussion-id', dataHolder.data('discussionId'));
|
||||
form
|
||||
.removeClass('js-main-target-form')
|
||||
.addClass("discussion-form js-discussion-note-form");
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
Called when clicking on the "add a comment" button on the side of a diff line.
|
||||
|
||||
|
||||
Inserts a temporary row for the form below the line.
|
||||
Sets up the form and shows it.
|
||||
*/
|
||||
|
|
@ -570,16 +629,19 @@
|
|||
nextRow = row.next();
|
||||
hasNotes = nextRow.is(".notes_holder");
|
||||
addForm = false;
|
||||
targetContent = ".notes_content";
|
||||
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"></td></tr>";
|
||||
notesContentSelector = ".notes_content";
|
||||
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>";
|
||||
if (this.isParallelView()) {
|
||||
lineType = $link.data("lineType");
|
||||
targetContent += "." + lineType;
|
||||
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\"></td><td class=\"notes_content parallel old\"></td><td class=\"notes_line\"></td><td class=\"notes_content parallel new\"></td></tr>";
|
||||
notesContentSelector += "." + lineType;
|
||||
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>";
|
||||
}
|
||||
notesContentSelector += " .content";
|
||||
if (hasNotes) {
|
||||
notesContent = nextRow.find(targetContent);
|
||||
nextRow.show();
|
||||
notesContent = nextRow.find(notesContentSelector);
|
||||
if (notesContent.length) {
|
||||
notesContent.show();
|
||||
replyButton = notesContent.find(".js-discussion-reply-button:visible");
|
||||
if (replyButton.length) {
|
||||
e.target = replyButton[0];
|
||||
|
|
@ -593,11 +655,13 @@
|
|||
}
|
||||
} else {
|
||||
row.after(rowCssToAdd);
|
||||
nextRow = row.next();
|
||||
notesContent = nextRow.find(notesContentSelector);
|
||||
addForm = true;
|
||||
}
|
||||
if (addForm) {
|
||||
newForm = this.formClone.clone();
|
||||
newForm.appendTo(row.next().find(targetContent));
|
||||
newForm.appendTo(notesContent);
|
||||
return this.setupDiscussionNoteForm($link, newForm);
|
||||
}
|
||||
};
|
||||
|
|
@ -605,7 +669,7 @@
|
|||
|
||||
/*
|
||||
Called in response to "cancel" on a diff note form.
|
||||
|
||||
|
||||
Shows the reply button again.
|
||||
Removes the form and if necessary it's temporary row.
|
||||
*/
|
||||
|
|
@ -616,7 +680,9 @@
|
|||
glForm = form.data('gl-form');
|
||||
glForm.destroy();
|
||||
form.find(".js-note-text").data("autosave").reset();
|
||||
form.prev(".js-discussion-reply-button").show();
|
||||
form
|
||||
.prev('.discussion-reply-holder')
|
||||
.show();
|
||||
if (row.is(".js-temp-notes-holder")) {
|
||||
return row.remove();
|
||||
} else {
|
||||
|
|
@ -634,7 +700,7 @@
|
|||
|
||||
/*
|
||||
Called after an attachment file has been selected.
|
||||
|
||||
|
||||
Updates the file name for the selected attachment.
|
||||
*/
|
||||
|
||||
|
|
@ -725,6 +791,18 @@
|
|||
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount);
|
||||
};
|
||||
|
||||
Notes.prototype.resolveDiscussion = function () {
|
||||
var $this = $(this),
|
||||
discussionId = $this.attr('data-discussion-id');
|
||||
|
||||
$this
|
||||
.closest('form')
|
||||
.attr('data-discussion-id', discussionId)
|
||||
.attr('data-resolve-all', 'true')
|
||||
.attr('data-namespace-path', $this.attr('data-namespace-path'))
|
||||
.attr('data-project-path', $this.attr('data-project-path'));
|
||||
};
|
||||
|
||||
return Notes;
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -35,10 +35,16 @@
|
|||
this.isOpen = !this.isOpen;
|
||||
if (!this.isOpen && !this.hasError) {
|
||||
this.content.hide();
|
||||
return this.collapsedContent.show();
|
||||
this.collapsedContent.show();
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
} else if (this.content) {
|
||||
this.collapsedContent.hide();
|
||||
return this.content.show();
|
||||
this.content.show();
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
} else {
|
||||
return this.getContentHTML();
|
||||
}
|
||||
|
|
@ -57,7 +63,11 @@
|
|||
_this.hasError = true;
|
||||
_this.content = $(ERROR_HTML);
|
||||
}
|
||||
return _this.collapsedContent.after(_this.content);
|
||||
_this.collapsedContent.after(_this.content);
|
||||
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
};
|
||||
})(this));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
// Hide element if Vue is still working on rendering it fully.
|
||||
[v-cloak="true"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,3 +147,8 @@
|
|||
color: $gl-link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.atwho-view small.description {
|
||||
float: right;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@
|
|||
.line_content {
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
white-space: pre;
|
||||
|
||||
&.old {
|
||||
background-color: $line-removed;
|
||||
|
|
@ -71,6 +70,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span.highlight_word {
|
||||
background-color: #fafe3d !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@
|
|||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
// Users List
|
||||
|
||||
.users-list {
|
||||
|
|
@ -98,3 +97,44 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.abuse-reports {
|
||||
.table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
.subheading {
|
||||
padding-bottom: $gl-padding;
|
||||
}
|
||||
.message {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.btn {
|
||||
white-space: normal;
|
||||
padding: $gl-btn-padding;
|
||||
}
|
||||
th {
|
||||
width: 15%;
|
||||
&.wide {
|
||||
width: 55%;
|
||||
}
|
||||
}
|
||||
@media (max-width: $screen-sm-max) {
|
||||
th {
|
||||
width: 100%;
|
||||
}
|
||||
td {
|
||||
width: 100%;
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
||||
.no-reports {
|
||||
.emoji-icon {
|
||||
margin-left: $btn-side-margin;
|
||||
margin-top: 3px;
|
||||
}
|
||||
span {
|
||||
font-size: 19px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,32 @@
|
|||
}
|
||||
}
|
||||
|
||||
.discussion-with-resolve-btn {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
table-layout: auto;
|
||||
|
||||
.btn-group {
|
||||
display: table-cell;
|
||||
float: none;
|
||||
width: 1%;
|
||||
|
||||
&:first-child {
|
||||
width: 100%;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.discussion-notes-count {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,3 +383,80 @@ ul.notes {
|
|||
color: $gl-link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.line-resolve-all-container {
|
||||
.btn-group {
|
||||
margin-top: -1px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.discussion-next-btn {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.line-resolve-all {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
background-color: $background-color;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $border-radius-default;
|
||||
|
||||
&.has-next-btn {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.line-resolve-btn {
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.line-resolve-text {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.line-resolve-btn {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: 0;
|
||||
|
||||
&.is-disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:not(.is-disabled):hover,
|
||||
&:not(.is-disabled):focus,
|
||||
&.is-active {
|
||||
color: $gl-text-green;
|
||||
|
||||
svg path {
|
||||
fill: $gl-text-green;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
color: $notes-action-color;
|
||||
|
||||
path {
|
||||
fill: $notes-action-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.discussion-next-btn {
|
||||
svg {
|
||||
margin: 0;
|
||||
|
||||
path {
|
||||
fill: $gray-darkest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,3 +228,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.u2f-registrations {
|
||||
th:not(:last-child), td:not(:last-child) {
|
||||
border-right: solid 1px transparent;
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +66,11 @@ module IssuableCollections
|
|||
key = 'issuable_sort'
|
||||
|
||||
cookies[key] = params[:sort] if params[:sort].present?
|
||||
|
||||
# id_desc and id_asc are old values for these two.
|
||||
cookies[key] = sort_value_recently_created if cookies[key] == 'id_desc'
|
||||
cookies[key] = sort_value_oldest_created if cookies[key] == 'id_asc'
|
||||
|
||||
params[:sort] = cookies[key]
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
|
|||
end
|
||||
|
||||
def destroy
|
||||
TodoService.new.mark_todos_as_done([todo], current_user)
|
||||
TodoService.new.mark_todos_as_done_by_ids([params[:id]], current_user)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' }
|
||||
|
|
@ -27,10 +27,6 @@ class Dashboard::TodosController < Dashboard::ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def todo
|
||||
@todo ||= find_todos.find(params[:id])
|
||||
end
|
||||
|
||||
def find_todos
|
||||
@todos ||= TodosFinder.new(current_user, params).execute
|
||||
end
|
||||
|
|
|
|||
|
|
@ -43,11 +43,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
# A U2F (universal 2nd factor) device's information is stored after successful
|
||||
# registration, which is then used while 2FA authentication is taking place.
|
||||
def create_u2f
|
||||
@u2f_registration = U2fRegistration.register(current_user, u2f_app_id, params[:device_response], session[:challenges])
|
||||
@u2f_registration = U2fRegistration.register(current_user, u2f_app_id, u2f_registration_params, session[:challenges])
|
||||
|
||||
if @u2f_registration.persisted?
|
||||
session.delete(:challenges)
|
||||
redirect_to profile_account_path, notice: "Your U2F device was registered!"
|
||||
redirect_to profile_two_factor_auth_path, notice: "Your U2F device was registered!"
|
||||
else
|
||||
@qr_code = build_qr_code
|
||||
setup_u2f_registration
|
||||
|
|
@ -91,15 +91,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
# Actual communication is performed using a Javascript API
|
||||
def setup_u2f_registration
|
||||
@u2f_registration ||= U2fRegistration.new
|
||||
@registration_key_handles = current_user.u2f_registrations.pluck(:key_handle)
|
||||
@u2f_registrations = current_user.u2f_registrations
|
||||
u2f = U2F::U2F.new(u2f_app_id)
|
||||
|
||||
registration_requests = u2f.registration_requests
|
||||
sign_requests = u2f.authentication_requests(@registration_key_handles)
|
||||
sign_requests = u2f.authentication_requests(@u2f_registrations.map(&:key_handle))
|
||||
session[:challenges] = registration_requests.map(&:challenge)
|
||||
|
||||
gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id,
|
||||
register_requests: registration_requests,
|
||||
sign_requests: sign_requests })
|
||||
end
|
||||
|
||||
def u2f_registration_params
|
||||
params.require(:u2f_registration).permit(:device_response, :name)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
class Profiles::U2fRegistrationsController < Profiles::ApplicationController
|
||||
def destroy
|
||||
u2f_registration = current_user.u2f_registrations.find(params[:id])
|
||||
u2f_registration.destroy
|
||||
redirect_to profile_two_factor_auth_path, notice: "Successfully deleted U2F device."
|
||||
end
|
||||
end
|
||||
|
|
@ -83,6 +83,7 @@ class Projects::ApplicationController < ApplicationController
|
|||
end
|
||||
|
||||
def apply_diff_view_cookie!
|
||||
@show_changes_tab = params[:view].present?
|
||||
cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present?
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def commit
|
||||
@commit ||= @project.commit(params[:id])
|
||||
@noteable = @commit ||= @project.commit(params[:id])
|
||||
end
|
||||
|
||||
def pipelines
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
class Projects::DiscussionsController < Projects::ApplicationController
|
||||
before_action :module_enabled
|
||||
before_action :merge_request
|
||||
before_action :discussion
|
||||
before_action :authorize_resolve_discussion!
|
||||
|
||||
def resolve
|
||||
discussion.resolve!(current_user)
|
||||
|
||||
MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request)
|
||||
|
||||
render json: {
|
||||
resolved_by: discussion.resolved_by.try(:name),
|
||||
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
|
||||
}
|
||||
end
|
||||
|
||||
def unresolve
|
||||
discussion.unresolve!
|
||||
|
||||
render json: {
|
||||
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def merge_request
|
||||
@merge_request ||= @project.merge_requests.find_by!(iid: params[:merge_request_id])
|
||||
end
|
||||
|
||||
def discussion
|
||||
@discussion ||= @merge_request.find_diff_discussion(params[:id]) || render_404
|
||||
end
|
||||
|
||||
def authorize_resolve_discussion!
|
||||
access_denied! unless discussion.can_resolve?(current_user)
|
||||
end
|
||||
|
||||
def module_enabled
|
||||
render_404 unless @project.merge_requests_enabled
|
||||
end
|
||||
end
|
||||
|
|
@ -27,6 +27,9 @@ class Projects::GitHttpClientController < Projects::ApplicationController
|
|||
@ci = true
|
||||
elsif auth_result.type == :oauth && !download_request?
|
||||
# Not allowed
|
||||
elsif auth_result.type == :missing_personal_token
|
||||
render_missing_personal_token
|
||||
return # Render above denied access, nothing left to do
|
||||
else
|
||||
@user = auth_result.user
|
||||
end
|
||||
|
|
@ -91,6 +94,13 @@ class Projects::GitHttpClientController < Projects::ApplicationController
|
|||
[nil, nil]
|
||||
end
|
||||
|
||||
def render_missing_personal_token
|
||||
render plain: "HTTP Basic: Access denied\n" \
|
||||
"You have 2FA enabled, please use a personal access token for Git over HTTP.\n" \
|
||||
"You can generate one at #{profile_personal_access_tokens_url}",
|
||||
status: 401
|
||||
end
|
||||
|
||||
def repository
|
||||
_, suffix = project_id_with_suffix
|
||||
if suffix == '.wiki.git'
|
||||
|
|
|
|||
|
|
@ -177,11 +177,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
protected
|
||||
|
||||
def issue
|
||||
@issue ||= begin
|
||||
@project.issues.find_by!(iid: params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_old
|
||||
end
|
||||
@noteable = @issue ||= @project.issues.find_by(iid: params[:id]) || redirect_old
|
||||
end
|
||||
alias_method :subscribable_resource, :issue
|
||||
alias_method :issuable, :issue
|
||||
|
|
@ -226,7 +222,6 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
|
||||
if issue
|
||||
redirect_to issue_path(issue)
|
||||
return
|
||||
else
|
||||
raise ActiveRecord::RecordNotFound.new
|
||||
end
|
||||
|
|
|
|||
|
|
@ -216,7 +216,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
@base_commit = @merge_request.diff_base_commit
|
||||
@diffs = @merge_request.diffs(diff_options) if @merge_request.compare
|
||||
@diff_notes_disabled = true
|
||||
|
||||
@pipeline = @merge_request.pipeline
|
||||
@statuses = @pipeline.statuses.relevant if @pipeline
|
||||
|
||||
|
|
@ -382,7 +381,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def merge_request
|
||||
@merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
|
||||
@issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
|
||||
end
|
||||
alias_method :subscribable_resource, :merge_request
|
||||
alias_method :issuable, :merge_request
|
||||
|
|
@ -436,12 +435,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
# :show, :diff, :commits, :builds. but not when request the data through AJAX
|
||||
def define_discussion_vars
|
||||
# Build a note object for comment form
|
||||
@note = @project.notes.new(noteable: @noteable)
|
||||
@note = @project.notes.new(noteable: @merge_request)
|
||||
|
||||
@discussions = @noteable.mr_and_commit_notes.
|
||||
inc_author_project_award_emoji.
|
||||
fresh.
|
||||
discussions
|
||||
@discussions = @merge_request.discussions
|
||||
|
||||
preload_noteable_for_regular_notes(@discussions.flat_map(&:notes))
|
||||
|
||||
|
|
@ -475,7 +471,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
}
|
||||
|
||||
@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(
|
||||
@grouped_diff_discussions.values.flat_map(&:notes),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
before_action :authorize_read_note!
|
||||
before_action :authorize_create_note!, only: [:create]
|
||||
before_action :authorize_admin_note!, only: [:update, :destroy]
|
||||
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
|
||||
before_action :find_current_user_notes, only: [:index]
|
||||
|
||||
def index
|
||||
|
|
@ -66,6 +67,33 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def resolve
|
||||
return render_404 unless note.resolvable?
|
||||
|
||||
note.resolve!(current_user)
|
||||
|
||||
MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(note.noteable)
|
||||
|
||||
discussion = note.discussion
|
||||
|
||||
render json: {
|
||||
resolved_by: note.resolved_by.try(:name),
|
||||
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
|
||||
}
|
||||
end
|
||||
|
||||
def unresolve
|
||||
return render_404 unless note.resolvable?
|
||||
|
||||
note.unresolve!
|
||||
|
||||
discussion = note.discussion
|
||||
|
||||
render json: {
|
||||
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def note
|
||||
|
|
@ -125,7 +153,7 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
id: note.id,
|
||||
name: note.name
|
||||
}
|
||||
elsif note.valid?
|
||||
elsif note.persisted?
|
||||
Banzai::NoteRenderer.render([note], @project, current_user)
|
||||
|
||||
attrs = {
|
||||
|
|
@ -138,7 +166,7 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
}
|
||||
|
||||
if note.diff_note?
|
||||
discussion = Discussion.new([note])
|
||||
discussion = note.to_discussion
|
||||
|
||||
attrs.merge!(
|
||||
diff_discussion_html: diff_discussion_html(discussion),
|
||||
|
|
@ -175,6 +203,10 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
return access_denied! unless can?(current_user, :admin_note, note)
|
||||
end
|
||||
|
||||
def authorize_resolve_note!
|
||||
return access_denied! unless can?(current_user, :resolve_note, note)
|
||||
end
|
||||
|
||||
def note_params
|
||||
params.require(:note).permit(
|
||||
:note, :noteable, :noteable_id, :noteable_type, :project_id,
|
||||
|
|
|
|||
|
|
@ -134,10 +134,22 @@ class ProjectsController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def autocomplete_sources
|
||||
note_type = params['type']
|
||||
note_id = params['type_id']
|
||||
noteable =
|
||||
case params[:type]
|
||||
when 'Issue'
|
||||
IssuesFinder.new(current_user, project_id: @project.id, state: 'all').
|
||||
execute.find_by(iid: params[:type_id])
|
||||
when 'MergeRequest'
|
||||
MergeRequestsFinder.new(current_user, project_id: @project.id, state: 'all').
|
||||
execute.find_by(iid: params[:type_id])
|
||||
when 'Commit'
|
||||
@project.commit(params[:type_id])
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
autocomplete = ::Projects::AutocompleteService.new(@project, current_user)
|
||||
participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id)
|
||||
participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
|
||||
|
||||
@suggestions = {
|
||||
emojis: Gitlab::AwardEmoji.urls,
|
||||
|
|
@ -145,7 +157,8 @@ class ProjectsController < Projects::ApplicationController
|
|||
milestones: autocomplete.milestones,
|
||||
mergerequests: autocomplete.merge_requests,
|
||||
labels: autocomplete.labels,
|
||||
members: participants
|
||||
members: participants,
|
||||
commands: autocomplete.commands(noteable, params[:type])
|
||||
}
|
||||
|
||||
respond_to do |format|
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class TodosFinder
|
|||
|
||||
attr_accessor :current_user, :params
|
||||
|
||||
def initialize(current_user, params)
|
||||
def initialize(current_user, params = {})
|
||||
@current_user = current_user
|
||||
@params = params
|
||||
end
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ module AppearancesHelper
|
|||
end
|
||||
|
||||
def custom_icon(icon_name, size: 16)
|
||||
# We can't simply do the below, because there are some .erb SVGs.
|
||||
# File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe
|
||||
render "shared/icons/#{icon_name}.svg", size: size
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,17 +11,14 @@ module BlobHelper
|
|||
def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
|
||||
return unless current_user
|
||||
|
||||
blob = project.repository.blob_at(ref, path) rescue nil
|
||||
blob = options.delete(:blob)
|
||||
blob ||= project.repository.blob_at(ref, path) rescue nil
|
||||
|
||||
return unless blob
|
||||
|
||||
from_mr = options[:from_merge_request_id]
|
||||
link_opts = {}
|
||||
link_opts[:from_merge_request_id] = from_mr if from_mr
|
||||
|
||||
edit_path = namespace_project_edit_blob_path(project.namespace, project,
|
||||
tree_join(ref, path),
|
||||
link_opts)
|
||||
options[:link_opts])
|
||||
|
||||
if !on_top_of_branch?(project, ref)
|
||||
button_tag "Edit", class: "btn disabled has-tooltip btn-file-option", title: "You can only edit files when you are on a branch", data: { container: 'body' }
|
||||
|
|
|
|||
|
|
@ -114,9 +114,17 @@ module IssuesHelper
|
|||
end
|
||||
|
||||
def award_user_list(awards, current_user)
|
||||
awards.map do |award|
|
||||
award.user == current_user ? 'me' : award.user.name
|
||||
end.join(', ')
|
||||
names = awards.map do |award|
|
||||
award.user == current_user ? 'You' : award.user.name
|
||||
end
|
||||
|
||||
# Take first 9 OR current user + first 9
|
||||
current_user_name = names.delete('You')
|
||||
names = names.first(9).insert(0, current_user_name).compact
|
||||
|
||||
names << "#{awards.size - names.size} more." if awards.size > names.size
|
||||
|
||||
names.to_sentence
|
||||
end
|
||||
|
||||
def award_active_class(awards, current_user)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ module NotesHelper
|
|||
}
|
||||
|
||||
if use_legacy_diff_note
|
||||
discussion_id = LegacyDiffNote.build_discussion_id(
|
||||
discussion_id = LegacyDiffNote.discussion_id(
|
||||
@comments_target[:noteable_type],
|
||||
@comments_target[:noteable_id] || @comments_target[:commit_id],
|
||||
line_code
|
||||
|
|
@ -60,7 +60,7 @@ module NotesHelper
|
|||
discussion_id: discussion_id
|
||||
)
|
||||
else
|
||||
discussion_id = DiffNote.build_discussion_id(
|
||||
discussion_id = DiffNote.discussion_id(
|
||||
@comments_target[:noteable_type],
|
||||
@comments_target[:noteable_id] || @comments_target[:commit_id],
|
||||
position
|
||||
|
|
@ -81,10 +81,8 @@ module NotesHelper
|
|||
|
||||
data = discussion.reply_attributes.merge(line_type: line_type)
|
||||
|
||||
content_tag(:div, class: "discussion-reply-holder") do
|
||||
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
|
||||
data: data, title: 'Add a reply'
|
||||
end
|
||||
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
|
||||
data: data, title: 'Add a reply'
|
||||
end
|
||||
|
||||
def preload_max_access_for_authors(notes, project)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,13 @@ module Emails
|
|||
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
|
||||
end
|
||||
|
||||
def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id)
|
||||
setup_merge_request_mail(merge_request_id, recipient_id)
|
||||
|
||||
@resolved_by = User.find(resolved_by_user_id)
|
||||
mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_merge_request_mail(merge_request_id, recipient_id)
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ class Ability
|
|||
:create_merge_request,
|
||||
:create_wiki,
|
||||
:push_code,
|
||||
:resolve_note,
|
||||
:create_container_image,
|
||||
:update_container_image,
|
||||
:create_environment,
|
||||
|
|
@ -457,7 +458,8 @@ class Ability
|
|||
rules += [
|
||||
:read_note,
|
||||
:update_note,
|
||||
:admin_note
|
||||
:admin_note,
|
||||
:resolve_note
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -465,6 +467,10 @@ class Ability
|
|||
rules += project_abilities(user, note.project)
|
||||
end
|
||||
|
||||
if note.for_merge_request? && note.noteable.author == user
|
||||
rules << :resolve_note
|
||||
end
|
||||
|
||||
rules
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ class DiffNote < Note
|
|||
validates :diff_line, presence: true
|
||||
validates :line_code, presence: true, line_code: true
|
||||
validates :noteable_type, inclusion: { in: ['Commit', 'MergeRequest'] }
|
||||
validates :resolved_by, presence: true, if: :resolved?
|
||||
validate :positions_complete
|
||||
validate :verify_supported
|
||||
|
||||
after_initialize :ensure_original_discussion_id
|
||||
before_validation :set_original_position, :update_position, on: :create
|
||||
before_validation :set_line_code
|
||||
before_validation :set_line_code, :set_original_discussion_id
|
||||
after_save :keep_around_commits
|
||||
|
||||
class << self
|
||||
|
|
@ -30,14 +32,6 @@ class DiffNote < Note
|
|||
{ position: position.to_json }
|
||||
end
|
||||
|
||||
def discussion_id
|
||||
@discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position)
|
||||
end
|
||||
|
||||
def original_discussion_id
|
||||
@original_discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position)
|
||||
end
|
||||
|
||||
def position=(new_position)
|
||||
if new_position.is_a?(String)
|
||||
new_position = JSON.parse(new_position) rescue nil
|
||||
|
|
@ -72,10 +66,48 @@ class DiffNote < Note
|
|||
self.position.diff_refs == diff_refs
|
||||
end
|
||||
|
||||
def resolvable?
|
||||
!system? && for_merge_request?
|
||||
end
|
||||
|
||||
def resolved?
|
||||
return false unless resolvable?
|
||||
|
||||
self.resolved_at.present?
|
||||
end
|
||||
|
||||
def resolve!(current_user)
|
||||
return unless resolvable?
|
||||
return if resolved?
|
||||
|
||||
self.resolved_at = Time.now
|
||||
self.resolved_by = current_user
|
||||
save!
|
||||
end
|
||||
|
||||
def unresolve!
|
||||
return unless resolvable?
|
||||
return unless resolved?
|
||||
|
||||
self.resolved_at = nil
|
||||
self.resolved_by = nil
|
||||
save!
|
||||
end
|
||||
|
||||
def discussion
|
||||
return unless resolvable?
|
||||
|
||||
self.noteable.find_diff_discussion(self.discussion_id)
|
||||
end
|
||||
|
||||
def to_discussion
|
||||
Discussion.new([self])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def supported?
|
||||
!self.for_merge_request? || self.noteable.has_complete_diff_refs?
|
||||
for_commit? || self.noteable.has_complete_diff_refs?
|
||||
end
|
||||
|
||||
def noteable_diff_refs
|
||||
|
|
@ -94,6 +126,26 @@ class DiffNote < Note
|
|||
self.line_code = self.position.line_code(self.project.repository)
|
||||
end
|
||||
|
||||
def ensure_original_discussion_id
|
||||
return unless self.persisted?
|
||||
return if self.original_discussion_id
|
||||
|
||||
set_original_discussion_id
|
||||
update_column(:original_discussion_id, self.original_discussion_id)
|
||||
end
|
||||
|
||||
def set_original_discussion_id
|
||||
self.original_discussion_id = Digest::SHA1.hexdigest(build_original_discussion_id)
|
||||
end
|
||||
|
||||
def build_discussion_id
|
||||
self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position)
|
||||
end
|
||||
|
||||
def build_original_discussion_id
|
||||
self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position)
|
||||
end
|
||||
|
||||
def update_position
|
||||
return unless supported?
|
||||
return if for_commit?
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
class Discussion
|
||||
NUMBER_OF_TRUNCATED_DIFF_LINES = 16
|
||||
|
||||
attr_reader :first_note, :notes
|
||||
attr_reader :first_note, :last_note, :notes
|
||||
|
||||
delegate :created_at,
|
||||
:project,
|
||||
|
|
@ -18,6 +18,12 @@ class Discussion
|
|||
|
||||
to: :first_note
|
||||
|
||||
delegate :resolved_at,
|
||||
:resolved_by,
|
||||
|
||||
to: :last_resolved_note,
|
||||
allow_nil: true
|
||||
|
||||
delegate :blob, :highlighted_diff_lines, to: :diff_file, allow_nil: true
|
||||
|
||||
def self.for_notes(notes)
|
||||
|
|
@ -30,13 +36,30 @@ class Discussion
|
|||
|
||||
def initialize(notes)
|
||||
@first_note = notes.first
|
||||
@last_note = notes.last
|
||||
@notes = notes
|
||||
end
|
||||
|
||||
def last_resolved_note
|
||||
return unless resolved?
|
||||
|
||||
@last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last
|
||||
end
|
||||
|
||||
def last_updated_at
|
||||
last_note.created_at
|
||||
end
|
||||
|
||||
def last_updated_by
|
||||
last_note.author
|
||||
end
|
||||
|
||||
def id
|
||||
first_note.discussion_id
|
||||
end
|
||||
|
||||
alias_method :to_param, :id
|
||||
|
||||
def diff_discussion?
|
||||
first_note.diff_note?
|
||||
end
|
||||
|
|
@ -45,6 +68,50 @@ class Discussion
|
|||
notes.any?(&:legacy_diff_note?)
|
||||
end
|
||||
|
||||
def resolvable?
|
||||
return @resolvable if defined?(@resolvable)
|
||||
|
||||
@resolvable = diff_discussion? && notes.any?(&:resolvable?)
|
||||
end
|
||||
|
||||
def resolved?
|
||||
return @resolved if defined?(@resolved)
|
||||
|
||||
@resolved = resolvable? && notes.none?(&:to_be_resolved?)
|
||||
end
|
||||
|
||||
def resolved_notes
|
||||
notes.select(&:resolved?)
|
||||
end
|
||||
|
||||
def to_be_resolved?
|
||||
resolvable? && !resolved?
|
||||
end
|
||||
|
||||
def can_resolve?(current_user)
|
||||
return false unless current_user
|
||||
return false unless resolvable?
|
||||
|
||||
current_user == self.noteable.author ||
|
||||
current_user.can?(:resolve_note, self.project)
|
||||
end
|
||||
|
||||
def resolve!(current_user)
|
||||
return unless resolvable?
|
||||
|
||||
notes.each do |note|
|
||||
note.resolve!(current_user) if note.resolvable?
|
||||
end
|
||||
end
|
||||
|
||||
def unresolve!
|
||||
return unless resolvable?
|
||||
|
||||
notes.each do |note|
|
||||
note.unresolve! if note.resolvable?
|
||||
end
|
||||
end
|
||||
|
||||
def for_target?(target)
|
||||
self.noteable == target && !diff_discussion?
|
||||
end
|
||||
|
|
@ -55,8 +122,20 @@ class Discussion
|
|||
@active = first_note.active?
|
||||
end
|
||||
|
||||
def collapsed?
|
||||
return false unless diff_discussion?
|
||||
|
||||
if resolvable?
|
||||
# New diff discussions only disappear once they are marked resolved
|
||||
resolved?
|
||||
else
|
||||
# Old diff discussions disappear once they become outdated
|
||||
!active?
|
||||
end
|
||||
end
|
||||
|
||||
def expanded?
|
||||
!diff_discussion? || active?
|
||||
!collapsed?
|
||||
end
|
||||
|
||||
def reply_attributes
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ class LegacyDiffNote < Note
|
|||
before_create :set_diff
|
||||
|
||||
class << self
|
||||
def build_discussion_id(noteable_type, noteable_id, line_code, active = true)
|
||||
[super(noteable_type, noteable_id), line_code, active].join("-")
|
||||
def build_discussion_id(noteable_type, noteable_id, line_code)
|
||||
[super(noteable_type, noteable_id), line_code].join("-")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -21,10 +21,6 @@ class LegacyDiffNote < Note
|
|||
{ line_code: line_code }
|
||||
end
|
||||
|
||||
def discussion_id
|
||||
@discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
|
||||
end
|
||||
|
||||
def project_repository
|
||||
if RequestStore.active?
|
||||
RequestStore.fetch("project:#{project_id}:repository") { self.project.repository }
|
||||
|
|
@ -119,4 +115,8 @@ class LegacyDiffNote < Note
|
|||
diffs = noteable.raw_diffs(Commit.max_diff_options)
|
||||
diffs.find { |d| d.new_path == self.diff.new_path }
|
||||
end
|
||||
|
||||
def build_discussion_id
|
||||
self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -418,6 +418,32 @@ class MergeRequest < ActiveRecord::Base
|
|||
)
|
||||
end
|
||||
|
||||
def discussions
|
||||
@discussions ||= self.mr_and_commit_notes.
|
||||
inc_relations_for_view.
|
||||
fresh.
|
||||
discussions
|
||||
end
|
||||
|
||||
def diff_discussions
|
||||
@diff_discussions ||= self.notes.diff_notes.discussions
|
||||
end
|
||||
|
||||
def find_diff_discussion(discussion_id)
|
||||
notes = self.notes.diff_notes.where(discussion_id: discussion_id).fresh.to_a
|
||||
return if notes.empty?
|
||||
|
||||
Discussion.new(notes)
|
||||
end
|
||||
|
||||
def discussions_resolvable?
|
||||
diff_discussions.any?(&:resolvable?)
|
||||
end
|
||||
|
||||
def discussions_resolved?
|
||||
discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?)
|
||||
end
|
||||
|
||||
def hook_attrs
|
||||
attrs = {
|
||||
source: source_project.try(:hook_attrs),
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ class Note < ActiveRecord::Base
|
|||
belongs_to :author, class_name: "User"
|
||||
belongs_to :updated_by, class_name: "User"
|
||||
|
||||
# Only used by DiffNote, but defined here so that it can be used in `Note.includes`
|
||||
belongs_to :resolved_by, class_name: "User"
|
||||
|
||||
has_many :todos, dependent: :destroy
|
||||
has_many :events, as: :target, dependent: :destroy
|
||||
|
||||
|
|
@ -59,7 +62,7 @@ class Note < ActiveRecord::Base
|
|||
scope :fresh, ->{ order(created_at: :asc, id: :asc) }
|
||||
scope :inc_author_project, ->{ includes(:project, :author) }
|
||||
scope :inc_author, ->{ includes(:author) }
|
||||
scope :inc_author_project_award_emoji, ->{ includes(:project, :author, :award_emoji) }
|
||||
scope :inc_relations_for_view, ->{ includes(:project, :author, :updated_by, :resolved_by, :award_emoji) }
|
||||
|
||||
scope :diff_notes, ->{ where(type: ['LegacyDiffNote', 'DiffNote']) }
|
||||
scope :non_diff_notes, ->{ where(type: ['Note', nil]) }
|
||||
|
|
@ -70,7 +73,9 @@ class Note < ActiveRecord::Base
|
|||
project: [:project_members, { group: [:group_members] }])
|
||||
end
|
||||
|
||||
after_initialize :ensure_discussion_id
|
||||
before_validation :nullify_blank_type, :nullify_blank_line_code
|
||||
before_validation :set_discussion_id
|
||||
after_save :keep_around_commit
|
||||
|
||||
class << self
|
||||
|
|
@ -82,13 +87,18 @@ class Note < ActiveRecord::Base
|
|||
[:discussion, noteable_type.try(:underscore), noteable_id].join("-")
|
||||
end
|
||||
|
||||
def discussion_id(*args)
|
||||
Digest::SHA1.hexdigest(build_discussion_id(*args))
|
||||
end
|
||||
|
||||
def discussions
|
||||
Discussion.for_notes(all)
|
||||
end
|
||||
|
||||
def grouped_diff_discussions
|
||||
notes = diff_notes.fresh.select(&:active?)
|
||||
Discussion.for_diff_notes(notes).map { |d| [d.line_code, d] }.to_h
|
||||
active_notes = diff_notes.fresh.select(&:active?)
|
||||
Discussion.for_diff_notes(active_notes).
|
||||
map { |d| [d.line_code, d] }.to_h
|
||||
end
|
||||
|
||||
# Searches for notes matching the given query.
|
||||
|
|
@ -129,13 +139,16 @@ class Note < ActiveRecord::Base
|
|||
true
|
||||
end
|
||||
|
||||
def discussion_id
|
||||
@discussion_id ||=
|
||||
if for_merge_request?
|
||||
[:discussion, :note, id].join("-")
|
||||
else
|
||||
self.class.build_discussion_id(noteable_type, noteable_id || commit_id)
|
||||
end
|
||||
def resolvable?
|
||||
false
|
||||
end
|
||||
|
||||
def resolved?
|
||||
false
|
||||
end
|
||||
|
||||
def to_be_resolved?
|
||||
resolvable? && !resolved?
|
||||
end
|
||||
|
||||
def max_attachment_size
|
||||
|
|
@ -243,4 +256,26 @@ class Note < ActiveRecord::Base
|
|||
def nullify_blank_line_code
|
||||
self.line_code = nil if self.line_code.blank?
|
||||
end
|
||||
|
||||
def ensure_discussion_id
|
||||
return unless self.persisted?
|
||||
return if self.discussion_id
|
||||
|
||||
set_discussion_id
|
||||
update_column(:discussion_id, self.discussion_id)
|
||||
end
|
||||
|
||||
def set_discussion_id
|
||||
self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id)
|
||||
end
|
||||
|
||||
def build_discussion_id
|
||||
if for_merge_request?
|
||||
# Notes on merge requests are always in a discussion of their own,
|
||||
# so we generate a unique discussion ID.
|
||||
[:discussion, :note, SecureRandom.hex].join("-")
|
||||
else
|
||||
self.class.build_discussion_id(noteable_type, noteable_id || commit_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,18 +3,19 @@
|
|||
class U2fRegistration < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
|
||||
def self.register(user, app_id, json_response, challenges)
|
||||
def self.register(user, app_id, params, challenges)
|
||||
u2f = U2F::U2F.new(app_id)
|
||||
registration = self.new
|
||||
|
||||
begin
|
||||
response = U2F::RegisterResponse.load_from_json(json_response)
|
||||
response = U2F::RegisterResponse.load_from_json(params[:device_response])
|
||||
registration_data = u2f.register!(challenges, response)
|
||||
registration.update(certificate: registration_data.certificate,
|
||||
key_handle: registration_data.key_handle,
|
||||
public_key: registration_data.public_key,
|
||||
counter: registration_data.counter,
|
||||
user: user)
|
||||
user: user,
|
||||
name: params[:name])
|
||||
rescue JSON::ParserError, NoMethodError, ArgumentError
|
||||
registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.')
|
||||
rescue U2F::Error => e
|
||||
|
|
|
|||
|
|
@ -69,14 +69,9 @@ class IssuableBaseService < BaseService
|
|||
end
|
||||
|
||||
def filter_labels
|
||||
if params[:add_label_ids].present? || params[:remove_label_ids].present?
|
||||
params.delete(:label_ids)
|
||||
|
||||
filter_labels_in_param(:add_label_ids)
|
||||
filter_labels_in_param(:remove_label_ids)
|
||||
else
|
||||
filter_labels_in_param(:label_ids)
|
||||
end
|
||||
filter_labels_in_param(:add_label_ids)
|
||||
filter_labels_in_param(:remove_label_ids)
|
||||
filter_labels_in_param(:label_ids)
|
||||
end
|
||||
|
||||
def filter_labels_in_param(key)
|
||||
|
|
@ -85,27 +80,86 @@ class IssuableBaseService < BaseService
|
|||
params[key] = project.labels.where(id: params[key]).pluck(:id)
|
||||
end
|
||||
|
||||
def process_label_ids(attributes, existing_label_ids: nil)
|
||||
label_ids = attributes.delete(:label_ids)
|
||||
add_label_ids = attributes.delete(:add_label_ids)
|
||||
remove_label_ids = attributes.delete(:remove_label_ids)
|
||||
|
||||
new_label_ids = existing_label_ids || label_ids || []
|
||||
|
||||
if add_label_ids.blank? && remove_label_ids.blank?
|
||||
new_label_ids = label_ids if label_ids
|
||||
else
|
||||
new_label_ids |= add_label_ids if add_label_ids
|
||||
new_label_ids -= remove_label_ids if remove_label_ids
|
||||
end
|
||||
|
||||
new_label_ids
|
||||
end
|
||||
|
||||
def merge_slash_commands_into_params!(issuable)
|
||||
description, command_params =
|
||||
SlashCommands::InterpretService.new(project, current_user).
|
||||
execute(params[:description], issuable)
|
||||
|
||||
params[:description] = description
|
||||
|
||||
params.merge!(command_params)
|
||||
end
|
||||
|
||||
def create_issuable(issuable, attributes, label_ids:)
|
||||
issuable.with_transaction_returning_status do
|
||||
if issuable.save
|
||||
issuable.update_attributes(label_ids: label_ids)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create(issuable)
|
||||
merge_slash_commands_into_params!(issuable)
|
||||
filter_params
|
||||
|
||||
params.delete(:state_event)
|
||||
params[:author] ||= current_user
|
||||
label_ids = process_label_ids(params)
|
||||
|
||||
issuable.assign_attributes(params)
|
||||
|
||||
before_create(issuable)
|
||||
|
||||
if params.present? && create_issuable(issuable, params, label_ids: label_ids)
|
||||
after_create(issuable)
|
||||
issuable.create_cross_references!(current_user)
|
||||
execute_hooks(issuable)
|
||||
end
|
||||
|
||||
issuable
|
||||
end
|
||||
|
||||
def before_create(issuable)
|
||||
# To be overridden by subclasses
|
||||
end
|
||||
|
||||
def after_create(issuable)
|
||||
# To be overridden by subclasses
|
||||
end
|
||||
|
||||
def update_issuable(issuable, attributes)
|
||||
issuable.with_transaction_returning_status do
|
||||
add_label_ids = attributes.delete(:add_label_ids)
|
||||
remove_label_ids = attributes.delete(:remove_label_ids)
|
||||
|
||||
issuable.label_ids |= add_label_ids if add_label_ids
|
||||
issuable.label_ids -= remove_label_ids if remove_label_ids
|
||||
|
||||
issuable.assign_attributes(attributes.merge(updated_by: current_user))
|
||||
|
||||
issuable.save
|
||||
issuable.update(attributes.merge(updated_by: current_user))
|
||||
end
|
||||
end
|
||||
|
||||
def update(issuable)
|
||||
change_state(issuable)
|
||||
change_subscription(issuable)
|
||||
change_todo(issuable)
|
||||
filter_params
|
||||
old_labels = issuable.labels.to_a
|
||||
old_mentioned_users = issuable.mentioned_users.to_a
|
||||
|
||||
params[:label_ids] = process_label_ids(params, existing_label_ids: issuable.label_ids)
|
||||
|
||||
if params.present? && update_issuable(issuable, params)
|
||||
issuable.reset_events_cache
|
||||
handle_common_system_notes(issuable, old_labels: old_labels)
|
||||
|
|
@ -135,6 +189,16 @@ class IssuableBaseService < BaseService
|
|||
end
|
||||
end
|
||||
|
||||
def change_todo(issuable)
|
||||
case params.delete(:todo_event)
|
||||
when 'add'
|
||||
todo_service.mark_todo(issuable, current_user)
|
||||
when 'done'
|
||||
todo = TodosFinder.new(current_user).execute.find_by(target: issuable)
|
||||
todo_service.mark_todos_as_done([todo], current_user) if todo
|
||||
end
|
||||
end
|
||||
|
||||
def has_changes?(issuable, old_labels: [])
|
||||
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
module Issues
|
||||
class CloseService < Issues::BaseService
|
||||
def execute(issue, commit: nil, notifications: true, system_note: true)
|
||||
return issue unless can?(current_user, :update_issue, issue)
|
||||
|
||||
if project.jira_tracker? && project.jira_service.active
|
||||
project.jira_service.execute(commit, issue)
|
||||
todo_service.close_issue(issue, current_user)
|
||||
|
|
|
|||
|
|
@ -1,26 +1,23 @@
|
|||
module Issues
|
||||
class CreateService < Issues::BaseService
|
||||
def execute
|
||||
filter_params
|
||||
label_params = params.delete(:label_ids)
|
||||
@request = params.delete(:request)
|
||||
@api = params.delete(:api)
|
||||
@issue = project.issues.new(params)
|
||||
@issue.author = params[:author] || current_user
|
||||
|
||||
@issue.spam = spam_service.check(@api)
|
||||
@issue = project.issues.new
|
||||
|
||||
if @issue.save
|
||||
@issue.update_attributes(label_ids: label_params)
|
||||
notification_service.new_issue(@issue, current_user)
|
||||
todo_service.new_issue(@issue, current_user)
|
||||
event_service.open_issue(@issue, current_user)
|
||||
user_agent_detail_service.create
|
||||
@issue.create_cross_references!(current_user)
|
||||
execute_hooks(@issue, 'open')
|
||||
end
|
||||
create(@issue)
|
||||
end
|
||||
|
||||
@issue
|
||||
def before_create(issuable)
|
||||
issuable.spam = spam_service.check(@api)
|
||||
end
|
||||
|
||||
def after_create(issuable)
|
||||
event_service.open_issue(issuable, current_user)
|
||||
notification_service.new_issue(issuable, current_user)
|
||||
todo_service.new_issue(issuable, current_user)
|
||||
user_agent_detail_service.create
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
module Issues
|
||||
class ReopenService < Issues::BaseService
|
||||
def execute(issue)
|
||||
return issue unless can?(current_user, :update_issue, issue)
|
||||
|
||||
if issue.reopen
|
||||
event_service.reopen_issue(issue, current_user)
|
||||
create_note(issue)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
module MergeRequests
|
||||
class CloseService < MergeRequests::BaseService
|
||||
def execute(merge_request, commit = nil)
|
||||
return merge_request unless can?(current_user, :update_merge_request, merge_request)
|
||||
|
||||
# If we close MergeRequest we want to ignore validation
|
||||
# so we can close broken one (Ex. fork project removed)
|
||||
merge_request.allow_broken = true
|
||||
|
|
|
|||
|
|
@ -7,26 +7,19 @@ module MergeRequests
|
|||
source_project = @project
|
||||
@project = Project.find(params[:target_project_id]) if params[:target_project_id]
|
||||
|
||||
filter_params
|
||||
label_params = params.delete(:label_ids)
|
||||
force_remove_source_branch = params.delete(:force_remove_source_branch)
|
||||
params[:target_project_id] ||= source_project.id
|
||||
|
||||
merge_request = MergeRequest.new(params)
|
||||
merge_request = MergeRequest.new
|
||||
merge_request.source_project = source_project
|
||||
merge_request.target_project ||= source_project
|
||||
merge_request.author = current_user
|
||||
merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch
|
||||
merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch)
|
||||
|
||||
if merge_request.save
|
||||
merge_request.update_attributes(label_ids: label_params)
|
||||
event_service.open_mr(merge_request, current_user)
|
||||
notification_service.new_merge_request(merge_request, current_user)
|
||||
todo_service.new_merge_request(merge_request, current_user)
|
||||
merge_request.create_cross_references!(current_user)
|
||||
execute_hooks(merge_request)
|
||||
end
|
||||
create(merge_request)
|
||||
end
|
||||
|
||||
merge_request
|
||||
def after_create(issuable)
|
||||
event_service.open_mr(issuable, current_user)
|
||||
notification_service.new_merge_request(issuable, current_user)
|
||||
todo_service.new_merge_request(issuable, current_user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
module MergeRequests
|
||||
class ReopenService < MergeRequests::BaseService
|
||||
def execute(merge_request)
|
||||
return merge_request unless can?(current_user, :update_merge_request, merge_request)
|
||||
|
||||
if merge_request.reopen
|
||||
event_service.reopen_mr(merge_request, current_user)
|
||||
create_note(merge_request)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
module MergeRequests
|
||||
class ResolvedDiscussionNotificationService < MergeRequests::BaseService
|
||||
def execute(merge_request)
|
||||
return unless merge_request.discussions_resolved?
|
||||
|
||||
SystemNoteService.resolve_all_discussions(merge_request, project, current_user)
|
||||
notification_service.resolve_all_discussions(merge_request, current_user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -11,10 +11,33 @@ module Notes
|
|||
return noteable.create_award_emoji(note.award_emoji_name, current_user)
|
||||
end
|
||||
|
||||
if note.save
|
||||
# We execute commands (extracted from `params[:note]`) on the noteable
|
||||
# **before** we save the note because if the note consists of commands
|
||||
# only, there is no need be create a note!
|
||||
slash_commands_service = SlashCommandsService.new(project, current_user)
|
||||
|
||||
if slash_commands_service.supported?(note)
|
||||
content, command_params = slash_commands_service.extract_commands(note)
|
||||
|
||||
only_commands = content.empty?
|
||||
|
||||
note.note = content
|
||||
end
|
||||
|
||||
if !only_commands && note.save
|
||||
# Finish the harder work in the background
|
||||
NewNoteWorker.perform_in(2.seconds, note.id, params)
|
||||
TodoService.new.new_note(note, current_user)
|
||||
todo_service.new_note(note, current_user)
|
||||
end
|
||||
|
||||
if command_params && command_params.any?
|
||||
slash_commands_service.execute(command_params, note)
|
||||
|
||||
# We must add the error after we call #save because errors are reset
|
||||
# when #save is called
|
||||
if only_commands
|
||||
note.errors.add(:commands_only, 'Your commands have been executed!')
|
||||
end
|
||||
end
|
||||
|
||||
note
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
module Notes
|
||||
class SlashCommandsService < BaseService
|
||||
UPDATE_SERVICES = {
|
||||
'Issue' => Issues::UpdateService,
|
||||
'MergeRequest' => MergeRequests::UpdateService
|
||||
}
|
||||
|
||||
def supported?(note)
|
||||
noteable_update_service(note) &&
|
||||
can?(current_user, :"update_#{note.noteable_type.underscore}", note.noteable)
|
||||
end
|
||||
|
||||
def extract_commands(note)
|
||||
return [note.note, {}] unless supported?(note)
|
||||
|
||||
SlashCommands::InterpretService.new(project, current_user).
|
||||
execute(note.note, note.noteable)
|
||||
end
|
||||
|
||||
def execute(command_params, note)
|
||||
return if command_params.empty?
|
||||
return unless supported?(note)
|
||||
|
||||
noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def noteable_update_service(note)
|
||||
UPDATE_SERVICES[note.noteable_type]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -148,6 +148,14 @@ class NotificationService
|
|||
)
|
||||
end
|
||||
|
||||
def resolve_all_discussions(merge_request, current_user)
|
||||
recipients = build_recipients(merge_request, merge_request.target_project, current_user, action: "resolve_all_discussions")
|
||||
|
||||
recipients.each do |recipient|
|
||||
mailer.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
# Notify new user with email after creation
|
||||
def new_user(user, token = nil)
|
||||
# Don't email omniauth created users
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
module Projects
|
||||
class AutocompleteService < BaseService
|
||||
def issues
|
||||
@project.issues.visible_to_user(current_user).opened.select([:iid, :title])
|
||||
IssuesFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
|
||||
end
|
||||
|
||||
def milestones
|
||||
|
|
@ -9,11 +9,34 @@ module Projects
|
|||
end
|
||||
|
||||
def merge_requests
|
||||
@project.merge_requests.opened.select([:iid, :title])
|
||||
MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
|
||||
end
|
||||
|
||||
def labels
|
||||
@project.labels.select([:title, :color])
|
||||
end
|
||||
|
||||
def commands(noteable, type)
|
||||
noteable ||=
|
||||
case type
|
||||
when 'Issue'
|
||||
@project.issues.build
|
||||
when 'MergeRequest'
|
||||
@project.merge_requests.build
|
||||
end
|
||||
|
||||
return [] unless noteable && noteable.is_a?(Issuable)
|
||||
|
||||
opts = {
|
||||
project: project,
|
||||
issuable: noteable,
|
||||
current_user: current_user
|
||||
}
|
||||
SlashCommands::InterpretService.command_definitions.map do |definition|
|
||||
next unless definition.available?(opts)
|
||||
|
||||
definition.to_h(opts)
|
||||
end.compact
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,40 +1,28 @@
|
|||
module Projects
|
||||
class ParticipantsService < BaseService
|
||||
def execute(noteable_type, noteable_id)
|
||||
@noteable_type = noteable_type
|
||||
@noteable_id = noteable_id
|
||||
attr_reader :noteable
|
||||
|
||||
def execute(noteable)
|
||||
@noteable = noteable
|
||||
|
||||
project_members = sorted(project.team.members)
|
||||
participants = target_owner + participants_in_target + all_members + groups + project_members
|
||||
participants = noteable_owner + participants_in_noteable + all_members + groups + project_members
|
||||
participants.uniq
|
||||
end
|
||||
|
||||
def target
|
||||
@target ||=
|
||||
case @noteable_type
|
||||
when "Issue"
|
||||
project.issues.find_by_iid(@noteable_id)
|
||||
when "MergeRequest"
|
||||
project.merge_requests.find_by_iid(@noteable_id)
|
||||
when "Commit"
|
||||
project.commit(@noteable_id)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def target_owner
|
||||
return [] unless target && target.author.present?
|
||||
def noteable_owner
|
||||
return [] unless noteable && noteable.author.present?
|
||||
|
||||
[{
|
||||
name: target.author.name,
|
||||
username: target.author.username
|
||||
name: noteable.author.name,
|
||||
username: noteable.author.username
|
||||
}]
|
||||
end
|
||||
|
||||
def participants_in_target
|
||||
return [] unless target
|
||||
def participants_in_noteable
|
||||
return [] unless noteable
|
||||
|
||||
users = target.participants(current_user)
|
||||
users = noteable.participants(current_user)
|
||||
sorted(users)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,236 @@
|
|||
module SlashCommands
|
||||
class InterpretService < BaseService
|
||||
include Gitlab::SlashCommands::Dsl
|
||||
|
||||
attr_reader :issuable
|
||||
|
||||
# Takes a text and interprets the commands that are extracted from it.
|
||||
# Returns the content without commands, and hash of changes to be applied to a record.
|
||||
def execute(content, issuable)
|
||||
@issuable = issuable
|
||||
@updates = {}
|
||||
|
||||
opts = {
|
||||
issuable: issuable,
|
||||
current_user: current_user,
|
||||
project: project
|
||||
}
|
||||
|
||||
content, commands = extractor.extract_commands(content, opts)
|
||||
|
||||
commands.each do |name, arg|
|
||||
definition = self.class.command_definitions_by_name[name.to_sym]
|
||||
next unless definition
|
||||
|
||||
definition.execute(self, opts, arg)
|
||||
end
|
||||
|
||||
[content, @updates]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extractor
|
||||
Gitlab::SlashCommands::Extractor.new(self.class.command_definitions)
|
||||
end
|
||||
|
||||
desc do
|
||||
"Close this #{issuable.to_ability_name.humanize(capitalize: false)}"
|
||||
end
|
||||
condition do
|
||||
issuable.persisted? &&
|
||||
issuable.open? &&
|
||||
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
|
||||
end
|
||||
command :close do
|
||||
@updates[:state_event] = 'close'
|
||||
end
|
||||
|
||||
desc do
|
||||
"Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}"
|
||||
end
|
||||
condition do
|
||||
issuable.persisted? &&
|
||||
issuable.closed? &&
|
||||
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
|
||||
end
|
||||
command :reopen do
|
||||
@updates[:state_event] = 'reopen'
|
||||
end
|
||||
|
||||
desc 'Change title'
|
||||
params '<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
|
|||
create_note(noteable: noteable, project: project, author: author, note: body)
|
||||
end
|
||||
|
||||
def self.resolve_all_discussions(merge_request, project, author)
|
||||
body = "Resolved all discussions"
|
||||
|
||||
create_note(noteable: merge_request, project: project, author: author, note: body)
|
||||
end
|
||||
|
||||
# Called when the title of a Noteable is changed
|
||||
#
|
||||
# noteable - Noteable object that responds to `title`
|
||||
|
|
|
|||
|
|
@ -142,7 +142,11 @@ class TodoService
|
|||
|
||||
# When user marks some todos as done
|
||||
def mark_todos_as_done(todos, current_user)
|
||||
todos = current_user.todos.where(id: todos.map(&:id)) unless todos.respond_to?(:update_all)
|
||||
mark_todos_as_done_by_ids(todos.select(&:id), current_user)
|
||||
end
|
||||
|
||||
def mark_todos_as_done_by_ids(ids, current_user)
|
||||
todos = current_user.todos.where(id: ids)
|
||||
|
||||
marked_todos = todos.update_all(state: :done)
|
||||
current_user.update_todos_count_cache
|
||||
|
|
@ -155,6 +159,10 @@ class TodoService
|
|||
create_todos(current_user, attributes)
|
||||
end
|
||||
|
||||
def todo_exist?(issuable, current_user)
|
||||
TodosFinder.new(current_user).execute.exists?(target: issuable)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_todos(users, attributes)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
- reporter = abuse_report.reporter
|
||||
- user = abuse_report.user
|
||||
%tr
|
||||
%th.visible-xs-block.visible-sm-block
|
||||
%strong User
|
||||
%td
|
||||
- if user
|
||||
= link_to user.name, user
|
||||
|
|
@ -9,6 +11,7 @@
|
|||
- else
|
||||
(removed)
|
||||
%td
|
||||
%strong.subheading.visible-xs-block.visible-sm-block Reported by
|
||||
- if reporter
|
||||
= link_to reporter.name, reporter
|
||||
- else
|
||||
|
|
@ -16,16 +19,16 @@
|
|||
.light.small
|
||||
= time_ago_with_tooltip(abuse_report.created_at)
|
||||
%td
|
||||
= markdown(abuse_report.message.squish!, pipeline: :single_line, author: reporter)
|
||||
%strong.subheading.visible-xs-block.visible-sm-block Message
|
||||
.message
|
||||
= markdown(abuse_report.message.squish!, pipeline: :single_line, author: reporter)
|
||||
%td
|
||||
- if user
|
||||
= link_to 'Remove user & report', admin_abuse_report_path(abuse_report, remove_user: true),
|
||||
data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-xs btn-remove js-remove-tr"
|
||||
|
||||
%td
|
||||
data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-sm btn-block btn-remove js-remove-tr"
|
||||
- if user && !user.blocked?
|
||||
= link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs"
|
||||
= link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-sm btn-block"
|
||||
- else
|
||||
.btn.btn-xs.disabled
|
||||
.btn.btn-sm.disabled.btn-block
|
||||
Already Blocked
|
||||
= link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-xs btn-close js-remove-tr"
|
||||
= link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
- page_title "Abuse Reports"
|
||||
- page_title 'Abuse Reports'
|
||||
%h3.page-title Abuse Reports
|
||||
%hr
|
||||
- if @abuse_reports.present?
|
||||
.table-holder
|
||||
%table.table
|
||||
%thead
|
||||
%tr
|
||||
%th User
|
||||
%th Reported by
|
||||
%th Message
|
||||
%th Primary action
|
||||
%th
|
||||
= render @abuse_reports
|
||||
= paginate @abuse_reports
|
||||
- else
|
||||
%h4 There are no abuse reports
|
||||
.abuse-reports
|
||||
- if @abuse_reports.present?
|
||||
.table-holder
|
||||
%table.table
|
||||
%thead.hidden-sm.hidden-xs
|
||||
%tr
|
||||
%th User
|
||||
%th Reported by
|
||||
%th.wide Message
|
||||
%th Action
|
||||
= render @abuse_reports
|
||||
- else
|
||||
.no-reports
|
||||
%span.pull-left
|
||||
There are no abuse reports!
|
||||
.pull-left
|
||||
= emoji_icon 'tada'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
%tr.notes_holder
|
||||
- expanded = local_assigns.fetch(:expanded, true)
|
||||
%tr.notes_holder{class: ('hide' unless expanded)}
|
||||
%td.notes_line{ colspan: 2 }
|
||||
%td.notes_content
|
||||
%ul.notes{ data: { discussion_id: discussion.id } }
|
||||
= render partial: "projects/notes/note", collection: discussion.notes, as: :note
|
||||
= link_to_reply_discussion(discussion)
|
||||
.content
|
||||
= render "discussions/notes", discussion: discussion
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@
|
|||
|
||||
.diff-content.code.js-syntax-highlight
|
||||
%table
|
||||
- discussion.truncated_diff_lines.each do |line|
|
||||
= render "projects/diffs/line", line: line, diff_file: diff_file, plain: true
|
||||
|
||||
- if discussion.for_line?(line)
|
||||
= render "discussions/diff_discussion", discussion: discussion
|
||||
- discussions = { discussion.line_code => discussion }
|
||||
= render partial: "projects/diffs/line",
|
||||
collection: discussion.truncated_diff_lines,
|
||||
as: :line,
|
||||
locals: { diff_file: diff_file,
|
||||
discussions: discussions,
|
||||
discussion_expanded: true,
|
||||
plain: true }
|
||||
|
|
|
|||
|
|
@ -5,8 +5,17 @@
|
|||
= link_to user_path(discussion.author) do
|
||||
= image_tag avatar_icon(discussion.author), class: "avatar s40"
|
||||
.timeline-content
|
||||
.discussion.js-toggle-container{ class: discussion.id }
|
||||
.discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } }
|
||||
.discussion-header
|
||||
.discussion-actions
|
||||
= link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do
|
||||
- if expanded
|
||||
= icon("chevron-up")
|
||||
- else
|
||||
= icon("chevron-down")
|
||||
|
||||
Toggle discussion
|
||||
|
||||
= link_to_member(@project, discussion.author, avatar: false)
|
||||
|
||||
.inline.discussion-headline-light
|
||||
|
|
@ -29,17 +38,11 @@
|
|||
|
||||
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
|
||||
|
||||
.discussion-actions
|
||||
= link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do
|
||||
- if expanded
|
||||
= icon("chevron-up")
|
||||
- else
|
||||
= icon("chevron-down")
|
||||
|
||||
Toggle discussion
|
||||
= render "discussions/headline", discussion: discussion
|
||||
|
||||
.discussion-body.js-toggle-content{ class: ("hide" unless expanded) }
|
||||
- if discussion.diff_discussion? && discussion.diff_file
|
||||
= render "discussions/diff_with_notes", discussion: discussion
|
||||
- else
|
||||
= render "discussions/notes", discussion: discussion
|
||||
.panel.panel-default
|
||||
= render "discussions/notes", discussion: discussion
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
- if discussion.resolved?
|
||||
.discussion-headline-light.js-discussion-headline
|
||||
Resolved
|
||||
- if discussion.resolved_by
|
||||
by
|
||||
= link_to_member(@project, discussion.resolved_by, avatar: false)
|
||||
= time_ago_with_tooltip(discussion.resolved_at, placement: "bottom")
|
||||
- elsif discussion.last_updated_at != discussion.created_at
|
||||
.discussion-headline-light.js-discussion-headline
|
||||
Last updated
|
||||
- if discussion.last_updated_by
|
||||
by
|
||||
= link_to_member(@project, discussion.last_updated_by, avatar: false)
|
||||
= time_ago_with_tooltip(discussion.last_updated_at, placement: "bottom")
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
- discussion = local_assigns.fetch(:discussion, nil)
|
||||
- if current_user
|
||||
%jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" }
|
||||
.btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" }
|
||||
%button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion",
|
||||
title: "Jump to next unresolved discussion",
|
||||
"aria-label" => "Jump to next unresolved discussion",
|
||||
data: { container: "body" } }
|
||||
= custom_icon("next_discussion")
|
||||
|
|
@ -1,5 +1,15 @@
|
|||
.panel.panel-default
|
||||
.notes{ data: { discussion_id: discussion.id } }
|
||||
%ul.notes.timeline
|
||||
= render partial: "projects/notes/note", collection: discussion.notes, as: :note
|
||||
= link_to_reply_discussion(discussion)
|
||||
%ul.notes{ data: { discussion_id: discussion.id } }
|
||||
= render partial: "projects/notes/note", collection: discussion.notes, as: :note
|
||||
|
||||
- if current_user
|
||||
.discussion-reply-holder
|
||||
- if discussion.diff_discussion?
|
||||
- line_type = local_assigns.fetch(:line_type, nil)
|
||||
|
||||
.btn-group-justified.discussion-with-resolve-btn{ role: "group" }
|
||||
.btn-group{ role: "group" }
|
||||
= link_to_reply_discussion(discussion, line_type)
|
||||
= render "discussions/resolve_all", discussion: discussion
|
||||
= render "discussions/jump_to_next", discussion: discussion
|
||||
- else
|
||||
= link_to_reply_discussion(discussion)
|
||||
|
|
|
|||
|
|
@ -1,22 +1,21 @@
|
|||
%tr.notes_holder
|
||||
- expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?)
|
||||
%tr.notes_holder{class: ('hide' unless expanded)}
|
||||
- if discussion_left
|
||||
%td.notes_line.old
|
||||
%td.notes_content.parallel.old
|
||||
%ul.notes{ data: { discussion_id: discussion_left.id } }
|
||||
= render partial: "projects/notes/note", collection: discussion_left.notes, as: :note
|
||||
|
||||
= link_to_reply_discussion(discussion_left, 'old')
|
||||
.content{class: ('hide' unless discussion_left.expanded?)}
|
||||
= render "discussions/notes", discussion: discussion_left, line_type: 'old'
|
||||
- else
|
||||
%td.notes_line.old= ""
|
||||
%td.notes_content.parallel.old= ""
|
||||
%td.notes_content.parallel.old
|
||||
.content
|
||||
|
||||
- if discussion_right
|
||||
%td.notes_line.new
|
||||
%td.notes_content.parallel.new
|
||||
%ul.notes{ data: { discussion_id: discussion_right.id } }
|
||||
= render partial: "projects/notes/note", collection: discussion_right.notes, as: :note
|
||||
|
||||
= link_to_reply_discussion(discussion_right, 'new')
|
||||
.content{class: ('hide' unless discussion_right.expanded?)}
|
||||
= render "discussions/notes", discussion: discussion_right, line_type: 'new'
|
||||
- else
|
||||
%td.notes_line.new= ""
|
||||
%td.notes_content.parallel.new= ""
|
||||
%td.notes_content.parallel.new
|
||||
.content
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
- if discussion.for_merge_request?
|
||||
%resolve-discussion-btn{ ":namespace-path" => "'#{discussion.project.namespace.path}'",
|
||||
":project-path" => "'#{discussion.project.path}'",
|
||||
":discussion-id" => "'#{discussion.id}'",
|
||||
":merge-request-id" => discussion.noteable.iid,
|
||||
":can-resolve" => discussion.can_resolve?(current_user),
|
||||
"inline-template" => true }
|
||||
.btn-group{ role: "group", "v-if" => "showButton" }
|
||||
%button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading" }
|
||||
= icon("spinner spin", "v-show" => "loading")
|
||||
{{ buttonText }}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
- project = @target_project || @project
|
||||
- noteable_class = @noteable.class if @noteable.present?
|
||||
- noteable_type = @noteable.class if @noteable.present?
|
||||
|
||||
:javascript
|
||||
GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_class, type_id: params[:id])}"
|
||||
GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_type, type_id: params[:id])}"
|
||||
GitLab.GfmAutoComplete.cachedData = undefined;
|
||||
GitLab.GfmAutoComplete.setup();
|
||||
|
|
|
|||
|
|
@ -75,8 +75,7 @@
|
|||
- blob = diff_file.blob
|
||||
- if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob)
|
||||
%table.code.white
|
||||
- diff_file.highlighted_diff_lines.each do |line|
|
||||
= render "projects/diffs/line", line: line, diff_file: diff_file, plain: true
|
||||
= render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true }
|
||||
- else
|
||||
No preview for this file type
|
||||
%br
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
%p
|
||||
All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{@resolved_by.name}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %>
|
||||
|
||||
<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %>
|
||||
|
|
@ -7,6 +7,10 @@
|
|||
= page_title
|
||||
%p
|
||||
You can generate a personal access token for each application you use that needs access to the GitLab API.
|
||||
%p
|
||||
You can also use personal access tokens to authenticate against Git over HTTP.
|
||||
They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.
|
||||
|
||||
.col-lg-9
|
||||
|
||||
- if flash[:personal_access_token]
|
||||
|
|
|
|||
|
|
@ -60,13 +60,38 @@
|
|||
two-factor authentication app before a U2F device. That way you'll always be able to
|
||||
log in - even when you're using an unsupported browser.
|
||||
.col-lg-9
|
||||
%p
|
||||
- if @registration_key_handles.present?
|
||||
= icon "check inverse", base: "circle", class: "text-success", text: "You have #{pluralize(@registration_key_handles.size, 'U2F device')} registered with GitLab."
|
||||
- if @u2f_registration.errors.present?
|
||||
= form_errors(@u2f_registration)
|
||||
= render "u2f/register"
|
||||
|
||||
%hr
|
||||
|
||||
%h5 U2F Devices (#{@u2f_registrations.length})
|
||||
|
||||
- if @u2f_registrations.present?
|
||||
.table-responsive
|
||||
%table.table.table-bordered.u2f-registrations
|
||||
%colgroup
|
||||
%col{ width: "50%" }
|
||||
%col{ width: "30%" }
|
||||
%col{ width: "20%" }
|
||||
%thead
|
||||
%tr
|
||||
%th Name
|
||||
%th Registered On
|
||||
%th
|
||||
%tbody
|
||||
- @u2f_registrations.each do |registration|
|
||||
%tr
|
||||
%td= registration.name.presence || "<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?
|
||||
: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>";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
|
||||
.zen-backdrop
|
||||
- classes << ' js-gfm-input js-autosize markdown-area'
|
||||
- if defined?(f) && f
|
||||
= f.text_area attr, class: classes, placeholder: placeholder
|
||||
= f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands }
|
||||
- else
|
||||
= text_area_tag attr, nil, class: classes, placeholder: placeholder
|
||||
%a.zen-control.zen-control-leave.js-zen-leave{ href: "#" }
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@
|
|||
\
|
||||
|
||||
- if editable_diff?(diff_file)
|
||||
= edit_blob_link(@merge_request.source_project,
|
||||
@merge_request.source_branch, diff_file.new_path,
|
||||
from_merge_request_id: @merge_request.id,
|
||||
skip_visible_check: true)
|
||||
- link_opts = @merge_request.id ? { from_merge_request_id: @merge_request.id } : {}
|
||||
= edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
|
||||
blob: blob, link_opts: link_opts)
|
||||
|
||||
= view_file_btn(diff_commit.id, diff_file.new_path, project)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
- email = local_assigns.fetch(:email, false)
|
||||
- plain = local_assigns.fetch(:plain, false)
|
||||
- type = line.type
|
||||
- line_code = diff_file.line_code(line) unless plain
|
||||
- line_code = diff_file.line_code(line)
|
||||
%tr.line_holder{ plain ? { class: type} : { class: type, id: line_code } }
|
||||
- case type
|
||||
- when 'match'
|
||||
|
|
@ -22,4 +23,15 @@
|
|||
= link_text
|
||||
- else
|
||||
%a{href: "##{line_code}", data: { linenumber: link_text }}
|
||||
%td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }= diff_line_content(line.text, type)
|
||||
%td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }<
|
||||
- if email
|
||||
%pre= diff_line_content(line.text, type)
|
||||
- else
|
||||
= diff_line_content(line.text, type)
|
||||
|
||||
- discussions = local_assigns.fetch(:discussions, nil)
|
||||
- if discussions && !line.meta?
|
||||
- discussion = discussions[line_code]
|
||||
- if discussion
|
||||
- discussion_expanded = local_assigns.fetch(:discussion_expanded, discussion.expanded?)
|
||||
= render "discussions/diff_discussion", discussion: discussion, expanded: discussion_expanded
|
||||
|
|
|
|||
|
|
@ -5,15 +5,12 @@
|
|||
|
||||
%table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' }
|
||||
- last_line = 0
|
||||
- diff_file.highlighted_diff_lines.each do |line|
|
||||
- last_line = line.new_pos
|
||||
= render "projects/diffs/line", line: line, diff_file: diff_file
|
||||
|
||||
- unless @diff_notes_disabled
|
||||
- line_code = diff_file.line_code(line)
|
||||
- discussion = @grouped_diff_discussions[line_code] if line_code
|
||||
- if discussion
|
||||
= render "discussions/diff_discussion", discussion: discussion
|
||||
- discussions = @grouped_diff_discussions unless @diff_notes_disabled
|
||||
= render partial: "projects/diffs/line",
|
||||
collection: diff_file.highlighted_diff_lines,
|
||||
as: :line,
|
||||
locals: { diff_file: diff_file, discussions: discussions }
|
||||
|
||||
- last_line = diff_file.highlighted_diff_lines.last.new_pos
|
||||
- if !diff_file.new_file && last_line > 0
|
||||
= diff_match_line last_line, last_line, bottom: true
|
||||
|
|
|
|||
|
|
@ -4,5 +4,8 @@
|
|||
= link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"}
|
||||
- if @merge_request.closed?
|
||||
= link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
|
||||
%comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" }
|
||||
%button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { namespace_path: "#{@merge_request.project.namespace.path}", project_path: "#{@merge_request.project.path}" } }
|
||||
{{ buttonText }}
|
||||
|
||||
#notes= render "projects/notes/notes_with_form"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
.mr-compare.merge-request
|
||||
%ul.merge-request-tabs.nav-links.no-top.no-bottom
|
||||
%li.commits-tab
|
||||
= link_to url_for(params), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do
|
||||
= link_to url_for(params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do
|
||||
Commits
|
||||
%span.badge= @commits.size
|
||||
- if @pipeline
|
||||
|
|
@ -52,11 +52,8 @@
|
|||
$('#merge_request_assignee_id').val("#{current_user.id}").trigger("change");
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
:javascript
|
||||
var merge_request
|
||||
merge_request = new MergeRequest({
|
||||
action: 'new',
|
||||
diffs_loaded: true,
|
||||
commits_loaded: true
|
||||
var merge_request = new MergeRequest({
|
||||
action: "#{(@show_changes_tab ? 'diffs' : 'new')}",
|
||||
setUrl: false
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
|
||||
- page_description @merge_request.description
|
||||
- page_card_attributes @merge_request.card_attributes
|
||||
- content_for :page_specific_javascripts do
|
||||
= page_specific_javascript_tag('diff_notes/diff_notes_bundle.js')
|
||||
|
||||
- if diff_view == :parallel
|
||||
- fluid_layout true
|
||||
|
|
@ -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
|
||||
Changes
|
||||
%span.badge= @merge_request.diff_size
|
||||
%li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true }
|
||||
%resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
|
||||
.line-resolve-all{ "v-show" => "discussionCount > 0",
|
||||
":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
|
||||
%span.line-resolve-btn.is-disabled{ type: "button",
|
||||
":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
|
||||
= render "shared/icons/icon_status_success.svg"
|
||||
%span.line-resolve-text
|
||||
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ discussionCount | pluralize 'discussion' }} resolved
|
||||
= render "discussions/jump_to_next"
|
||||
|
||||
.tab-content
|
||||
.tab-content#diff-notes-app
|
||||
#notes.notes.tab-pane.voting_notes
|
||||
.content-block.content-block-small.oneline-block
|
||||
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form" }, authenticity_token: true do |f|
|
||||
= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
|
||||
= hidden_field_tag :view, diff_view
|
||||
= hidden_field_tag :line_type
|
||||
= note_target_fields(@note)
|
||||
|
|
@ -10,8 +10,12 @@
|
|||
= f.hidden_field :position
|
||||
|
||||
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
|
||||
= render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here..."
|
||||
= render 'projects/notes/hints'
|
||||
= render 'projects/zen', f: f,
|
||||
attr: :note,
|
||||
classes: 'note-textarea js-note-text',
|
||||
placeholder: "Write a comment or drag your files here...",
|
||||
supports_slash_commands: true
|
||||
= render 'projects/notes/hints', supports_slash_commands: true
|
||||
.error-alert
|
||||
|
||||
.note-form-actions.clearfix
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
|
||||
.comment-toolbar.clearfix
|
||||
.toolbar-text
|
||||
Styling with
|
||||
= link_to 'Markdown', help_page_path('markdown/markdown'), target: '_blank', tabindex: -1
|
||||
is supported
|
||||
- if supports_slash_commands
|
||||
and
|
||||
= link_to 'slash commands', help_page_path('workflow/slash_commands'), target: '_blank', tabindex: -1
|
||||
are
|
||||
- else
|
||||
is
|
||||
supported
|
||||
%button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' }
|
||||
= icon('file-image-o', class: 'toolbar-button-icon')
|
||||
Attach a file
|
||||
Attach a file
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
- return unless note.author
|
||||
- return if note.cross_reference_not_visible_for?(current_user)
|
||||
- can_resolve = can?(current_user, :resolve_note, note)
|
||||
|
||||
- note_editable = note_editable?(note)
|
||||
%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable} }
|
||||
|
|
@ -16,19 +17,48 @@
|
|||
commented
|
||||
%a{ href: "##{dom_id(note)}" }
|
||||
= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
|
||||
.note-actions
|
||||
- access = note_max_access_for_user(note)
|
||||
- if access and not note.system
|
||||
%span.note-role.hidden-xs= access
|
||||
- if current_user and not note.system
|
||||
= link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do
|
||||
= icon('spinner spin')
|
||||
= icon('smile-o')
|
||||
- if note_editable
|
||||
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
|
||||
= icon('pencil')
|
||||
= link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do
|
||||
= icon('trash-o')
|
||||
- unless note.system?
|
||||
.note-actions
|
||||
- access = note_max_access_for_user(note)
|
||||
- if access
|
||||
%span.note-role.hidden-xs= access
|
||||
|
||||
- if note.resolvable?
|
||||
%resolve-btn{ ":namespace-path" => "'#{note.project.namespace.path}'",
|
||||
":project-path" => "'#{note.project.path}'",
|
||||
":discussion-id" => "'#{note.discussion_id}'",
|
||||
":note-id" => note.id,
|
||||
":resolved" => note.resolved?,
|
||||
":can-resolve" => can_resolve,
|
||||
":resolved-by" => "'#{note.resolved_by.try(:name)}'",
|
||||
"v-show" => "#{can_resolve || note.resolved?}",
|
||||
"inline-template" => true,
|
||||
"v-ref:note_#{note.id}" => true }
|
||||
|
||||
.note-action-button
|
||||
= icon("spin spinner", "v-show" => "loading")
|
||||
%button.line-resolve-btn{ type: "button",
|
||||
class: ("is-disabled" unless can_resolve),
|
||||
":class" => "{ 'is-active': isResolved }",
|
||||
":aria-label" => "buttonText",
|
||||
"@click" => "resolve",
|
||||
":title" => "buttonText",
|
||||
"v-show" => "!loading",
|
||||
"v-el:button" => true }
|
||||
|
||||
= render "shared/icons/icon_status_success.svg"
|
||||
|
||||
- if current_user
|
||||
- if note.emoji_awardable?
|
||||
= link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do
|
||||
= icon('spinner spin')
|
||||
= icon('smile-o')
|
||||
|
||||
- if note_editable
|
||||
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
|
||||
= icon('pencil')
|
||||
= link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do
|
||||
= icon('trash-o')
|
||||
.note-body{class: note_editable ? 'js-task-list-container' : ''}
|
||||
.note-text.md
|
||||
= preserve do
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue