Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorSean McGivern <sean@gitlab.com>2019-07-03 12:28:13 +0300
committerSean McGivern <sean@gitlab.com>2019-07-03 12:28:13 +0300
commitb94daa35a4be584623792653b537d6ab68bdabdd (patch)
treecd620ef82dc85cec1cb401c7a5cf880c47418708 /app
parent83330822d6ee3c59a057adeda35b23ab0021b63a (diff)
parentf90a7601c40c82bd230f9c014bc4f64744e77b5e (diff)
Merge branch 'master' into michel.engelen/gitlab-ce-issue/55953
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussion_reply.vue55
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_gutter_avatars.vue27
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue17
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_comment_row.vue40
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue85
-rw-r--r--app/assets/javascripts/diffs/store/actions.js34
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js65
-rw-r--r--app/assets/javascripts/diffs/store/utils.js45
-rw-r--r--app/assets/javascripts/group.js4
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue3
-rw-r--r--app/assets/javascripts/ide/components/external_link.vue2
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue21
-rw-r--r--app/assets/javascripts/ide/components/repo_file_status_icon.vue5
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue5
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js11
-rw-r--r--app/assets/javascripts/manual_ordering.js2
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue26
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue51
-rw-r--r--app/assets/javascripts/notes/components/discussion_reply_placeholder.vue8
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue10
-rw-r--r--app/assets/javascripts/notes/stores/actions.js12
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue15
-rw-r--r--app/assets/javascripts/performance_bar/components/simple_metric.vue33
-rw-r--r--app/assets/javascripts/projects/project_new.js4
-rw-r--r--app/assets/javascripts/registry/components/app.vue109
-rw-r--r--app/assets/javascripts/registry/components/svg_message.vue24
-rw-r--r--app/assets/javascripts/registry/index.js10
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue2
-rw-r--r--app/assets/stylesheets/pages/container_registry.scss6
-rw-r--r--app/assets/stylesheets/pages/diff.scss16
-rw-r--r--app/assets/stylesheets/pages/notes.scss29
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb2
-rw-r--r--app/controllers/projects/settings/repository_controller.rb2
-rw-r--r--app/models/clusters/applications/ingress.rb15
-rw-r--r--app/models/clusters/applications/jupyter.rb4
-rw-r--r--app/models/clusters/applications/prometheus.rb8
-rw-r--r--app/models/concerns/update_project_statistics.rb19
-rw-r--r--app/models/deploy_token.rb14
-rw-r--r--app/models/namespace.rb4
-rw-r--r--app/models/namespace/aggregation_schedule.rb40
-rw-r--r--app/models/namespace/root_storage_statistics.rb28
-rw-r--r--app/models/project_services/bugzilla_service.rb18
-rw-r--r--app/models/project_services/custom_issue_tracker_service.rb20
-rw-r--r--app/models/project_services/gitlab_issue_tracker_service.rb10
-rw-r--r--app/models/project_services/issue_tracker_service.rb33
-rw-r--r--app/models/project_services/jira_service.rb19
-rw-r--r--app/models/project_services/redmine_service.rb18
-rw-r--r--app/models/project_services/youtrack_service.rb12
-rw-r--r--app/models/release.rb10
-rw-r--r--app/models/service.rb2
-rw-r--r--app/services/deploy_tokens/create_service.rb4
-rw-r--r--app/services/namespaces/statistics_refresher_service.rb22
-rw-r--r--app/services/releases/concerns.rb4
-rw-r--r--app/services/releases/create_service.rb1
-rw-r--r--app/views/clusters/clusters/_form.html.haml2
-rw-r--r--app/views/groups/settings/_general.html.haml2
-rw-r--r--app/views/projects/deploy_tokens/_form.html.haml5
-rw-r--r--app/views/projects/registry/repositories/index.html.haml54
-rw-r--r--app/workers/all_queues.yml4
-rw-r--r--app/workers/namespaces/prune_aggregation_schedules_worker.rb22
-rw-r--r--app/workers/namespaces/root_statistics_worker.rb31
-rw-r--r--app/workers/namespaces/schedule_aggregation_worker.rb45
66 files changed, 919 insertions, 350 deletions
diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
new file mode 100644
index 00000000000..2aa5e9b3339
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
@@ -0,0 +1,55 @@
+<script>
+import { mapGetters } from 'vuex';
+import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue';
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+
+export default {
+ name: 'DiffDiscussionReply',
+ components: {
+ NoteSignedOutWidget,
+ ReplyPlaceholder,
+ UserAvatarLink,
+ },
+ props: {
+ hasForm: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ renderReplyPlaceholder: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters({
+ currentUser: 'getUserData',
+ userCanReply: 'userCanReply',
+ }),
+ },
+};
+</script>
+
+<template>
+ <div class="discussion-reply-holder d-flex clearfix">
+ <template v-if="userCanReply">
+ <slot v-if="hasForm" name="form"></slot>
+ <template v-else-if="renderReplyPlaceholder">
+ <user-avatar-link
+ :link-href="currentUser.path"
+ :img-src="currentUser.avatar_url"
+ :img-alt="currentUser.name"
+ :img-size="40"
+ class="d-none d-sm-block"
+ />
+ <reply-placeholder
+ class="qa-discussion-reply"
+ :button-text="__('Start a new discussion...')"
+ @onClick="$emit('showNewDiscussionForm')"
+ />
+ </template>
+ </template>
+ <note-signed-out-widget v-else />
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index 4c73eea4049..b0460bacff2 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -80,7 +80,6 @@ export default {
v-show="isExpanded(discussion)"
:discussion="discussion"
:render-diff-file="false"
- :always-expanded="true"
:discussions-by-diff-order="true"
:line="line"
:help-page-path="helpPagePath"
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index eb9f1465945..4b226e30699 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -151,7 +151,11 @@ export default {
stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false);
},
methods: {
- ...mapActions('diffs', ['toggleFileDiscussions', 'toggleFullDiff']),
+ ...mapActions('diffs', [
+ 'toggleFileDiscussions',
+ 'toggleFileDiscussionWrappers',
+ 'toggleFullDiff',
+ ]),
handleToggleFile(e, checkTarget) {
if (
!checkTarget ||
@@ -165,7 +169,7 @@ export default {
this.$emit('showForkMessage');
},
handleToggleDiscussions() {
- this.toggleFileDiscussions(this.diffFile);
+ this.toggleFileDiscussionWrappers(this.diffFile);
},
handleFileNameClick(e) {
const isLinkToOtherPage =
diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
index e28909b7be3..af5550aec3b 100644
--- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
+++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
@@ -1,5 +1,4 @@
<script>
-import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import { pluralize, truncate } from '~/lib/utils/text_utility';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
@@ -19,11 +18,13 @@ export default {
type: Array,
required: true,
},
+ discussionsExpanded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
- discussionsExpanded() {
- return this.discussions.every(discussion => discussion.expanded);
- },
allDiscussions() {
return this.discussions.reduce((acc, note) => acc.concat(note.notes), []);
},
@@ -45,26 +46,14 @@ export default {
},
},
methods: {
- ...mapActions(['toggleDiscussion']),
getTooltipText(noteData) {
let { note } = noteData;
-
if (note.length > LENGTH_OF_AVATAR_TOOLTIP) {
note = truncate(note, LENGTH_OF_AVATAR_TOOLTIP);
}
return `${noteData.author.name}: ${note}`;
},
- toggleDiscussions() {
- const forceExpanded = this.discussions.some(discussion => !discussion.expanded);
-
- this.discussions.forEach(discussion => {
- this.toggleDiscussion({
- discussionId: discussion.id,
- forceExpanded,
- });
- });
- },
},
};
</script>
@@ -76,7 +65,7 @@ export default {
type="button"
:aria-label="__('Show comments')"
class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button"
- @click="toggleDiscussions"
+ @click="$emit('toggleLineDiscussions')"
>
<icon :size="12" name="collapse" />
</button>
@@ -87,7 +76,7 @@ export default {
:img-src="note.author.avatar_url"
:tooltip-text="getTooltipText(note)"
class="diff-comment-avatar js-diff-comment-avatar"
- @click.native="toggleDiscussions"
+ @click.native="$emit('toggleLineDiscussions')"
/>
<span
v-if="moreText"
@@ -97,7 +86,7 @@ export default {
data-container="body"
data-placement="top"
role="button"
- @click="toggleDiscussions"
+ @click="$emit('toggleLineDiscussions')"
>+{{ moreCount }}</span
>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
index 1281f9b17ef..351110f0a87 100644
--- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
@@ -105,7 +105,13 @@ export default {
},
},
methods: {
- ...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']),
+ ...mapActions('diffs', [
+ 'loadMoreLines',
+ 'showCommentForm',
+ 'setHighlightedRow',
+ 'toggleLineDiscussions',
+ 'toggleLineDiscussionWrappers',
+ ]),
handleCommentButton() {
this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
},
@@ -184,7 +190,14 @@ export default {
@click="setHighlightedRow(lineCode)"
>
</a>
- <diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" />
+ <diff-gutter-avatars
+ v-if="shouldShowAvatarsOnGutter"
+ :discussions="line.discussions"
+ :discussions-expanded="line.discussionsExpanded"
+ @toggleLineDiscussions="
+ toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded })
+ "
+ />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
index 1faa0493e79..ca3285e9afd 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
@@ -1,11 +1,14 @@
<script>
-import diffDiscussions from './diff_discussions.vue';
-import diffLineNoteForm from './diff_line_note_form.vue';
+import { mapActions } from 'vuex';
+import DiffDiscussions from './diff_discussions.vue';
+import DiffLineNoteForm from './diff_line_note_form.vue';
+import DiffDiscussionReply from './diff_discussion_reply.vue';
export default {
components: {
- diffDiscussions,
- diffLineNoteForm,
+ DiffDiscussions,
+ DiffLineNoteForm,
+ DiffDiscussionReply,
},
props: {
line: {
@@ -32,10 +35,12 @@ export default {
if (!this.line.discussions || !this.line.discussions.length) {
return false;
}
-
- return this.line.discussions.every(discussion => discussion.expanded);
+ return this.line.discussionsExpanded;
},
},
+ methods: {
+ ...mapActions('diffs', ['showCommentForm']),
+ },
};
</script>
@@ -49,13 +54,22 @@ export default {
:discussions="line.discussions"
:help-page-path="helpPagePath"
/>
- <diff-line-note-form
- v-if="line.hasForm"
- :diff-file-hash="diffFileHash"
- :line="line"
- :note-target-line="line"
- :help-page-path="helpPagePath"
- />
+ <diff-discussion-reply
+ :has-form="line.hasForm"
+ :render-reply-placeholder="Boolean(line.discussions.length)"
+ @showNewDiscussionForm="
+ showCommentForm({ lineCode: line.line_code, fileHash: diffFileHash })
+ "
+ >
+ <template #form>
+ <diff-line-note-form
+ :diff-file-hash="diffFileHash"
+ :line="line"
+ :note-target-line="line"
+ :help-page-path="helpPagePath"
+ />
+ </template>
+ </diff-discussion-reply>
</div>
</td>
</tr>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
index d2e54edca85..c00b0e010ff 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
@@ -1,11 +1,14 @@
<script>
-import diffDiscussions from './diff_discussions.vue';
-import diffLineNoteForm from './diff_line_note_form.vue';
+import { mapActions } from 'vuex';
+import DiffDiscussions from './diff_discussions.vue';
+import DiffLineNoteForm from './diff_line_note_form.vue';
+import DiffDiscussionReply from './diff_discussion_reply.vue';
export default {
components: {
- diffDiscussions,
- diffLineNoteForm,
+ DiffDiscussions,
+ DiffLineNoteForm,
+ DiffDiscussionReply,
},
props: {
line: {
@@ -29,24 +32,30 @@ export default {
computed: {
hasExpandedDiscussionOnLeft() {
return this.line.left && this.line.left.discussions.length
- ? this.line.left.discussions.every(discussion => discussion.expanded)
+ ? this.line.left.discussionsExpanded
: false;
},
hasExpandedDiscussionOnRight() {
return this.line.right && this.line.right.discussions.length
- ? this.line.right.discussions.every(discussion => discussion.expanded)
+ ? this.line.right.discussionsExpanded
: false;
},
hasAnyExpandedDiscussion() {
return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight;
},
shouldRenderDiscussionsOnLeft() {
- return this.line.left && this.line.left.discussions && this.hasExpandedDiscussionOnLeft;
+ return (
+ this.line.left &&
+ this.line.left.discussions &&
+ this.line.left.discussions.length &&
+ this.hasExpandedDiscussionOnLeft
+ );
},
shouldRenderDiscussionsOnRight() {
return (
this.line.right &&
this.line.right.discussions &&
+ this.line.right.discussions.length &&
this.hasExpandedDiscussionOnRight &&
this.line.right.type
);
@@ -81,6 +90,22 @@ export default {
return hasCommentFormOnLeft || hasCommentFormOnRight;
},
+ shouldRenderReplyPlaceholderOnLeft() {
+ return Boolean(
+ this.line.left && this.line.left.discussions && this.line.left.discussions.length,
+ );
+ },
+ shouldRenderReplyPlaceholderOnRight() {
+ return Boolean(
+ this.line.right && this.line.right.discussions && this.line.right.discussions.length,
+ );
+ },
+ },
+ methods: {
+ ...mapActions('diffs', ['showCommentForm']),
+ showNewDiscussionForm() {
+ this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.diffFileHash });
+ },
},
};
</script>
@@ -90,37 +115,49 @@ export default {
<td class="notes-content parallel old" colspan="2">
<div v-if="shouldRenderDiscussionsOnLeft" class="content">
<diff-discussions
- v-if="line.left.discussions.length"
:discussions="line.left.discussions"
:line="line.left"
:help-page-path="helpPagePath"
/>
</div>
- <diff-line-note-form
- v-if="showLeftSideCommentForm"
- :diff-file-hash="diffFileHash"
- :line="line.left"
- :note-target-line="line.left"
- :help-page-path="helpPagePath"
- line-position="left"
- />
+ <diff-discussion-reply
+ :has-form="showLeftSideCommentForm"
+ :render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft"
+ @showNewDiscussionForm="showNewDiscussionForm"
+ >
+ <template #form>
+ <diff-line-note-form
+ :diff-file-hash="diffFileHash"
+ :line="line.left"
+ :note-target-line="line.left"
+ :help-page-path="helpPagePath"
+ line-position="left"
+ />
+ </template>
+ </diff-discussion-reply>
</td>
<td class="notes-content parallel new" colspan="2">
<div v-if="shouldRenderDiscussionsOnRight" class="content">
<diff-discussions
- v-if="line.right.discussions.length"
:discussions="line.right.discussions"
:line="line.right"
:help-page-path="helpPagePath"
/>
</div>
- <diff-line-note-form
- v-if="showRightSideCommentForm"
- :diff-file-hash="diffFileHash"
- :line="line.right"
- :note-target-line="line.right"
- line-position="right"
- />
+ <diff-discussion-reply
+ :has-form="showRightSideCommentForm"
+ :render-reply-placeholder="shouldRenderReplyPlaceholderOnRight"
+ @showNewDiscussionForm="showNewDiscussionForm"
+ >
+ <template #form>
+ <diff-line-note-form
+ :diff-file-hash="diffFileHash"
+ :line="line.right"
+ :note-target-line="line.right"
+ line-position="right"
+ />
+ </template>
+ </diff-discussion-reply>
</td>
</tr>
</template>
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 88d7b4bba63..32e0d8f42ee 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -12,6 +12,7 @@ import {
getNoteFormData,
convertExpandLines,
idleCallback,
+ allDiscussionWrappersExpanded,
} from './utils';
import * as types from './mutation_types';
import {
@@ -79,6 +80,7 @@ export const assignDiscussionsToDiff = (
discussions = rootState.notes.discussions,
) => {
const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles);
+ const hash = getLocationHash();
discussions
.filter(discussion => discussion.diff_discussion)
@@ -86,6 +88,7 @@ export const assignDiscussionsToDiff = (
commit(types.SET_LINE_DISCUSSIONS_FOR_FILE, {
discussion,
diffPositionByLineCode,
+ hash,
});
});
@@ -99,6 +102,10 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id });
};
+export const toggleLineDiscussions = ({ commit }, options) => {
+ commit(types.TOGGLE_LINE_DISCUSSIONS, options);
+};
+
export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => {
const discussion = rootState.notes.discussions.find(d => d.id === discussionId);
@@ -257,6 +264,31 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => {
});
};
+export const toggleFileDiscussionWrappers = ({ commit }, diff) => {
+ const discussionWrappersExpanded = allDiscussionWrappersExpanded(diff);
+ let linesWithDiscussions;
+ if (diff.highlighted_diff_lines) {
+ linesWithDiscussions = diff.highlighted_diff_lines.filter(line => line.discussions.length);
+ }
+ if (diff.parallel_diff_lines) {
+ linesWithDiscussions = diff.parallel_diff_lines.filter(
+ line =>
+ (line.left && line.left.discussions.length) ||
+ (line.right && line.right.discussions.length),
+ );
+ }
+
+ if (linesWithDiscussions.length) {
+ linesWithDiscussions.forEach(line => {
+ commit(types.TOGGLE_LINE_DISCUSSIONS, {
+ fileHash: diff.file_hash,
+ lineCode: line.line_code,
+ expanded: !discussionWrappersExpanded,
+ });
+ });
+ }
+};
+
export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
const postData = getNoteFormData({
commit: state.commit,
@@ -267,7 +299,7 @@ export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
return dispatch('saveNote', postData, { root: true })
.then(result => dispatch('updateDiscussion', result.discussion, { root: true }))
.then(discussion => dispatch('assignDiscussionsToDiff', [discussion]))
- .then(() => dispatch('updateResolvableDiscussonsCounts', null, { root: true }))
+ .then(() => dispatch('updateResolvableDiscussionsCounts', null, { root: true }))
.then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash))
.catch(() => createFlash(s__('MergeRequests|Saving the comment failed')));
};
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 8d6111da500..9db56331faa 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -35,3 +35,5 @@ export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINE
export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE';
export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER';
+
+export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 00181a63c43..a66f205bbbd 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -6,6 +6,7 @@ import {
addContextLines,
prepareDiffData,
isDiscussionApplicableToLine,
+ updateLineInFile,
} from './utils';
import * as types from './mutation_types';
@@ -109,7 +110,7 @@ export default {
}));
},
- [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode }) {
+ [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) {
const { latestDiff } = state;
const discussionLineCode = discussion.line_code;
@@ -130,13 +131,27 @@ export default {
: [],
});
+ const setDiscussionsExpanded = line => {
+ const isLineNoteTargeted = line.discussions.some(
+ disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`),
+ );
+
+ return {
+ ...line,
+ discussionsExpanded:
+ line.discussions && line.discussions.length
+ ? line.discussions.some(disc => !disc.resolved) || isLineNoteTargeted
+ : false,
+ };
+ };
+
state.diffFiles = state.diffFiles.map(diffFile => {
if (diffFile.file_hash === fileHash) {
const file = { ...diffFile };
if (file.highlighted_diff_lines) {
file.highlighted_diff_lines = file.highlighted_diff_lines.map(line =>
- lineCheck(line) ? mapDiscussions(line) : line,
+ setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
);
}
@@ -148,8 +163,10 @@ export default {
if (left || right) {
return {
...line,
- left: line.left ? mapDiscussions(line.left) : null,
- right: line.right ? mapDiscussions(line.right, () => !left) : null,
+ left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null,
+ right: line.right
+ ? setDiscussionsExpanded(mapDiscussions(line.right, () => !left))
+ : null,
};
}
@@ -173,32 +190,11 @@ export default {
[types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) {
const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
if (selectedFile) {
- if (selectedFile.parallel_diff_lines) {
- const targetLine = selectedFile.parallel_diff_lines.find(
- line =>
- (line.left && line.left.line_code === lineCode) ||
- (line.right && line.right.line_code === lineCode),
- );
- if (targetLine) {
- const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
-
- Object.assign(targetLine[side], {
- discussions: targetLine[side].discussions.filter(discussion => discussion.notes.length),
- });
- }
- }
-
- if (selectedFile.highlighted_diff_lines) {
- const targetInlineLine = selectedFile.highlighted_diff_lines.find(
- line => line.line_code === lineCode,
- );
-
- if (targetInlineLine) {
- Object.assign(targetInlineLine, {
- discussions: targetInlineLine.discussions.filter(discussion => discussion.notes.length),
- });
- }
- }
+ updateLineInFile(selectedFile, lineCode, line =>
+ Object.assign(line, {
+ discussions: line.discussions.filter(discussion => discussion.notes.length),
+ }),
+ );
if (selectedFile.discussions && selectedFile.discussions.length) {
selectedFile.discussions = selectedFile.discussions.filter(
@@ -207,6 +203,15 @@ export default {
}
}
},
+
+ [types.TOGGLE_LINE_DISCUSSIONS](state, { fileHash, lineCode, expanded }) {
+ const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
+
+ updateLineInFile(selectedFile, lineCode, line =>
+ Object.assign(line, { discussionsExpanded: expanded }),
+ );
+ },
+
[types.TOGGLE_FOLDER_OPEN](state, path) {
state.treeEntries[path].opened = !state.treeEntries[path].opened;
},
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 71956255eef..1c3ed84001c 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -454,3 +454,48 @@ export const convertExpandLines = ({
};
export const idleCallback = cb => requestIdleCallback(cb);
+
+export const updateLineInFile = (selectedFile, lineCode, updateFn) => {
+ if (selectedFile.parallel_diff_lines) {
+ const targetLine = selectedFile.parallel_diff_lines.find(
+ line =>
+ (line.left && line.left.line_code === lineCode) ||
+ (line.right && line.right.line_code === lineCode),
+ );
+ if (targetLine) {
+ const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
+
+ updateFn(targetLine[side]);
+ }
+ }
+ if (selectedFile.highlighted_diff_lines) {
+ const targetInlineLine = selectedFile.highlighted_diff_lines.find(
+ line => line.line_code === lineCode,
+ );
+
+ if (targetInlineLine) {
+ updateFn(targetInlineLine);
+ }
+ }
+};
+
+export const allDiscussionWrappersExpanded = diff => {
+ const discussionsExpandedArray = [];
+ if (diff.parallel_diff_lines) {
+ diff.parallel_diff_lines.forEach(line => {
+ if (line.left && line.left.discussions.length) {
+ discussionsExpandedArray.push(line.left.discussionsExpanded);
+ }
+ if (line.right && line.right.discussions.length) {
+ discussionsExpandedArray.push(line.right.discussionsExpanded);
+ }
+ });
+ } else if (diff.highlighted_diff_lines) {
+ diff.parallel_diff_lines.forEach(line => {
+ if (line.discussions.length) {
+ discussionsExpandedArray.push(line.discussionsExpanded);
+ }
+ });
+ }
+ return discussionsExpandedArray.every(el => el);
+};
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index 903c838e266..460174caf4d 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { slugifyWithHyphens } from './lib/utils/text_utility';
+import { slugify } from './lib/utils/text_utility';
export default class Group {
constructor() {
@@ -14,7 +14,7 @@ export default class Group {
}
update() {
- const slug = slugifyWithHyphens(this.groupName.val());
+ const slug = slugify(this.groupName.val());
this.groupPath.val(slug);
}
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index 4be4b02ac1e..c8fbc3cb9f1 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -107,7 +107,8 @@ export default {
@click="openFileInEditor"
>
<span class="multi-file-commit-list-file-path d-flex align-items-center">
- <file-icon :file-name="file.name" class="append-right-8" />{{ file.name }}
+ <file-icon :file-name="file.name" class="append-right-8" />
+ {{ file.name }}
</span>
<div class="ml-auto d-flex align-items-center">
<div class="d-flex align-items-center ide-commit-list-changed-icon">
diff --git a/app/assets/javascripts/ide/components/external_link.vue b/app/assets/javascripts/ide/components/external_link.vue
index 954f84cea17..d1857f0176a 100644
--- a/app/assets/javascripts/ide/components/external_link.vue
+++ b/app/assets/javascripts/ide/components/external_link.vue
@@ -27,7 +27,7 @@ export default {
target="_blank"
rel="noopener noreferrer"
>
- <span class="vertical-align-middle">Open in file view</span>
+ <span class="vertical-align-middle">{{ __('Open in file view') }}</span>
<icon :size="16" name="external-link" css-classes="vertical-align-middle space-right" />
</a>
</div>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index f952b1e7b80..5fcb11a232e 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -8,6 +8,7 @@ import { activityBarViews, viewerTypes } from '../constants';
import Editor from '../lib/editor';
import ExternalLink from './external_link.vue';
import FileTemplatesBar from './file_templates/bar.vue';
+import { __ } from '~/locale';
export default {
components: {
@@ -160,7 +161,14 @@ export default {
this.createEditorInstance();
})
.catch(err => {
- flash('Error setting up editor. Please try again.', 'alert', document, null, false, true);
+ flash(
+ __('Error setting up editor. Please try again.'),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
throw err;
});
},
@@ -247,12 +255,8 @@ export default {
role="button"
@click.prevent="setFileViewMode({ file, viewMode: 'editor' })"
>
- <template v-if="viewer === $options.viewerTypes.edit">
- {{ __('Edit') }}
- </template>
- <template v-else>
- {{ __('Review') }}
- </template>
+ <template v-if="viewer === $options.viewerTypes.edit">{{ __('Edit') }}</template>
+ <template v-else>{{ __('Review') }}</template>
</a>
</li>
<li v-if="file.previewMode" :class="previewTabCSS">
@@ -260,9 +264,8 @@ export default {
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode: 'preview' })"
+ >{{ file.previewMode.previewTitle }}</a
>
- {{ file.previewMode.previewTitle }}
- </a>
</li>
</ul>
<external-link :file="file" />
diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
index a964d90b090..84a962bfc7d 100644
--- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue
+++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
@@ -1,4 +1,5 @@
<script>
+import { __, sprintf } from '~/locale';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import '~/lib/utils/datetime_utility';
@@ -18,7 +19,9 @@ export default {
},
computed: {
lockTooltip() {
- return `Locked by ${this.file.file_lock.user.name}`;
+ return sprintf(__(`Locked by %{fileLockUserName}`), {
+ fileLockUserName: this.file.file_lock.user.name,
+ });
},
},
};
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index f6aa2295844..7615cfc966e 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -1,4 +1,5 @@
<script>
+import { __, sprintf } from '~/locale';
import { mapActions } from 'vuex';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -27,9 +28,9 @@ export default {
computed: {
closeLabel() {
if (this.fileHasChanged) {
- return `${this.tab.name} changed`;
+ return sprintf(__(`%{tabname} changed`), { tabname: this.tab.name });
}
- return `Close ${this.tab.name}`;
+ return sprintf(__(`Close %{tabname}`, { tabname: this.tab.name }));
},
showChangedIcon() {
if (this.tab.pending) return true;
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index cc1d85fd97d..d38f59b5861 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -44,11 +44,18 @@ export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' :
export const dasherize = str => str.replace(/[_\s]+/g, '-');
/**
- * Replaces whitespaces with hyphens and converts to lower case
+ * Replaces whitespaces with hyphens, convert to lower case and remove non-allowed special characters
* @param {String} str
* @returns {String}
*/
-export const slugifyWithHyphens = str => str.toLowerCase().replace(/\s+/g, '-');
+export const slugify = str => {
+ const slug = str
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-zA-Z0-9_.-]+/g, '-');
+
+ return slug === '-' ? '' : slug;
+};
/**
* Replaces whitespaces with underscore and converts to lower case
diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js
index e16ddbfef7e..012d1e70410 100644
--- a/app/assets/javascripts/manual_ordering.js
+++ b/app/assets/javascripts/manual_ordering.js
@@ -21,7 +21,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) =>
const initManualOrdering = () => {
const issueList = document.querySelector('.manual-ordering');
- if (!issueList || !(gon.features && gon.features.manualSorting)) {
+ if (!issueList || !(gon.features && gon.features.manualSorting) || !(gon.current_user_id > 0)) {
return;
}
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 1357a5268d6..f4570c1292c 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -39,15 +39,23 @@ export default {
</script>
<template>
- <div class="discussion-with-resolve-btn clearfix">
- <reply-placeholder class="qa-discussion-reply" @onClick="$emit('showReplyForm')" />
-
- <div class="btn-group discussion-actions" role="group">
- <resolve-discussion-button
- v-if="discussion.resolvable"
- :is-resolving="isResolving"
- :button-title="resolveButtonTitle"
- @onClick="$emit('resolve')"
+ <div class="discussion-with-resolve-btn">
+ <reply-placeholder
+ :button-text="s__('MergeRequests|Reply...')"
+ class="qa-discussion-reply"
+ @onClick="$emit('showReplyForm')"
+ />
+ <resolve-discussion-button
+ v-if="discussion.resolvable"
+ :is-resolving="isResolving"
+ :button-title="resolveButtonTitle"
+ @onClick="$emit('resolve')"
+ />
+ <div v-if="discussion.resolvable" class="btn-group discussion-actions ml-sm-2" role="group">
+ <resolve-with-issue-button v-if="resolveWithIssuePath" :url="resolveWithIssuePath" />
+ <jump-to-next-discussion-button
+ v-if="shouldShowJumpToNextDiscussion"
+ @onClick="$emit('jumpToNextDiscussion')"
/>
<resolve-with-issue-button
v-if="discussion.resolvable && resolveWithIssuePath"
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 30971ad5227..2ff0fee62f3 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -1,11 +1,11 @@
<script>
-import { mapGetters } from 'vuex';
+import { mapGetters, mapActions } from 'vuex';
import { SYSTEM_NOTE } from '../constants';
import { __ } from '~/locale';
-import NoteableNote from './noteable_note.vue';
-import PlaceholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
-import PlaceholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
+import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
+import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
import SystemNote from '~/vue_shared/components/notes/system_note.vue';
+import NoteableNote from './noteable_note.vue';
import ToggleRepliesWidget from './toggle_replies_widget.vue';
import NoteEditedText from './note_edited_text.vue';
@@ -72,6 +72,7 @@ export default {
},
},
methods: {
+ ...mapActions(['toggleDiscussion']),
componentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === SYSTEM_NOTE) {
@@ -101,7 +102,7 @@ export default {
<component
:is="componentName(firstNote)"
:note="componentData(firstNote)"
- :line="line"
+ :line="line || diffLine"
:commit="commit"
:help-page-path="helpPagePath"
:show-reply-button="userCanReply"
@@ -118,23 +119,29 @@ export default {
/>
<slot slot="avatar-badge" name="avatar-badge"></slot>
</component>
- <toggle-replies-widget
- v-if="hasReplies"
- :collapsed="!isExpanded"
- :replies="replies"
- @toggle="$emit('toggleDiscussion')"
- />
- <template v-if="isExpanded">
- <component
- :is="componentName(note)"
- v-for="note in replies"
- :key="note.id"
- :note="componentData(note)"
- :help-page-path="helpPagePath"
- :line="line"
- @handleDeleteNote="$emit('deleteNote')"
+ <div
+ :class="discussion.diff_discussion ? 'discussion-collapsible bordered-box clearfix' : ''"
+ >
+ <toggle-replies-widget
+ v-if="hasReplies"
+ :collapsed="!isExpanded"
+ :replies="replies"
+ :class="{ 'discussion-toggle-replies': discussion.diff_discussion }"
+ @toggle="toggleDiscussion({ discussionId: discussion.id })"
/>
- </template>
+ <template v-if="isExpanded">
+ <component
+ :is="componentName(note)"
+ v-for="note in replies"
+ :key="note.id"
+ :note="componentData(note)"
+ :help-page-path="helpPagePath"
+ :line="line"
+ @handleDeleteNote="$emit('deleteNote')"
+ />
+ </template>
+ <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
+ </div>
</template>
<template v-else>
<component
@@ -148,8 +155,8 @@ export default {
>
<slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
</component>
+ <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
</template>
</ul>
- <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
index ea590905e3c..0204169214b 100644
--- a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
+++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
@@ -1,6 +1,12 @@
<script>
export default {
name: 'ReplyPlaceholder',
+ props: {
+ buttonText: {
+ type: String,
+ required: true,
+ },
+ },
};
</script>
@@ -12,6 +18,6 @@ export default {
:title="s__('MergeRequests|Add a reply')"
@click="$emit('onClick')"
>
- {{ s__('MergeRequests|Reply...') }}
+ {{ buttonText }}
</button>
</template>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 0031056ad4f..a71a89cfffc 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -132,7 +132,7 @@ export default {
return this.discussion.diff_discussion && this.renderDiffFile;
},
shouldGroupReplies() {
- return !this.shouldRenderDiffs && !this.discussion.diff_discussion;
+ return !this.shouldRenderDiffs;
},
wrapperComponent() {
return this.shouldRenderDiffs ? diffWithNote : 'div';
@@ -248,6 +248,11 @@ export default {
clearDraft(this.autosaveKey);
},
saveReply(noteText, form, callback) {
+ if (!noteText) {
+ this.cancelReplyForm();
+ callback();
+ return;
+ }
const postData = {
in_reply_to_discussion_id: this.discussion.reply_id,
target_type: this.getNoteableData.targetType,
@@ -361,7 +366,6 @@ Please check your network connection and try again.`;
:line="line"
:should-group-replies="shouldGroupReplies"
@startReplying="showReplyForm"
- @toggleDiscussion="toggleDiscussionHandler"
@deleteNote="deleteNoteHandler"
>
<slot slot="avatar-badge" name="avatar-badge"></slot>
@@ -374,7 +378,7 @@ Please check your network connection and try again.`;
<div
v-else-if="showReplies"
:class="{ 'is-replying': isReplying }"
- class="discussion-reply-holder"
+ class="discussion-reply-holder clearfix"
>
<user-avatar-link
v-if="!isReplying && userCanReply"
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 63658d49a05..9054b4779aa 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -51,7 +51,7 @@ export const fetchDiscussions = ({ commit, dispatch }, { path, filter }) =>
.then(res => res.json())
.then(discussions => {
commit(types.SET_INITIAL_DISCUSSIONS, discussions);
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
});
export const updateDiscussion = ({ commit, state }, discussion) => {
@@ -67,7 +67,7 @@ export const deleteNote = ({ commit, dispatch, state }, note) =>
commit(types.DELETE_NOTE, note);
dispatch('updateMergeRequestWidget');
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
if (isInMRPage()) {
dispatch('diffs/removeDiscussionsFromDiff', discussion);
@@ -117,7 +117,7 @@ export const replyToDiscussion = ({ commit, state, getters, dispatch }, { endpoi
dispatch('updateMergeRequestWidget');
dispatch('startTaskList');
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
} else {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
}
@@ -135,7 +135,7 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) =>
dispatch('updateMergeRequestWidget');
dispatch('startTaskList');
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
}
return res;
});
@@ -168,7 +168,7 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved,
commit(mutationType, res);
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
dispatch('updateMergeRequestWidget');
});
@@ -442,7 +442,7 @@ export const startTaskList = ({ dispatch }) =>
}),
);
-export const updateResolvableDiscussonsCounts = ({ commit }) =>
+export const updateResolvableDiscussionsCounts = ({ commit }) =>
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
export const submitSuggestion = (
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 185003c306e..015c1527500 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -4,14 +4,12 @@ import { glEmojiTag } from '~/emoji';
import detailedMetric from './detailed_metric.vue';
import requestSelector from './request_selector.vue';
-import simpleMetric from './simple_metric.vue';
import { s__ } from '~/locale';
export default {
components: {
detailedMetric,
requestSelector,
- simpleMetric,
},
props: {
store: {
@@ -43,8 +41,13 @@ export default {
details: 'details',
keys: ['feature', 'request'],
},
+ {
+ metric: 'redis',
+ header: 'Redis calls',
+ details: 'details',
+ keys: ['cmd'],
+ },
],
- simpleMetrics: ['redis'],
data() {
return { currentRequestId: '' };
},
@@ -124,12 +127,6 @@ export default {
</button>
<a v-else :href="profileUrl">{{ s__('PerformanceBar|profile') }}</a>
</div>
- <simple-metric
- v-for="metric in $options.simpleMetrics"
- :key="metric"
- :current-request="currentRequest"
- :metric="metric"
- />
<div id="peek-view-gc" class="view">
<span v-if="currentRequest.details" class="bold">
<span title="Invoke Time">{{ currentRequest.details.gc.gc_time }}</span
diff --git a/app/assets/javascripts/performance_bar/components/simple_metric.vue b/app/assets/javascripts/performance_bar/components/simple_metric.vue
deleted file mode 100644
index 358a57d5bc5..00000000000
--- a/app/assets/javascripts/performance_bar/components/simple_metric.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-<script>
-export default {
- props: {
- currentRequest: {
- type: Object,
- required: true,
- },
- metric: {
- type: String,
- required: true,
- },
- },
- computed: {
- duration() {
- return (
- this.currentRequest.details[this.metric] &&
- this.currentRequest.details[this.metric].duration
- );
- },
- calls() {
- return (
- this.currentRequest.details[this.metric] && this.currentRequest.details[this.metric].calls
- );
- },
- },
-};
-</script>
-<template>
- <div :id="`peek-view-${metric}`" class="view">
- <span v-if="currentRequest.details" class="bold"> {{ duration }} / {{ calls }} </span>
- {{ metric }}
- </div>
-</template>
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index ea82ff4e340..9066844f687 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
-import { slugifyWithHyphens } from '../lib/utils/text_utility';
+import { slugify } from '../lib/utils/text_utility';
import { s__ } from '~/locale';
let hasUserDefinedProjectPath = false;
@@ -34,7 +34,7 @@ const deriveProjectPathFromUrl = $projectImportUrl => {
};
const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
- const slug = slugifyWithHyphens($projectNameInput.val());
+ const slug = slugify($projectNameInput.val());
$projectPathInput.val(slug);
};
diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue
index ee973017387..7752723baac 100644
--- a/app/assets/javascripts/registry/components/app.vue
+++ b/app/assets/javascripts/registry/components/app.vue
@@ -3,22 +3,81 @@ import { mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import store from '../stores';
import CollapsibleContainer from './collapsible_container.vue';
+import SvgMessage from './svg_message.vue';
+import { s__, sprintf } from '../../locale';
export default {
name: 'RegistryListApp',
components: {
CollapsibleContainer,
GlLoadingIcon,
+ SvgMessage,
},
props: {
endpoint: {
type: String,
required: true,
},
+ characterError: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ noContainersImage: {
+ type: String,
+ required: true,
+ },
+ containersErrorImage: {
+ type: String,
+ required: true,
+ },
+ repositoryUrl: {
+ type: String,
+ required: true,
+ },
},
store,
computed: {
...mapGetters(['isLoading', 'repos']),
+ dockerConnectionErrorText() {
+ return sprintf(
+ s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an
+ issue with your project name or path. For more information, please review the
+ %{docLinkStart}Container Registry documentation%{docLinkEnd}.`),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ introText() {
+ return sprintf(
+ s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
+ project can have its own space to store its Docker images. Learn more about the
+ %{docLinkStart}Container Registry%{docLinkEnd}.`),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ noContainerImagesText() {
+ return sprintf(
+ s__(`ContainerRegistry|With the Container Registry, every project can have its own space to
+ store its Docker images. Learn more about the %{docLinkStart}Container Registry%{docLinkEnd}.`),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
},
created() {
this.setMainEndpoint(this.endpoint);
@@ -33,20 +92,44 @@ export default {
</script>
<template>
<div>
- <gl-loading-icon v-if="isLoading" size="md" />
+ <svg-message v-if="characterError" id="invalid-characters" :svg-path="containersErrorImage">
+ <h4>
+ {{ s__('ContainerRegistry|Docker connection error') }}
+ </h4>
+ <p v-html="dockerConnectionErrorText"></p>
+ </svg-message>
+
+ <gl-loading-icon v-else-if="isLoading" size="md" class="prepend-top-16" />
+
+ <div v-else-if="!isLoading && !characterError && repos.length">
+ <h4>{{ s__('ContainerRegistry|Container Registry') }}</h4>
+ <p v-html="introText"></p>
+ <collapsible-container v-for="item in repos" :key="item.id" :repo="item" />
+ </div>
+
+ <svg-message
+ v-else-if="!isLoading && !characterError && !repos.length"
+ id="no-container-images"
+ :svg-path="noContainersImage"
+ >
+ <h4>
+ {{ s__('ContainerRegistry|There are no container images stored for this project') }}
+ </h4>
+ <p v-html="noContainerImagesText"></p>
- <collapsible-container
- v-for="item in repos"
- v-else-if="!isLoading && repos.length"
- :key="item.id"
- :repo="item"
- />
+ <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
+ <p>
+ {{
+ s__(
+ 'ContainerRegistry|You can add an image to this registry with the following commands:',
+ )
+ }}
+ </p>
- <p v-else-if="!isLoading && !repos.length">
- {{
- __(`No container images stored for this project.
- Add one by following the instructions above.`)
- }}
- </p>
+ <pre>
+ docker build -t {{ repositoryUrl }} .
+ docker push {{ repositoryUrl }}
+ </pre>
+ </svg-message>
</div>
</template>
diff --git a/app/assets/javascripts/registry/components/svg_message.vue b/app/assets/javascripts/registry/components/svg_message.vue
new file mode 100644
index 00000000000..d0d44bf2d14
--- /dev/null
+++ b/app/assets/javascripts/registry/components/svg_message.vue
@@ -0,0 +1,24 @@
+<script>
+export default {
+ name: 'RegistrySvgMessage',
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ svgPath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div :id="id" class="empty-state container-message mw-70p">
+ <div class="svg-content">
+ <img :src="svgPath" class="flex-align-self-center" />
+ </div>
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js
index 025afefe7f0..d8daec29fda 100644
--- a/app/assets/javascripts/registry/index.js
+++ b/app/assets/javascripts/registry/index.js
@@ -14,12 +14,22 @@ export default () =>
const { dataset } = document.querySelector(this.$options.el);
return {
endpoint: dataset.endpoint,
+ characterError: Boolean(dataset.characterError),
+ helpPagePath: dataset.helpPagePath,
+ noContainersImage: dataset.noContainersImage,
+ containersErrorImage: dataset.containersErrorImage,
+ repositoryUrl: dataset.repositoryUrl,
};
},
render(createElement) {
return createElement('registry-app', {
props: {
endpoint: this.endpoint,
+ characterError: this.characterError,
+ helpPagePath: this.helpPagePath,
+ noContainersImage: this.noContainersImage,
+ containersErrorImage: this.containersErrorImage,
+ repositoryUrl: this.repositoryUrl,
},
});
},
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index c82b65cd97b..0031ba04d78 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -28,7 +28,7 @@ export default {
computed: {
releasedTimeAgo() {
return sprintf(__('released %{time}'), {
- time: this.timeFormated(this.release.created_at),
+ time: this.timeFormated(this.release.released_at),
});
},
userImageAltDescription() {
@@ -56,8 +56,8 @@ export default {
<div class="card-body">
<h2 class="card-title mt-0">
{{ release.name }}
- <gl-badge v-if="release.pre_release" variant="warning" class="align-middle">{{
- __('Pre-release')
+ <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
+ __('Upcoming Release')
}}</gl-badge>
</h2>
@@ -74,7 +74,7 @@ export default {
<div class="append-right-4">
&bull;
- <span v-gl-tooltip.bottom :title="tooltipTitle(release.created_at)">
+ <span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)">
{{ releasedTimeAgo }}
</span>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index baed26a157c..af02b8969ee 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -39,7 +39,7 @@ export default {
</script>
<template>
- <timeline-entry-item class="note being-posted fade-in-half">
+ <timeline-entry-item class="note note-wrapper being-posted fade-in-half">
<div class="timeline-icon">
<user-avatar-link
:link-href="getUserData.path"
diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss
index dfff3e15556..cca5214a508 100644
--- a/app/assets/stylesheets/pages/container_registry.scss
+++ b/app/assets/stylesheets/pages/container_registry.scss
@@ -2,6 +2,12 @@
* Container Registry
*/
+.container-message {
+ pre {
+ white-space: pre-line;
+ }
+}
+
.container-image {
border-bottom: 1px solid $white-normal;
}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index d2d35d91e0b..623c44e062f 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -1093,6 +1093,17 @@ table.code {
line-height: 0;
}
+.discussion-collapsible {
+ margin: 0 $gl-padding $gl-padding 71px;
+}
+
+.parallel {
+ .discussion-collapsible {
+ margin: $gl-padding;
+ margin-top: 0;
+ }
+}
+
@media (max-width: map-get($grid-breakpoints, md)-1) {
.diffs .files {
@include fixed-width-container;
@@ -1110,6 +1121,11 @@ table.code {
padding-right: 0;
}
}
+
+ .discussion-collapsible {
+ margin: $gl-padding;
+ margin-top: 0;
+ }
}
.image-diff-overlay,
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index e880b941d67..7bd1a4138e4 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -134,6 +134,16 @@ $note-form-margin-left: 72px;
}
}
+ .discussion-toggle-replies {
+ border-top: 0;
+ border-radius: 4px 4px 0 0;
+
+ &.collapsed {
+ border: 0;
+ border-radius: 4px;
+ }
+ }
+
.note-created-ago,
.note-updated-at {
white-space: normal;
@@ -462,6 +472,14 @@ $note-form-margin-left: 72px;
position: relative;
}
+ .notes-content .discussion-notes.diff-discussions {
+ border-bottom: 1px solid $border-color;
+
+ &:nth-last-child(1) {
+ border-bottom: 0;
+ }
+ }
+
.notes_holder {
font-family: $regular-font;
@@ -517,6 +535,17 @@ $note-form-margin-left: 72px;
.discussion-reply-holder {
border-radius: 0 0 $border-radius-default $border-radius-default;
position: relative;
+
+ .discussion-form {
+ width: 100%;
+ background-color: $gray-light;
+ padding: 0;
+ }
+
+ .disabled-comment {
+ padding: $gl-vert-padding 0;
+ width: 100%;
+ }
}
}
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 6d60117c37d..e205e2fd4f8 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -46,6 +46,8 @@ module Projects
repository.save! if repository.has_tags?
end
end
+ rescue ContainerRegistry::Path::InvalidRegistryPathError
+ @character_error = true
end
end
end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index ac3004d069f..bc2ce15286f 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -99,7 +99,7 @@ module Projects
end
def deploy_token_params
- params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry)
+ params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :username)
end
end
end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index a1023f44049..1430b82c2f2 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -35,11 +35,8 @@ module Clusters
'stable/nginx-ingress'
end
- # We will implement this in future MRs.
- # Basically we need to check all dependent applications are not installed
- # first.
def allowed_to_uninstall?
- false
+ external_ip_or_hostname? && application_jupyter_nil_or_installable?
end
def install_command
@@ -52,6 +49,10 @@ module Clusters
)
end
+ def external_ip_or_hostname?
+ external_ip.present? || external_hostname.present?
+ end
+
def schedule_status_update
return unless installed?
return if external_ip
@@ -63,6 +64,12 @@ module Clusters
def ingress_service
cluster.kubeclient.get_service('ingress-nginx-ingress-controller', Gitlab::Kubernetes::Helm::NAMESPACE)
end
+
+ private
+
+ def application_jupyter_nil_or_installable?
+ cluster.application_jupyter.nil? || cluster.application_jupyter&.installable?
+ end
end
end
end
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index 9e4b87d0993..9ede0615fa3 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -23,9 +23,7 @@ module Clusters
return unless cluster&.application_ingress_available?
ingress = cluster.application_ingress
- if ingress.external_ip || ingress.external_hostname
- self.status = 'installable'
- end
+ self.status = 'installable' if ingress.external_ip_or_hostname?
end
def chart
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index a6b7617b830..805c8a73f8c 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -49,14 +49,6 @@ module Clusters
)
end
- def uninstall_command
- Gitlab::Kubernetes::Helm::DeleteCommand.new(
- name: name,
- rbac: cluster.platform_kubernetes_rbac?,
- files: files
- )
- end
-
def upgrade_command(values)
::Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
index 1f881249322..570a735973f 100644
--- a/app/models/concerns/update_project_statistics.rb
+++ b/app/models/concerns/update_project_statistics.rb
@@ -19,9 +19,9 @@
#
# - `statistic_attribute` must be an ActiveRecord attribute
# - The model must implement `project` and `project_id`. i.e. direct Project relationship or delegation
-#
module UpdateProjectStatistics
extend ActiveSupport::Concern
+ include AfterCommitQueue
class_methods do
attr_reader :project_statistics_name, :statistic_attribute
@@ -31,7 +31,6 @@ module UpdateProjectStatistics
#
# - project_statistics_name: A column of `ProjectStatistics` to update
# - statistic_attribute: An attribute of the current model, default to `size`
- #
def update_project_statistics(project_statistics_name:, statistic_attribute: :size)
@project_statistics_name = project_statistics_name
@statistic_attribute = statistic_attribute
@@ -51,6 +50,7 @@ module UpdateProjectStatistics
delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i
update_project_statistics(delta)
+ schedule_namespace_aggregation_worker
end
def update_project_statistics_attribute_changed?
@@ -59,6 +59,8 @@ module UpdateProjectStatistics
def update_project_statistics_after_destroy
update_project_statistics(-read_attribute(self.class.statistic_attribute).to_i)
+
+ schedule_namespace_aggregation_worker
end
def project_destroyed?
@@ -68,5 +70,18 @@ module UpdateProjectStatistics
def update_project_statistics(delta)
ProjectStatistics.increment_statistic(project_id, self.class.project_statistics_name, delta)
end
+
+ def schedule_namespace_aggregation_worker
+ run_after_commit do
+ next unless schedule_aggregation_worker?
+
+ Namespaces::ScheduleAggregationWorker.perform_async(project.namespace_id)
+ end
+ end
+
+ def schedule_aggregation_worker?
+ !project.nil? &&
+ Feature.enabled?(:update_statistics_namespace, project.root_ancestor)
+ end
end
end
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index b0e570f52ba..33f0be91632 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -16,6 +16,14 @@ class DeployToken < ApplicationRecord
has_many :projects, through: :project_deploy_tokens
validate :ensure_at_least_one_scope
+ validates :username,
+ length: { maximum: 255 },
+ allow_nil: true,
+ format: {
+ with: /\A[a-zA-Z0-9\.\+_-]+\z/,
+ message: "can contain only letters, digits, '_', '-', '+', and '.'"
+ }
+
before_save :ensure_token
accepts_nested_attributes_for :project_deploy_tokens
@@ -39,7 +47,7 @@ class DeployToken < ApplicationRecord
end
def username
- "gitlab+deploy-token-#{id}"
+ super || default_username
end
def has_access_to?(requested_project)
@@ -75,4 +83,8 @@ class DeployToken < ApplicationRecord
def ensure_at_least_one_scope
errors.add(:base, "Scopes can't be blank") unless read_repository || read_registry
end
+
+ def default_username
+ "gitlab+deploy-token-#{id}" if persisted?
+ end
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index bfa33dc86ac..af50293a179 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -293,6 +293,10 @@ class Namespace < ApplicationRecord
end
end
+ def aggregation_scheduled?
+ aggregation_schedule.present?
+ end
+
private
def parent_changed?
diff --git a/app/models/namespace/aggregation_schedule.rb b/app/models/namespace/aggregation_schedule.rb
index 43afd0b954c..355593597c6 100644
--- a/app/models/namespace/aggregation_schedule.rb
+++ b/app/models/namespace/aggregation_schedule.rb
@@ -1,7 +1,47 @@
# frozen_string_literal: true
class Namespace::AggregationSchedule < ApplicationRecord
+ include AfterCommitQueue
+ include ExclusiveLeaseGuard
+
self.primary_key = :namespace_id
+ DEFAULT_LEASE_TIMEOUT = 3.hours
+ REDIS_SHARED_KEY = 'gitlab:update_namespace_statistics_delay'.freeze
+
belongs_to :namespace
+
+ after_create :schedule_root_storage_statistics
+
+ def self.delay_timeout
+ redis_timeout = Gitlab::Redis::SharedState.with do |redis|
+ redis.get(REDIS_SHARED_KEY)
+ end
+
+ redis_timeout.nil? ? DEFAULT_LEASE_TIMEOUT : redis_timeout.to_i
+ end
+
+ def schedule_root_storage_statistics
+ run_after_commit_or_now do
+ try_obtain_lease do
+ Namespaces::RootStatisticsWorker
+ .perform_async(namespace_id)
+
+ Namespaces::RootStatisticsWorker
+ .perform_in(self.class.delay_timeout, namespace_id)
+ end
+ end
+ end
+
+ private
+
+ # Used by ExclusiveLeaseGuard
+ def lease_timeout
+ self.class.delay_timeout
+ end
+
+ # Used by ExclusiveLeaseGuard
+ def lease_key
+ "namespace:namespaces_root_statistics:#{namespace_id}"
+ end
end
diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb
index de28eb6b37f..56c430013ee 100644
--- a/app/models/namespace/root_storage_statistics.rb
+++ b/app/models/namespace/root_storage_statistics.rb
@@ -1,10 +1,38 @@
# frozen_string_literal: true
class Namespace::RootStorageStatistics < ApplicationRecord
+ STATISTICS_ATTRIBUTES = %w(storage_size repository_size wiki_size lfs_objects_size build_artifacts_size packages_size).freeze
+
self.primary_key = :namespace_id
belongs_to :namespace
has_one :route, through: :namespace
delegate :all_projects, to: :namespace
+
+ def recalculate!
+ update!(attributes_from_project_statistics)
+ end
+
+ private
+
+ def attributes_from_project_statistics
+ from_project_statistics
+ .take
+ .attributes
+ .slice(*STATISTICS_ATTRIBUTES)
+ end
+
+ def from_project_statistics
+ all_projects
+ .joins('INNER JOIN project_statistics ps ON ps.project_id = projects.id')
+ .select(
+ 'COALESCE(SUM(ps.storage_size), 0) AS storage_size',
+ 'COALESCE(SUM(ps.repository_size), 0) AS repository_size',
+ 'COALESCE(SUM(ps.wiki_size), 0) AS wiki_size',
+ 'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size',
+ 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size',
+ 'COALESCE(SUM(ps.packages_size), 0) AS packages_size'
+ )
+ end
end
diff --git a/app/models/project_services/bugzilla_service.rb b/app/models/project_services/bugzilla_service.rb
index 1a2bb6a171b..8b79b5e9f0c 100644
--- a/app/models/project_services/bugzilla_service.rb
+++ b/app/models/project_services/bugzilla_service.rb
@@ -3,22 +3,14 @@
class BugzillaService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
+ prop_accessor :project_url, :issues_url, :new_issue_url
- def title
- if self.properties && self.properties['title'].present?
- self.properties['title']
- else
- 'Bugzilla'
- end
+ def default_title
+ 'Bugzilla'
end
- def description
- if self.properties && self.properties['description'].present?
- self.properties['description']
- else
- 'Bugzilla issue tracker'
- end
+ def default_description
+ s_('IssueTracker|Bugzilla issue tracker')
end
def self.to_param
diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb
index b8f8072869c..535fcf6b94e 100644
--- a/app/models/project_services/custom_issue_tracker_service.rb
+++ b/app/models/project_services/custom_issue_tracker_service.rb
@@ -5,24 +5,12 @@ class CustomIssueTrackerService < IssueTrackerService
prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
- def title
- if self.properties && self.properties['title'].present?
- self.properties['title']
- else
- 'Custom Issue Tracker'
- end
+ def default_title
+ 'Custom Issue Tracker'
end
- def title=(value)
- self.properties['title'] = value if self.properties
- end
-
- def description
- if self.properties && self.properties['description'].present?
- self.properties['description']
- else
- 'Custom issue tracker'
- end
+ def default_description
+ s_('IssueTracker|Custom issue tracker')
end
def self.to_param
diff --git a/app/models/project_services/gitlab_issue_tracker_service.rb b/app/models/project_services/gitlab_issue_tracker_service.rb
index fa9abf58e62..51032932eab 100644
--- a/app/models/project_services/gitlab_issue_tracker_service.rb
+++ b/app/models/project_services/gitlab_issue_tracker_service.rb
@@ -5,10 +5,18 @@ class GitlabIssueTrackerService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
+ prop_accessor :project_url, :issues_url, :new_issue_url
default_value_for :default, true
+ def default_title
+ 'GitLab'
+ end
+
+ def default_description
+ s_('IssueTracker|GitLab issue tracker')
+ end
+
def self.to_param
'gitlab'
end
diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb
index f54497fc6d8..3a1130ffc15 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -5,6 +5,8 @@ class IssueTrackerService < Service
default_value_for :category, 'issue_tracker'
+ before_save :handle_properties
+
# Pattern used to extract links from comments
# Override this method on services that uses different patterns
# This pattern does not support cross-project references
@@ -18,6 +20,37 @@ class IssueTrackerService < Service
end
end
+ # this will be removed as part of https://gitlab.com/gitlab-org/gitlab-ce/issues/63084
+ def title
+ if title_attribute = read_attribute(:title)
+ title_attribute
+ elsif self.properties && self.properties['title'].present?
+ self.properties['title']
+ else
+ default_title
+ end
+ end
+
+ # this will be removed as part of https://gitlab.com/gitlab-org/gitlab-ce/issues/63084
+ def description
+ if description_attribute = read_attribute(:description)
+ description_attribute
+ elsif self.properties && self.properties['description'].present?
+ self.properties['description']
+ else
+ default_description
+ end
+ end
+
+ def handle_properties
+ properties.slice('title', 'description').each do |key, _|
+ current_value = self.properties.delete(key)
+ value = attribute_changed?(key) ? attribute_change(key).last : current_value
+
+ write_attribute(key, value)
+ end
+ end
+
def default?
default
end
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index f31eb7fd19a..a3b89b2543a 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -17,7 +17,7 @@ class JiraService < IssueTrackerService
# Jira Cloud version is deprecating authentication via username and password.
# We should use username/password for Jira Server and email/api_token for Jira Cloud,
# for more information check: https://gitlab.com/gitlab-org/gitlab-ce/issues/49936.
- prop_accessor :username, :password, :url, :api_url, :jira_issue_transition_id, :title, :description
+ prop_accessor :username, :password, :url, :api_url, :jira_issue_transition_id
before_update :reset_password
@@ -37,7 +37,6 @@ class JiraService < IssueTrackerService
def initialize_properties
super do
self.properties = {
- title: issues_tracker['title'],
url: issues_tracker['url'],
api_url: issues_tracker['api_url']
}
@@ -74,20 +73,12 @@ class JiraService < IssueTrackerService
[Jira service documentation](#{help_page_url('user/project/integrations/jira')})."
end
- def title
- if self.properties && self.properties['title'].present?
- self.properties['title']
- else
- 'Jira'
- end
+ def default_title
+ 'Jira'
end
- def description
- if self.properties && self.properties['description'].present?
- self.properties['description']
- else
- s_('JiraService|Jira issue tracker')
- end
+ def default_description
+ s_('JiraService|Jira issue tracker')
end
def self.to_param
diff --git a/app/models/project_services/redmine_service.rb b/app/models/project_services/redmine_service.rb
index a80be4b06da..5ca057ca833 100644
--- a/app/models/project_services/redmine_service.rb
+++ b/app/models/project_services/redmine_service.rb
@@ -3,22 +3,14 @@
class RedmineService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
+ prop_accessor :project_url, :issues_url, :new_issue_url
- def title
- if self.properties && self.properties['title'].present?
- self.properties['title']
- else
- 'Redmine'
- end
+ def default_title
+ 'Redmine'
end
- def description
- if self.properties && self.properties['description'].present?
- self.properties['description']
- else
- 'Redmine issue tracker'
- end
+ def default_description
+ s_('IssueTracker|Redmine issue tracker')
end
def self.to_param
diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb
index 175c2ebf197..f9de1f7dc49 100644
--- a/app/models/project_services/youtrack_service.rb
+++ b/app/models/project_services/youtrack_service.rb
@@ -3,7 +3,7 @@
class YoutrackService < IssueTrackerService
validates :project_url, :issues_url, presence: true, public_url: true, if: :activated?
- prop_accessor :description, :project_url, :issues_url
+ prop_accessor :project_url, :issues_url
# {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030
def self.reference_pattern(only_long: false)
@@ -14,16 +14,12 @@ class YoutrackService < IssueTrackerService
end
end
- def title
+ def default_title
'YouTrack'
end
- def description
- if self.properties && self.properties['description'].present?
- self.properties['description']
- else
- 'YouTrack issue tracker'
- end
+ def default_description
+ s_('IssueTracker|YouTrack issue tracker')
end
def self.to_param
diff --git a/app/models/release.rb b/app/models/release.rb
index 7bbeb3c9976..459a7c29ad0 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -12,12 +12,16 @@ class Release < ApplicationRecord
has_many :links, class_name: 'Releases::Link'
+ default_value_for :released_at, allows_nil: false do
+ Time.zone.now
+ end
+
accepts_nested_attributes_for :links, allow_destroy: true
validates :description, :project, :tag, presence: true
validates :name, presence: true, on: :create
- scope :sorted, -> { order(created_at: :desc) }
+ scope :sorted, -> { order(released_at: :desc) }
delegate :repository, to: :project
@@ -44,6 +48,10 @@ class Release < ApplicationRecord
end
end
+ def upcoming_release?
+ released_at.present? && released_at > Time.zone.now
+ end
+
private
def actual_sha
diff --git a/app/models/service.rb b/app/models/service.rb
index 40033003f3b..752467622f2 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -129,7 +129,7 @@ class Service < ApplicationRecord
def api_field_names
fields.map { |field| field[:name] }
- .reject { |field_name| field_name =~ /(password|token|key)/ }
+ .reject { |field_name| field_name =~ /(password|token|key|title|description)/ }
end
def global_fields
diff --git a/app/services/deploy_tokens/create_service.rb b/app/services/deploy_tokens/create_service.rb
index dc0122002e9..327a1dbf408 100644
--- a/app/services/deploy_tokens/create_service.rb
+++ b/app/services/deploy_tokens/create_service.rb
@@ -3,7 +3,9 @@
module DeployTokens
class CreateService < BaseService
def execute
- @project.deploy_tokens.create(params)
+ @project.deploy_tokens.create(params) do |deploy_token|
+ deploy_token.username = params[:username].presence
+ end
end
end
end
diff --git a/app/services/namespaces/statistics_refresher_service.rb b/app/services/namespaces/statistics_refresher_service.rb
new file mode 100644
index 00000000000..c07b302839b
--- /dev/null
+++ b/app/services/namespaces/statistics_refresher_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class StatisticsRefresherService
+ RefresherError = Class.new(StandardError)
+
+ def execute(root_namespace)
+ root_storage_statistics = find_or_create_root_storage_statistics(root_namespace.id)
+
+ root_storage_statistics.recalculate!
+ rescue ActiveRecord::ActiveRecordError => e
+ raise RefresherError.new(e.message)
+ end
+
+ private
+
+ def find_or_create_root_storage_statistics(root_namespace_id)
+ Namespace::RootStorageStatistics
+ .safe_find_or_create_by!(namespace_id: root_namespace_id)
+ end
+ end
+end
diff --git a/app/services/releases/concerns.rb b/app/services/releases/concerns.rb
index ff6b696ca96..618d96717b8 100644
--- a/app/services/releases/concerns.rb
+++ b/app/services/releases/concerns.rb
@@ -22,6 +22,10 @@ module Releases
params[:description]
end
+ def released_at
+ params[:released_at]
+ end
+
def release
strong_memoize(:release) do
project.releases.find_by_tag(tag_name)
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index a271a7e5e49..5b13ac631ba 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -58,6 +58,7 @@ module Releases
author: current_user,
tag: tag.name,
sha: tag.dereferenced_target.sha,
+ released_at: released_at,
links_attributes: params.dig(:assets, 'links') || []
)
end
diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml
index 455322b2089..3d0266a2d5b 100644
--- a/app/views/clusters/clusters/_form.html.haml
+++ b/app/views/clusters/clusters/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field|
+= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_integration_form' } do |field|
= form_errors(@cluster)
.form-group
%h5= s_('ClusterIntegration|Integration status')
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index e12748666c8..db1849ebb45 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -13,7 +13,7 @@
= f.text_field :id, class: 'form-control w-auto', readonly: true
.row.prepend-top-8
- .form-group.col-md-9.append-bottom-0
+ .form-group.col-md-9
= f.label :description, _('Group description (optional)'), class: 'label-bold'
= f.text_area :description, class: 'form-control', rows: 3, maxlength: 250
diff --git a/app/views/projects/deploy_tokens/_form.html.haml b/app/views/projects/deploy_tokens/_form.html.haml
index 5412fcbc9d8..f846dbd3763 100644
--- a/app/views/projects/deploy_tokens/_form.html.haml
+++ b/app/views/projects/deploy_tokens/_form.html.haml
@@ -13,6 +13,11 @@
= f.text_field :expires_at, class: 'datepicker form-control qa-deploy-token-expires-at', value: f.object.expires_at
.form-group
+ = f.label :username, class: 'label-bold'
+ = f.text_field :username, class: 'form-control qa-deploy-token-username'
+ .text-secondary= s_('DeployTokens|Default format is "gitlab+deploy-token-{n}". Enter custom username if you want to change it.')
+
+ .form-group
= f.label :scopes, class: 'label-bold'
%fieldset.form-group.form-check
= f.check_box :read_repository, class: 'form-check-input qa-deploy-token-read-repository'
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index db1f15f96b8..e34973f1f43 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -1,49 +1,9 @@
-- page_title "Container Registry"
-
%section
- .settings-header
- %h4
- = page_title
- %p
- = s_('ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images.')
- %p.append-bottom-0
- = succeed '.' do
- = s_('ContainerRegistry|Learn more about')
- = link_to _('Container Registry'), help_page_path('user/project/container_registry'), target: '_blank'
.row.registry-placeholder.prepend-bottom-10
- .col-lg-12
- #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json) } }
-
- .row.prepend-top-10
- .col-lg-12
- .card
- .card-header
- = s_('ContainerRegistry|How to use the Container Registry')
- .card-body
- %p
- - link_token = link_to(_('personal access token'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank')
- - link_2fa = link_to(_('2FA enabled'), help_page_path('user/profile/account/two_factor_authentication'), target: '_blank')
- = s_('ContainerRegistry|First log in to GitLab&rsquo;s Container Registry using your GitLab username and password. If you have %{link_2fa} you need to use a %{link_token}:').html_safe % { link_2fa: link_2fa, link_token: link_token }
- %pre
- docker login #{Gitlab.config.registry.host_port}
- %br
- %p
- - deploy_token = link_to(_('deploy token'), help_page_path('user/project/deploy_tokens/index', anchor: 'read-container-registry-images'), target: '_blank')
- = s_('ContainerRegistry|You can also use a %{deploy_token} for read-only access to the registry images.').html_safe % { deploy_token: deploy_token }
- %br
- %p
- = s_('ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands').html_safe % { build: "<code>build</code>".html_safe, push: "<code>push</code>".html_safe }
- %pre
- :plain
- docker build -t #{escape_once(@project.container_registry_url)} .
- docker push #{escape_once(@project.container_registry_url)}
- %hr
- %h5.prepend-top-default
- = s_('ContainerRegistry|Use different image names')
- %p.light
- = s_('ContainerRegistry|GitLab supports up to 3 levels of image names. The following examples of images are valid for your project:')
- %pre
- :plain
- #{escape_once(@project.container_registry_url)}:tag
- #{escape_once(@project.container_registry_url)}/optional-image-name:tag
- #{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag
+ .col-12
+ #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json),
+ "help_page_path" => help_page_path('user/project/container_registry'),
+ "no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
+ "containers_error_image" => image_path('illustrations/docker-error-state.svg'),
+ "repository_url" => escape_once(@project.container_registry_url),
+ character_error: @character_error.to_s } }
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index e55962b629e..3d34bfc05c7 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -26,6 +26,7 @@
- cronjob:issue_due_scheduler
- cronjob:prune_web_hook_logs
- cronjob:schedule_migrate_external_diffs
+- cronjob:namespaces_prune_aggregation_schedules
- gcp_cluster:cluster_install_app
- gcp_cluster:cluster_patch_app
@@ -101,6 +102,9 @@
- todos_destroyer:todos_destroyer_project_private
- todos_destroyer:todos_destroyer_private_features
+- update_namespace_statistics:namespaces_schedule_aggregation
+- update_namespace_statistics:namespaces_root_statistics
+
- object_pool:object_pool_create
- object_pool:object_pool_schedule_join
- object_pool:object_pool_join
diff --git a/app/workers/namespaces/prune_aggregation_schedules_worker.rb b/app/workers/namespaces/prune_aggregation_schedules_worker.rb
new file mode 100644
index 00000000000..4e40feee702
--- /dev/null
+++ b/app/workers/namespaces/prune_aggregation_schedules_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class PruneAggregationSchedulesWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ # Worker to prune pending rows on Namespace::AggregationSchedule
+ # It's scheduled to run once a day at 1:05am.
+ def perform
+ aggregation_schedules.find_each do |aggregation_schedule|
+ aggregation_schedule.schedule_root_storage_statistics
+ end
+ end
+
+ private
+
+ def aggregation_schedules
+ Namespace::AggregationSchedule.all
+ end
+ end
+end
diff --git a/app/workers/namespaces/root_statistics_worker.rb b/app/workers/namespaces/root_statistics_worker.rb
new file mode 100644
index 00000000000..48876825564
--- /dev/null
+++ b/app/workers/namespaces/root_statistics_worker.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class RootStatisticsWorker
+ include ApplicationWorker
+
+ queue_namespace :update_namespace_statistics
+
+ def perform(namespace_id)
+ namespace = Namespace.find(namespace_id)
+
+ return unless update_statistics_enabled_for?(namespace) && namespace.aggregation_scheduled?
+
+ Namespaces::StatisticsRefresherService.new.execute(namespace)
+
+ namespace.aggregation_schedule.destroy
+ rescue ::Namespaces::StatisticsRefresherService::RefresherError, ActiveRecord::RecordNotFound => ex
+ log_error(namespace.full_path, ex.message) if namespace
+ end
+
+ private
+
+ def log_error(namespace_path, error_message)
+ Gitlab::SidekiqLogger.error("Namespace statistics can't be updated for #{namespace_path}: #{error_message}")
+ end
+
+ def update_statistics_enabled_for?(namespace)
+ Feature.enabled?(:update_statistics_namespace, namespace)
+ end
+ end
+end
diff --git a/app/workers/namespaces/schedule_aggregation_worker.rb b/app/workers/namespaces/schedule_aggregation_worker.rb
new file mode 100644
index 00000000000..a4594b84b13
--- /dev/null
+++ b/app/workers/namespaces/schedule_aggregation_worker.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class ScheduleAggregationWorker
+ include ApplicationWorker
+
+ queue_namespace :update_namespace_statistics
+
+ def perform(namespace_id)
+ return unless aggregation_schedules_table_exists?
+
+ namespace = Namespace.find(namespace_id)
+ root_ancestor = namespace.root_ancestor
+
+ return unless update_statistics_enabled_for?(root_ancestor) && !root_ancestor.aggregation_scheduled?
+
+ Namespace::AggregationSchedule.safe_find_or_create_by!(namespace_id: root_ancestor.id)
+ rescue ActiveRecord::RecordNotFound
+ log_error(namespace_id)
+ end
+
+ private
+
+ # On db/post_migrate/20180529152628_schedule_to_archive_legacy_traces.rb
+ # traces are archived through build.trace.archive, which in consequence
+ # calls UpdateProjectStatistics#schedule_namespace_statistics_worker.
+ #
+ # The migration and specs fails since NamespaceAggregationSchedule table
+ # does not exist at that point.
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/50712
+ def aggregation_schedules_table_exists?
+ return true unless Rails.env.test?
+
+ Namespace::AggregationSchedule.table_exists?
+ end
+
+ def log_error(root_ancestor_id)
+ Gitlab::SidekiqLogger.error("Namespace can't be scheduled for aggregation: #{root_ancestor_id} does not exist")
+ end
+
+ def update_statistics_enabled_for?(root_ancestor)
+ Feature.enabled?(:update_statistics_namespace, root_ancestor)
+ end
+ end
+end