Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-01-14 12:07:41 +00:00
parent 02ab65d49f
commit 4ce0bee95d
93 changed files with 1318 additions and 1134 deletions

View File

@ -6,6 +6,7 @@ import { __ } from '~/locale';
import createFlash from '~/flash';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
import eventHub from '../../notes/event_hub';
import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
@ -145,6 +146,9 @@ export default {
},
watch: {
diffViewType() {
if (this.needsReload() || this.needsFirstLoad()) {
this.refetchDiffData();
}
this.adjustView();
},
shouldShow() {
@ -224,6 +228,16 @@ export default {
{ timeout: 1000 },
);
},
needsReload() {
return (
this.glFeatures.singleMrDiffView &&
this.diffFiles.length &&
isSingleViewStyle(this.diffFiles[0])
);
},
needsFirstLoad() {
return this.glFeatures.singleMrDiffView && !this.diffFiles.length;
},
fetchData(toggleTree = true) {
if (this.glFeatures.diffsBatchLoad) {
this.fetchDiffFilesMeta()
@ -237,6 +251,13 @@ export default {
});
this.fetchDiffFilesBatch()
.then(() => {
// Guarantee the discussions are assigned after the batch finishes.
// Just watching the length of the discussions or the diff files
// isn't enough, because with split diff loading, neither will
// change when loading the other half of the diff files.
this.setDiscussions();
})
.then(() => this.startDiffRendering())
.catch(() => {
createFlash(__('Something went wrong on our end. Please try again!'));
@ -250,6 +271,7 @@ export default {
requestIdleCallback(
() => {
this.setDiscussions();
this.startRenderDiffsQueue();
},
{ timeout: 1000 },

View File

@ -4,6 +4,7 @@ import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import createFlash from '~/flash';
import { hasDiff } from '~/helpers/diffs_helper';
import eventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
@ -55,12 +56,7 @@ export default {
return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed);
},
hasDiff() {
return (
(this.file.highlighted_diff_lines &&
this.file.parallel_diff_lines &&
this.file.parallel_diff_lines.length > 0) ||
!this.file.blob.readable_text
);
return hasDiff(this.file);
},
isFileTooLarge() {
return this.file.viewer.error === diffViewerErrors.too_large;

View File

@ -65,6 +65,10 @@ export const fetchDiffFiles = ({ state, commit }) => {
w: state.showWhitespace ? '0' : '1',
};
if (state.useSingleDiffStyle) {
urlParams.view = state.diffViewType;
}
commit(types.SET_LOADING, true);
worker.addEventListener('message', ({ data }) => {
@ -90,13 +94,22 @@ export const fetchDiffFiles = ({ state, commit }) => {
};
export const fetchDiffFilesBatch = ({ commit, state }) => {
const urlParams = {
per_page: DIFFS_PER_PAGE,
w: state.showWhitespace ? '0' : '1',
};
if (state.useSingleDiffStyle) {
urlParams.view = state.diffViewType;
}
commit(types.SET_BATCH_LOADING, true);
commit(types.SET_RETRIEVING_BATCHES, true);
const getBatch = page =>
axios
.get(state.endpointBatch, {
params: { page, per_page: DIFFS_PER_PAGE, w: state.showWhitespace ? '0' : '1' },
params: { ...urlParams, page },
})
.then(({ data: { pagination, diff_files } }) => {
commit(types.SET_DIFF_DATA_BATCH, { diff_files });
@ -150,7 +163,10 @@ export const assignDiscussionsToDiff = (
{ commit, state, rootState },
discussions = rootState.notes.discussions,
) => {
const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles);
const diffPositionByLineCode = getDiffPositionByLineCode(
state.diffFiles,
state.useSingleDiffStyle,
);
const hash = getLocationHash();
discussions
@ -339,24 +355,23 @@ 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),
);
}
const lineCodesWithDiscussions = new Set();
const { parallel_diff_lines: parallelLines, highlighted_diff_lines: inlineLines } = diff;
const allLines = inlineLines.concat(
parallelLines.map(line => line.left),
parallelLines.map(line => line.right),
);
const lineHasDiscussion = line => Boolean(line?.discussions.length);
const registerDiscussionLine = line => lineCodesWithDiscussions.add(line.line_code);
if (linesWithDiscussions.length) {
linesWithDiscussions.forEach(line => {
allLines.filter(lineHasDiscussion).forEach(registerDiscussionLine);
if (lineCodesWithDiscussions.size) {
Array.from(lineCodesWithDiscussions).forEach(lineCode => {
commit(types.TOGGLE_LINE_DISCUSSIONS, {
fileHash: diff.file_hash,
lineCode: line.line_code,
expanded: !discussionWrappersExpanded,
lineCode,
});
});
}

View File

@ -45,26 +45,28 @@ export default {
},
[types.SET_DIFF_DATA](state, data) {
let files = state.diffFiles;
if (
!(
gon &&
gon.features &&
gon.features.diffsBatchLoad &&
window.location.search.indexOf('diff_id') === -1
)
!(gon?.features?.diffsBatchLoad && window.location.search.indexOf('diff_id') === -1) &&
data.diff_files
) {
prepareDiffData(data);
files = prepareDiffData(data, files);
}
Object.assign(state, {
...convertObjectPropsToCamelCase(data),
diffFiles: files,
});
},
[types.SET_DIFF_DATA_BATCH](state, data) {
prepareDiffData(data);
const files = prepareDiffData(data, state.diffFiles);
state.diffFiles.push(...data.diff_files);
Object.assign(state, {
...convertObjectPropsToCamelCase(data),
diffFiles: files,
});
},
[types.RENDER_FILE](state, file) {
@ -88,11 +90,11 @@ export default {
if (!diffFile) return;
if (diffFile.highlighted_diff_lines) {
if (diffFile.highlighted_diff_lines.length) {
diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm;
}
if (diffFile.parallel_diff_lines) {
if (diffFile.parallel_diff_lines.length) {
const line = diffFile.parallel_diff_lines.find(l => {
const { left, right } = l;
@ -153,13 +155,13 @@ export default {
},
[types.EXPAND_ALL_FILES](state) {
state.diffFiles = state.diffFiles.map(file => ({
...file,
viewer: {
...file.viewer,
collapsed: false,
},
}));
state.diffFiles.forEach(file => {
Object.assign(file, {
viewer: Object.assign(file.viewer, {
collapsed: false,
}),
});
});
},
[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) {
@ -197,29 +199,29 @@ export default {
};
};
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 =>
setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
);
state.diffFiles.forEach(file => {
if (file.file_hash === fileHash) {
if (file.highlighted_diff_lines.length) {
file.highlighted_diff_lines.forEach(line => {
Object.assign(
line,
setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
);
});
}
if (file.parallel_diff_lines) {
file.parallel_diff_lines = file.parallel_diff_lines.map(line => {
if (file.parallel_diff_lines.length) {
file.parallel_diff_lines.forEach(line => {
const left = line.left && lineCheck(line.left);
const right = line.right && lineCheck(line.right);
if (left || right) {
return {
...line,
Object.assign(line, {
left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null,
right: line.right
? setDiscussionsExpanded(mapDiscussions(line.right, () => !left))
: null,
};
});
}
return line;
@ -227,15 +229,15 @@ export default {
}
if (!file.parallel_diff_lines || !file.highlighted_diff_lines) {
file.discussions = (file.discussions || [])
const newDiscussions = (file.discussions || [])
.filter(d => d.id !== discussion.id)
.concat(discussion);
Object.assign(file, {
discussions: newDiscussions,
});
}
return file;
}
return diffFile;
});
},
@ -259,9 +261,9 @@ 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 }),
);
updateLineInFile(selectedFile, lineCode, line => {
Object.assign(line, { discussionsExpanded: expanded });
});
},
[types.TOGGLE_FOLDER_OPEN](state, path) {

View File

@ -185,6 +185,7 @@ export function addContextLines(options) {
* Trims the first char of the `richText` property when it's either a space or a diff symbol.
* @param {Object} line
* @returns {Object}
* @deprecated
*/
export function trimFirstCharOfLineContent(line = {}) {
// eslint-disable-next-line no-param-reassign
@ -212,79 +213,171 @@ function getLineCode({ left, right }, index) {
return index;
}
// This prepares and optimizes the incoming diff data from the server
// by setting up incremental rendering and removing unneeded data
export function prepareDiffData(diffData) {
const filesLength = diffData.diff_files.length;
let showingLines = 0;
for (let i = 0; i < filesLength; i += 1) {
const file = diffData.diff_files[i];
if (file.parallel_diff_lines) {
const linesLength = file.parallel_diff_lines.length;
for (let u = 0; u < linesLength; u += 1) {
const line = file.parallel_diff_lines[u];
line.line_code = getLineCode(line, u);
if (line.left) {
line.left = trimFirstCharOfLineContent(line.left);
line.left.discussions = [];
line.left.hasForm = false;
}
if (line.right) {
line.right = trimFirstCharOfLineContent(line.right);
line.right.discussions = [];
line.right.hasForm = false;
}
}
}
if (file.highlighted_diff_lines) {
const linesLength = file.highlighted_diff_lines.length;
for (let u = 0; u < linesLength; u += 1) {
const line = file.highlighted_diff_lines[u];
Object.assign(line, {
...trimFirstCharOfLineContent(line),
discussions: [],
hasForm: false,
});
}
showingLines += file.parallel_diff_lines.length;
}
const name = (file.viewer && file.viewer.name) || diffViewerModes.text;
Object.assign(file, {
renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY,
collapsed: name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED,
isShowingFullFile: false,
isLoadingFullFile: false,
discussions: [],
renderingLines: false,
});
}
function diffFileUniqueId(file) {
return `${file.content_sha}-${file.file_hash}`;
}
export function getDiffPositionByLineCode(diffFiles) {
return diffFiles.reduce((acc, diffFile) => {
// We can only use highlightedDiffLines to create the map of diff lines because
// highlightedDiffLines will also include every parallel diff line in it.
if (diffFile.highlighted_diff_lines) {
function combineDiffFilesWithPriorFiles(files, prior = []) {
files.forEach(file => {
const id = diffFileUniqueId(file);
const oldMatch = prior.find(oldFile => diffFileUniqueId(oldFile) === id);
if (oldMatch) {
const missingInline = !file.highlighted_diff_lines;
const missingParallel = !file.parallel_diff_lines;
if (missingInline) {
Object.assign(file, {
highlighted_diff_lines: oldMatch.highlighted_diff_lines,
});
}
if (missingParallel) {
Object.assign(file, {
parallel_diff_lines: oldMatch.parallel_diff_lines,
});
}
}
});
return files;
}
function ensureBasicDiffFileLines(file) {
const missingInline = !file.highlighted_diff_lines;
const missingParallel = !file.parallel_diff_lines;
Object.assign(file, {
highlighted_diff_lines: missingInline ? [] : file.highlighted_diff_lines,
parallel_diff_lines: missingParallel ? [] : file.parallel_diff_lines,
});
return file;
}
function cleanRichText(text) {
return text ? text.replace(/^[+ -]/, '') : undefined;
}
function prepareLine(line) {
return Object.assign(line, {
rich_text: cleanRichText(line.rich_text),
discussionsExpanded: true,
discussions: [],
hasForm: false,
text: undefined,
});
}
function prepareDiffFileLines(file) {
const inlineLines = file.highlighted_diff_lines;
const parallelLines = file.parallel_diff_lines;
let parallelLinesCount = 0;
inlineLines.forEach(prepareLine);
parallelLines.forEach((line, index) => {
Object.assign(line, { line_code: getLineCode(line, index) });
if (line.left) {
parallelLinesCount += 1;
prepareLine(line.left);
}
if (line.right) {
parallelLinesCount += 1;
prepareLine(line.right);
}
Object.assign(file, {
inlineLinesCount: inlineLines.length,
parallelLinesCount,
});
});
return file;
}
function getVisibleDiffLines(file) {
return Math.max(file.inlineLinesCount, file.parallelLinesCount);
}
function finalizeDiffFile(file) {
const name = (file.viewer && file.viewer.name) || diffViewerModes.text;
const lines = getVisibleDiffLines(file);
Object.assign(file, {
renderIt: lines < LINES_TO_BE_RENDERED_DIRECTLY,
collapsed: name === diffViewerModes.text && lines > MAX_LINES_TO_BE_RENDERED,
isShowingFullFile: false,
isLoadingFullFile: false,
discussions: [],
renderingLines: false,
});
return file;
}
export function prepareDiffData(diffData, priorFiles) {
return combineDiffFilesWithPriorFiles(diffData.diff_files, priorFiles)
.map(ensureBasicDiffFileLines)
.map(prepareDiffFileLines)
.map(finalizeDiffFile);
}
export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) {
let lines = [];
const hasInlineDiffs = diffFiles.some(file => file.highlighted_diff_lines.length > 0);
if (!useSingleDiffStyle || hasInlineDiffs) {
// In either of these cases, we can use `highlighted_diff_lines` because
// that will include all of the parallel diff lines, too
lines = diffFiles.reduce((acc, diffFile) => {
diffFile.highlighted_diff_lines.forEach(line => {
if (line.line_code) {
acc[line.line_code] = {
base_sha: diffFile.diff_refs.base_sha,
head_sha: diffFile.diff_refs.head_sha,
start_sha: diffFile.diff_refs.start_sha,
new_path: diffFile.new_path,
old_path: diffFile.old_path,
old_line: line.old_line,
new_line: line.new_line,
line_code: line.line_code,
position_type: 'text',
};
acc.push({ file: diffFile, line });
});
return acc;
}, []);
} else {
// If we're in single diff view mode and the inline lines haven't been
// loaded yet, we need to parse the parallel lines
lines = diffFiles.reduce((acc, diffFile) => {
diffFile.parallel_diff_lines.forEach(pair => {
// It's possible for a parallel line to have an opposite line that doesn't exist
// For example: *deleted* lines will have `null` right lines, while
// *added* lines will have `null` left lines.
// So we have to check each line before we push it onto the array so we're not
// pushing null line diffs
if (pair.left) {
acc.push({ file: diffFile, line: pair.left });
}
if (pair.right) {
acc.push({ file: diffFile, line: pair.right });
}
});
return acc;
}, []);
}
return lines.reduce((acc, { file, line }) => {
if (line.line_code) {
acc[line.line_code] = {
base_sha: file.diff_refs.base_sha,
head_sha: file.diff_refs.head_sha,
start_sha: file.diff_refs.start_sha,
new_path: file.new_path,
old_path: file.old_path,
old_line: line.old_line,
new_line: line.new_line,
line_code: line.line_code,
position_type: 'text',
};
}
return acc;
@ -462,47 +555,47 @@ export const convertExpandLines = ({
export const idleCallback = cb => requestIdleCallback(cb);
function getLinesFromFileByLineCode(file, lineCode) {
const parallelLines = file.parallel_diff_lines;
const inlineLines = file.highlighted_diff_lines;
const matchesCode = line => line.line_code === lineCode;
return [
...parallelLines.reduce((acc, line) => {
if (line.left) {
acc.push(line.left);
}
if (line.right) {
acc.push(line.right);
}
return acc;
}, []),
...inlineLines,
].filter(matchesCode);
}
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);
}
}
getLinesFromFileByLineCode(selectedFile, lineCode).forEach(updateFn);
};
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.highlighted_diff_lines.forEach(line => {
if (line.discussions.length) {
discussionsExpandedArray.push(line.discussionsExpanded);
}
});
}
return discussionsExpandedArray.every(el => el);
let discussionsExpanded = true;
const changeExpandedResult = line => {
if (line && line.discussions.length) {
discussionsExpanded = discussionsExpanded && line.discussionsExpanded;
}
};
diff.parallel_diff_lines.forEach(line => {
changeExpandedResult(line.left);
changeExpandedResult(line.right);
});
diff.highlighted_diff_lines.forEach(line => {
changeExpandedResult(line);
});
return discussionsExpanded;
};

View File

@ -1,6 +1,7 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import dateFormat from 'dateformat';
import createFlash from '~/flash';
import { GlFormInput, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui';
import { __, sprintf, n__ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
@ -11,6 +12,8 @@ import TrackEventDirective from '~/vue_shared/directives/track_event';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { trackClickErrorLinkToSentryOptions } from '../utils';
import query from '../queries/details.query.graphql';
export default {
components: {
LoadingButton,
@ -27,6 +30,14 @@ export default {
},
mixins: [timeagoMixin],
props: {
issueId: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
issueDetailsPath: {
type: String,
required: true,
@ -44,8 +55,28 @@ export default {
required: true,
},
},
apollo: {
GQLerror: {
query,
variables() {
return {
fullPath: this.projectPath,
errorId: `gid://gitlab/Gitlab::ErrorTracking::DetailedError/${this.issueId}`,
};
},
pollInterval: 2000,
update: data => data.project.sentryDetailedError,
error: () => createFlash(__('Failed to load error details from Sentry.')),
result(res) {
if (res.data.project?.sentryDetailedError) {
this.$apollo.queries.GQLerror.stopPolling();
}
},
},
},
data() {
return {
GQLerror: null,
issueCreationInProgress: false,
};
},
@ -56,26 +87,28 @@ export default {
return sprintf(
__('Reported %{timeAgo} by %{reportedBy}'),
{
reportedBy: `<strong>${this.error.culprit}</strong>`,
reportedBy: `<strong>${this.GQLerror.culprit}</strong>`,
timeAgo: this.timeFormatted(this.stacktraceData.date_received),
},
false,
);
},
firstReleaseLink() {
return `${this.error.external_base_url}/releases/${this.error.first_release_short_version}`;
return `${this.error.external_base_url}/releases/${this.GQLerror.firstReleaseShortVersion}`;
},
lastReleaseLink() {
return `${this.error.external_base_url}releases/${this.error.last_release_short_version}`;
return `${this.error.external_base_url}releases/${this.GQLerror.lastReleaseShortVersion}`;
},
showDetails() {
return Boolean(!this.loading && this.error && this.error.id);
return Boolean(
!this.loading && !this.$apollo.queries.GQLerror.loading && this.error && this.GQLerror,
);
},
showStacktrace() {
return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length);
},
issueTitle() {
return this.error.title;
return this.GQLerror.title;
},
issueDescription() {
return sprintf(
@ -84,13 +117,13 @@ export default {
),
{
description: '# Error Details:\n',
errorUrl: `${this.error.external_url}\n`,
firstSeen: `\n${this.error.first_seen}\n`,
lastSeen: `${this.error.last_seen}\n`,
countLabel: n__('- Event', '- Events', this.error.count),
count: `${this.error.count}\n`,
userCountLabel: n__('- User', '- Users', this.error.user_count),
userCount: `${this.error.user_count}\n`,
errorUrl: `${this.GQLerror.externalUrl}\n`,
firstSeen: `\n${this.GQLerror.firstSeen}\n`,
lastSeen: `${this.GQLerror.lastSeen}\n`,
countLabel: n__('- Event', '- Events', this.GQLerror.count),
count: `${this.GQLerror.count}\n`,
userCountLabel: n__('- User', '- Users', this.GQLerror.userCount),
userCount: `${this.GQLerror.userCount}\n`,
},
false,
);
@ -119,7 +152,7 @@ export default {
<template>
<div>
<div v-if="loading" class="py-3">
<div v-if="$apollo.queries.GQLerror.loading || loading" class="py-3">
<gl-loading-icon :size="3" />
</div>
<div v-else-if="showDetails" class="error-details">
@ -129,7 +162,7 @@ export default {
<gl-form-input class="hidden" name="issue[title]" :value="issueTitle" />
<input name="issue[description]" :value="issueDescription" type="hidden" />
<gl-form-input
:value="error.id"
:value="GQLerror.id"
class="hidden"
name="issue[sentry_issue_attributes][sentry_issue_identifier]"
/>
@ -145,16 +178,16 @@ export default {
</form>
</div>
<div>
<tooltip-on-truncate :title="error.title" truncate-target="child" placement="top">
<h2 class="text-truncate">{{ error.title }}</h2>
<tooltip-on-truncate :title="GQLerror.title" truncate-target="child" placement="top">
<h2 class="text-truncate">{{ GQLerror.title }}</h2>
</tooltip-on-truncate>
<template v-if="error.tags">
<gl-badge v-if="error.tags.level" variant="danger" class="rounded-pill mr-2">{{
errorLevel
}}</gl-badge>
<gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill">{{
error.tags.logger
}}</gl-badge>
<gl-badge v-if="error.tags.level" variant="danger" class="rounded-pill mr-2"
>{{ errorLevel }}
</gl-badge>
<gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill"
>{{ error.tags.logger }}
</gl-badge>
</template>
<h3>{{ __('Error details') }}</h3>
@ -168,35 +201,35 @@ export default {
<li>
<span class="bold">{{ __('Sentry event') }}:</span>
<gl-link
v-track-event="trackClickErrorLinkToSentryOptions(error.external_url)"
:href="error.external_url"
v-track-event="trackClickErrorLinkToSentryOptions(GQLerror.externalUrl)"
:href="GQLerror.externalUrl"
target="_blank"
>
<span class="text-truncate">{{ error.external_url }}</span>
<span class="text-truncate">{{ GQLerror.externalUrl }}</span>
<icon name="external-link" class="ml-1 flex-shrink-0" />
</gl-link>
</li>
<li v-if="error.first_release_short_version">
<li v-if="GQLerror.firstReleaseShortVersion">
<span class="bold">{{ __('First seen') }}:</span>
{{ formatDate(error.first_seen) }}
{{ formatDate(GQLerror.firstSeen) }}
<gl-link :href="firstReleaseLink" target="_blank">
<span>{{ __('Release') }}: {{ error.first_release_short_version }}</span>
<span>{{ __('Release') }}: {{ GQLerror.firstReleaseShortVersion }}</span>
</gl-link>
</li>
<li v-if="error.last_release_short_version">
<li v-if="GQLerror.lastReleaseShortVersion">
<span class="bold">{{ __('Last seen') }}:</span>
{{ formatDate(error.last_seen) }}
{{ formatDate(GQLerror.lastSeen) }}
<gl-link :href="lastReleaseLink" target="_blank">
<span>{{ __('Release') }}: {{ error.last_release_short_version }}</span>
<span>{{ __('Release') }}: {{ GQLerror.lastReleaseShortVersion }}</span>
</gl-link>
</li>
<li>
<span class="bold">{{ __('Events') }}:</span>
<span>{{ error.count }}</span>
<span>{{ GQLerror.count }}</span>
</li>
<li>
<span class="bold">{{ __('Users') }}:</span>
<span>{{ error.user_count }}</span>
<span>{{ GQLerror.userCount }}</span>
</li>
</ul>

View File

@ -1,22 +1,39 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import store from './store';
import ErrorDetails from './components/error_details.vue';
import csrf from '~/lib/utils/csrf';
Vue.use(VueApollo);
export default () => {
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
// eslint-disable-next-line no-new
new Vue({
el: '#js-error_details',
apolloProvider,
components: {
ErrorDetails,
},
store,
render(createElement) {
const domEl = document.querySelector(this.$options.el);
const { issueDetailsPath, issueStackTracePath, projectIssuesPath } = domEl.dataset;
const {
issueId,
projectPath,
issueDetailsPath,
issueStackTracePath,
projectIssuesPath,
} = domEl.dataset;
return createElement('error-details', {
props: {
issueId,
projectPath,
issueDetailsPath,
issueStackTracePath,
projectIssuesPath,

View File

@ -0,0 +1,18 @@
query errorDetails($fullPath: ID!, $errorId: ID!) {
project(fullPath: $fullPath) {
sentryDetailedError(id: $errorId) {
id
sentryId
title
userCount
count
firstSeen
lastSeen
message
culprit
externalUrl
firstReleaseShortVersion
lastReleaseShortVersion
}
}
}

View File

@ -0,0 +1,19 @@
export function hasInlineLines(diffFile) {
return diffFile?.highlighted_diff_lines?.length > 0; /* eslint-disable-line camelcase */
}
export function hasParallelLines(diffFile) {
return diffFile?.parallel_diff_lines?.length > 0; /* eslint-disable-line camelcase */
}
export function isSingleViewStyle(diffFile) {
return !hasParallelLines(diffFile) || !hasInlineLines(diffFile);
}
export function hasDiff(diffFile) {
return (
hasInlineLines(diffFile) ||
hasParallelLines(diffFile) ||
!diffFile?.blob?.readable_text /* eslint-disable-line camelcase */
);
}

View File

@ -39,7 +39,7 @@ export const requestMetricsDashboard = ({ commit }) => {
};
export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => {
commit(types.SET_ALL_DASHBOARDS, response.all_dashboards);
commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups);
commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard);
return dispatch('fetchPrometheusMetrics', params);
};
export const receiveMetricsDashboardFailure = ({ commit }, error) => {

View File

@ -84,23 +84,26 @@ export default {
state.emptyState = 'loading';
state.showEmptyState = true;
},
[types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
state.dashboard.panel_groups = groupData.map((group, i) => {
const key = `${slugify(group.group || 'default')}-${i}`;
let { panels = [] } = group;
[types.RECEIVE_METRICS_DATA_SUCCESS](state, dashboard) {
state.dashboard = {
...dashboard,
panel_groups: dashboard.panel_groups.map((group, i) => {
const key = `${slugify(group.group || 'default')}-${i}`;
let { panels = [] } = group;
// each panel has metric information that needs to be normalized
panels = panels.map(panel => ({
...panel,
metrics: normalizePanelMetrics(panel.metrics, panel.y_label),
}));
// each panel has metric information that needs to be normalized
panels = panels.map(panel => ({
...panel,
metrics: normalizePanelMetrics(panel.metrics, panel.y_label),
}));
return {
...group,
panels,
key,
};
});
return {
...group,
panels,
key,
};
}),
};
if (!state.dashboard.panel_groups.length) {
state.emptyState = 'noData';

View File

@ -18,9 +18,11 @@ module Projects::ErrorTrackingHelper
opts = [project, issue_id, { format: :json }]
{
'project-issues-path' => project_issues_path(project),
'issue-id' => issue_id,
'project-path' => project.full_path,
'issue-details-path' => details_project_error_tracking_index_path(*opts),
'issue-update-path' => update_project_error_tracking_index_path(*opts),
'project-issues-path' => project_issues_path(project),
'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts)
}
end

View File

@ -11,7 +11,7 @@ module Ci
has_many :trigger_requests
validates :token, presence: true, uniqueness: true
validates :owner, presence: true, unless: :supports_legacy_tokens?
validates :owner, presence: true
before_validation :set_default_values
@ -31,17 +31,8 @@ module Ci
token[0...4] if token.present?
end
def legacy?
self.owner_id.blank?
end
def supports_legacy_tokens?
Feature.enabled?(:use_legacy_pipeline_triggers, project)
end
def can_access_project?
supports_legacy_tokens? && legacy? ||
Ability.allowed?(self.owner, :create_build, project)
Ability.allowed?(self.owner, :create_build, project)
end
end
end

View File

@ -5,13 +5,12 @@ module Ci
delegate { @subject.project }
with_options scope: :subject, score: 0
condition(:legacy) { @subject.supports_legacy_tokens? && @subject.legacy? }
with_score 0
condition(:is_owner) { @user && @subject.owner_id == @user.id }
rule { ~can?(:admin_build) }.prevent :admin_trigger
rule { legacy | is_owner }.enable :admin_trigger
rule { is_owner }.enable :admin_trigger
rule { can?(:admin_build) }.enable :manage_trigger
end

View File

@ -4,7 +4,7 @@
= link_to s_('Nav|Home'), root_path
%li
- if current_user
= link_to s_('Nav|Sign out and sign in with a different account'), destroy_user_session_path
= link_to s_('Nav|Sign out and sign in with a different account'), destroy_user_session_path, method: :post
- else
= link_to s_('Nav|Sign In / Register'), new_session_path(:user, redirect_to_referer: 'yes')
%li

View File

@ -47,4 +47,4 @@
- if current_user_menu?(:sign_out)
%li.divider
%li
= link_to _("Sign out"), destroy_user_session_path, class: "sign-out-link", data: { qa_selector: 'sign_out_link' }
= link_to _("Sign out"), destroy_user_session_path, method: :post, class: "sign-out-link", data: { qa_selector: 'sign_out_link' }

View File

@ -55,7 +55,7 @@
- if Feature.enabled?(:user_mode_in_session)
- if header_link?(:admin_mode)
= nav_link(controller: 'admin/sessions') do
= link_to destroy_admin_session_path, class: 'd-lg-none lock-open-icon' do
= link_to destroy_admin_session_path, method: :post, class: 'd-lg-none lock-open-icon' do
= _('Leave Admin Mode')
- elsif current_user.admin?
= nav_link(controller: 'admin/sessions') do

View File

@ -1,9 +0,0 @@
- if Feature.enabled?(:use_legacy_pipeline_triggers, @project)
%p.append-bottom-default
Triggers with the
%span.badge.badge-primary legacy
label do not have an associated user and only have access to the current project.
%br
= succeed '.' do
Learn more in the
= link_to 'triggers documentation', help_page_path('ci/triggers/README'), target: '_blank'

View File

@ -1,6 +1,5 @@
.row.prepend-top-default.append-bottom-default.triggers-container
.col-lg-12
= render "projects/triggers/content"
.card
.card-header
Manage your project's triggers

View File

@ -7,12 +7,7 @@
%span= trigger.short_token
.label-container
- if trigger.legacy?
- if trigger.supports_legacy_tokens?
%span.badge.badge-primary.has-tooltip{ title: "Trigger makes use of deprecated functionality" } legacy
- else
%span.badge.badge-danger.has-tooltip{ title: "Trigger is invalid due to being a legacy trigger. We recommend replacing it with a new trigger" } invalid
- elsif !trigger.can_access_project?
- unless trigger.can_access_project?
%span.badge.badge-danger.has-tooltip{ title: "Trigger user has insufficient permissions to project" } invalid
%td

View File

@ -1,9 +1,7 @@
- page_title "Trigger"
.row.prepend-top-default.append-bottom-default
.col-lg-3
= render "content"
.col-lg-9
.col-lg-12
%h4.prepend-top-0
Update trigger
= render "form", btn_text: "Save trigger"

View File

@ -1,5 +0,0 @@
---
title: Migrate the database to activate projects prometheus service integration for projects with prometheus installed on shared k8s cluster.
merge_request: 19956
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Remove feature flag 'use_legacy_pipeline_triggers' and remove legacy tokens
merge_request: 21732
author:
type: removed

View File

@ -0,0 +1,5 @@
---
title: Add epics to project import/export
merge_request: 19883
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Use GraphQL to load error tracking detail page content
merge_request: 22422
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Allow a pipeline (parent) to create a child pipeline as downstream pipeline within the same project
merge_request: 21830
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Load MR diff types lazily to reduce initial diff payload size
merge_request: 19930
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: User signout and admin mode disable use now POST instead of GET
merge_request: 22113
author: Diego Louzán
type: other

View File

@ -203,7 +203,7 @@ Devise.setup do |config|
config.navigational_formats = [:"*/*", "*/*", :html, :zip]
# The default HTTP method used to sign out a resource. Default is :delete.
config.sign_out_via = :get
config.sign_out_via = :post
# ==> OmniAuth
# To configure a new OmniAuth provider copy and edit omniauth.rb.sample

View File

@ -24,7 +24,7 @@ namespace :admin do
end
resource :session, only: [:new, :create] do
get 'destroy', action: :destroy, as: :destroy
post 'destroy', action: :destroy, as: :destroy
end
resource :impersonation, only: :destroy

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class DeleteLegacyTriggers < ActiveRecord::Migration[5.2]
DOWNTIME = false
def up
execute <<~SQL
DELETE FROM ci_triggers WHERE owner_id IS NULL
SQL
change_column_null :ci_triggers, :owner_id, false
end
def down
change_column_null :ci_triggers, :owner_id, true
end
end

View File

@ -1,88 +0,0 @@
# frozen_string_literal: true
class PatchPrometheusServicesForSharedClusterApplications < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
MIGRATION = 'ActivatePrometheusServicesForSharedClusterApplications'.freeze
BATCH_SIZE = 500
DELAY = 2.minutes
disable_ddl_transaction!
module Migratable
module Applications
class Prometheus < ActiveRecord::Base
self.table_name = 'clusters_applications_prometheus'
enum status: {
errored: -1,
installed: 3,
updated: 5
}
end
end
class Project < ActiveRecord::Base
self.table_name = 'projects'
include ::EachBatch
scope :with_application_on_group_clusters, -> {
joins("INNER JOIN namespaces ON namespaces.id = projects.namespace_id")
.joins("INNER JOIN cluster_groups ON cluster_groups.group_id = namespaces.id")
.joins("INNER JOIN clusters ON clusters.id = cluster_groups.cluster_id AND clusters.cluster_type = #{Cluster.cluster_types['group_type']}")
.joins("INNER JOIN clusters_applications_prometheus ON clusters_applications_prometheus.cluster_id = clusters.id
AND clusters_applications_prometheus.status IN (#{Applications::Prometheus.statuses[:installed]}, #{Applications::Prometheus.statuses[:updated]})")
}
scope :without_active_prometheus_services, -> {
joins("LEFT JOIN services ON services.project_id = projects.id AND services.type = 'PrometheusService'")
.where("services.id IS NULL OR (services.active = FALSE AND services.properties = '{}')")
}
end
class Cluster < ActiveRecord::Base
self.table_name = 'clusters'
enum cluster_type: {
instance_type: 1,
group_type: 2
}
def self.has_prometheus_application?
joins("INNER JOIN clusters_applications_prometheus ON clusters_applications_prometheus.cluster_id = clusters.id
AND clusters_applications_prometheus.status IN (#{Applications::Prometheus.statuses[:installed]}, #{Applications::Prometheus.statuses[:updated]})").exists?
end
end
end
def up
projects_without_active_prometheus_service.group('projects.id').each_batch(of: BATCH_SIZE) do |batch, index|
bg_migrations_batch = batch.select('projects.id').map { |project| [MIGRATION, project.id] }
delay = index * DELAY
BackgroundMigrationWorker.bulk_perform_in(delay.seconds, bg_migrations_batch)
end
end
def down
# no-op
end
private
def projects_without_active_prometheus_service
scope = Migratable::Project.without_active_prometheus_services
return scope if migrate_instance_cluster?
scope.with_application_on_group_clusters
end
def migrate_instance_cluster?
if instance_variable_defined?('@migrate_instance_cluster')
@migrate_instance_cluster
else
@migrate_instance_cluster = Migratable::Cluster.instance_type.has_prometheus_application?
end
end
end

View File

@ -994,7 +994,7 @@ ActiveRecord::Schema.define(version: 2020_01_08_233040) do
t.datetime "created_at"
t.datetime "updated_at"
t.integer "project_id"
t.integer "owner_id"
t.integer "owner_id", null: false
t.string "description"
t.string "ref"
t.index ["owner_id"], name: "index_ci_triggers_on_owner_id"

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

View File

@ -0,0 +1,86 @@
---
type: reference
---
# Parent-child pipelines
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/16094) in GitLab Starter 12.7.
As pipelines grow more complex, a few related problems start to emerge:
- The staged structure, where all steps in a stage must be completed before the first
job in next stage begins, causes arbitrary waits, slowing things down.
- Configuration for the single global pipeline becomes very long and complicated,
making it hard to manage.
- Imports with [`include`](yaml/README.md#include) increase the complexity of the configuration, and create the potential
for namespace collisions where jobs are unintentionally duplicated.
- Pipeline UX can become unwieldy with so many jobs and stages to work with.
Additionally, sometimes the behavior of a pipeline needs to be more dynamic. The ability
to choose to start sub-pipelines (or not) is a powerful ability, especially if the
YAML is dynamically generated.
![Parent pipeline graph expanded](img/parent_pipeline_graph_expanded_v12_6.png)
Similarly to [multi-project pipelines](multi_project_pipelines.md), a pipeline can trigger a
set of concurrently running child pipelines, but within the same project:
- Child pipelines still execute each of their jobs according to a stage sequence, but
would be free to continue forward through their stages without waiting for unrelated
jobs in the parent pipeline to finish.
- The configuration is split up into smaller child pipeline configurations, which are
easier to understand. This reduces the cognitive load to understand the overall configuration.
- Imports are done at the child pipeline level, reducing the likelihood of collisions.
- Each pipeline has only the steps relevant steps, making it easier to understand what's going on.
Child pipelines work well with other GitLab CI features:
- Use [`only: changes`](yaml/README.md#onlychangesexceptchanges) to trigger pipelines only when
certain files change. This is useful for monorepos, for example.
- Since the parent pipeline in `.gitlab-ci.yml` and the child pipeline run as normal
pipelines, they can have their own behaviors and sequencing in relation to triggers.
All of this will work with [`include:`](yaml/README.md#include) feature so you can compose
the child pipeline configuration.
## Examples
The simplest case is [triggering a child pipeline](yaml/README.md#trigger-premium) using a
local YAML file to define the pipeline configuration. In this case, the parent pipeline will
trigger the child pipeline, and continue without waiting:
```yaml
microservice_a:
trigger:
include: path/to/microservice_a.yml
```
You can include multiple files when composing a child pipeline:
```yaml
microservice_a:
trigger:
include:
- local: path/to/microservice_a.yml
- template: SAST.gitlab-ci.yml
```
NOTE: **Note:**
The max number of entries that are accepted for `trigger:include:` is three.
Similar to [multi-project pipelines](multi_project_pipelines.md#mirroring-status-from-triggered-pipeline),
we can set the parent pipeline to depend on the status of the child pipeline upon completion:
```yaml
microservice_a:
trigger:
include:
- local: path/to/microservice_a.yml
- template: SAST.gitlab-ci.yml
strategy: depend
```
## Limitations
A parent pipeline can trigger many child pipelines, but a child pipeline cannot trigger
further child pipelines. See the [related issue](https://gitlab.com/gitlab-org/gitlab/issues/29651) for discussion on possible future improvements.

View File

@ -246,6 +246,13 @@ Pipelines for different projects can be combined and visualized together.
For more information, see [Multi-project pipelines](multi_project_pipelines.md).
## Parent-child pipelines
Complex pipelines can be broken down into one parent pipeline that can trigger
multiple child sub-pipelines, which all run in the same project and with the same SHA.
For more information, see [Parent-Child pipelines](parent_child_pipelines.md).
## Working with pipelines
In general, pipelines are executed automatically and require no intervention once created.

View File

@ -2600,14 +2600,17 @@ job split into three separate jobs.
from `trigger` definition is started by GitLab, a downstream pipeline gets
created.
Learn more about [multi-project pipelines](../multi_project_pipelines.md#creating-multi-project-pipelines-from-gitlab-ciyml).
This keyword allows the creation of two different types of downstream pipelines:
- [Multi-project pipelines](../multi_project_pipelines.md#creating-multi-project-pipelines-from-gitlab-ciyml)
- [Child pipelines](../parent_child_pipelines.md)
NOTE: **Note:**
Using a `trigger` with `when:manual` together results in the error `jobs:#{job-name}
when should be on_success, on_failure or always`, because `when:manual` prevents
triggers being used.
#### Simple `trigger` syntax
#### Simple `trigger` syntax for multi-project pipelines
The simplest way to configure a downstream trigger is to use `trigger` keyword
with a full path to a downstream project:
@ -2622,7 +2625,7 @@ staging:
trigger: my/deployment
```
#### Complex `trigger` syntax
#### Complex `trigger` syntax for multi-project pipelines
It is possible to configure a branch name that GitLab will use to create
a downstream pipeline with:
@ -2657,6 +2660,28 @@ upstream_bridge:
pipeline: other/project
```
#### `trigger` syntax for child pipeline
To create a [child pipeline](../parent_child_pipelines.md), specify the path to the
YAML file containing the CI config of the child pipeline:
```yaml
trigger_job:
trigger:
include: path/to/child-pipeline.yml
```
Similar to [multi-project pipelines](../multi_project_pipelines.md#mirroring-status-from-triggered-pipeline),
it is possible to mirror the status from a triggered pipeline:
```yaml
trigger_job:
trigger:
include:
- local: path/to/child-pipeline.yml
strategy: depend
```
### `interruptible`
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/23464) in GitLab 12.3.

View File

@ -1,52 +0,0 @@
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# Create missing PrometheusServices records or sets active attribute to true
# for all projects which belongs to cluster with Prometheus Application installed.
class ActivatePrometheusServicesForSharedClusterApplications
module Migratable
# Migration model namespace isolated from application code.
class PrometheusService < ActiveRecord::Base
self.inheritance_column = :_type_disabled
self.table_name = 'services'
default_scope { where("services.type = 'PrometheusService'") }
def self.for_project(project_id)
new(
project_id: project_id,
active: true,
properties: '{}',
type: 'PrometheusService',
template: false,
push_events: true,
issues_events: true,
merge_requests_events: true,
tag_push_events: true,
note_events: true,
category: 'monitoring',
default: false,
wiki_page_events: true,
pipeline_events: true,
confidential_issues_events: true,
commit_events: true,
job_events: true,
confidential_note_events: true,
deployment_events: false
)
end
def managed?
properties == '{}'
end
end
end
def perform(project_id)
service = Migratable::PrometheusService.find_by(project_id: project_id) || Migratable::PrometheusService.for_project(project_id)
service.update!(active: true) if service.managed?
end
end
end
end

View File

@ -22,12 +22,6 @@ module Gitlab
end
end
def uses_unsupported_legacy_trigger?
trigger_request.present? &&
trigger_request.trigger.legacy? &&
!trigger_request.trigger.supports_legacy_tokens?
end
def branch_exists?
strong_memoize(:is_branch) do
project.repository.branch_exists?(ref)

View File

@ -14,16 +14,12 @@ module Gitlab
return error('Pipelines are disabled!')
end
if @command.uses_unsupported_legacy_trigger?
return error('Trigger token is invalid because is not owned by any user')
unless allowed_to_create_pipeline?
return error('Insufficient permissions to create a new pipeline')
end
unless allowed_to_trigger_pipeline?
if can?(current_user, :create_pipeline, project)
return error("Insufficient permissions for protected ref '#{command.ref}'")
else
return error('Insufficient permissions to create a new pipeline')
end
unless allowed_to_write_ref?
return error("Insufficient permissions for protected ref '#{command.ref}'")
end
end
@ -31,17 +27,13 @@ module Gitlab
@pipeline.errors.any?
end
def allowed_to_trigger_pipeline?
if current_user
allowed_to_create?
else # legacy triggers don't have a corresponding user
!@command.protected_ref?
end
private
def allowed_to_create_pipeline?
can?(current_user, :create_pipeline, project)
end
def allowed_to_create?
return unless can?(current_user, :create_pipeline, project)
def allowed_to_write_ref?
access = Gitlab::UserAccess.new(current_user, project: project)
if @command.branch_exists?

View File

@ -26,6 +26,8 @@ module Gitlab
end
def find
return if epic? && group.nil?
find_object || klass.create(project_attributes)
end
@ -54,10 +56,10 @@ module Gitlab
# or, if group is present:
# `"{table_name}"."project_id" = {project.id} OR "{table_name}"."group_id" = {group.id}`
def where_clause_base
clause = table[:project_id].eq(project.id) if project
clause = clause.or(table[:group_id].eq(group.id)) if group
clause
[].tap do |clauses|
clauses << table[:project_id].eq(project.id) if project
clauses << table[:group_id].eq(group.id) if group
end.reduce(:or)
end
# Returns Arel clause `"{table_name}"."title" = '{attributes['title']}'`
@ -108,6 +110,10 @@ module Gitlab
klass == MergeRequest
end
def epic?
klass == Epic
end
# If an existing group milestone used the IID
# claim the IID back and set the group milestone to use one available
# This is necessary to fix situations like the following:

View File

@ -322,6 +322,13 @@ excluded_attributes:
- :board_id
- :label_id
- :milestone_id
epic:
- :start_date_sourcing_milestone_id
- :due_date_sourcing_milestone_id
- :parent_id
- :state_id
- :start_date_sourcing_epic_id
- :due_date_sourcing_epic_id
methods:
notes:
- :type
@ -374,6 +381,7 @@ ee:
- design_versions:
- actions:
- :design # Duplicate export of issues.designs in order to link the record to both Issue and Action
- :epic
- protected_branches:
- :unprotect_access_levels
- protected_environments:

View File

@ -40,7 +40,21 @@ module Gitlab
IMPORTED_OBJECT_MAX_RETRIES = 5.freeze
EXISTING_OBJECT_CHECK = %i[milestone milestones label labels project_label project_labels group_label group_labels project_feature merge_request ProjectCiCdSetting container_expiration_policy].freeze
EXISTING_OBJECT_CHECK = %i[
milestone
milestones
label
labels
project_label
project_labels
group_label
group_labels
project_feature
merge_request
epic
ProjectCiCdSetting
container_expiration_policy
].freeze
TOKEN_RESET_MODELS = %i[Project Namespace Ci::Trigger Ci::Build Ci::Runner ProjectHook].freeze
@ -86,9 +100,6 @@ module Gitlab
def create
return if unknown_service?
# Do not import legacy triggers
return if !Feature.enabled?(:use_legacy_pipeline_triggers, @project) && legacy_trigger?
setup_models
object = generate_imported_object
@ -345,10 +356,6 @@ module Gitlab
!Object.const_defined?(parsed_relation_hash['type'])
end
def legacy_trigger?
@relation_name == :'Ci::Trigger' && @relation_hash['owner_id'].nil?
end
def find_or_create_object!
if UNIQUE_RELATIONS.include?(@relation_name)
unique_relation_object = relation_class.find_or_create_by(project_id: @project.id)

View File

@ -4,7 +4,7 @@ module Gitlab
module ImportExport
class RelationTreeRestorer
# Relations which cannot be saved at project level (and have a group assigned)
GROUP_MODELS = [GroupLabel, Milestone].freeze
GROUP_MODELS = [GroupLabel, Milestone, Epic].freeze
attr_reader :user
attr_reader :shared

View File

@ -82,6 +82,7 @@
"fuzzaldrin-plus": "^0.5.0",
"glob": "^7.1.2",
"graphql": "^14.0.2",
"immer": "^5.2.1",
"imports-loader": "^0.8.0",
"jed": "^1.1.1",
"jest-transform-graphql": "^2.1.0",

View File

@ -108,9 +108,9 @@ module QA
# Click :commit_button and keep retrying just in case part of the
# animation is still in process even when the buttons have the
# expected visibility.
commit_success_msg_shown = retry_until do
click_element :commit_to_current_branch_radio
click_element :commit_button
commit_success_msg_shown = retry_until(sleep_interval: 5) do
click_element(:commit_to_current_branch_radio) if has_element?(:commit_to_current_branch_radio)
click_element(:commit_button) if has_element?(:commit_button)
wait(reload: false) do
has_text?('Your changes have been committed')

View File

@ -122,7 +122,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
describe '#destroy' do
context 'for regular users' do
it 'shows error page' do
get :destroy
post :destroy
expect(response).to have_gitlab_http_status(404)
expect(controller.current_user_mode.admin_mode?).to be(false)
@ -139,7 +139,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
post :create, params: { password: user.password }
expect(controller.current_user_mode.admin_mode?).to be(true)
get :destroy
post :destroy
expect(response).to have_gitlab_http_status(:found)
expect(response).to redirect_to(root_path)

View File

@ -32,8 +32,6 @@ describe 'a maintainer edits files on a source-branch of an MR from a fork', :js
wait_for_requests
end
it_behaves_like 'rendering a single diff version'
it 'mentions commits will go to the source branch' do
expect(page).to have_content('Your changes can be committed to fix because a merge request is open.')
end

View File

@ -13,15 +13,12 @@ describe 'User comments on a diff', :js do
let(:user) { create(:user) }
before do
stub_feature_flags(single_mr_diff_view: false)
project.add_maintainer(user)
sign_in(user)
visit(diffs_project_merge_request_path(project, merge_request))
end
it_behaves_like 'rendering a single diff version'
context 'when viewing comments' do
context 'when toggling inline comments' do
context 'in a single file' do

View File

@ -9,7 +9,6 @@ describe 'Merge request > User creates image diff notes', :js do
let(:user) { project.creator }
before do
stub_feature_flags(single_mr_diff_view: false)
sign_in(user)
# Stub helper to return any blob file as image from public app folder.
@ -18,8 +17,6 @@ describe 'Merge request > User creates image diff notes', :js do
allow_any_instance_of(DiffHelper).to receive(:diff_file_old_blob_raw_url).and_return('/favicon.png')
end
it_behaves_like 'rendering a single diff version'
context 'create commit diff notes' do
commit_id = '2f63565e7aac07bcdadb654e253078b727143ec4'

View File

@ -7,7 +7,6 @@ describe 'User expands diff', :js do
let(:merge_request) { create(:merge_request, source_branch: 'expand-collapse-files', source_project: project, target_project: project) }
before do
stub_feature_flags(single_mr_diff_view: false)
stub_feature_flags(diffs_batch_load: false)
allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes)
@ -18,8 +17,6 @@ describe 'User expands diff', :js do
wait_for_requests
end
it_behaves_like 'rendering a single diff version'
it 'allows user to expand diff' do
page.within find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9"]') do
click_link 'Click to expand it.'

View File

@ -14,15 +14,12 @@ describe 'Merge request > User posts diff notes', :js do
let(:test_note_comment) { 'this is a test note!' }
before do
stub_feature_flags(single_mr_diff_view: false)
set_cookie('sidebar_collapsed', 'true')
project.add_developer(user)
sign_in(user)
end
it_behaves_like 'rendering a single diff version'
context 'when hovering over a parallel view diff file' do
before do
visit diffs_project_merge_request_path(project, merge_request, view: 'parallel')

View File

@ -165,9 +165,9 @@ describe 'Merge request > User posts notes', :js do
find('.js-note-edit').click
page.within('.current-note-edit-form') do
expect(find('#note_note').value).to eq('This is the new content')
expect(find('#note_note').value).to include('This is the new content')
first('.js-md').click
expect(find('#note_note').value).to eq('This is the new content****')
expect(find('#note_note').value).to include('This is the new content****')
end
end

View File

@ -9,7 +9,6 @@ describe 'Merge request > User resolves conflicts', :js do
before do
# In order to have the diffs collapsed, we need to disable the increase feature
stub_feature_flags(gitlab_git_diff_size_limit_increase: false)
stub_feature_flags(single_mr_diff_view: false)
end
def create_merge_request(source_branch)
@ -18,8 +17,6 @@ describe 'Merge request > User resolves conflicts', :js do
end
end
it_behaves_like 'rendering a single diff version'
shared_examples 'conflicts are resolved in Interactive mode' do
it 'conflicts are resolved in Interactive mode' do
within find('.files-wrapper .diff-file', text: 'files/ruby/popen.rb') do

View File

@ -20,12 +20,9 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
before do
stub_feature_flags(single_mr_diff_view: false)
stub_feature_flags(diffs_batch_load: false)
end
it_behaves_like 'rendering a single diff version'
context 'no threads' do
before do
project.add_maintainer(user)

View File

@ -21,7 +21,6 @@ describe 'Merge request > User sees avatars on diff notes', :js do
let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) }
before do
stub_feature_flags(single_mr_diff_view: false)
stub_feature_flags(diffs_batch_load: false)
project.add_maintainer(user)
sign_in user
@ -29,8 +28,6 @@ describe 'Merge request > User sees avatars on diff notes', :js do
set_cookie('sidebar_collapsed', 'true')
end
it_behaves_like 'rendering a single diff version'
context 'discussion tab' do
before do
visit project_merge_request_path(project, merge_request)

View File

@ -10,12 +10,9 @@ describe 'Merge request > User sees diff', :js do
let(:merge_request) { create(:merge_request, source_project: project) }
before do
stub_feature_flags(single_mr_diff_view: false)
stub_feature_flags(diffs_batch_load: false)
end
it_behaves_like 'rendering a single diff version'
context 'when linking to note' do
describe 'with unresolved note' do
let(:note) { create :diff_note_on_merge_request, project: project, noteable: merge_request }

View File

@ -11,14 +11,11 @@ describe 'Merge request > User sees MR with deleted source branch', :js do
let(:user) { project.creator }
before do
stub_feature_flags(single_mr_diff_view: false)
merge_request.update!(source_branch: 'this-branch-does-not-exist')
sign_in(user)
visit project_merge_request_path(project, merge_request)
end
it_behaves_like 'rendering a single diff version'
it 'shows a message about missing source branch' do
expect(page).to have_content('Source branch does not exist.')
end

View File

@ -16,7 +16,6 @@ describe 'Merge request > User sees versions', :js do
let!(:params) { {} }
before do
stub_feature_flags(single_mr_diff_view: false)
stub_feature_flags(diffs_batch_load: false)
project.add_maintainer(user)
@ -24,8 +23,6 @@ describe 'Merge request > User sees versions', :js do
visit diffs_project_merge_request_path(project, merge_request, params)
end
it_behaves_like 'rendering a single diff version'
shared_examples 'allows commenting' do |file_id:, line_code:, comment:|
it do
diff_file_selector = ".diff-file[id='#{file_id}']"

View File

@ -25,15 +25,12 @@ describe 'User comments on a diff', :js do
let(:user) { create(:user) }
before do
stub_feature_flags(single_mr_diff_view: false)
project.add_maintainer(user)
sign_in(user)
visit(diffs_project_merge_request_path(project, merge_request))
end
it_behaves_like 'rendering a single diff version'
context 'single suggestion note' do
it 'hides suggestion popover' do
click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']"))

View File

@ -8,7 +8,6 @@ describe 'Merge request > User toggles whitespace changes', :js do
let(:user) { project.creator }
before do
stub_feature_flags(single_mr_diff_view: false)
project.add_maintainer(user)
sign_in(user)
visit diffs_project_merge_request_path(project, merge_request)
@ -16,8 +15,6 @@ describe 'Merge request > User toggles whitespace changes', :js do
find('.js-show-diff-settings').click
end
it_behaves_like 'rendering a single diff version'
it 'has a button to toggle whitespace changes' do
expect(page).to have_content 'Show whitespace changes'
end

View File

@ -9,7 +9,6 @@ describe 'User views diffs', :js do
let(:project) { create(:project, :public, :repository) }
before do
stub_feature_flags(single_mr_diff_view: false)
stub_feature_flags(diffs_batch_load: false)
visit(diffs_project_merge_request_path(project, merge_request))
@ -18,8 +17,6 @@ describe 'User views diffs', :js do
find('.js-toggle-tree-list').click
end
it_behaves_like 'rendering a single diff version'
shared_examples 'unfold diffs' do
it 'unfolds diffs upwards' do
first('.js-unfold').click

View File

@ -12,11 +12,9 @@ describe 'Editing file blob', :js do
let(:readme_file_path) { 'README.md' }
before do
stub_feature_flags(web_ide_default: false, single_mr_diff_view: false)
stub_feature_flags(web_ide_default: false)
end
it_behaves_like 'rendering a single diff version'
context 'as a developer' do
let(:user) { create(:user) }
let(:role) { :developer }

View File

@ -9,14 +9,11 @@ describe 'View on environment', :js do
let(:user) { project.creator }
before do
stub_feature_flags(single_mr_diff_view: false)
stub_feature_flags(diffs_batch_load: false)
project.add_maintainer(user)
end
it_behaves_like 'rendering a single diff version'
context 'when the branch has a route map' do
let(:route_map) do
<<-MAP.strip_heredoc

View File

@ -65,22 +65,6 @@ describe 'Triggers', :js do
expect(page.find('.triggers-list')).to have_content new_trigger_title
expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
end
it 'edit "legacy" trigger and save' do
# Create new trigger without owner association, i.e. Legacy trigger
create(:ci_trigger, owner: user, project: @project).update_attribute(:owner, nil)
visit project_settings_ci_cd_path(@project)
# See if the trigger can be edited and description is blank
find('a[title="Edit"]').send_keys(:return)
expect(page.find('#trigger_description').value).to have_content ''
# See if trigger can be updated with description and saved successfully
fill_in 'trigger_description', with: new_trigger_title
click_button 'Save trigger'
expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.'
expect(page.find('.triggers-list')).to have_content new_trigger_title
end
end
describe 'trigger "Revoke" workflow' do
@ -106,43 +90,18 @@ describe 'Triggers', :js do
end
describe 'show triggers workflow' do
before do
stub_feature_flags(use_legacy_pipeline_triggers: false)
end
it 'contains trigger description placeholder' do
expect(page.find('#trigger_description')['placeholder']).to eq 'Trigger description'
end
it 'show "invalid" badge for legacy trigger' do
create(:ci_trigger, owner: user, project: @project).update_attribute(:owner, nil)
visit project_settings_ci_cd_path(@project)
expect(page.find('.triggers-list')).to have_content 'invalid'
end
it 'show "invalid" badge for trigger with owner having insufficient permissions' do
create(:ci_trigger, owner: guest_user, project: @project, description: trigger_title)
visit project_settings_ci_cd_path(@project)
# See if trigger without owner (i.e. legacy) shows "legacy" badge and is non-editable
expect(page.find('.triggers-list')).to have_content 'invalid'
expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]')
end
it 'do not show "Edit" or full token for legacy trigger' do
create(:ci_trigger, owner: user, project: @project, description: trigger_title)
.update_attribute(:owner, nil)
visit project_settings_ci_cd_path(@project)
# See if trigger not owned shows only first few token chars and doesn't have copy-to-clipboard button
expect(page.find('.triggers-list')).to have_content(@project.triggers.first.token[0..3])
expect(page.find('.triggers-list')).not_to have_selector('button.btn-clipboard')
# See if trigger is non-editable
expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]')
end
it 'do not show "Edit" or full token for not owned trigger' do
# Create trigger with user different from current_user
create(:ci_trigger, owner: user2, project: @project, description: trigger_title)
@ -169,56 +128,5 @@ describe 'Triggers', :js do
expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]')
end
context 'when :use_legacy_pipeline_triggers feature flag is enabled' do
before do
stub_feature_flags(use_legacy_pipeline_triggers: true)
end
it 'show "legacy" badge for legacy trigger' do
create(:ci_trigger, owner: nil, project: @project)
visit project_settings_ci_cd_path(@project)
# See if trigger without owner (i.e. legacy) shows "legacy" badge and is editable
expect(page.find('.triggers-list')).to have_content 'legacy'
expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]')
end
it 'show "invalid" badge for trigger with owner having insufficient permissions' do
create(:ci_trigger, owner: guest_user, project: @project, description: trigger_title)
visit project_settings_ci_cd_path(@project)
# See if trigger without owner (i.e. legacy) shows "legacy" badge and is non-editable
expect(page.find('.triggers-list')).to have_content 'invalid'
expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]')
end
it 'do not show "Edit" or full token for not owned trigger' do
# Create trigger with user different from current_user
create(:ci_trigger, owner: user2, project: @project, description: trigger_title)
visit project_settings_ci_cd_path(@project)
# See if trigger not owned by current_user shows only first few token chars and doesn't have copy-to-clipboard button
expect(page.find('.triggers-list')).to have_content(@project.triggers.first.token[0..3])
expect(page.find('.triggers-list')).not_to have_selector('button.btn-clipboard')
# See if trigger owner name doesn't match with current_user and trigger is non-editable
expect(page.find('.triggers-list .trigger-owner')).not_to have_content user.name
expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]')
end
it 'show "Edit" and full token for owned trigger' do
create(:ci_trigger, owner: user, project: @project, description: trigger_title)
visit project_settings_ci_cd_path(@project)
# See if trigger shows full token and has copy-to-clipboard button
expect(page.find('.triggers-list')).to have_content @project.triggers.first.token
expect(page.find('.triggers-list')).to have_selector('button.btn-clipboard')
# See if trigger owner name matches with current_user and is editable
expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]')
end
end
end
end

View File

@ -175,6 +175,67 @@
}
}
]
},
{
"id": 3,
"title": "Issue with Epic",
"author_id": 1,
"project_id": 8,
"created_at": "2019-12-08T19:41:11.233Z",
"updated_at": "2019-12-08T19:41:53.194Z",
"position": 0,
"branch_name": null,
"description": "Donec at nulla vitae sem molestie rutrum ut at sem.",
"state": "opened",
"iid": 3,
"updated_by_id": null,
"confidential": false,
"due_date": null,
"moved_to_id": null,
"issue_assignees": [],
"notes": [],
"milestone": {
"id": 2,
"title": "A group milestone",
"description": "Group-level milestone",
"due_date": null,
"created_at": "2016-06-14T15:02:04.415Z",
"updated_at": "2016-06-14T15:02:04.415Z",
"state": "active",
"iid": 1,
"group_id": 100
},
"epic": {
"id": 1,
"group_id": 5,
"author_id": 1,
"assignee_id": null,
"iid": 1,
"updated_by_id": null,
"last_edited_by_id": null,
"lock_version": 0,
"start_date": null,
"end_date": null,
"last_edited_at": null,
"created_at": "2019-12-08T19:37:07.098Z",
"updated_at": "2019-12-08T19:43:11.568Z",
"title": "An epic",
"description": null,
"start_date_sourcing_milestone_id": null,
"due_date_sourcing_milestone_id": null,
"start_date_fixed": null,
"due_date_fixed": null,
"start_date_is_fixed": null,
"due_date_is_fixed": null,
"closed_by_id": null,
"closed_at": null,
"parent_id": null,
"relative_position": null,
"state_id": "opened",
"start_date_sourcing_epic_id": null,
"due_date_sourcing_epic_id": null,
"milestone_id": null
}
}
],
"snippets": [

View File

@ -13,6 +13,7 @@ describe('ErrorDetails', () => {
let wrapper;
let actions;
let getters;
let mocks;
const findInput = name => {
const inputs = wrapper.findAll(GlFormInput).filter(c => c.attributes('name') === name);
@ -24,13 +25,27 @@ describe('ErrorDetails', () => {
stubs: { LoadingButton },
localVue,
store,
mocks,
propsData: {
issueId: '123',
projectPath: '/root/gitlab-test',
issueDetailsPath: '/123/details',
issueStackTracePath: '/stacktrace',
projectIssuesPath: '/test-project/issues/',
csrfToken: 'fakeToken',
},
});
wrapper.setData({
GQLerror: {
id: 129381,
title: 'Issue title',
externalUrl: 'http://sentry.gitlab.net/gitlab',
firstSeen: '2017-05-26T13:32:48Z',
lastSeen: '2018-05-26T13:32:48Z',
count: 12,
userCount: 2,
},
});
}
beforeEach(() => {
@ -61,6 +76,19 @@ describe('ErrorDetails', () => {
},
},
});
const query = jest.fn();
mocks = {
$apollo: {
query,
queries: {
GQLerror: {
loading: true,
stopPolling: jest.fn(),
},
},
},
};
});
afterEach(() => {
@ -85,10 +113,11 @@ describe('ErrorDetails', () => {
beforeEach(() => {
store.state.details.loading = false;
store.state.details.error.id = 1;
mocks.$apollo.queries.GQLerror.loading = false;
mountComponent();
});
it('should show Sentry error details without stacktrace', () => {
mountComponent();
expect(wrapper.find(GlLink).exists()).toBe(true);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.find(Stacktrace).exists()).toBe(false);
@ -99,13 +128,17 @@ describe('ErrorDetails', () => {
it('should show language and error level badges', () => {
store.state.details.error.tags = { level: 'error', logger: 'ruby' };
mountComponent();
expect(wrapper.findAll(GlBadge).length).toBe(2);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.findAll(GlBadge).length).toBe(2);
});
});
it('should NOT show the badge if the tag is not present', () => {
store.state.details.error.tags = { level: 'error' };
mountComponent();
expect(wrapper.findAll(GlBadge).length).toBe(1);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.findAll(GlBadge).length).toBe(1);
});
});
});
@ -113,8 +146,10 @@ describe('ErrorDetails', () => {
it('should show stacktrace', () => {
store.state.details.loadingStacktrace = false;
mountComponent();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find(Stacktrace).exists()).toBe(true);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find(Stacktrace).exists()).toBe(true);
});
});
it('should NOT show stacktrace if no entries', () => {
@ -128,15 +163,6 @@ describe('ErrorDetails', () => {
describe('When a user clicks the create issue button', () => {
beforeEach(() => {
store.state.details.error = {
id: 129381,
title: 'Issue title',
external_url: 'http://sentry.gitlab.net/gitlab',
first_seen: '2017-05-26T13:32:48Z',
last_seen: '2018-05-26T13:32:48Z',
count: 12,
user_count: 2,
};
mountComponent();
});

View File

@ -0,0 +1,113 @@
import * as diffsHelper from '~/helpers/diffs_helper';
describe('diffs helper', () => {
function getDiffFile(withOverrides = {}) {
return {
parallel_diff_lines: ['line'],
highlighted_diff_lines: ['line'],
blob: {
readable_text: 'text',
},
...withOverrides,
};
}
describe('hasInlineLines', () => {
it('is false when the file does not exist', () => {
expect(diffsHelper.hasInlineLines()).toBeFalsy();
});
it('is false when the file does not have the highlighted_diff_lines property', () => {
const missingInline = getDiffFile({ highlighted_diff_lines: undefined });
expect(diffsHelper.hasInlineLines(missingInline)).toBeFalsy();
});
it('is false when the file has zero highlighted_diff_lines', () => {
const emptyInline = getDiffFile({ highlighted_diff_lines: [] });
expect(diffsHelper.hasInlineLines(emptyInline)).toBeFalsy();
});
it('is true when the file has at least 1 highlighted_diff_lines', () => {
expect(diffsHelper.hasInlineLines(getDiffFile())).toBeTruthy();
});
});
describe('hasParallelLines', () => {
it('is false when the file does not exist', () => {
expect(diffsHelper.hasParallelLines()).toBeFalsy();
});
it('is false when the file does not have the parallel_diff_lines property', () => {
const missingInline = getDiffFile({ parallel_diff_lines: undefined });
expect(diffsHelper.hasParallelLines(missingInline)).toBeFalsy();
});
it('is false when the file has zero parallel_diff_lines', () => {
const emptyInline = getDiffFile({ parallel_diff_lines: [] });
expect(diffsHelper.hasParallelLines(emptyInline)).toBeFalsy();
});
it('is true when the file has at least 1 parallel_diff_lines', () => {
expect(diffsHelper.hasParallelLines(getDiffFile())).toBeTruthy();
});
});
describe('isSingleViewStyle', () => {
it('is true when the file has at least 1 inline line but no parallel lines for any reason', () => {
const noParallelLines = getDiffFile({ parallel_diff_lines: undefined });
const emptyParallelLines = getDiffFile({ parallel_diff_lines: [] });
expect(diffsHelper.isSingleViewStyle(noParallelLines)).toBeTruthy();
expect(diffsHelper.isSingleViewStyle(emptyParallelLines)).toBeTruthy();
});
it('is true when the file has at least 1 parallel line but no inline lines for any reason', () => {
const noInlineLines = getDiffFile({ highlighted_diff_lines: undefined });
const emptyInlineLines = getDiffFile({ highlighted_diff_lines: [] });
expect(diffsHelper.isSingleViewStyle(noInlineLines)).toBeTruthy();
expect(diffsHelper.isSingleViewStyle(emptyInlineLines)).toBeTruthy();
});
it('is true when the file does not have any inline lines or parallel lines for any reason', () => {
const noLines = getDiffFile({
highlighted_diff_lines: undefined,
parallel_diff_lines: undefined,
});
const emptyLines = getDiffFile({
highlighted_diff_lines: [],
parallel_diff_lines: [],
});
expect(diffsHelper.isSingleViewStyle(noLines)).toBeTruthy();
expect(diffsHelper.isSingleViewStyle(emptyLines)).toBeTruthy();
expect(diffsHelper.isSingleViewStyle()).toBeTruthy();
});
it('is false when the file has both inline and parallel lines', () => {
expect(diffsHelper.isSingleViewStyle(getDiffFile())).toBeFalsy();
});
});
describe.each`
context | inline | parallel | blob | expected
${'only has inline lines'} | ${['line']} | ${undefined} | ${undefined} | ${true}
${'only has parallel lines'} | ${undefined} | ${['line']} | ${undefined} | ${true}
${"doesn't have inline, parallel, or blob"} | ${undefined} | ${undefined} | ${undefined} | ${true}
${'has blob readable text'} | ${undefined} | ${undefined} | ${{ readable_text: 'text' }} | ${false}
`('when hasDiff', ({ context, inline, parallel, blob, expected }) => {
it(`${context}`, () => {
const diffFile = getDiffFile({
highlighted_diff_lines: inline,
parallel_diff_lines: parallel,
blob,
});
expect(diffsHelper.hasDiff(diffFile)).toEqual(expected);
});
});
});

View File

@ -345,7 +345,7 @@ describe('Dashboard', () => {
it('metrics can be swapped', done => {
const firstDraggable = findDraggables().at(0);
const mockMetrics = [...metricsGroupsAPIResponse[1].panels];
const mockMetrics = [...metricsGroupsAPIResponse.panel_groups[1].panels];
const firstTitle = mockMetrics[0].title;
const secondTitle = mockMetrics[1].title;

View File

@ -331,77 +331,80 @@ export const mockedQueryResultPayloadCoresTotal = {
],
};
export const metricsGroupsAPIResponse = [
{
group: 'Response metrics (NGINX Ingress VTS)',
priority: 10,
panels: [
{
metrics: [
{
id: 'response_metrics_nginx_ingress_throughput_status_code',
label: 'Status Code',
metric_id: 1,
prometheus_endpoint_path:
'/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=sum%28rate%28nginx_upstream_responses_total%7Bupstream%3D~%22%25%7Bkube_namespace%7D-%25%7Bci_environment_slug%7D-.%2A%22%7D%5B2m%5D%29%29+by+%28status_code%29',
query_range:
'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) by (status_code)',
unit: 'req / sec',
},
],
title: 'Throughput',
type: 'area-chart',
weight: 1,
y_label: 'Requests / Sec',
},
],
},
{
group: 'System metrics (Kubernetes)',
priority: 5,
panels: [
{
title: 'Memory Usage (Pod average)',
type: 'area-chart',
y_label: 'Memory Used per Pod',
weight: 2,
metrics: [
{
id: 'system_metrics_kubernetes_container_memory_average',
query_range:
'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024',
label: 'Pod average',
unit: 'MB',
metric_id: 17,
prometheus_endpoint_path:
'/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024',
appearance: {
line: {
width: 2,
export const metricsGroupsAPIResponse = {
dashboard: 'Environment metrics',
panel_groups: [
{
group: 'Response metrics (NGINX Ingress VTS)',
priority: 10,
panels: [
{
metrics: [
{
id: 'response_metrics_nginx_ingress_throughput_status_code',
label: 'Status Code',
metric_id: 1,
prometheus_endpoint_path:
'/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=sum%28rate%28nginx_upstream_responses_total%7Bupstream%3D~%22%25%7Bkube_namespace%7D-%25%7Bci_environment_slug%7D-.%2A%22%7D%5B2m%5D%29%29+by+%28status_code%29',
query_range:
'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) by (status_code)',
unit: 'req / sec',
},
],
title: 'Throughput',
type: 'area-chart',
weight: 1,
y_label: 'Requests / Sec',
},
],
},
{
group: 'System metrics (Kubernetes)',
priority: 5,
panels: [
{
title: 'Memory Usage (Pod average)',
type: 'area-chart',
y_label: 'Memory Used per Pod',
weight: 2,
metrics: [
{
id: 'system_metrics_kubernetes_container_memory_average',
query_range:
'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024',
label: 'Pod average',
unit: 'MB',
metric_id: 17,
prometheus_endpoint_path:
'/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024',
appearance: {
line: {
width: 2,
},
},
},
},
],
},
{
title: 'Core Usage (Total)',
type: 'area-chart',
y_label: 'Total Cores',
weight: 3,
metrics: [
{
id: 'system_metrics_kubernetes_container_cores_total',
query_range:
'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)',
label: 'Total',
unit: 'cores',
metric_id: 13,
},
],
},
],
},
];
],
},
{
title: 'Core Usage (Total)',
type: 'area-chart',
y_label: 'Total Cores',
weight: 3,
metrics: [
{
id: 'system_metrics_kubernetes_container_cores_total',
query_range:
'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)',
label: 'Total',
unit: 'cores',
metric_id: 13,
},
],
},
],
},
],
};
export const environmentData = [
{

View File

@ -298,7 +298,7 @@ describe('Monitoring store actions', () => {
);
expect(commit).toHaveBeenCalledWith(
types.RECEIVE_METRICS_DATA_SUCCESS,
metricsDashboardResponse.dashboard.panel_groups,
metricsDashboardResponse.dashboard,
);
expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetrics', params);
});
@ -441,7 +441,7 @@ describe('Monitoring store actions', () => {
beforeEach(() => {
state = storeState();
[metric] = metricsDashboardResponse.dashboard.panel_groups[0].panels[0].metrics;
[data] = metricsGroupsAPIResponse[0].panels[0].metrics;
[data] = metricsGroupsAPIResponse.panel_groups[0].panels[0].metrics;
});
it('commits result', done => {

View File

@ -100,12 +100,12 @@ describe('Monitoring mutations', () => {
values: [[0, 1], [1, 1], [1, 3]],
},
];
const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
const { dashboard } = metricsDashboardResponse;
const getMetric = () => stateCopy.dashboard.panel_groups[0].panels[0].metrics[0];
describe('REQUEST_METRIC_RESULT', () => {
beforeEach(() => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboard);
});
it('stores a loading state on a metric', () => {
expect(stateCopy.showEmptyState).toBe(true);
@ -128,7 +128,7 @@ describe('Monitoring mutations', () => {
describe('RECEIVE_METRIC_RESULT_SUCCESS', () => {
beforeEach(() => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboard);
});
it('clears empty state', () => {
expect(stateCopy.showEmptyState).toBe(true);
@ -161,7 +161,7 @@ describe('Monitoring mutations', () => {
describe('RECEIVE_METRIC_RESULT_FAILURE', () => {
beforeEach(() => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboard);
});
it('maintains the loading state when a metric fails', () => {
expect(stateCopy.showEmptyState).toBe(true);

View File

@ -80,11 +80,20 @@ describe Projects::ErrorTrackingHelper do
let(:issue_id) { 1234 }
let(:route_params) { [project.owner, project, issue_id, { format: :json }] }
let(:details_path) { details_namespace_project_error_tracking_index_path(*route_params) }
let(:project_path) { project.full_path }
let(:stack_trace_path) { stack_trace_namespace_project_error_tracking_index_path(*route_params) }
let(:issues_path) { project_issues_path(project) }
let(:result) { helper.error_details_data(project, issue_id) }
it 'returns the correct issue id' do
expect(result['issue-id']).to eq issue_id
end
it 'returns the correct project path' do
expect(result['project-path']).to eq project_path
end
it 'returns the correct details path' do
expect(result['issue-details-path']).to eq details_path
end

View File

@ -10,6 +10,7 @@ import CompareVersions from '~/diffs/components/compare_versions.vue';
import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue';
import CommitWidget from '~/diffs/components/commit_widget.vue';
import TreeList from '~/diffs/components/tree_list.vue';
import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants';
import createDiffsStore from '../create_diffs_store';
import diffsMockData from '../mock_data/merge_request_diffs';
@ -41,7 +42,6 @@ describe('diffs/components/app', () => {
changesEmptyStateIllustration: '',
dismissEndpoint: '',
showSuggestPopover: true,
useSingleDiffStyle: false,
...props,
},
store,
@ -53,6 +53,12 @@ describe('diffs/components/app', () => {
});
}
function getOppositeViewType(currentViewType) {
return currentViewType === INLINE_DIFF_VIEW_TYPE
? PARALLEL_DIFF_VIEW_TYPE
: INLINE_DIFF_VIEW_TYPE;
}
beforeEach(() => {
// setup globals (needed for component to mount :/)
window.mrTabs = jasmine.createSpyObj('mrTabs', ['resetViewContainer']);
@ -82,9 +88,146 @@ describe('diffs/components/app', () => {
spyOn(wrapper.vm, 'startRenderDiffsQueue');
spyOn(wrapper.vm, 'unwatchDiscussions');
store.state.diffs.retrievingBatches = true;
store.state.diffs.diffFiles = [];
wrapper.vm.$nextTick(done);
});
describe('when the diff view type changes and it should load a single diff view style', () => {
const noLinesDiff = {
highlighted_diff_lines: [],
parallel_diff_lines: [],
};
const parallelLinesDiff = {
highlighted_diff_lines: [],
parallel_diff_lines: ['line'],
};
const inlineLinesDiff = {
highlighted_diff_lines: ['line'],
parallel_diff_lines: [],
};
const fullDiff = {
highlighted_diff_lines: ['line'],
parallel_diff_lines: ['line'],
};
function expectFetchToOccur({
vueInstance,
done = () => {},
batch = false,
existingFiles = 1,
} = {}) {
vueInstance.$nextTick(() => {
expect(vueInstance.diffFiles.length).toEqual(existingFiles);
if (!batch) {
expect(vueInstance.fetchDiffFiles).toHaveBeenCalled();
expect(vueInstance.fetchDiffFilesBatch).not.toHaveBeenCalled();
} else {
expect(vueInstance.fetchDiffFiles).not.toHaveBeenCalled();
expect(vueInstance.fetchDiffFilesBatch).toHaveBeenCalled();
}
done();
});
}
beforeEach(() => {
wrapper.vm.glFeatures.singleMrDiffView = true;
});
it('fetches diffs if it has none', done => {
wrapper.vm.isLatestVersion = () => false;
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
expectFetchToOccur({ vueInstance: wrapper.vm, batch: false, existingFiles: 0, done });
});
it('fetches diffs if it has both view styles, but no lines in either', done => {
wrapper.vm.isLatestVersion = () => false;
store.state.diffs.diffFiles.push(noLinesDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
expectFetchToOccur({ vueInstance: wrapper.vm, done });
});
it('fetches diffs if it only has inline view style', done => {
wrapper.vm.isLatestVersion = () => false;
store.state.diffs.diffFiles.push(inlineLinesDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
expectFetchToOccur({ vueInstance: wrapper.vm, done });
});
it('fetches diffs if it only has parallel view style', done => {
wrapper.vm.isLatestVersion = () => false;
store.state.diffs.diffFiles.push(parallelLinesDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
expectFetchToOccur({ vueInstance: wrapper.vm, done });
});
it('fetches batch diffs if it has none', done => {
wrapper.vm.glFeatures.diffsBatchLoad = true;
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, existingFiles: 0, done });
});
it('fetches batch diffs if it has both view styles, but no lines in either', done => {
wrapper.vm.glFeatures.diffsBatchLoad = true;
store.state.diffs.diffFiles.push(noLinesDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done });
});
it('fetches batch diffs if it only has inline view style', done => {
wrapper.vm.glFeatures.diffsBatchLoad = true;
store.state.diffs.diffFiles.push(inlineLinesDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done });
});
it('fetches batch diffs if it only has parallel view style', done => {
wrapper.vm.glFeatures.diffsBatchLoad = true;
store.state.diffs.diffFiles.push(parallelLinesDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done });
});
it('does not fetch diffs if it has already fetched both styles of diff', () => {
wrapper.vm.glFeatures.diffsBatchLoad = false;
store.state.diffs.diffFiles.push(fullDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
expect(wrapper.vm.diffFiles.length).toEqual(1);
expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled();
});
it('does not fetch batch diffs if it has already fetched both styles of diff', () => {
wrapper.vm.glFeatures.diffsBatchLoad = true;
store.state.diffs.diffFiles.push(fullDiff);
store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType);
expect(wrapper.vm.diffFiles.length).toEqual(1);
expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled();
expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled();
});
});
it('calls fetchDiffFiles if diffsBatchLoad is not enabled', done => {
wrapper.vm.glFeatures.diffsBatchLoad = false;
wrapper.vm.fetchData(false);

View File

@ -120,7 +120,7 @@ describe('DiffsStoreActions', () => {
describe('fetchDiffFiles', () => {
it('should fetch diff files', done => {
const endpoint = '/fetch/diff/files?w=1';
const endpoint = '/fetch/diff/files?view=inline&w=1';
const mock = new MockAdapter(axios);
const res = { diff_files: 1, merge_request_diffs: [] };
mock.onGet(endpoint).reply(200, res);
@ -128,7 +128,7 @@ describe('DiffsStoreActions', () => {
testAction(
fetchDiffFiles,
{},
{ endpoint },
{ endpoint, diffFiles: [], showWhitespace: false, diffViewType: 'inline' },
[
{ type: types.SET_LOADING, payload: true },
{ type: types.SET_LOADING, payload: false },

View File

@ -52,7 +52,14 @@ describe('DiffsStoreMutations', () => {
describe('SET_DIFF_DATA', () => {
it('should set diff data type properly', () => {
const state = {};
const state = {
diffFiles: [
{
content_sha: diffFileMockData.content_sha,
file_hash: diffFileMockData.file_hash,
},
],
};
const diffMock = {
diff_files: [diffFileMockData],
};
@ -62,9 +69,41 @@ describe('DiffsStoreMutations', () => {
const firstLine = state.diffFiles[0].parallel_diff_lines[0];
expect(firstLine.right.text).toBeUndefined();
expect(state.diffFiles.length).toEqual(1);
expect(state.diffFiles[0].renderIt).toEqual(true);
expect(state.diffFiles[0].collapsed).toEqual(false);
});
describe('given diffsBatchLoad feature flag is enabled', () => {
beforeEach(() => {
gon.features = { diffsBatchLoad: true };
});
afterEach(() => {
delete gon.features;
});
it('should not modify the existing state', () => {
const state = {
diffFiles: [
{
content_sha: diffFileMockData.content_sha,
file_hash: diffFileMockData.file_hash,
highlighted_diff_lines: [],
},
],
};
const diffMock = {
diff_files: [diffFileMockData],
};
mutations[types.SET_DIFF_DATA](state, diffMock);
// If the batch load is enabled, there shouldn't be any processing
// done on the existing state object, so we shouldn't have this.
expect(state.diffFiles[0].parallel_diff_lines).toBeUndefined();
});
});
});
describe('SET_DIFFSET_DIFF_DATA_BATCH_DATA', () => {
@ -168,11 +207,17 @@ describe('DiffsStoreMutations', () => {
it('should update the state with the given data for the given file hash', () => {
const fileHash = 123;
const state = {
diffFiles: [{}, { file_hash: fileHash, existing_field: 0 }],
diffFiles: [{}, { content_sha: 'abc', file_hash: fileHash, existing_field: 0 }],
};
const data = {
diff_files: [
{ file_hash: fileHash, extra_field: 1, existing_field: 1, viewer: { name: 'text' } },
{
content_sha: 'abc',
file_hash: fileHash,
extra_field: 1,
existing_field: 1,
viewer: { name: 'text' },
},
],
};
@ -208,7 +253,7 @@ describe('DiffsStoreMutations', () => {
discussions: [],
},
right: {
line_code: 'ABC_1',
line_code: 'ABC_2',
discussions: [],
},
},
@ -274,7 +319,7 @@ describe('DiffsStoreMutations', () => {
discussions: [],
},
right: {
line_code: 'ABC_1',
line_code: 'ABC_2',
discussions: [],
},
},
@ -352,7 +397,7 @@ describe('DiffsStoreMutations', () => {
discussions: [],
},
right: {
line_code: 'ABC_1',
line_code: 'ABC_2',
discussions: [],
},
},
@ -448,6 +493,7 @@ describe('DiffsStoreMutations', () => {
discussions: [],
},
],
parallel_diff_lines: [],
},
],
};

View File

@ -314,11 +314,29 @@ describe('DiffsStoreUtils', () => {
});
describe('prepareDiffData', () => {
let mock;
let preparedDiff;
let splitInlineDiff;
let splitParallelDiff;
let completedDiff;
beforeEach(() => {
preparedDiff = { diff_files: [getDiffFileMock()] };
mock = getDiffFileMock();
preparedDiff = { diff_files: [mock] };
splitInlineDiff = {
diff_files: [Object.assign({}, mock, { parallel_diff_lines: undefined })],
};
splitParallelDiff = {
diff_files: [Object.assign({}, mock, { highlighted_diff_lines: undefined })],
};
completedDiff = {
diff_files: [Object.assign({}, mock, { highlighted_diff_lines: undefined })],
};
utils.prepareDiffData(preparedDiff);
utils.prepareDiffData(splitInlineDiff);
utils.prepareDiffData(splitParallelDiff);
utils.prepareDiffData(completedDiff, [mock]);
});
it('sets the renderIt and collapsed attribute on files', () => {
@ -359,6 +377,19 @@ describe('DiffsStoreUtils', () => {
expect(firstLine.line_code).toEqual(firstLine.right.line_code);
});
it('guarantees an empty array for both diff styles', () => {
expect(splitInlineDiff.diff_files[0].parallel_diff_lines.length).toEqual(0);
expect(splitInlineDiff.diff_files[0].highlighted_diff_lines.length).toBeGreaterThan(0);
expect(splitParallelDiff.diff_files[0].parallel_diff_lines.length).toBeGreaterThan(0);
expect(splitParallelDiff.diff_files[0].highlighted_diff_lines.length).toEqual(0);
});
it('merges existing diff files with newly loaded diff files to ensure split diffs are eventually completed', () => {
expect(completedDiff.diff_files.length).toEqual(1);
expect(completedDiff.diff_files[0].parallel_diff_lines.length).toBeGreaterThan(0);
expect(completedDiff.diff_files[0].highlighted_diff_lines.length).toBeGreaterThan(0);
});
});
describe('isDiscussionApplicableToLine', () => {

View File

@ -1,75 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::BackgroundMigration::ActivatePrometheusServicesForSharedClusterApplications, :migration, schema: 2019_12_20_102807 do
include MigrationHelpers::PrometheusServiceHelpers
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:services) { table(:services) }
let(:namespace) { namespaces.create(name: 'user', path: 'user') }
let(:project) { projects.create(namespace_id: namespace.id) }
let(:columns) do
%w(project_id active properties type template push_events
issues_events merge_requests_events tag_push_events
note_events category default wiki_page_events pipeline_events
confidential_issues_events commit_events job_events
confidential_note_events deployment_events)
end
describe '#perform' do
it 'is idempotent' do
expect { subject.perform(project.id) }.to change { services.order(:id).map { |row| row.attributes } }
expect { subject.perform(project.id) }.not_to change { services.order(:id).map { |row| row.attributes } }
end
context 'non prometheus services' do
it 'does not change them' do
other_type = 'SomeOtherService'
services.create(service_params_for(project.id, active: true, type: other_type))
expect { subject.perform(project.id) }.not_to change { services.where(type: other_type).order(:id).map { |row| row.attributes } }
end
end
context 'prometheus services are configured manually ' do
it 'does not change them' do
properties = '{"api_url":"http://test.dev","manual_configuration":"1"}'
services.create(service_params_for(project.id, properties: properties, active: false))
expect { subject.perform(project.id) }.not_to change { services.order(:id).map { |row| row.attributes } }
end
end
context 'prometheus integration services do not exist' do
it 'creates missing services entries' do
subject.perform(project.id)
rows = services.order(:id).map { |row| row.attributes.slice(*columns).symbolize_keys }
expect([service_params_for(project.id, active: true)]).to eq rows
end
end
context 'prometheus integration services exist' do
context 'in active state' do
it 'does not change them' do
services.create(service_params_for(project.id, active: true))
expect { subject.perform(project.id) }.not_to change { services.order(:id).map { |row| row.attributes } }
end
end
context 'not in active state' do
it 'sets active attribute to true' do
service = services.create(service_params_for(project.id))
expect { subject.perform(project.id) }.to change { service.reload.active? }.from(false).to(true)
end
end
end
end
end

View File

@ -76,45 +76,8 @@ describe Gitlab::Ci::Pipeline::Chain::Validate::Abilities do
end
end
context 'when pipeline triggered by legacy trigger' do
let(:user) { nil }
let(:trigger_request) do
build_stubbed(:ci_trigger_request, trigger: build_stubbed(:ci_trigger, owner: nil))
end
context 'when :use_legacy_pipeline_triggers feature flag is enabled' do
before do
stub_feature_flags(use_legacy_pipeline_triggers: true)
step.perform!
end
it 'allows legacy triggers to create a pipeline' do
expect(pipeline).to be_valid
end
it 'does not break the chain' do
expect(step.break?).to eq false
end
end
context 'when :use_legacy_pipeline_triggers feature flag is disabled' do
before do
stub_feature_flags(use_legacy_pipeline_triggers: false)
step.perform!
end
it 'prevents legacy triggers from creating a pipeline' do
expect(pipeline.errors.to_a).to include /Trigger token is invalid/
end
it 'breaks the pipeline builder chain' do
expect(step.break?).to eq true
end
end
end
describe '#allowed_to_create?' do
subject { step.allowed_to_create? }
describe '#allowed_to_write_ref?' do
subject { step.send(:allowed_to_write_ref?) }
context 'when user is a developer' do
before do

View File

@ -578,3 +578,30 @@ zoom_meetings:
sentry_issue:
- issue
design_versions: *version
epic:
- subscriptions
- award_emoji
- description_versions
- author
- assignee
- issues
- epic_issues
- milestone
- notes
- label_links
- labels
- todos
- metrics
- group
- parent
- children
- updated_by
- last_edited_by
- closed_by
- start_date_sourcing_milestone
- due_date_sourcing_milestone
- start_date_sourcing_epic
- due_date_sourcing_epic
- events
- resource_label_events
- user_mentions

View File

@ -36,10 +36,6 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
end
context 'JSON' do
before do
stub_feature_flags(use_legacy_pipeline_triggers: false)
end
it 'restores models based on JSON' do
expect(@restored_project_json).to be_truthy
end
@ -502,7 +498,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
end
it_behaves_like 'restores project successfully',
issues: 2,
issues: 3,
labels: 2,
label_with_priorities: 'A project label',
milestones: 2,
@ -515,7 +511,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
it 'restores issue states' do
expect(project.issues.with_state(:closed).count).to eq(1)
expect(project.issues.with_state(:opened).count).to eq(1)
expect(project.issues.with_state(:opened).count).to eq(2)
end
end

View File

@ -766,3 +766,33 @@ ContainerExpirationPolicy:
- older_than
- keep_n
- enabled
Epic:
- id
- milestone_id
- group_id
- author_id
- assignee_id
- iid
- updated_by_id
- last_edited_by_id
- lock_version
- start_date
- end_date
- last_edited_at
- created_at
- updated_at
- title
- description
- start_date_sourcing_milestone_id
- due_date_sourcing_milestone_id
- start_date_fixed
- due_date_fixed
- start_date_is_fixed
- due_date_is_fixed
- closed_by_id
- closed_at
- parent_id
- relative_position
- state_id
- start_date_sourcing_epic_id
- due_date_sourcing_epic_id

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20191204114127_delete_legacy_triggers.rb')
describe DeleteLegacyTriggers, :migration, schema: 2019_11_25_140458 do
let(:ci_trigger_table) { table(:ci_triggers) }
let(:user) { table(:users).create!(name: 'test', email: 'test@example.com', projects_limit: 1) }
before do
@trigger_with_user = ci_trigger_table.create!(owner_id: user.id)
ci_trigger_table.create!(owner_id: nil)
ci_trigger_table.create!(owner_id: nil)
end
it 'removes legacy triggers which has null owner_id' do
expect do
migrate!
end.to change(ci_trigger_table, :count).by(-2)
expect(ci_trigger_table.all).to eq([@trigger_with_user])
end
end

View File

@ -1,134 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20191220102807_patch_prometheus_services_for_shared_cluster_applications.rb')
describe PatchPrometheusServicesForSharedClusterApplications, :migration, :sidekiq do
include MigrationHelpers::PrometheusServiceHelpers
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:services) { table(:services) }
let(:clusters) { table(:clusters) }
let(:cluster_groups) { table(:cluster_groups) }
let(:clusters_applications_prometheus) { table(:clusters_applications_prometheus) }
let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') }
let(:application_statuses) do
{
errored: -1,
installed: 3,
updated: 5
}
end
let(:cluster_types) do
{
instance_type: 1,
group_type: 2
}
end
describe '#up' do
let!(:project_with_missing_service) { projects.create!(name: 'gitlab', path: 'gitlab-ce', namespace_id: namespace.id) }
let(:project_with_inactive_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) }
let(:project_with_active_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) }
let(:project_with_manual_active_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) }
let(:project_with_manual_inactive_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) }
let(:project_with_active_not_prometheus_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) }
let(:project_with_inactive_not_prometheus_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) }
before do
services.create(service_params_for(project_with_inactive_service.id, active: false))
services.create(service_params_for(project_with_active_service.id, active: true))
services.create(service_params_for(project_with_active_not_prometheus_service.id, active: true, type: 'other'))
services.create(service_params_for(project_with_inactive_not_prometheus_service.id, active: false, type: 'other'))
services.create(service_params_for(project_with_manual_inactive_service.id, active: false, properties: { some: 'data' }.to_json))
services.create(service_params_for(project_with_manual_active_service.id, active: true, properties: { some: 'data' }.to_json))
end
shared_examples 'patch prometheus services post migration' do
context 'prometheus application is installed on the cluster' do
it 'schedules a background migration' do
clusters_applications_prometheus.create(cluster_id: cluster.id, status: application_statuses[:installed], version: '123')
Sidekiq::Testing.fake! do
Timecop.freeze do
background_migrations = [["ActivatePrometheusServicesForSharedClusterApplications", project_with_missing_service.id],
["ActivatePrometheusServicesForSharedClusterApplications", project_with_inactive_service.id],
["ActivatePrometheusServicesForSharedClusterApplications", project_with_active_not_prometheus_service.id],
["ActivatePrometheusServicesForSharedClusterApplications", project_with_inactive_not_prometheus_service.id]]
migrate!
enqueued_migrations = BackgroundMigrationWorker.jobs.map { |job| job['args'] }
expect(enqueued_migrations).to match_array(background_migrations)
end
end
end
end
context 'prometheus application was recently updated on the cluster' do
it 'schedules a background migration' do
clusters_applications_prometheus.create(cluster_id: cluster.id, status: application_statuses[:updated], version: '123')
Sidekiq::Testing.fake! do
Timecop.freeze do
background_migrations = [["ActivatePrometheusServicesForSharedClusterApplications", project_with_missing_service.id],
["ActivatePrometheusServicesForSharedClusterApplications", project_with_inactive_service.id],
["ActivatePrometheusServicesForSharedClusterApplications", project_with_active_not_prometheus_service.id],
["ActivatePrometheusServicesForSharedClusterApplications", project_with_inactive_not_prometheus_service.id]]
migrate!
enqueued_migrations = BackgroundMigrationWorker.jobs.map { |job| job['args'] }
expect(enqueued_migrations).to match_array(background_migrations)
end
end
end
end
context 'prometheus application failed to install on the cluster' do
it 'does not schedule a background migration' do
clusters_applications_prometheus.create(cluster_id: cluster.id, status: application_statuses[:errored], version: '123')
Sidekiq::Testing.fake! do
Timecop.freeze do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq 0
end
end
end
end
context 'prometheus application is NOT installed on the cluster' do
it 'does not schedule a background migration' do
Sidekiq::Testing.fake! do
Timecop.freeze do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq 0
end
end
end
end
end
context 'Cluster is group_type' do
let(:cluster) { clusters.create(name: 'cluster', cluster_type: cluster_types[:group_type]) }
before do
cluster_groups.create(group_id: namespace.id, cluster_id: cluster.id)
end
it_behaves_like 'patch prometheus services post migration'
end
context 'Cluster is instance_type' do
let(:cluster) { clusters.create(name: 'cluster', cluster_type: cluster_types[:instance_type]) }
it_behaves_like 'patch prometheus services post migration'
end
end
end

View File

@ -11,6 +11,10 @@ describe Ci::Trigger do
it { is_expected.to have_many(:trigger_requests) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:owner) }
end
describe 'before_validation' do
it 'sets an random token if none provided' do
trigger = create(:ci_trigger_without_token, project: project)
@ -35,63 +39,22 @@ describe Ci::Trigger do
end
end
describe '#legacy?' do
let(:trigger) { create(:ci_trigger, owner: owner, project: project) }
subject { trigger }
context 'when owner is blank' do
let(:owner) { nil }
it { is_expected.to be_legacy }
end
context 'when owner is set' do
let(:owner) { create(:user) }
it { is_expected.not_to be_legacy }
end
end
describe '#can_access_project?' do
let(:owner) { create(:user) }
let(:trigger) { create(:ci_trigger, owner: owner, project: project) }
context 'when owner is blank' do
subject { trigger.can_access_project? }
context 'and is member of the project' do
before do
stub_feature_flags(use_legacy_pipeline_triggers: false)
trigger.update_attribute(:owner, nil)
project.add_developer(owner)
end
subject { trigger.can_access_project? }
it { is_expected.to eq(false) }
context 'when :use_legacy_pipeline_triggers feature flag is enabled' do
before do
stub_feature_flags(use_legacy_pipeline_triggers: true)
end
subject { trigger.can_access_project? }
it { is_expected.to eq(true) }
end
it { is_expected.to eq(true) }
end
context 'when owner is set' do
subject { trigger.can_access_project? }
context 'and is member of the project' do
before do
project.add_developer(owner)
end
it { is_expected.to eq(true) }
end
context 'and is not member of the project' do
it { is_expected.to eq(false) }
end
context 'and is not member of the project' do
it { is_expected.to eq(false) }
end
end
end

View File

@ -10,60 +10,6 @@ describe Ci::TriggerPolicy do
subject { described_class.new(user, trigger) }
describe '#rules' do
context 'when owner is undefined' do
before do
stub_feature_flags(use_legacy_pipeline_triggers: false)
trigger.update_attribute(:owner, nil)
end
context 'when user is maintainer of the project' do
before do
project.add_maintainer(user)
end
it { is_expected.to be_allowed(:manage_trigger) }
it { is_expected.not_to be_allowed(:admin_trigger) }
end
context 'when user is developer of the project' do
before do
project.add_developer(user)
end
it { is_expected.not_to be_allowed(:manage_trigger) }
it { is_expected.not_to be_allowed(:admin_trigger) }
end
context 'when :use_legacy_pipeline_triggers feature flag is enabled' do
before do
stub_feature_flags(use_legacy_pipeline_triggers: true)
end
context 'when user is maintainer of the project' do
before do
project.add_maintainer(user)
end
it { is_expected.to be_allowed(:manage_trigger) }
it { is_expected.to be_allowed(:admin_trigger) }
end
context 'when user is developer of the project' do
before do
project.add_developer(user)
end
it { is_expected.not_to be_allowed(:manage_trigger) }
it { is_expected.not_to be_allowed(:admin_trigger) }
end
context 'when user is not member of the project' do
it { is_expected.not_to be_allowed(:manage_trigger) }
it { is_expected.not_to be_allowed(:admin_trigger) }
end
end
end
context 'when owner is an user' do
before do
trigger.update!(owner: user)

View File

@ -87,22 +87,6 @@ describe API::Triggers do
expect(pipeline.variables.map { |v| { v.key => v.value } }.last).to eq(variables)
end
end
context 'when legacy trigger' do
before do
trigger.update(owner: nil)
end
it 'creates pipeline' do
post api("/projects/#{project.id}/trigger/pipeline"), params: options.merge(ref: 'master')
expect(response).to have_gitlab_http_status(201)
expect(json_response).to include('id' => pipeline.id)
pipeline.builds.reload
expect(pipeline.builds.pending.size).to eq(2)
expect(pipeline.builds.size).to eq(5)
end
end
end
context 'when triggering a pipeline from a trigger token' do

View File

@ -161,3 +161,17 @@ describe Admin::GroupsController, "routing" do
expect(get("/admin/groups/#{name}/edit")).to route_to('admin/groups#edit', id: name)
end
end
describe Admin::SessionsController, "routing" do
it "to #new" do
expect(get("/admin/session/new")).to route_to('admin/sessions#new')
end
it "to #create" do
expect(post("/admin/session")).to route_to('admin/sessions#create')
end
it "to #destroy" do
expect(post("/admin/session/destroy")).to route_to('admin/sessions#destroy')
end
end

View File

@ -256,10 +256,8 @@ describe "Authentication", "routing" do
expect(post("/users/sign_in")).to route_to('sessions#create')
end
# sign_out with GET instead of DELETE facilitates ad-hoc single-sign-out processes
# (https://gitlab.com/gitlab-org/gitlab-foss/issues/39708)
it "GET /users/sign_out" do
expect(get("/users/sign_out")).to route_to('sessions#destroy')
it "POST /users/sign_out" do
expect(post("/users/sign_out")).to route_to('sessions#destroy')
end
it "POST /users/password" do

View File

@ -1123,21 +1123,6 @@ describe Ci::CreatePipelineService do
it_behaves_like 'when ref is protected'
end
context 'when ref is not protected' do
context 'when trigger belongs to no one' do
let(:user) {}
let(:trigger) { create(:ci_trigger, owner: nil) }
let(:trigger_request) { create(:ci_trigger_request, trigger: trigger) }
let(:pipeline) { execute_service(trigger_request: trigger_request) }
it 'creates an unprotected pipeline' do
expect(pipeline).to be_persisted
expect(pipeline).not_to be_protected
expect(Ci::Pipeline.count).to eq(1)
end
end
end
context 'when pipeline is running for a tag' do
before do
config = YAML.dump(test: { script: 'test', only: ['branches'] },

View File

@ -1,35 +0,0 @@
# frozen_string_literal: true
module MigrationHelpers
module PrometheusServiceHelpers
def service_params_for(project_id, params = {})
{
project_id: project_id,
active: false,
properties: '{}',
type: 'PrometheusService',
template: false,
push_events: true,
issues_events: true,
merge_requests_events: true,
tag_push_events: true,
note_events: true,
category: 'monitoring',
default: false,
wiki_page_events: true,
pipeline_events: true,
confidential_issues_events: true,
commit_events: true,
job_events: true,
confidential_note_events: true,
deployment_events: false
}.merge(params)
end
def row_attributes(entity)
entity.attributes.with_indifferent_access.tap do |hash|
hash.merge!(hash.slice(:created_at, :updated_at).transform_values { |v| v.to_s(:db) })
end
end
end
end

View File

@ -1,21 +0,0 @@
# frozen_string_literal: true
# This pending test can be removed when `single_mr_diff_view` is enabled by default
# disabling the feature flag above is then not needed anymore.
RSpec.shared_examples 'rendering a single diff version' do |attribute|
before do
stub_feature_flags(diffs_batch_load: false)
end
pending 'allows editing diff settings single_mr_diff_view is enabled' do
project = create(:project, :repository)
user = project.creator
merge_request = create(:merge_request, source_project: project)
stub_feature_flags(single_mr_diff_view: true)
sign_in(user)
visit(diffs_project_merge_request_path(project, merge_request))
expect(page).to have_selector('.js-show-diff-settings')
end
end

View File

@ -5640,6 +5640,11 @@ immediate@~3.0.5:
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
immer@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/immer/-/immer-5.2.1.tgz#7d4f74c242178e87151d595f48db1b5c51580485"
integrity sha512-9U1GEbJuH6nVoyuFRgTQDGMzcBuNBPfXM3M7Pp/sdmYKTKYOBUZGgeUb9H57GfLK/xC1DMLarWX2FrhMBfUJ8g==
import-fresh@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546"