Commit b94daa35 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'master' into michel.engelen/gitlab-ce-issue/55953

parents 83330822 f90a7601

Too many changes to show.

To preserve performance only 1000 of 1000+ files are displayed.

......@@ -6,8 +6,8 @@
/doc/ @axil @marcia @eread @mikelewis
# Frontend maintainers should see everything in `app/assets/`
app/assets/ @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann @kushalpandya
*.scss @annabeldunstone @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann @kushalpandya
app/assets/ @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann @kushalpandya @pslaughter
*.scss @annabeldunstone @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann @kushalpandya @pslaughter
# Someone from the database team should review changes in `db/`
db/ @abrandl @NikolayS
......
......@@ -8,7 +8,7 @@
.use-pg: &use-pg
services:
- name: postgres:9.6
- name: postgres:9.6.11
command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
- name: redis:alpine
......@@ -32,7 +32,7 @@
DOCKER_HOST: tcp://docker:2375
script:
- node --version
- retry yarn install --frozen-lockfile --production --cache-folder .yarn-cache
- retry yarn install --frozen-lockfile --production --cache-folder .yarn-cache --prefer-offline
- free -m
- retry bundle exec rake gitlab:assets:compile
- time scripts/build_assets_image
......@@ -82,7 +82,7 @@ gitlab:assets:compile pull-cache:
stage: prepare
script:
- node --version
- retry yarn install --frozen-lockfile --cache-folder .yarn-cache
- retry yarn install --frozen-lockfile --cache-folder .yarn-cache --prefer-offline
- free -m
- retry bundle exec rake gitlab:assets:compile
- scripts/clean-old-cached-assets
......@@ -231,7 +231,7 @@ qa:selectors:
before_script: []
script:
- date
- yarn install --frozen-lockfile --cache-folder .yarn-cache
- yarn install --frozen-lockfile --cache-folder .yarn-cache --prefer-offline
- date
- yarn run webpack-prod
......
.use-pg: &use-pg
services:
- name: postgres:9.6
- name: postgres:9.6.11
command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
- name: redis:alpine
......@@ -245,7 +245,7 @@ migration:path-pg:
.db-rollback: &db-rollback
extends: .dedicated-no-docs-and-no-qa-pull-cache-job
script:
- bundle exec rake db:migrate VERSION=20170523121229
- bundle exec rake db:migrate VERSION=20180101160629
- bundle exec rake db:migrate SKIP_SCHEMA_VERSION_CHECK=true
dependencies:
- setup-test-env
......
......@@ -236,5 +236,5 @@ danger-review:
script:
- git version
- node --version
- yarn install --frozen-lockfile --cache-folder .yarn-cache
- yarn install --frozen-lockfile --cache-folder .yarn-cache --prefer-offline
- danger --fail-on-errors=true
<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>
......@@ -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"
......
......@@ -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 =
......
<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>
......
......@@ -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>
<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>
......
<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>
......@@ -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')));
};
......
......@@ -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';
......@@ -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;
},
......
......@@ -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);
};
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);
}
......
......@@ -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">
......
......@@ -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>
......
......@@ -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" />
......
<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,
});
},
},
};
......
<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;
......
......@@ -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
......
......@@ -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;
}
......
......@@ -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"
......
<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>
<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>
......@@ -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"
......
......@@ -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 = (
......
......@@ -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
......
<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>
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);
};
......
......@@ -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>
<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>
......@@ -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,
},
});
},
......
......@@ -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>
......
......@@ -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"
......
......@@ -2,6 +2,12 @@
* Container Registry
*/
.container-message {
pre {
white-space: pre-line;
}
}
.container-image {
border-bottom: 1px solid $white-normal;
}
......
......@@ -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,
......
......@@ -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%;
}
}
}
......
......@@ -46,6 +46,8 @@ module Projects
repository.save! if repository.has_tags?
end
end
rescue ContainerRegistry::Path::InvalidRegistryPathError
@character_error = true
end
end
end
......
......@@ -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
......
......@@ -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
......@@ -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
......
......@@ -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,
......
......@@ -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
......@@ -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
......@@ -293,6 +293,10 @@ class Namespace < ApplicationRecord
end
end
def aggregation_scheduled?
aggregation_schedule.present?
end
private
def parent_changed?
......
# 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
# 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
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
# 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
......@@ -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)
......
......@@ -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
......
= 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')
......
......@@ -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
......
......@@ -12,6 +12,11 @@
= f.label :expires_at, class: 'label-bold'
= 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
......
- 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 } }
......@@ -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
......
# 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
# 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
# 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
---
title: Resolve Multiple discussions per line in merge request diffs
merge_request: 28748
author:
type: added
---
title: Updated container registry to display error message when special characters in path. Documentation has also been updated.
merge_request: 29616
author:
type: changed
---
title: Allow custom username for deploy tokens
merge_request: 29639
author:
type: added
---
title: Implement borderless discussion design with new reply field
merge_request: 28580
author:
type: added
---
title: Remove istanbul JavaScript package
merge_request: 30232
author: Takuya Noguchi
type: other
---
title: Allow Ingress to be uninstalled from the UI
merge_request: 29977
author:
type: added
---
title: Show an Upcoming Status for Releases
merge_request: 29577
author:
type: added
---
title: Don't let logged out user do manual order
merge_request: 30264
author:
type: fixed
---
title: Cache Flipper persisted names directly to local memory storage
merge_request: 30265
author:
type: performance
---
title: Add Redis call details in Peek performance bar
merge_request: 30191
author:
type: changed
---
title: Replace slugifyWithHyphens with improved slugify function
merge_request: 30172
author: Luke Ward
type: fixed
---
title: Make sure UnicornSampler is started only in master process.
merge_request: 30215
author:
type: fixed
---
title: Use PostgreSQL 9.6.11 in CI tests
merge_request: 30270
author: Takuya Noguchi
type: other
---
title: Fix typo in updateResolvableDiscussionsCounts action
merge_request: 30278
author: Frank van Rest
type: other
......@@ -441,6 +441,9 @@ Settings.cron_jobs['prune_web_hook_logs_worker']['job_class'] = 'PruneWebHookLog
Settings.cron_jobs['schedule_migrate_external_diffs_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['schedule_migrate_external_diffs_worker']['cron'] ||= '15 * * * *'
Settings.cron_jobs['schedule_migrate_external_diffs_worker']['job_class'] = 'ScheduleMigrateExternalDiffsWorker'
Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker']['cron'] ||= '5 1 * * *'
Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker']['job_class'] = 'Namespaces::PruneAggregationSchedulesWorker'
Gitlab.ee do
Settings.cron_jobs['clear_shared_runners_minutes_worker'] ||= Settingslogic.new({})
......
require 'prometheus/client'
require 'prometheus/client/support/unicorn'
# Keep separate directories for separate processes
def prometheus_default_multiproc_dir
return unless Rails.env.development? || Rails.env.test?
if Sidekiq.server?
Rails.root.join('tmp/prometheus_multiproc_dir/sidekiq')
elsif defined?(Unicorn::Worker)
Rails.root.join('tmp/prometheus_multiproc_dir/unicorn')
elsif defined?(::Puma)
Rails.root.join('tmp/prometheus_multiproc_dir/puma')
else
Rails.root.join('tmp/prometheus_multiproc_dir')
end
end
Prometheus::Client.configure do |config|
config.logger = Rails.logger
config.initial_mmap_file_size = 4 * 1024
config.multiprocess_files_dir = ENV['prometheus_multiproc_dir']
if Rails.env.development? || Rails.env.test?
config.multiprocess_files_dir ||= Rails.root.join('tmp/prometheus_multiproc_dir')
end
config.multiprocess_files_dir = ENV['prometheus_multiproc_dir'] || prometheus_default_multiproc_dir
config.pid_provider = Prometheus::Client::Support::Unicorn.method(:worker_pid_provider)
end
......@@ -29,15 +41,13 @@ if !Rails.env.test? && Gitlab::Metrics.prometheus_metrics_enabled?
Gitlab::Cluster::LifecycleEvents.on_worker_start do
defined?(::Prometheus::Client.reinitialize_on_pid_change) && Prometheus::Client.reinitialize_on_pid_change
if defined?(::Unicorn)
Gitlab::Metrics::Samplers::UnicornSampler.initialize_instance(Settings.monitoring.unicorn_sampler_interval).start
end
Gitlab::Metrics::Samplers::RubySampler.initialize_instance(Settings.monitoring.ruby_sampler_interval).start
end
if defined?(::Puma)
Gitlab::Cluster::LifecycleEvents.on_master_start do
Gitlab::Cluster::LifecycleEvents.on_master_start do
if defined?(::Unicorn)
Gitlab::Metrics::Samplers::UnicornSampler.initialize_instance(Settings.monitoring.unicorn_sampler_interval).start
elsif defined?(::Puma)
Gitlab::Metrics::Samplers::PumaSampler.initialize_instance(Settings.monitoring.puma_sampler_interval).start
end
end
......
......@@ -4,12 +4,22 @@
ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, req|
if [:throttle, :blacklist].include? req.env['rack.attack.match_type']
Gitlab::AuthLogger.error(
rack_attack_info = {
message: 'Rack_Attack',
env: req.env['rack.attack.match_type'],
ip: req.ip,
request_method: req.request_method,
fullpath: req.fullpath
)
}
if req.env['rack.attack.matched'] != 'throttle_unauthenticated'
user_id = req.env['rack.attack.match_discriminator']
user = User.find_by(id: user_id)
rack_attack_info[:user_id] = user_id
rack_attack_info[:username] = user.username unless user.nil?
end
Gitlab::AuthLogger.error(rack_attack_info)
end
end
......@@ -94,6 +94,7 @@
- [migrate_external_diffs, 1]
- [update_project_statistics, 1]
- [phabricator_import_import_tasks, 1]
- [update_namespace_statistics, 1]
# EE-specific queues
- [ldap_group_sync, 2]
......
This diff is collapsed.
class FixNamespaces < ActiveRecord::Migration[4.2]
DOWNTIME = false
def up
namespaces = exec_query('SELECT id, path FROM namespaces WHERE name <> path and type is null')
namespaces.each do |row|
id = row['id']
path = row['path']
exec_query("UPDATE namespaces SET name = '#{path}' WHERE id = #{id}")
end
end
def down
end
end
class ChangeStateToAllowEmptyMergeRequestDiffs < ActiveRecord::Migration[4.2]
def up
change_column :merge_request_diffs, :state, :string, null: true,
default: nil
end
def down
change_column :merge_request_diffs, :state, :string, null: false,
default: 'collected'
end
end
# rubocop:disable all
class AddIndexOnIid < ActiveRecord::Migration[4.2]
def change
RemoveDuplicateIid.clean(Issue)
RemoveDuplicateIid.clean(MergeRequest, 'target_project_id')
RemoveDuplicateIid.clean(Milestone)
add_index :issues, [:project_id, :iid], unique: true
add_index :merge_requests, [:target_project_id, :iid], unique: true
add_index :milestones, [:project_id, :iid], unique: true
end
end
class RemoveDuplicateIid
def self.clean(klass, project_field = 'project_id')
duplicates = klass.find_by_sql("SELECT iid, #{project_field} FROM #{klass.table_name} GROUP BY #{project_field}, iid HAVING COUNT(*) > 1")
duplicates.each do |duplicate|
project_id = duplicate.send(project_field)
iid = duplicate.iid
items = klass.of_projects(project_id).where(iid: iid)
if items.size > 1
puts "Remove #{klass.name} duplicates for iid: #{iid} and project_id: #{project_id}"
items.shift
items.each do |item|
item.destroy
puts '.'
end
end
end
end
end
# rubocop:disable all
class IndexOnCurrentSignInAt < ActiveRecord::Migration[4.2]
def change
add_index :users, :current_sign_in_at
end
end
# rubocop:disable all
class AddNotesIndexUpdatedAt < ActiveRecord::Migration[4.2]
def change
add_index :notes, :updated_at
end
end
# rubocop:disable all
class AddRepoSizeToDb < ActiveRecord::Migration[4.2]
def change
add_column :projects, :repository_size, :float, default: 0
end
end
# rubocop:disable all
class MigrateRepoSize < ActiveRecord::Migration[4.2]
DOWNTIME = false
def up
project_data = execute('SELECT projects.id, namespaces.path AS namespace_path, projects.path AS project_path FROM projects LEFT JOIN namespaces ON projects.namespace_id = namespaces.id')
project_data.each do |project|
id = project['id']
namespace_path = project['namespace_path'] || ''
path = File.join(namespace_path, project['project_path'] + '.git')
begin
repo = Gitlab::Git::Repository.new('default', path, '', '')
if repo.empty?
print '-'
else
size = repo.size
print '.'
execute("UPDATE projects SET repository_size = #{size} WHERE id = #{id}")
end
rescue => e
puts "\nFailed to update project #{id}: #{e}"
end
end
puts "\nDone"
end
def down
end
end
# rubocop:disable all
class AddPositionToMergeRequest < ActiveRecord::Migration[4.2]
def change
add_column :merge_requests, :position, :integer, default: 0
end
end
# rubocop:disable all
class CreateUsersStarProjects < ActiveRecord::Migration[4.2]
DOWNTIME = false
def change
create_table :users_star_projects do |t|
t.integer :project_id, null: false
t.integer :user_id, null: false
t.timestamps null: true
end
add_index :users_star_projects, :user_id
add_index :users_star_projects, :project_id
add_index :users_star_projects, [:user_id, :project_id], unique: true
add_column :projects, :star_count, :integer, default: 0, null: false
add_index :projects, :star_count, using: :btree
end
end
# rubocop:disable all
class CreateLabels < ActiveRecord::Migration[4.2]
DOWNTIME = false
def change
create_table :labels do |t|
t.string :title
t.string :color
t.integer :project_id
t.timestamps null: true
end
end
end
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment