Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-09-28 09:09:35 +00:00
parent 1d42c38d9b
commit 15b3452054
93 changed files with 1075 additions and 1475 deletions

View File

@ -216,7 +216,6 @@ linters:
- "app/views/projects/mattermosts/_team_selection.html.haml"
- "app/views/projects/mattermosts/new.html.haml"
- "app/views/projects/merge_requests/_commits.html.haml"
- "app/views/projects/merge_requests/_discussion.html.haml"
- "app/views/projects/merge_requests/_how_to_merge.html.haml"
- "app/views/projects/merge_requests/_mr_title.html.haml"
- "app/views/projects/merge_requests/conflicts/_commit_stats.html.haml"

View File

@ -1,65 +0,0 @@
/* global CommentsStore */
import $ from 'jquery';
import Vue from 'vue';
import { __ } from '~/locale';
const CommentAndResolveBtn = Vue.extend({
props: {
discussionId: {
type: String,
required: true,
},
},
data() {
return {
textareaIsEmpty: true,
discussion: {},
};
},
computed: {
showButton() {
if (this.discussion) {
return this.discussion.isResolvable();
}
return false;
},
isDiscussionResolved() {
return this.discussion.isResolved();
},
buttonText() {
if (this.textareaIsEmpty) {
return this.isDiscussionResolved ? __('Unresolve thread') : __('Resolve thread');
}
return this.isDiscussionResolved
? __('Comment & unresolve thread')
: __('Comment & resolve thread');
},
},
created() {
if (this.discussionId) {
this.discussion = CommentsStore.state[this.discussionId];
}
},
mounted() {
if (!this.discussionId) return;
const $textarea = $(
`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`,
);
this.textareaIsEmpty = $textarea.val() === '';
$textarea.on('input.comment-and-resolve-btn', () => {
this.textareaIsEmpty = $textarea.val() === '';
});
},
destroyed() {
if (!this.discussionId) return;
$(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`).off(
'input.comment-and-resolve-btn',
);
},
});
Vue.component('comment-and-resolve-btn', CommentAndResolveBtn);

View File

@ -1,189 +0,0 @@
/* global CommentsStore */
import $ from 'jquery';
import Vue from 'vue';
import collapseIcon from '../icons/collapse_icon.svg';
import Notes from '../../notes';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import { n__ } from '~/locale';
const DiffNoteAvatars = Vue.extend({
components: {
userAvatarImage,
},
props: {
discussionId: {
type: String,
required: true,
},
},
data() {
return {
isVisible: false,
lineType: '',
storeState: CommentsStore.state,
shownAvatars: 3,
collapseIcon,
};
},
computed: {
discussionClassName() {
return `js-diff-avatars-${this.discussionId}`;
},
notesSubset() {
let notes = [];
if (this.discussion) {
notes = Object.keys(this.discussion.notes)
.slice(0, this.shownAvatars)
.map(noteId => this.discussion.notes[noteId]);
}
return notes;
},
extraNotesTitle() {
if (this.discussion) {
const extra = this.discussion.notesCount() - this.shownAvatars;
return n__('%d more comment', '%d more comments', extra);
}
return '';
},
discussion() {
return this.storeState[this.discussionId];
},
notesCount() {
if (this.discussion) {
return this.discussion.notesCount();
}
return 0;
},
moreText() {
const plusSign = this.notesCount < 100 ? '+' : '';
return `${plusSign}${this.notesCount - this.shownAvatars}`;
},
},
watch: {
storeState: {
handler() {
this.$nextTick(() => {
$('.has-tooltip', this.$el).tooltip('_fixTitle');
// We need to add/remove a class to an element that is outside the Vue instance
this.addNoCommentClass();
});
},
deep: true,
},
},
mounted() {
this.$nextTick(() => {
this.addNoCommentClass();
this.setDiscussionVisible();
this.lineType = $(this.$el)
.closest('.diff-line-num')
.hasClass('old_line')
? 'old'
: 'new';
});
$(document).on('toggle.comments', () => {
this.$nextTick(() => {
this.setDiscussionVisible();
});
});
},
beforeDestroy() {
this.addNoCommentClass();
$(document).off('toggle.comments');
},
methods: {
clickedAvatar(e) {
Notes.instance.onAddDiffNote(e);
// Toggle the active state of the toggle all button
this.toggleDiscussionsToggleState();
this.$nextTick(() => {
this.setDiscussionVisible();
$('.has-tooltip', this.$el).tooltip('_fixTitle');
$('.has-tooltip', this.$el).tooltip('hide');
});
},
addNoCommentClass() {
const { notesCount } = this;
$(this.$el)
.closest('.js-avatar-container')
.toggleClass('no-comment-btn', notesCount > 0)
.nextUntil('.js-avatar-container')
.toggleClass('no-comment-btn', notesCount > 0);
},
toggleDiscussionsToggleState() {
const $notesHolders = $(this.$el)
.closest('.code')
.find('.notes_holder');
const $visibleNotesHolders = $notesHolders.filter(':visible');
const $toggleDiffCommentsBtn = $(this.$el)
.closest('.diff-file')
.find('.js-toggle-diff-comments');
$toggleDiffCommentsBtn.toggleClass(
'active',
$notesHolders.length === $visibleNotesHolders.length,
);
},
setDiscussionVisible() {
this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(
':visible',
);
},
getTooltipText(note) {
return `${note.authorName}: ${note.noteTruncated}`;
},
},
template: `
<div class="diff-comment-avatar-holders"
:class="discussionClassName"
v-show="notesCount !== 0">
<div v-if="!isVisible">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image
v-for="note in notesSubset"
:key="note.id"
class="diff-comment-avatar js-diff-comment-avatar"
@click.native="clickedAvatar($event)"
:img-src="note.authorAvatar"
:tooltip-text="getTooltipText(note)"
:data-line-type="lineType"
:size="19"
data-html="true"
/>
<span v-if="notesCount > shownAvatars"
class="diff-comments-more-count has-tooltip js-diff-comment-avatar"
data-container="body"
data-placement="top"
ref="extraComments"
role="button"
:data-line-type="lineType"
:title="extraNotesTitle"
@click="clickedAvatar($event)">{{ moreText }}</span>
</div>
<button class="diff-notes-collapse js-diff-comment-avatar"
type="button"
aria-label="Show comments"
:data-line-type="lineType"
@click="clickedAvatar($event)"
v-if="isVisible"
v-html="collapseIcon">
</button>
</div>
`,
});
Vue.component('diff-note-avatars', DiffNoteAvatars);

View File

@ -1,210 +0,0 @@
/* eslint-disable func-names, no-continue */
/* global CommentsStore */
import $ from 'jquery';
import 'vendor/jquery.scrollTo';
import Vue from 'vue';
import { __ } from '~/locale';
import DiscussionMixins from '../mixins/discussion';
const JumpToDiscussion = Vue.extend({
mixins: [DiscussionMixins],
props: {
discussionId: {
type: String,
required: true,
},
},
data() {
return {
discussions: CommentsStore.state,
discussion: {},
};
},
computed: {
buttonText() {
if (this.discussionId) {
return __('Jump to next unresolved thread');
}
return __('Jump to first unresolved thread');
},
allResolved() {
return this.unresolvedDiscussionCount === 0;
},
showButton() {
if (this.discussionId) {
if (this.unresolvedDiscussionCount > 1) {
return true;
}
return this.discussionId !== this.lastResolvedId;
}
return this.unresolvedDiscussionCount >= 1;
},
lastResolvedId() {
let lastId;
Object.keys(this.discussions).forEach(discussionId => {
const discussion = this.discussions[discussionId];
if (!discussion.isResolved()) {
lastId = discussion.id;
}
});
return lastId;
},
},
created() {
this.discussion = this.discussions[this.discussionId];
},
methods: {
jumpToNextUnresolvedDiscussion() {
let discussionsSelector;
let discussionIdsInScope;
let firstUnresolvedDiscussionId;
let nextUnresolvedDiscussionId;
let activeTab = window.mrTabs.currentAction;
let hasDiscussionsToJumpTo = true;
let jumpToFirstDiscussion = !this.discussionId;
const discussionIdsForElements = function(elements) {
return elements
.map(function() {
return $(this).attr('data-discussion-id');
})
.toArray();
};
const { discussions } = this;
if (activeTab === 'diffs') {
discussionsSelector = '.diffs .notes[data-discussion-id]';
discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
let unresolvedDiscussionCount = 0;
for (let i = 0; i < discussionIdsInScope.length; i += 1) {
const discussionId = discussionIdsInScope[i];
const discussion = discussions[discussionId];
if (discussion && !discussion.isResolved()) {
unresolvedDiscussionCount += 1;
}
}
if (this.discussionId && !this.discussion.isResolved()) {
// If this is the last unresolved discussion on the diffs tab,
// there are no discussions to jump to.
if (unresolvedDiscussionCount === 1) {
hasDiscussionsToJumpTo = false;
}
} else if (unresolvedDiscussionCount === 0) {
// If there are no unresolved discussions on the diffs tab at all,
// there are no discussions to jump to.
hasDiscussionsToJumpTo = false;
}
} else if (activeTab !== 'show') {
// If we are on the commits or builds tabs,
// there are no discussions to jump to.
hasDiscussionsToJumpTo = false;
}
if (!hasDiscussionsToJumpTo) {
// If there are no discussions to jump to on the current page,
// switch to the notes tab and jump to the first discussion there.
window.mrTabs.activateTab('show');
activeTab = 'show';
jumpToFirstDiscussion = true;
}
if (activeTab === 'show') {
discussionsSelector = '.discussion[data-discussion-id]';
discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
}
let currentDiscussionFound = false;
for (let i = 0; i < discussionIdsInScope.length; i += 1) {
const discussionId = discussionIdsInScope[i];
const discussion = discussions[discussionId];
if (!discussion) {
// Discussions for comments on commits in this MR don't have a resolved status.
continue;
}
if (!firstUnresolvedDiscussionId && !discussion.isResolved()) {
firstUnresolvedDiscussionId = discussionId;
if (jumpToFirstDiscussion) {
break;
}
}
if (!jumpToFirstDiscussion) {
if (currentDiscussionFound) {
if (!discussion.isResolved()) {
nextUnresolvedDiscussionId = discussionId;
break;
} else {
continue;
}
}
if (discussionId === this.discussionId) {
currentDiscussionFound = true;
}
}
}
nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId;
if (!nextUnresolvedDiscussionId) {
return;
}
let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`);
if (activeTab === 'show') {
$target = $target.closest('.note-discussion');
// If the next discussion is closed, toggle it open.
if ($target.find('.js-toggle-content').is(':hidden')) {
$target.find('.js-toggle-button i').trigger('click');
}
} else if (activeTab === 'diffs') {
// Resolved discussions are hidden in the diffs tab by default.
// If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab.
// When jumping between unresolved discussions on the diffs tab, we show them.
$target.closest('.content').show();
const $notesHolder = $target.closest('tr.notes_holder');
// Image diff discussions does not use notes_holder
// so we should keep original $target value in those cases
if ($notesHolder.length > 0) {
$target = $notesHolder;
}
$target.show();
// If we are on the diffs tab, we don't scroll to the discussion itself, but to
// 4 diff lines above it: the line the discussion was in response to + 3 context
let prevEl;
for (let i = 0; i < 4; i += 1) {
prevEl = $target.prev();
// If the discussion doesn't have 4 lines above it, we'll have to do with fewer.
if (!prevEl.hasClass('line_holder')) {
break;
}
$target = prevEl;
}
}
$.scrollTo($target, {
offset: -150,
});
},
},
});
Vue.component('jump-to-discussion', JumpToDiscussion);

View File

@ -1,28 +0,0 @@
/* global CommentsStore */
import Vue from 'vue';
const NewIssueForDiscussion = Vue.extend({
props: {
discussionId: {
type: String,
required: true,
},
},
data() {
return {
discussions: CommentsStore.state,
};
},
computed: {
discussion() {
return this.discussions[this.discussionId];
},
showButton() {
if (this.discussion) return !this.discussion.isResolved();
return false;
},
},
});
Vue.component('new-issue-for-discussion-btn', NewIssueForDiscussion);

View File

@ -1,145 +0,0 @@
/* global CommentsStore */
/* global ResolveService */
import $ from 'jquery';
import Vue from 'vue';
import { deprecatedCreateFlash as Flash } from '../../flash';
import { sprintf, __ } from '~/locale';
const ResolveBtn = Vue.extend({
props: {
noteId: {
type: Number,
required: true,
},
discussionId: {
type: String,
required: true,
},
resolved: {
type: Boolean,
required: true,
},
canResolve: {
type: Boolean,
required: true,
},
resolvedBy: {
type: String,
required: true,
},
authorName: {
type: String,
required: true,
},
authorAvatar: {
type: String,
required: true,
},
noteTruncated: {
type: String,
required: true,
},
},
data() {
return {
discussions: CommentsStore.state,
loading: false,
};
},
computed: {
discussion() {
return this.discussions[this.discussionId];
},
note() {
return this.discussion ? this.discussion.getNote(this.noteId) : {};
},
buttonText() {
if (this.isResolved) {
return sprintf(__('Resolved by %{resolvedByName}'), {
resolvedByName: this.resolvedByName,
});
} else if (this.canResolve) {
return __('Mark as resolved');
}
return __('Unable to resolve');
},
isResolved() {
if (this.note) {
return this.note.resolved;
}
return false;
},
resolvedByName() {
return this.note.resolved_by;
},
},
watch: {
discussions: {
handler: 'updateTooltip',
deep: true,
},
},
mounted() {
$(this.$refs.button).tooltip({
container: 'body',
});
},
beforeDestroy() {
CommentsStore.delete(this.discussionId, this.noteId);
},
created() {
CommentsStore.create({
discussionId: this.discussionId,
noteId: this.noteId,
canResolve: this.canResolve,
resolved: this.resolved,
resolvedBy: this.resolvedBy,
authorName: this.authorName,
authorAvatar: this.authorAvatar,
noteTruncated: this.noteTruncated,
});
},
methods: {
updateTooltip() {
this.$nextTick(() => {
$(this.$refs.button)
.tooltip('hide')
.tooltip('_fixTitle');
});
},
resolve() {
if (!this.canResolve) return;
let promise;
this.loading = true;
if (this.isResolved) {
promise = ResolveService.unresolve(this.noteId);
} else {
promise = ResolveService.resolve(this.noteId);
}
promise
.then(resp => resp.json())
.then(data => {
this.loading = false;
const resolvedBy = data ? data.resolved_by : null;
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolvedBy);
this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus();
this.updateTooltip();
})
.catch(
() =>
new Flash(__('An error occurred when trying to resolve a comment. Please try again.')),
);
},
},
});
Vue.component('resolve-btn', ResolveBtn);

View File

@ -1,28 +0,0 @@
/* global CommentsStore */
import Vue from 'vue';
import DiscussionMixins from '../mixins/discussion';
window.ResolveCount = Vue.extend({
mixins: [DiscussionMixins],
props: {
loggedOut: {
type: Boolean,
required: true,
},
},
data() {
return {
discussions: CommentsStore.state,
};
},
computed: {
allResolved() {
return this.resolvedDiscussionCount === this.discussionCount;
},
resolvedCountText() {
return this.discussionCount === 1 ? 'discussion' : 'discussions';
},
},
});

View File

@ -1,72 +0,0 @@
/* eslint-disable func-names, new-cap */
import $ from 'jquery';
import Vue from 'vue';
import './models/discussion';
import './models/note';
import './stores/comments';
import './services/resolve';
import './mixins/discussion';
import './components/comment_resolve_btn';
import './components/jump_to_discussion';
import './components/resolve_btn';
import './components/resolve_count';
import './components/diff_note_avatars';
import './components/new_issue_for_discussion';
export default () => {
const projectPathHolder =
document.querySelector('.merge-request') || document.querySelector('.commit-box');
const { projectPath } = projectPathHolder.dataset;
const COMPONENT_SELECTOR =
'resolve-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn';
window.gl = window.gl || {};
window.gl.diffNoteApps = {};
window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath);
gl.diffNotesCompileComponents = () => {
$('diff-note-avatars').each(function() {
const tmp = Vue.extend({
template: $(this).get(0).outerHTML,
});
const tmpApp = new tmp().$mount();
$(this).replaceWith(tmpApp.$el);
$(tmpApp.$el).one('remove.vue', () => {
tmpApp.$destroy();
tmpApp.$el.remove();
});
});
const $components = $(COMPONENT_SELECTOR).filter(function() {
return $(this).closest('resolve-count').length !== 1;
});
if ($components) {
$components.each(function() {
const $this = $(this);
const noteId = $this.attr(':note-id');
const discussionId = $this.attr(':discussion-id');
if ($this.is('comment-and-resolve-btn') && !discussionId) return;
const tmp = Vue.extend({
template: $this.get(0).outerHTML,
});
const tmpApp = new tmp().$mount();
if (noteId) {
gl.diffNoteApps[`note_${noteId}`] = tmpApp;
}
$this.replaceWith(tmpApp.$el);
});
}
};
gl.diffNotesCompileComponents();
$(window).trigger('resize.nav');
};

View File

@ -1 +0,0 @@
<svg width="11" height="11" viewBox="0 0 9 13"><path d="M2.57568253,6.49866948 C2.50548852,6.57199715 2.44637866,6.59708255 2.39835118,6.57392645 C2.3503237,6.55077034 2.32631032,6.48902165 2.32631032,6.38867852 L2.32631032,-2.13272614 C2.32631032,-2.23306927 2.3503237,-2.29481796 2.39835118,-2.31797406 C2.44637866,-2.34113017 2.50548852,-2.31604477 2.57568253,-2.24271709 L6.51022184,1.86747129 C6.53977721,1.8983461 6.56379059,1.93500939 6.5822627,1.97746225 L6.5822627,2.27849013 C6.56379059,2.31708364 6.53977721,2.35374693 6.51022184,2.38848109 L2.57568253,6.49866948 Z" transform="translate(4.454287, 2.127976) rotate(90.000000) translate(-4.454287, -2.127976) "></path><path d="M3.74312342,2.09553332 C3.74312342,1.99519019 3.77821989,1.9083561 3.8484139,1.83502843 C3.91860791,1.76170075 4.00173115,1.72503747 4.09778611,1.72503747 L4.80711151,1.72503747 C4.90316647,1.72503747 4.98628971,1.76170075 5.05648372,1.83502843 C5.12667773,1.9083561 5.16177421,1.99519019 5.16177421,2.09553332 L5.16177421,10.2464421 C5.16177421,10.3467853 5.12667773,10.4336194 5.05648372,10.506947 C4.98628971,10.5802747 4.90316647,10.616938 4.80711151,10.616938 L4.09778611,10.616938 C4.00173115,10.616938 3.91860791,10.5802747 3.8484139,10.506947 C3.77821989,10.4336194 3.74312342,10.3467853 3.74312342,10.2464421 L3.74312342,2.09553332 Z" transform="translate(4.452449, 6.170988) rotate(-90.000000) translate(-4.452449, -6.170988) "></path><path d="M2.57568253,14.6236695 C2.50548852,14.6969971 2.44637866,14.7220826 2.39835118,14.6989264 C2.3503237,14.6757703 2.32631032,14.6140216 2.32631032,14.5136785 L2.32631032,5.99227386 C2.32631032,5.89193073 2.3503237,5.83018204 2.39835118,5.80702594 C2.44637866,5.78386983 2.50548852,5.80895523 2.57568253,5.88228291 L6.51022184,9.99247129 C6.53977721,10.0233461 6.56379059,10.0600094 6.5822627,10.1024622 L6.5822627,10.4034901 C6.56379059,10.4420836 6.53977721,10.4787469 6.51022184,10.5134811 L2.57568253,14.6236695 Z" transform="translate(4.454287, 10.252976) scale(1, -1) rotate(90.000000) translate(-4.454287, -10.252976) "></path></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,37 +0,0 @@
/* eslint-disable guard-for-in, no-restricted-syntax, */
const DiscussionMixins = {
computed: {
discussionCount() {
return Object.keys(this.discussions).length;
},
resolvedDiscussionCount() {
let resolvedCount = 0;
for (const discussionId in this.discussions) {
const discussion = this.discussions[discussionId];
if (discussion.isResolved()) {
resolvedCount += 1;
}
}
return resolvedCount;
},
unresolvedDiscussionCount() {
let unresolvedCount = 0;
for (const discussionId in this.discussions) {
const discussion = this.discussions[discussionId];
if (!discussion.isResolved()) {
unresolvedCount += 1;
}
}
return unresolvedCount;
},
},
};
export default DiscussionMixins;

View File

@ -1,99 +0,0 @@
/* eslint-disable guard-for-in, no-restricted-syntax */
/* global NoteModel */
import $ from 'jquery';
import Vue from 'vue';
import { localTimeAgo } from '../../lib/utils/datetime_utility';
class DiscussionModel {
constructor(discussionId) {
this.id = discussionId;
this.notes = {};
this.loading = false;
this.canResolve = false;
}
createNote(noteObj) {
Vue.set(this.notes, noteObj.noteId, new NoteModel(this.id, noteObj));
}
deleteNote(noteId) {
Vue.delete(this.notes, noteId);
}
getNote(noteId) {
return this.notes[noteId];
}
notesCount() {
return Object.keys(this.notes).length;
}
isResolved() {
for (const noteId in this.notes) {
const note = this.notes[noteId];
if (!note.resolved) {
return false;
}
}
return true;
}
resolveAllNotes(resolvedBy) {
for (const noteId in this.notes) {
const note = this.notes[noteId];
if (!note.resolved) {
note.resolved = true;
note.resolved_by = resolvedBy;
}
}
}
unResolveAllNotes() {
for (const noteId in this.notes) {
const note = this.notes[noteId];
if (note.resolved) {
note.resolved = false;
note.resolved_by = null;
}
}
}
updateHeadline(data) {
const discussionSelector = `.discussion[data-discussion-id="${this.id}"]`;
const $discussionHeadline = $(`${discussionSelector} .js-discussion-headline`);
if (data.discussion_headline_html) {
if ($discussionHeadline.length) {
$discussionHeadline.replaceWith(data.discussion_headline_html);
} else {
$(`${discussionSelector} .discussion-header`).append(data.discussion_headline_html);
}
localTimeAgo($('.js-timeago', `${discussionSelector}`));
} else {
$discussionHeadline.remove();
}
}
isResolvable() {
if (!this.canResolve) {
return false;
}
for (const noteId in this.notes) {
const note = this.notes[noteId];
if (note.canResolve) {
return true;
}
}
return false;
}
}
window.DiscussionModel = DiscussionModel;

View File

@ -1,14 +0,0 @@
class NoteModel {
constructor(discussionId, noteObj) {
this.discussionId = discussionId;
this.id = noteObj.noteId;
this.canResolve = noteObj.canResolve;
this.resolved = noteObj.resolved;
this.resolved_by = noteObj.resolvedBy;
this.authorName = noteObj.authorName;
this.authorAvatar = noteObj.authorAvatar;
this.noteTruncated = noteObj.noteTruncated;
}
}
window.NoteModel = NoteModel;

View File

@ -1,86 +0,0 @@
/* global CommentsStore */
import Vue from 'vue';
import { deprecatedCreateFlash as Flash } from '../../flash';
import { __ } from '~/locale';
window.gl = window.gl || {};
class ResolveServiceClass {
constructor(root) {
this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`);
this.discussionResource = Vue.resource(
`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`,
);
}
resolve(noteId) {
return this.noteResource.save({ noteId }, {});
}
unresolve(noteId) {
return this.noteResource.delete({ noteId }, {});
}
toggleResolveForDiscussion(mergeRequestId, discussionId) {
const discussion = CommentsStore.state[discussionId];
const isResolved = discussion.isResolved();
let promise;
if (isResolved) {
promise = this.unResolveAll(mergeRequestId, discussionId);
} else {
promise = this.resolveAll(mergeRequestId, discussionId);
}
promise
.then(resp => resp.json())
.then(data => {
discussion.loading = false;
const resolvedBy = data ? data.resolved_by : null;
if (isResolved) {
discussion.unResolveAllNotes();
} else {
discussion.resolveAllNotes(resolvedBy);
}
if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data);
})
.catch(
() =>
new Flash(__('An error occurred when trying to resolve a discussion. Please try again.')),
);
}
resolveAll(mergeRequestId, discussionId) {
const discussion = CommentsStore.state[discussionId];
discussion.loading = true;
return this.discussionResource.save(
{
mergeRequestId,
discussionId,
},
{},
);
}
unResolveAll(mergeRequestId, discussionId) {
const discussion = CommentsStore.state[discussionId];
discussion.loading = true;
return this.discussionResource.delete(
{
mergeRequestId,
discussionId,
},
{},
);
}
}
gl.DiffNotesResolveServiceClass = ResolveServiceClass;

View File

@ -1,56 +0,0 @@
/* eslint-disable no-restricted-syntax, guard-for-in */
/* global DiscussionModel */
import Vue from 'vue';
window.CommentsStore = {
state: {},
get(discussionId, noteId) {
return this.state[discussionId].getNote(noteId);
},
createDiscussion(discussionId, canResolve) {
let discussion = this.state[discussionId];
if (!this.state[discussionId]) {
discussion = new DiscussionModel(discussionId);
Vue.set(this.state, discussionId, discussion);
}
if (canResolve !== undefined) {
discussion.canResolve = canResolve;
}
return discussion;
},
create(noteObj) {
const discussion = this.createDiscussion(noteObj.discussionId);
discussion.createNote(noteObj);
},
update(discussionId, noteId, resolved, resolvedBy) {
const discussion = this.state[discussionId];
const note = discussion.getNote(noteId);
note.resolved = resolved;
note.resolved_by = resolvedBy;
},
delete(discussionId, noteId) {
const discussion = this.state[discussionId];
discussion.deleteNote(noteId);
if (discussion.notesCount() === 0) {
Vue.delete(this.state, discussionId);
}
},
unresolvedDiscussionIds() {
const ids = [];
for (const discussionId in this.state) {
const discussion = this.state[discussionId];
if (!discussion.isResolved()) {
ids.push(discussion.id);
}
}
return ids;
},
};

View File

@ -1,32 +1,26 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { escape } from 'lodash';
import { GlButton, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintf } from '~/locale';
import { __, sprintf } from '~/locale';
import { deprecatedCreateFlash as 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';
import { diffViewerErrors } from '~/ide/constants';
import { GENERIC_ERROR, DIFF_FILE } from '../i18n';
export default {
components: {
DiffFileHeader,
DiffContent,
GlButton,
GlLoadingIcon,
},
directives: {
SafeHtml,
},
mixins: [glFeatureFlagsMixin()],
i18n: {
genericError: GENERIC_ERROR,
...DIFF_FILE,
},
props: {
file: {
type: Object,
@ -59,7 +53,7 @@ export default {
...mapGetters('diffs', ['getDiffFileDiscussions']),
viewBlobLink() {
return sprintf(
this.$options.i18n.blobView,
__('You can %{linkStart}view the blob%{linkEnd} instead.'),
{
linkStart: `<a href="${escape(this.file.view_path)}">`,
linkEnd: '</a>',
@ -81,7 +75,9 @@ export default {
},
forkMessage() {
return sprintf(
this.$options.i18n.editInFork,
__(
"You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.",
),
{
tag_start: '<span class="js-file-fork-suggestion-section-action">',
tag_end: '</span>',
@ -152,7 +148,7 @@ export default {
})
.catch(() => {
this.isLoadingCollapsedDiff = false;
createFlash(this.$options.i18n.genericError);
createFlash(__('Something went wrong on our end. Please try again!'));
});
},
showForkMessage() {
@ -192,14 +188,14 @@ export default {
<a
:href="file.fork_path"
class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success"
>{{ $options.i18n.fork }}</a
>{{ __('Fork') }}</a
>
<button
class="js-cancel-fork-suggestion-button btn btn-grouped"
type="button"
@click="hideForkMessage"
>
{{ $options.i18n.cancel }}
{{ __('Cancel') }}
</button>
</div>
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
@ -209,16 +205,11 @@ export default {
<div v-safe-html="errorMessage" class="nothing-here-block"></div>
</div>
<template v-else>
<div
v-show="isCollapsed"
class="gl-p-7 gl-bg-gray-10 gl-text-center collapsed-file-warning"
>
<p class="gl-mb-8 gl-mt-5">
{{ $options.i18n.collapsed }}
</p>
<gl-button class="gl-mb-5" data-testid="expandButton" @click="handleToggle">
{{ $options.i18n.expand }}
</gl-button>
<div v-show="isCollapsed" class="nothing-here-block diff-collapsed">
{{ __('This diff is collapsed.') }}
<a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{
__('Click to expand it.')
}}</a>
</div>
<diff-content
v-show="!isCollapsed && !isFileTooLarge"

View File

@ -1,14 +0,0 @@
import { __ } from '~/locale';
export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!');
export const DIFF_FILE = {
blobView: __('You can %{linkStart}view the blob%{linkEnd} instead.'),
editInFork: __(
"You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.",
),
fork: __('Fork'),
cancel: __('Cancel'),
collapsed: __('This file is collapsed.'),
expand: __('Expand file'),
};

View File

@ -0,0 +1,60 @@
<script>
import { mapGetters } from 'vuex';
import { GlModal } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlModal,
},
computed: {
...mapGetters(['isSavingOrTesting']),
primaryProps() {
return {
text: __('Save'),
attributes: [
{ variant: 'success' },
{ category: 'primary' },
{ disabled: this.isSavingOrTesting },
],
};
},
cancelProps() {
return {
text: __('Cancel'),
};
},
},
methods: {
onSubmit() {
this.$emit('submit');
},
},
};
</script>
<template>
<gl-modal
modal-id="confirmSaveIntegration"
size="sm"
:title="s__('Integrations|Save settings?')"
:action-primary="primaryProps"
:action-cancel="cancelProps"
@primary="onSubmit"
>
<p>
{{
s__(
'Integrations|Saving will update the default settings for all projects that are not using custom settings.',
)
}}
</p>
<p class="gl-mb-0">
{{
s__(
'Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.',
)
}}
</p>
</gl-modal>
</template>

View File

@ -1,8 +1,9 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { GlButton, GlModalDirective } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import { integrationLevels } from '../constants';
import OverrideDropdown from './override_dropdown.vue';
import ActiveCheckbox from './active_checkbox.vue';
@ -10,6 +11,7 @@ import JiraTriggerFields from './jira_trigger_fields.vue';
import JiraIssuesFields from './jira_issues_fields.vue';
import TriggerFields from './trigger_fields.vue';
import DynamicField from './dynamic_field.vue';
import ConfirmationModal from './confirmation_modal.vue';
export default {
name: 'IntegrationForm',
@ -20,8 +22,12 @@ export default {
JiraIssuesFields,
TriggerFields,
DynamicField,
ConfirmationModal,
GlButton,
},
directives: {
'gl-modal': GlModalDirective,
},
mixins: [glFeatureFlagsMixin()],
computed: {
...mapGetters(['currentKey', 'propsSource', 'isSavingOrTesting']),
@ -32,6 +38,9 @@ export default {
isJira() {
return this.propsSource.type === 'jira';
},
isInstanceLevel() {
return this.propsSource.integrationLevel === integrationLevels.INSTANCE;
},
showJiraIssuesFields() {
return this.isJira && this.glFeatures.jiraIssuesIntegration;
},
@ -82,7 +91,21 @@ export default {
v-bind="propsSource.jiraIssuesProps"
/>
<div v-if="isEditable" class="footer-block row-content-block">
<template v-if="isInstanceLevel">
<gl-button
v-gl-modal.confirmSaveIntegration
category="primary"
variant="success"
:loading="isSaving"
:disabled="isSavingOrTesting"
data-qa-selector="save_changes_button"
>
{{ __('Save changes') }}
</gl-button>
<confirmation-modal @submit="onSaveClick" />
</template>
<gl-button
v-else
category="primary"
variant="success"
type="submit"
@ -93,6 +116,7 @@ export default {
>
{{ __('Save changes') }}
</gl-button>
<gl-button
v-if="propsSource.canTest"
:loading="isTesting"

View File

@ -396,10 +396,6 @@ export default class MergeRequestTabs {
initChangesDropdown(this.stickyTop);
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
}
localTimeAgo($('.js-timeago', 'div#diffs'));
syntaxHighlight($('#diffs .js-syntax-highlight'));

View File

@ -479,11 +479,6 @@ export default class Notes {
row = form;
}
const lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
const diffAvatarContainer = row
.prevAll('.line_holder')
.first()
.find(`.js-avatar-container.${lineType}_line`);
// is this the first note of discussion?
discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
if (!discussionContainer.length) {
@ -519,12 +514,6 @@ export default class Notes {
Notes.animateAppendNote(noteEntity.html, discussionContainer);
}
if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) {
gl.diffNotesCompileComponents();
this.renderDiscussionAvatar(diffAvatarContainer, noteEntity);
}
localTimeAgo($('.js-timeago'), false);
Notes.checkMergeRequestStatus();
return this.updateNotesCount(1);
@ -538,19 +527,6 @@ export default class Notes {
.get(0);
}
renderDiscussionAvatar(diffAvatarContainer, noteEntity) {
let avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
if (!avatarHolder.length) {
avatarHolder = document.createElement('diff-note-avatars');
avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id);
diffAvatarContainer.append(avatarHolder);
gl.diffNotesCompileComponents();
}
}
/**
* Called in response the main target form has been successfully submitted.
*
@ -605,10 +581,6 @@ export default class Notes {
form.find('#note_type').val('');
form.find('#note_project_id').remove();
form.find('#in_reply_to_discussion_id').remove();
form
.find('.js-comment-resolve-button')
.closest('comment-and-resolve-btn')
.remove();
this.parentTimeline = form.parents('.timeline');
if (form.length) {
@ -714,10 +686,6 @@ export default class Notes {
$note_li.replaceWith($noteEntityEl);
this.setupNewNote($noteEntityEl);
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
}
}
checkContentToAllowEditing($el) {
@ -844,12 +812,6 @@ export default class Notes {
const $notes = $note.closest('.discussion-notes');
const discussionId = $('.notes', $notes).data('discussionId');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) {
gl.diffNoteApps[noteElId].$destroy();
}
}
$note.remove();
// check if this is the last note for this line
@ -979,13 +941,6 @@ export default class Notes {
form.removeClass('js-main-target-form').addClass('discussion-form js-discussion-note-form');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
const $commentBtn = form.find('comment-and-resolve-btn');
$commentBtn.attr(':discussion-id', `'${discussionID}'`);
gl.diffNotesCompileComponents();
}
form.find('.js-note-text').focus();
form.find('.js-comment-resolve-button').attr('data-discussion-id', discussionID);
}

View File

@ -1,11 +1,8 @@
<script>
/* eslint-disable vue/no-v-html */
import Vue from 'vue';
import Cookies from 'js-cookie';
import { GlIcon } from '@gitlab/ui';
import Translate from '../../../../../vue_shared/translate';
// Full path is needed for Jest to be able to correctly mock this file
import illustrationSvg from '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(Translate);
@ -20,12 +17,10 @@ export default {
data() {
return {
docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl,
imageUrl: document.getElementById('pipeline-schedules-callout').dataset.imageUrl,
calloutDismissed: parseBoolean(Cookies.get(cookieKey)),
};
},
created() {
this.illustrationSvg = illustrationSvg;
},
methods: {
dismissCallout() {
this.calloutDismissed = true;
@ -40,7 +35,9 @@ export default {
<button id="dismiss-callout-btn" class="btn btn-default close" @click="dismissCallout">
<gl-icon name="close" aria-hidden="true" />
</button>
<div class="svg-container" v-html="illustrationSvg"></div>
<div class="svg-container">
<img :src="imageUrl" />
</div>
<div class="user-callout-copy">
<h4>{{ __('Scheduling Pipelines') }}</h4>
<p>

View File

@ -57,16 +57,10 @@ export default class SingleFileDiff {
this.content.hide();
this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down');
this.collapsedContent.show();
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
}
} else if (this.content) {
this.collapsedContent.hide();
this.content.show();
this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
}
} else {
this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
return this.getContentHTML(cb);
@ -90,10 +84,6 @@ export default class SingleFileDiff {
}
this.collapsedContent.after(this.content);
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
}
const $file = $(this.file);
FilesCommentButton.init($file);

View File

@ -9,6 +9,12 @@ const fileExtensionIcons = {
'md.rendered': 'markdown',
markdown: 'markdown',
'markdown.rendered': 'markdown',
mdown: 'markdown',
'mdown.rendered': 'markdown',
mkd: 'markdown',
'mkd.rendered': 'markdown',
mkdn: 'markdown',
'mkdn.rendered': 'markdown',
rst: 'markdown',
blink: 'blink',
css: 'css',

View File

@ -123,20 +123,13 @@
}
.build-header {
.ci-header-container,
.header-action-buttons {
display: flex;
}
.ci-header-container {
min-height: 54px;
}
.page-content-header {
padding: 10px 0 9px;
}
.header-action-buttons {
display: flex;
@include media-breakpoint-down(xs) {
.sidebar-toggle-btn {
margin-top: 0;

View File

@ -1013,31 +1013,35 @@ button.mini-pipeline-graph-dropdown-toggle {
/**
* Terminal
*/
.terminal-icon {
margin-left: 3px;
}
[data-page='projects:jobs:terminal'],
[data-page='projects:environments:terminal'] {
.terminal-container {
.content-block {
border-bottom: 0;
}
.terminal-container {
.content-block {
border-bottom: 0;
}
#terminal {
margin-top: 10px;
#terminal {
margin-top: 10px;
min-height: 450px;
box-sizing: border-box;
> div {
min-height: 450px;
> div {
min-height: 450px;
}
}
}
}
.ci-header-container {
min-height: 55px;
/**
* Pipelines / Jobs header
*/
[data-page='projects:pipelines:show'],
[data-page='projects:jobs:show'] {
.ci-header-container {
min-height: $gl-spacing-scale-7;
display: flex;
.text-center {
padding-top: 12px;
.text-center {
padding-top: 12px;
}
}
}

View File

@ -13,13 +13,14 @@ class InvitesController < ApplicationController
respond_to :html
def show
track_experiment('opened')
track_new_user_invite_experiment('opened')
accept if skip_invitation_prompt?
end
def accept
if member.accept_invite!(current_user)
track_experiment('accepted')
track_new_user_invite_experiment('accepted')
track_invitation_reminders_experiment('accepted')
redirect_to invite_details[:path], notice: _("You have been granted %{member_human_access} access to %{title} %{name}.") %
{ member_human_access: member.human_access, title: invite_details[:title], name: invite_details[:name] }
else
@ -105,13 +106,25 @@ class InvitesController < ApplicationController
end
end
def track_experiment(action)
def track_new_user_invite_experiment(action)
return unless params[:new_user_invite]
property = params[:new_user_invite] == 'experiment' ? 'experiment_group' : 'control_group'
track_experiment(:invite_email, action, property)
end
def track_invitation_reminders_experiment(action)
return unless Gitlab::Experimentation.enabled?(:invitation_reminders)
property = Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group'
track_experiment(:invitation_reminders, action, property)
end
def track_experiment(experiment_key, action, property)
Gitlab::Tracking.event(
Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category],
Gitlab::Experimentation.experiment(experiment_key).tracking_category,
action,
property: property,
label: Digest::MD5.hexdigest(member.to_global_id.to_s)

View File

@ -77,6 +77,15 @@ module Emails
Gitlab::Tracking.event(Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category], 'sent', property: 'control_group')
end
end
if member.invite_to_unknown_user? && Gitlab::Experimentation.enabled?(:invitation_reminders)
Gitlab::Tracking.event(
Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category,
'sent',
property: Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group',
label: Digest::MD5.hexdigest(member.to_global_id.to_s)
)
end
end
def member_invite_accepted_email(member_source_type, member_id)

View File

@ -42,6 +42,7 @@ module Ci
has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
@ -577,11 +578,11 @@ module Ci
end
def retried
@retried ||= (statuses.order(id: :desc) - statuses.latest)
@retried ||= (statuses.order(id: :desc) - latest_statuses)
end
def coverage
coverage_array = statuses.latest.map(&:coverage).compact
coverage_array = latest_statuses.map(&:coverage).compact
if coverage_array.size >= 1
'%.2f' % (coverage_array.reduce(:+) / coverage_array.size)
end

View File

@ -102,7 +102,7 @@ module Clusters
def terminals(environment, data)
pods = filter_by_project_environment(data[:pods], environment.project.full_path_slug, environment.slug)
terminals = pods.flat_map { |pod| terminals_for_pod(api_url, environment.deployment_namespace, pod) }.compact
terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
terminals.each { |terminal| add_terminal_auth(terminal, **terminal_auth) }
end
def kubeclient

View File

@ -78,7 +78,10 @@ class Member < ApplicationRecord
scope :request, -> { where.not(requested_at: nil) }
scope :non_request, -> { where(requested_at: nil) }
scope :not_accepted_invitations_by_user, -> (user) { invite.where(invite_accepted_at: nil, created_by: user) }
scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) }
scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) }
scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) }
scope :last_ten_days_excluding_today, -> (today = Date.current) { where(created_at: (today - 10).beginning_of_day..(today - 1).end_of_day) }
scope :has_access, -> { active.where('access_level > 0') }

View File

@ -15,7 +15,7 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :target_project_id
expose :squash
expose :rebase_in_progress?, as: :rebase_in_progress
expose :default_squash_commit_message, if: -> (merge_request, _) { merge_request.mergeable? }
expose :default_squash_commit_message
expose :commits_count
expose :merge_ongoing?, as: :merge_ongoing
expose :work_in_progress?, as: :work_in_progress
@ -25,10 +25,10 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :source_branch_exists?, as: :source_branch_exists
expose :branch_missing?, as: :branch_missing
expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity,
if: -> (merge_request, _) { merge_request.mergeable? } do |merge_request|
expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity do |merge_request|
merge_request.recent_commits.without_merge_commits
end
expose :diff_head_sha do |merge_request|
merge_request.diff_head_sha.presence
end

View File

@ -47,6 +47,7 @@ class PipelineSerializer < BaseSerializer
:retryable_builds,
:scheduled_actions,
:stages,
:latest_statuses,
:trigger_requests,
:user,
{
@ -62,7 +63,14 @@ class PipelineSerializer < BaseSerializer
pending_builds: :project,
project: [:route, { namespace: :route }],
triggered_by_pipeline: [{ project: [:route, { namespace: :route }] }, :user],
triggered_pipelines: [{ project: [:route, { namespace: :route }] }, :user, :source_job]
triggered_pipelines: [
{
project: [:route, { namespace: :route }]
},
:source_job,
:latest_statuses,
:user
]
}
]
end

View File

@ -31,14 +31,14 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def update_retried
# find the latest builds for each name
latest_statuses = pipeline.statuses.latest
latest_statuses = pipeline.latest_statuses
.group(:name)
.having('count(*) > 1')
.pluck(Arel.sql('MAX(id)'), 'name')
# mark builds that are retried
if latest_statuses.any?
pipeline.statuses.latest
pipeline.latest_statuses
.where(name: latest_statuses.map(&:second))
.where.not(id: latest_statuses.map(&:first))
.update_all(retried: true)

View File

@ -1,9 +0,0 @@
- discussion = local_assigns.fetch(:discussion, nil)
- if current_user
%jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" }
.btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" }
%button.btn.gl-button.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion",
":title" => "buttonText",
":aria-label" => "buttonText",
data: { container: "body" } }
= custom_icon("next_discussion")

View File

@ -1,8 +0,0 @@
- if merge_request.discussions_can_be_resolved_by?(current_user) && can?(current_user, :create_issue, @project)
.btn-group{ role: "group", "v-if" => "unresolvedDiscussionCount > 0" }
= link_to custom_icon('icon_mr_issue'),
new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid),
title: 'Resolve all discussions in new issue',
aria: { label: 'Resolve all discussions in new issue' },
data: { container: 'body' },
class: 'new-issue-for-discussion btn gl-button btn-default discussion-create-issue-btn has-tooltip'

View File

@ -1,10 +0,0 @@
- if discussion.can_resolve?(current_user) && can?(current_user, :create_issue, @project)
%new-issue-for-discussion-btn{ ":discussion-id" => "'#{discussion.id}'",
"inline-template" => true }
.btn-group{ role: "group", "v-if" => "showButton" }
= link_to custom_icon('icon_mr_issue'),
new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id),
title: 'Resolve this thread in a new issue',
aria: { label: 'Resolve this thread in a new issue' },
data: { container: 'body' },
class: 'new-issue-for-discussion btn gl-button btn-default discussion-create-issue-btn has-tooltip'

View File

@ -21,22 +21,8 @@
- if can_create_note?
%a.user-avatar-link.d-none.d-sm-block{ href: user_path(current_user) }
= image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40'
- if discussion.potentially_resolvable?
- line_type = local_assigns.fetch(:line_type, nil)
.discussion-with-resolve-btn
.btn-group.discussion-with-resolve-btn{ role: "group" }
.btn-group{ role: "group" }
= link_to_reply_discussion(discussion, line_type)
= render "discussions/resolve_all", discussion: discussion
.btn-group.discussion-actions
= render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable
= render "discussions/jump_to_next", discussion: discussion
- else
.discussion-with-resolve-btn
= link_to_reply_discussion(discussion)
.discussion-with-resolve-btn
= link_to_reply_discussion(discussion)
- elsif !current_user
.disabled-comment.text-center
Please

View File

@ -55,12 +55,13 @@
- unless use_startup_css?
= stylesheet_link_tag_defer "themes/#{user_application_theme_css_filename}" if user_application_theme_css_filename
= stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations']
= stylesheet_link_tag_defer 'performance_bar' if performance_bar_enabled?
= stylesheet_link_tag_defer "highlight/themes/#{user_color_scheme}"
= render 'layouts/startup_css_activation'
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
= Gon::Base.render_data(nonce: content_security_policy_nonce)
- if content_for?(:library_javascripts)

View File

@ -46,4 +46,4 @@
%td{ style: "font-family: 'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace; font-size: 14px; line-height: 1.4; vertical-align: baseline; padding:0 8px;" }
API
= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.statuses.latest.failed
= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.latest_statuses.failed

View File

@ -7,7 +7,7 @@ The Auto DevOps pipeline failed for pipeline <%= @pipeline.iid %> (<%= pipeline_
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
<% failed = @pipeline.statuses.latest.failed -%>
<% failed = @pipeline.latest_statuses.failed -%>
had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
<% failed.each do |build| -%>

View File

@ -108,4 +108,4 @@
%td{ style: "font-family:'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace;font-size:14px;line-height:1.4;vertical-align:baseline;padding:0 5px;" }
API
= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.statuses.latest.failed
= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.latest_statuses.failed

View File

@ -27,7 +27,7 @@ Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
<% failed = @pipeline.statuses.latest.failed -%>
<% failed = @pipeline.latest_statuses.failed -%>
had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
<% failed.each do |build| -%>

View File

@ -21,9 +21,6 @@
- else
= add_diff_note_button(line_code, diff_file.position(line), type)
%a{ href: "##{line_code}", data: { linenumber: link_text } }
- discussion = line_discussions.try(:first)
- if discussion && discussion.resolvable? && !plain
%diff-note-avatars{ "discussion-id" => discussion.id }
%td.new_line.diff-line-num{ class: type, data: { linenumber: line.new_pos } }
- link_text = type == "old" ? " " : line.new_pos
- if plain

View File

@ -20,9 +20,6 @@
%td.old_line.diff-line-num.js-avatar-container{ class: left.type, data: { linenumber: left.old_pos } }
= add_diff_note_button(left_line_code, left_position, 'old')
%a{ href: "##{left_line_code}", data: { linenumber: left.old_pos } }
- discussion_left = discussions_left.try(:first)
- if discussion_left && discussion_left.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_left.id }
%td.line_content.parallel.left-side{ id: left_line_code, class: left.type }= diff_line_content(left.rich_text)
- else
%td.old_line.diff-line-num.empty-cell
@ -41,9 +38,6 @@
%td.new_line.diff-line-num.js-avatar-container{ class: right.type, data: { linenumber: right.new_pos } }
= add_diff_note_button(right_line_code, right_position, 'new')
%a{ href: "##{right_line_code}", data: { linenumber: right.new_pos } }
- discussion_right = discussions_right.try(:first)
- if discussion_right && discussion_right.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_right.id }
%td.line_content.parallel.right-side{ id: right_line_code, class: right.type }= diff_line_content(right.rich_text)
- else
%td.old_line.diff-line-num.empty-cell

View File

@ -1,11 +0,0 @@
- content_for :note_actions do
- if can?(current_user, :update_merge_request, @merge_request)
- if @merge_request.open?
= link_to 'Close merge request', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: { original_text: "Close merge request", alternative_text: "Comment & close merge request"}
- if @merge_request.reopenable?
= link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-close js-note-target-reopen", title: "Reopen merge request", data: { original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
%comment-and-resolve-btn{ "inline-template" => true }
%button.btn.btn-nr.btn-default.gl-mr-3.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } }
{{ buttonText }}
#notes= render "shared/notes/notes_with_form", :autocomplete => true

View File

@ -2,7 +2,7 @@
- page_title _("Pipeline Schedules")
#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules') } }
#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), image_url: image_path('pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg') } }
.top-area
- schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) }
= render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope

View File

@ -1 +0,0 @@
<svg viewBox="0 0 20 19" ><path d="M15.21 7.783h-3.317c-.268 0-.472.218-.472.486v.953c0 .28.212.486.473.486h3.318v1.575c0 .36.233.452.52.23l3.06-2.37c.274-.213.286-.582 0-.804l-3.06-2.37c-.275-.213-.52-.12-.52.23v1.583zm.57-3.66c-1.558-1.22-3.783-1.98-6.254-1.98C4.816 2.143 1 4.91 1 8.333c0 1.964 1.256 3.715 3.216 4.846-.447 1.615-1.132 2.195-1.732 2.882-.142.174-.304.32-.256.56v.01c.047.213.218.368.41.368h.046c.37-.048.743-.116 1.085-.213 1.645-.425 3.13-1.22 4.377-2.34.447.048.913.077 1.38.077 2.092 0 4.01-.546 5.492-1.454-.416-.208-.798-.475-1.134-.792-1.227.63-2.743 1.008-4.36 1.008-.41 0-.828-.03-1.237-.078l-.543-.058-.41.368c-.78.696-1.655 1.248-2.616 1.654.248-.445.486-.977.667-1.664l.257-.928-.828-.484c-1.646-.948-2.598-2.32-2.598-3.763 0-2.69 3.35-4.952 7.308-4.952 1.893 0 3.647.518 4.962 1.353.393-.266.827-.473 1.29-.61z" /></svg>

Before

Width:  |  Height:  |  Size: 853 B

View File

@ -0,0 +1,5 @@
---
title: Add confirmation modal on instance-level integration form
merge_request: 42840
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Add markdown icon to more file extensions
merge_request: 43479
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Add Debian API skeleton
merge_request: 42670
author: Mathieu Parent
type: added

View File

@ -0,0 +1,5 @@
---
title: Fix suggested squashed messages for MR
merge_request: 43508
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Improve n+1 in pipeline serializer for triggered pipelines
merge_request: 42421
author:
type: performance

View File

@ -1,5 +0,0 @@
---
title: Color/position tweaks for collapsed diff files
merge_request: 42465
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: 'Revert 42465 and 42343: Expanded collapsed diff files'
merge_request: 43361
author:
type: other

View File

@ -0,0 +1,7 @@
---
name: debian_packages
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42670
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/5835
group: group::package
type: development
default_enabled: false

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddDebianMaxFileSizeToPlanLimits < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :plan_limits, :debian_max_file_size, :bigint, default: 3.gigabytes, null: false
end
end

View File

@ -0,0 +1 @@
18a3981a3becefe6700dd5fea87e8ba9478c0e83ddc80de1b3ee2ed77c221ce6

View File

@ -14365,7 +14365,8 @@ CREATE TABLE plan_limits (
nuget_max_file_size bigint DEFAULT 524288000 NOT NULL,
pypi_max_file_size bigint DEFAULT '3221225472'::bigint NOT NULL,
generic_packages_max_file_size bigint DEFAULT '5368709120'::bigint NOT NULL,
golang_max_file_size bigint DEFAULT 104857600 NOT NULL
golang_max_file_size bigint DEFAULT 104857600 NOT NULL,
debian_max_file_size bigint DEFAULT '3221225472'::bigint NOT NULL
);
CREATE SEQUENCE plan_limits_id_seq

View File

@ -552,6 +552,9 @@ Plan.default.actual_limits.update!(maven_max_file_size: 100.megabytes)
# For PyPI Packages
Plan.default.actual_limits.update!(pypi_max_file_size: 100.megabytes)
# For Debian Packages
Plan.default.actual_limits.update!(debian_max_file_size: 100.megabytes)
```
Set the limit to `0` to allow any file size.

View File

@ -13714,7 +13714,7 @@ type Project {
requestAccessEnabled: Boolean
"""
Find a single requirement. Available only when feature flag `requirements_management` is enabled.
Find a single requirement
"""
requirement(
"""
@ -13754,7 +13754,7 @@ type Project {
requirementStatesCount: RequirementStatesCount
"""
Find requirements. Available only when feature flag `requirements_management` is enabled.
Find requirements
"""
requirements(
"""

View File

@ -39901,7 +39901,7 @@
},
{
"name": "requirement",
"description": "Find a single requirement. Available only when feature flag `requirements_management` is enabled.",
"description": "Find a single requirement",
"args": [
{
"name": "iid",
@ -40004,7 +40004,7 @@
},
{
"name": "requirements",
"description": "Find requirements. Available only when feature flag `requirements_management` is enabled.",
"description": "Find requirements",
"args": [
{
"name": "iid",

View File

@ -1899,7 +1899,7 @@ Autogenerated return type of PipelineRetry.
| `removeSourceBranchAfterMerge` | Boolean | Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project |
| `repository` | Repository | Git repository of the project |
| `requestAccessEnabled` | Boolean | Indicates if users can request member access to the project |
| `requirement` | Requirement | Find a single requirement. Available only when feature flag `requirements_management` is enabled. |
| `requirement` | Requirement | Find a single requirement |
| `requirementStatesCount` | RequirementStatesCount | Number of requirements for the project by their state |
| `sastCiConfiguration` | SastCiConfiguration | SAST CI configuration for the project |
| `securityDashboardPath` | String | Path to project's security dashboard |

View File

@ -75,7 +75,7 @@ For information about setting a maximum artifact size for a project, see
> - [Support for external `.gitlab-ci.yml` locations](https://gitlab.com/gitlab-org/gitlab/-/issues/14376) introduced in GitLab 12.6.
By default we look for the `.gitlab-ci.yml` file in the project's root
directory. If needed, you can specify an alternate path and file name, including locations outside the project.
directory. If needed, you can specify an alternate path and filename, including locations outside the project.
To customize the path:
@ -297,7 +297,7 @@ into your `README.md`:
### Badge styles
Pipeline badges can be rendered in different styles by adding the `style=style_name` parameter to the URL. Currently two styles are available:
Pipeline badges can be rendered in different styles by adding the `style=style_name` parameter to the URL. Two styles are available:
#### Flat (default)

View File

@ -196,6 +196,8 @@ module API
mount ::API::ComposerPackages
mount ::API::ConanProjectPackages
mount ::API::ConanInstancePackages
mount ::API::DebianGroupPackages
mount ::API::DebianProjectPackages
mount ::API::MavenPackages
mount ::API::NpmPackages
mount ::API::GenericPackages

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module API
class DebianGroupPackages < Grape::API::Instance
params do
requires :id, type: String, desc: 'The ID of a group'
end
resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
before do
not_found! unless ::Feature.enabled?(:debian_packages, user_group)
authorize_read_package!(user_group)
end
namespace ':id/-/packages/debian' do
include DebianPackageEndpoints
end
end
end
end

View File

@ -0,0 +1,124 @@
# frozen_string_literal: true
module API
module DebianPackageEndpoints
extend ActiveSupport::Concern
DISTRIBUTION_REGEX = %r{[a-zA-Z0-9][a-zA-Z0-9.-]*}.freeze
COMPONENT_REGEX = %r{[a-z-]+}.freeze
ARCHITECTURE_REGEX = %r{[a-z][a-z0-9]*}.freeze
LETTER_REGEX = %r{(lib)?[a-z0-9]}.freeze
PACKAGE_REGEX = API::NO_SLASH_URL_PART_REGEX
DISTRIBUTION_REQUIREMENTS = {
distribution: DISTRIBUTION_REGEX
}.freeze
COMPONENT_ARCHITECTURE_REQUIREMENTS = {
component: COMPONENT_REGEX,
architecture: ARCHITECTURE_REGEX
}.freeze
COMPONENT_LETTER_SOURCE_PACKAGE_REQUIREMENTS = {
component: COMPONENT_REGEX,
letter: LETTER_REGEX,
source_package: PACKAGE_REGEX
}.freeze
FILE_NAME_REQUIREMENTS = {
file_name: API::NO_SLASH_URL_PART_REGEX
}.freeze
included do
helpers ::API::Helpers::PackagesHelpers
helpers ::API::Helpers::Packages::BasicAuthHelpers
format :txt
rescue_from ArgumentError do |e|
render_api_error!(e.message, 400)
end
rescue_from ActiveRecord::RecordInvalid do |e|
render_api_error!(e.message, 400)
end
before do
require_packages_enabled!
end
params do
requires :distribution, type: String, desc: 'The Debian Codename', file_path: true
end
namespace 'dists/*distribution', requirements: DISTRIBUTION_REQUIREMENTS do
# GET {projects|groups}/:id/-/packages/debian/dists/*distribution/Release.gpg
desc 'The Release file signature' do
detail 'This feature was introduced in GitLab 13.5'
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'Release.gpg' do
not_found!
end
# GET {projects|groups}/:id/-/packages/debian/dists/*distribution/Release
desc 'The unsigned Release file' do
detail 'This feature was introduced in GitLab 13.5'
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'Release' do
# https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286
'TODO Release'
end
# GET {projects|groups}/:id/-/packages/debian/dists/*distribution/InRelease
desc 'The signed Release file' do
detail 'This feature was introduced in GitLab 13.5'
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'InRelease' do
not_found!
end
params do
requires :component, type: String, desc: 'The Debian Component'
requires :architecture, type: String, desc: 'The Debian Architecture'
end
namespace ':component/binary-:architecture', requirements: COMPONENT_ARCHITECTURE_REQUIREMENTS do
# GET {projects|groups}/:id/-/packages/debian/dists/*distribution/:component/binary-:architecture/Packages
desc 'The binary files index' do
detail 'This feature was introduced in GitLab 13.5'
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'Packages' do
# https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286
'TODO Packages'
end
end
end
params do
requires :component, type: String, desc: 'The Debian Component'
requires :letter, type: String, desc: 'The Debian Classification (first-letter or lib-first-letter)'
requires :source_package, type: String, desc: 'The Debian Source Package Name'
end
namespace 'pool/:component/:letter/:source_package', requirements: COMPONENT_LETTER_SOURCE_PACKAGE_REQUIREMENTS do
# GET {projects|groups}/:id/-/packages/debian/pool/:component/:letter/:source_package/:file_name
params do
requires :file_name, type: String, desc: 'The Debian File Name'
end
desc 'The package' do
detail 'This feature was introduced in GitLab 13.5'
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get ':file_name', requirements: FILE_NAME_REQUIREMENTS do
# https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286
'TODO File'
end
end
end
end
end

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
module API
class DebianProjectPackages < Grape::API::Instance
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
before do
not_found! unless ::Feature.enabled?(:debian_packages, user_project)
authorize_read_package!
end
namespace ':id/-/packages/debian' do
include DebianPackageEndpoints
params do
requires :file_name, type: String, desc: 'The file name'
end
namespace 'incoming/:file_name', requirements: FILE_NAME_REQUIREMENTS do
# PUT {projects|groups}/:id/-/packages/debian/incoming/:file_name
params do
requires :file, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)'
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
put do
authorize_upload!(authorized_user_project)
bad_request!('File is too large') if authorized_user_project.actual_limits.exceeded?(:debian_max_file_size, params[:file].size)
package_event('push_package')
created!
rescue ObjectStorage::RemoteStoreError => e
Gitlab::ErrorTracking.track_exception(e, extra: { file_name: params[:file_name], project_id: authorized_user_project.id })
forbidden!
end
# PUT {projects|groups}/:id/-/packages/debian/incoming/:file_name/authorize
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
post 'authorize' do
authorize_workhorse!(
subject: authorized_user_project,
has_length: false,
maximum_size: authorized_user_project.actual_limits.debian_max_file_size
)
end
end
end
end
end
end

View File

@ -2675,12 +2675,6 @@ msgstr ""
msgid "An error occurred when toggling the notification subscription"
msgstr ""
msgid "An error occurred when trying to resolve a comment. Please try again."
msgstr ""
msgid "An error occurred when trying to resolve a discussion. Please try again."
msgstr ""
msgid "An error occurred when updating the issue weight"
msgstr ""
@ -10458,9 +10452,6 @@ msgstr ""
msgid "Expand approvers"
msgstr ""
msgid "Expand file"
msgstr ""
msgid "Expand milestones"
msgstr ""
@ -13770,6 +13761,15 @@ msgstr ""
msgid "Integrations|Includes commit title and branch"
msgstr ""
msgid "Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults."
msgstr ""
msgid "Integrations|Save settings?"
msgstr ""
msgid "Integrations|Saving will update the default settings for all projects that are not using custom settings."
msgstr ""
msgid "Integrations|Standard"
msgstr ""
@ -14451,9 +14451,6 @@ msgstr ""
msgid "July"
msgstr ""
msgid "Jump to first unresolved thread"
msgstr ""
msgid "Jump to next unresolved thread"
msgstr ""
@ -21760,9 +21757,6 @@ msgstr ""
msgid "Resolved by %{name}"
msgstr ""
msgid "Resolved by %{resolvedByName}"
msgstr ""
msgid "Resolves IP addresses once and uses them to submit requests"
msgstr ""
@ -26026,9 +26020,6 @@ msgstr ""
msgid "This field is required."
msgstr ""
msgid "This file is collapsed."
msgstr ""
msgid "This group"
msgstr ""
@ -27248,9 +27239,6 @@ msgstr ""
msgid "Unable to load the merge request widget. Try reloading the page."
msgstr ""
msgid "Unable to resolve"
msgstr ""
msgid "Unable to save iteration. Please try again"
msgstr ""

View File

@ -164,7 +164,7 @@ module QA
def has_pipeline_status?(text)
# Pipelines can be slow, so we wait a bit longer than the usual 10 seconds
has_element?(:merge_request_pipeline_info_content, text: text, wait: 30)
has_element?(:merge_request_pipeline_info_content, text: text, wait: 60)
end
def has_title?(title)
@ -198,7 +198,7 @@ module QA
end
def merged?
has_element?(:merged_status_content, text: 'The changes were merged into', wait: 30)
has_element?(:merged_status_content, text: 'The changes were merged into', wait: 60)
end
# Check if the MR is able to be merged

View File

@ -16,7 +16,7 @@ module QA
project.initialize_with_readme = true
end
it 'sets labels' do
it 'sets labels', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1032' do
Resource::Repository::ProjectPush.fabricate! do |push|
push.project = project
push.commit_message = commit_message
@ -35,7 +35,7 @@ module QA
end
context 'when labels are set already' do
it 'removes them' do
it 'removes them', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1033' do
Resource::Repository::ProjectPush.fabricate! do |push|
push.project = project
push.file_content = "Unlabel test #{SecureRandom.hex(8)}"

View File

@ -29,7 +29,7 @@ module QA
runner.remove_via_api!
end
it 'sets merge when pipeline succeeds' do
it 'sets merge when pipeline succeeds', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1037' do
Resource::Repository::Commit.fabricate_via_api! do |commit|
commit.project = project
commit.commit_message = 'Add .gitlab-ci.yml'
@ -72,7 +72,7 @@ module QA
expect(merge_request.merge_when_pipeline_succeeds).to be true
end
it 'merges when pipeline succeeds' do
it 'merges when pipeline succeeds', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1036' do
Resource::Repository::Commit.fabricate_via_api! do |commit|
commit.project = project
commit.commit_message = 'Add .gitlab-ci.yml'

View File

@ -17,7 +17,7 @@ module QA
end
end
it 'removes the source branch' do
it 'removes the source branch', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1035' do
Resource::Repository::ProjectPush.fabricate! do |push|
push.project = project
push.branch_name = branch

View File

@ -16,7 +16,7 @@ module QA
end
end
it 'sets a target branch' do
it 'sets a target branch', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1034' do
target_branch = "push-options-test-target-#{SecureRandom.hex(8)}"
Resource::Repository::ProjectPush.fabricate! do |push|

View File

@ -14,7 +14,7 @@ module QA
end
end
it 'sets title and description' do
it 'sets title and description', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1038' do
description = "This is a test of MR push options"
title = "MR push options test #{SecureRandom.hex(8)}"

View File

@ -29,6 +29,43 @@ RSpec.describe InvitesController, :snowplow do
end
end
shared_examples "tracks the 'accepted' event for the invitation reminders experiment" do
before do
stub_experiment(invitation_reminders: true)
allow(Gitlab::Experimentation).to receive(:enabled_for_attribute?).with(:invitation_reminders, member.invite_email).and_return(experimental_group)
end
context 'when in the control group' do
let(:experimental_group) { false }
it "tracks the 'accepted' event" do
request
expect_snowplow_event(
category: 'Growth::Acquisition::Experiment::InvitationReminders',
label: md5_member_global_id,
property: 'control_group',
action: 'accepted'
)
end
end
context 'when in the experimental group' do
let(:experimental_group) { true }
it "tracks the 'accepted' event" do
request
expect_snowplow_event(
category: 'Growth::Acquisition::Experiment::InvitationReminders',
label: md5_member_global_id,
property: 'experimental_group',
action: 'accepted'
)
end
end
end
describe 'GET #show' do
subject(:request) { get :show, params: params }
@ -89,6 +126,7 @@ RSpec.describe InvitesController, :snowplow do
end
end
it_behaves_like "tracks the 'accepted' event for the invitation reminders experiment"
it_behaves_like 'invalid token'
end
@ -150,6 +188,7 @@ RSpec.describe InvitesController, :snowplow do
end
end
it_behaves_like "tracks the 'accepted' event for the invitation reminders experiment"
it_behaves_like 'invalid token'
end

View File

@ -15,11 +15,11 @@ RSpec.describe 'User expands diff', :js do
it 'allows user to expand diff' do
page.within find('[id="19763941ab80e8c09871c0a425f0560d9053bcb3"]') do
find('[data-testid="expandButton"]').click
click_link 'Click to expand it.'
wait_for_requests
expect(page).not_to have_content('Expand File')
expect(page).not_to have_content('Click to expand it.')
expect(page).to have_selector('.code')
end
end

View File

@ -0,0 +1 @@
empty

View File

@ -1,136 +0,0 @@
/* global CommentsStore */
import '~/diff_notes/models/discussion';
import '~/diff_notes/models/note';
import '~/diff_notes/stores/comments';
function createDiscussion(noteId = 1, resolved = true) {
CommentsStore.create({
discussionId: 'a',
noteId,
canResolve: true,
resolved,
resolvedBy: 'test',
authorName: 'test',
authorAvatar: 'test',
noteTruncated: 'test...',
});
}
beforeEach(() => {
CommentsStore.state = {};
});
describe('New discussion', () => {
it('creates new discussion', () => {
expect(Object.keys(CommentsStore.state).length).toBe(0);
createDiscussion();
expect(Object.keys(CommentsStore.state).length).toBe(1);
});
it('creates new note in discussion', () => {
createDiscussion();
createDiscussion(2);
const discussion = CommentsStore.state.a;
expect(Object.keys(discussion.notes).length).toBe(2);
});
});
describe('Get note', () => {
beforeEach(() => {
createDiscussion();
});
it('gets note by ID', () => {
const note = CommentsStore.get('a', 1);
expect(note).toBeDefined();
expect(note.id).toBe(1);
});
});
describe('Delete discussion', () => {
beforeEach(() => {
createDiscussion();
});
it('deletes discussion by ID', () => {
CommentsStore.delete('a', 1);
expect(Object.keys(CommentsStore.state).length).toBe(0);
});
it('deletes discussion when no more notes', () => {
createDiscussion();
createDiscussion(2);
expect(Object.keys(CommentsStore.state).length).toBe(1);
expect(Object.keys(CommentsStore.state.a.notes).length).toBe(2);
CommentsStore.delete('a', 1);
CommentsStore.delete('a', 2);
expect(Object.keys(CommentsStore.state).length).toBe(0);
});
});
describe('Update note', () => {
beforeEach(() => {
createDiscussion();
});
it('updates note to be unresolved', () => {
CommentsStore.update('a', 1, false, 'test');
const note = CommentsStore.get('a', 1);
expect(note.resolved).toBe(false);
});
});
describe('Discussion resolved', () => {
beforeEach(() => {
createDiscussion();
});
it('is resolved with single note', () => {
const discussion = CommentsStore.state.a;
expect(discussion.isResolved()).toBe(true);
});
it('is unresolved with 2 notes', () => {
const discussion = CommentsStore.state.a;
createDiscussion(2, false);
expect(discussion.isResolved()).toBe(false);
});
it('is resolved with 2 notes', () => {
const discussion = CommentsStore.state.a;
createDiscussion(2);
expect(discussion.isResolved()).toBe(true);
});
it('resolve all notes', () => {
const discussion = CommentsStore.state.a;
createDiscussion(2, false);
discussion.resolveAllNotes();
expect(discussion.isResolved()).toBe(true);
});
it('unresolve all notes', () => {
const discussion = CommentsStore.state.a;
createDiscussion(2);
discussion.unResolveAllNotes();
expect(discussion.isResolved()).toBe(false);
});
});

View File

@ -90,8 +90,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
expect(vm.$el.innerText).toContain('This file is collapsed.');
expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
expect(vm.$el.innerText).toContain('This diff is collapsed');
expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
@ -102,8 +102,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
expect(vm.$el.innerText).toContain('This file is collapsed.');
expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
expect(vm.$el.innerText).toContain('This diff is collapsed');
expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
@ -121,8 +121,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
expect(vm.$el.innerText).toContain('This file is collapsed.');
expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
expect(vm.$el.innerText).toContain('This diff is collapsed');
expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
@ -135,7 +135,7 @@ describe('DiffFile', () => {
vm.file.viewer.name = diffViewerModes.renamed;
vm.$nextTick(() => {
expect(vm.$el.innerText).not.toContain('This file is collapsed.');
expect(vm.$el.innerText).not.toContain('This diff is collapsed');
done();
});
@ -148,7 +148,7 @@ describe('DiffFile', () => {
vm.file.viewer.name = diffViewerModes.mode_changed;
vm.$nextTick(() => {
expect(vm.$el.innerText).not.toContain('This file is collapsed.');
expect(vm.$el.innerText).not.toContain('This diff is collapsed');
done();
});

View File

@ -0,0 +1,51 @@
import { shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import { createStore } from '~/integrations/edit/store';
import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
describe('ConfirmationModal', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(ConfirmationModal, {
store: createStore(),
});
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const findGlModal = () => wrapper.find(GlModal);
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders GlModal with correct copy', () => {
expect(findGlModal().exists()).toBe(true);
expect(findGlModal().attributes('title')).toBe('Save settings?');
expect(findGlModal().text()).toContain(
'Saving will update the default settings for all projects that are not using custom settings.',
);
expect(findGlModal().text()).toContain(
'Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.',
);
});
it('emits `submit` event when `primary` event is emitted on GlModal', async () => {
expect(wrapper.emitted().submit).toBeUndefined();
findGlModal().vm.$emit('primary');
await wrapper.vm.$nextTick();
expect(wrapper.emitted().submit).toHaveLength(1);
});
});
});

View File

@ -4,6 +4,7 @@ import { createStore } from '~/integrations/edit/store';
import IntegrationForm from '~/integrations/edit/components/integration_form.vue';
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
@ -22,6 +23,7 @@ describe('IntegrationForm', () => {
stubs: {
OverrideDropdown,
ActiveCheckbox,
ConfirmationModal,
JiraTriggerFields,
TriggerFields,
},
@ -40,6 +42,7 @@ describe('IntegrationForm', () => {
const findOverrideDropdown = () => wrapper.find(OverrideDropdown);
const findActiveCheckbox = () => wrapper.find(ActiveCheckbox);
const findConfirmationModal = () => wrapper.find(ConfirmationModal);
const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields);
const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields);
const findTriggerFields = () => wrapper.find(TriggerFields);
@ -63,6 +66,26 @@ describe('IntegrationForm', () => {
});
});
describe('integrationLevel is instance', () => {
it('renders ConfirmationModal', () => {
createComponent({
integrationLevel: 'instance',
});
expect(findConfirmationModal().exists()).toBe(true);
});
});
describe('integrationLevel is not instance', () => {
it('does not render ConfirmationModal', () => {
createComponent({
integrationLevel: 'project',
});
expect(findConfirmationModal().exists()).toBe(false);
});
});
describe('type is "slack"', () => {
beforeEach(() => {
createComponent({ type: 'slack' });

View File

@ -2,6 +2,7 @@ export const mockIntegrationProps = {
id: 25,
initialActivated: true,
showActive: true,
editable: true,
triggerFieldsProps: {
initialTriggerCommit: false,
initialTriggerMergeRequest: false,

View File

@ -1,23 +1,18 @@
import Vue from 'vue';
import Cookies from 'js-cookie';
import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue';
import '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
jest.mock(
'~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg',
() => '<svg></svg>',
);
const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout);
const cookieKey = 'pipeline_schedules_callout_dismissed';
const docsUrl = 'help/ci/scheduled_pipelines';
const imageUrl = 'pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
describe('Pipeline Schedule Callout', () => {
let calloutComponent;
beforeEach(() => {
setFixtures(`
<div id='pipeline-schedules-callout' data-docs-url=${docsUrl}></div>
<div id='pipeline-schedules-callout' data-docs-url=${docsUrl} data-image-url=${imageUrl}></div>
`);
});
@ -30,13 +25,13 @@ describe('Pipeline Schedule Callout', () => {
expect(calloutComponent).toBeDefined();
});
it('correctly sets illustrationSvg', () => {
expect(calloutComponent.illustrationSvg).toContain('<svg');
});
it('correctly sets docsUrl', () => {
expect(calloutComponent.docsUrl).toContain(docsUrl);
});
it('correctly sets imageUrl', () => {
expect(calloutComponent.imageUrl).toContain(imageUrl);
});
});
describe(`when ${cookieKey} cookie is set`, () => {
@ -68,8 +63,8 @@ describe('Pipeline Schedule Callout', () => {
expect(calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull();
});
it('renders the callout svg', () => {
expect(calloutComponent.$el.outerHTML).toContain('<svg');
it('renders the callout img', () => {
expect(calloutComponent.$el.outerHTML).toContain('<img');
});
it('renders the callout title', () => {

View File

@ -242,6 +242,7 @@ ci_pipelines:
- latest_builds_report_results
- messages
- pipeline_artifacts
- latest_statuses
ci_refs:
- project
- ci_pipelines

View File

@ -1508,12 +1508,44 @@ RSpec.describe Notify do
)
end
describe 'group invitation' do
describe 'invitations' do
let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } }
let(:group_member) { invite_to_group(group, inviter: inviter) }
let(:inviter) { owner }
subject { described_class.member_invited_email('group', group_member.id, group_member.invite_token) }
subject { described_class.member_invited_email('Group', group_member.id, group_member.invite_token) }
shared_examples "tracks the 'sent' event for the invitation reminders experiment" do
before do
stub_experiment(invitation_reminders: true)
allow(Gitlab::Experimentation).to receive(:enabled_for_attribute?).with(:invitation_reminders, group_member.invite_email).and_return(experimental_group)
end
it "tracks the 'sent' event", :snowplow do
subject.deliver_now
expect_snowplow_event(
category: 'Growth::Acquisition::Experiment::InvitationReminders',
label: Digest::MD5.hexdigest(group_member.to_global_id.to_s),
property: experimental_group ? 'experimental_group' : 'control_group',
action: 'sent'
)
end
end
describe 'tracking for the invitation reminders experiment' do
context 'when invite email is in the experimental group' do
let(:experimental_group) { true }
it_behaves_like "tracks the 'sent' event for the invitation reminders experiment"
end
context 'when invite email is in the control group' do
let(:experimental_group) { false }
it_behaves_like "tracks the 'sent' event for the invitation reminders experiment"
end
end
context 'when invite_email_experiment is disabled' do
before do

View File

@ -2436,7 +2436,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#retry_failed' do
let(:latest_status) { pipeline.statuses.latest.pluck(:status) }
let(:latest_status) { pipeline.latest_statuses.pluck(:status) }
before do
stub_not_protect_default_branch

View File

@ -205,6 +205,16 @@ RSpec.describe Member do
it { expect(described_class.non_request).to include @accepted_request_member }
end
describe '.not_accepted_invitations' do
let_it_be(:not_accepted_invitation) { create(:project_member, :invited) }
let_it_be(:accepted_invitation) { create(:project_member, :invited, invite_accepted_at: Date.today) }
subject { described_class.not_accepted_invitations }
it { is_expected.to include(not_accepted_invitation) }
it { is_expected.not_to include(accepted_invitation) }
end
describe '.not_accepted_invitations_by_user' do
let(:invited_by_user) { create(:project_member, :invited, project: project, created_by: @owner_user) }
@ -218,6 +228,29 @@ RSpec.describe Member do
it { is_expected.to contain_exactly(invited_by_user) }
end
describe '.not_expired' do
let_it_be(:expiring_yesterday) { create(:group_member, expires_at: 1.day.ago) }
let_it_be(:expiring_today) { create(:group_member, expires_at: Date.today) }
let_it_be(:expiring_tomorrow) { create(:group_member, expires_at: 1.day.from_now) }
let_it_be(:not_expiring) { create(:group_member) }
subject { described_class.not_expired }
it { is_expected.not_to include(expiring_yesterday, expiring_today) }
it { is_expected.to include(expiring_tomorrow, not_expiring) }
end
describe '.last_ten_days_excluding_today' do
let_it_be(:created_today) { create(:group_member, created_at: Date.today.beginning_of_day) }
let_it_be(:created_yesterday) { create(:group_member, created_at: 1.day.ago) }
let_it_be(:created_eleven_days_ago) { create(:group_member, created_at: 11.days.ago) }
subject { described_class.last_ten_days_excluding_today }
it { is_expected.to include(created_yesterday) }
it { is_expected.not_to include(created_today, created_eleven_days_ago) }
end
describe '.search_invite_email' do
it 'returns only members the matching e-mail' do
create(:group_member, :invited)

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::DebianGroupPackages do
include HttpBasicAuthHelpers
include WorkhorseHelpers
include_context 'Debian repository shared context', :group do
describe 'GET groups/:id/-/packages/debian/dists/*distribution/Release.gpg' do
let(:url) { "/groups/#{group.id}/-/packages/debian/dists/#{distribution}/Release.gpg" }
it_behaves_like 'Debian group repository GET endpoint', :not_found, nil
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/Release' do
let(:url) { "/groups/#{group.id}/-/packages/debian/dists/#{distribution}/Release" }
it_behaves_like 'Debian group repository GET endpoint', :success, 'TODO Release'
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/InRelease' do
let(:url) { "/groups/#{group.id}/-/packages/debian/dists/#{distribution}/InRelease" }
it_behaves_like 'Debian group repository GET endpoint', :not_found, nil
end
describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/binary-:architecture/Packages' do
let(:url) { "/groups/#{group.id}/-/packages/debian/dists/#{distribution}/#{component}/binary-#{architecture}/Packages" }
it_behaves_like 'Debian group repository GET endpoint', :success, 'TODO Packages'
end
describe 'GET groups/:id/-/packages/debian/pool/:component/:letter/:source_package/:file_name' do
let(:url) { "/groups/#{group.id}/-/packages/debian/pool/#{component}/#{letter}/#{source_package}/#{package_name}_#{package_version}_#{architecture}.deb" }
it_behaves_like 'Debian group repository GET endpoint', :success, 'TODO File'
end
end
end

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::DebianProjectPackages do
include HttpBasicAuthHelpers
include WorkhorseHelpers
include_context 'Debian repository shared context', :project do
describe 'GET projects/:id/-/packages/debian/dists/*distribution/Release.gpg' do
let(:url) { "/projects/#{project.id}/-/packages/debian/dists/#{distribution}/Release.gpg" }
it_behaves_like 'Debian project repository GET endpoint', :not_found, nil
end
describe 'GET projects/:id/-/packages/debian/dists/*distribution/Release' do
let(:url) { "/projects/#{project.id}/-/packages/debian/dists/#{distribution}/Release" }
it_behaves_like 'Debian project repository GET endpoint', :success, 'TODO Release'
end
describe 'GET projects/:id/-/packages/debian/dists/*distribution/InRelease' do
let(:url) { "/projects/#{project.id}/-/packages/debian/dists/#{distribution}/InRelease" }
it_behaves_like 'Debian project repository GET endpoint', :not_found, nil
end
describe 'GET projects/:id/-/packages/debian/dists/*distribution/:component/binary-:architecture/Packages' do
let(:url) { "/projects/#{project.id}/-/packages/debian/dists/#{distribution}/#{component}/binary-#{architecture}/Packages" }
it_behaves_like 'Debian project repository GET endpoint', :success, 'TODO Packages'
end
describe 'GET projects/:id/-/packages/debian/pool/:component/:letter/:source_package/:file_name' do
let(:url) { "/projects/#{project.id}/-/packages/debian/pool/#{component}/#{letter}/#{source_package}/#{package_name}_#{package_version}_#{architecture}.deb" }
it_behaves_like 'Debian project repository GET endpoint', :success, 'TODO File'
end
describe 'PUT projects/:id/-/packages/debian/incoming/:file_name' do
let(:method) { :put }
let(:url) { "/projects/#{project.id}/-/packages/debian/incoming/#{file_name}" }
it_behaves_like 'Debian project repository PUT endpoint', :created, nil
end
end
end

View File

@ -3,12 +3,11 @@
require 'spec_helper'
RSpec.describe MergeRequestPollCachedWidgetEntity do
include ProjectForksHelper
using RSpec::Parameterized::TableSyntax
let(:project) { create :project, :repository }
let(:resource) { create(:merge_request, source_project: project, target_project: project) }
let(:user) { create(:user) }
let_it_be(:project, refind: true) { create :project, :repository }
let_it_be(:resource, refind: true) { create(:merge_request, source_project: project, target_project: project) }
let_it_be(:user) { create(:user) }
let(:request) { double('request', current_user: user, project: project) }
@ -174,8 +173,6 @@ RSpec.describe MergeRequestPollCachedWidgetEntity do
end
context 'when auto merge is not enabled' do
let(:resource) { create(:merge_request) }
it 'returns auto merge related information' do
expect(subject[:auto_merge_enabled]).to be_falsy
end
@ -215,16 +212,5 @@ RSpec.describe MergeRequestPollCachedWidgetEntity do
expect(subject[:commits_without_merge_commits].size).to eq(12)
end
end
context 'when merge request is not mergeable' do
before do
allow(resource).to receive(:mergeable?).and_return(false)
end
it 'does not have default_squash_commit_message and commits_without_merge_commits' do
expect(subject[:default_squash_commit_message]).to eq(nil)
expect(subject[:commits_without_merge_commits]).to eq(nil)
end
end
end
end

View File

@ -155,7 +155,7 @@ RSpec.describe PipelineSerializer do
it 'verifies number of queries', :request_store do
recorded = ActiveRecord::QueryRecorder.new { subject }
expected_queries = Gitlab.ee? ? 43 : 40
expected_queries = Gitlab.ee? ? 39 : 36
expect(recorded.count).to be_within(2).of(expected_queries)
expect(recorded.cached_count).to eq(0)
@ -176,7 +176,7 @@ RSpec.describe PipelineSerializer do
# pipeline. With the same ref this check is cached but if refs are
# different then there is an extra query per ref
# https://gitlab.com/gitlab-org/gitlab-foss/issues/46368
expected_queries = Gitlab.ee? ? 49 : 46
expected_queries = Gitlab.ee? ? 42 : 39
expect(recorded.count).to be_within(2).of(expected_queries)
expect(recorded.cached_count).to eq(0)
@ -199,11 +199,10 @@ RSpec.describe PipelineSerializer do
it 'verifies number of queries', :request_store do
recorded = ActiveRecord::QueryRecorder.new { subject }
# 99 queries by default + 2 related to preloading
# :source_pipeline and :source_job
# Existing numbers are high and require performance optimization
# Ongoing issue:
# https://gitlab.com/gitlab-org/gitlab/-/issues/225156
expected_queries = Gitlab.ee? ? 95 : 86
expected_queries = Gitlab.ee? ? 85 : 76
expect(recorded.count).to be_within(2).of(expected_queries)
expect(recorded.cached_count).to eq(0)

View File

@ -43,12 +43,12 @@ RSpec.shared_context 'Pipeline Processing Service Tests With Yaml' do
{
pipeline: pipeline.status,
stages: pipeline.stages.pluck(:name, :status).to_h,
jobs: pipeline.statuses.latest.pluck(:name, :status).to_h
jobs: pipeline.latest_statuses.pluck(:name, :status).to_h
}
end
def event_on_jobs(event, job_names)
statuses = pipeline.statuses.latest.by_name(job_names).to_a
statuses = pipeline.latest_statuses.by_name(job_names).to_a
expect(statuses.count).to eq(job_names.count) # ensure that we have the same counts
statuses.each { |status| status.public_send("#{event}!") }

View File

@ -0,0 +1,309 @@
# frozen_string_literal: true
RSpec.shared_context 'Debian repository shared context' do |object_type|
before do
stub_feature_flags(debian_packages: true)
end
if object_type == :project
let(:project) { create(:project, :public) }
elsif object_type == :group
let(:group) { create(:group, :public) }
end
let(:user) { create(:user) }
let(:personal_access_token) { create(:personal_access_token, user: user) }
let(:distribution) { 'bullseye' }
let(:component) { 'main' }
let(:architecture) { 'amd64' }
let(:source_package) { 'sample' }
let(:letter) { source_package[0..2] == 'lib' ? source_package[0..3] : source_package[0] }
let(:package_name) { 'libsample0' }
let(:package_version) { '1.2.3~alpha2-1' }
let(:file_name) { "#{package_name}_#{package_version}_#{architecture}.deb" }
let(:method) { :get }
let(:workhorse_params) do
if method == :put
file_upload = fixture_file_upload("spec/fixtures/packages/debian/#{file_name}")
{ file: file_upload }
else
{}
end
end
let(:params) { workhorse_params }
let(:auth_headers) { {} }
let(:workhorse_headers) do
if method == :put
workhorse_token = JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256')
{ 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token }
else
{}
end
end
let(:headers) { auth_headers.merge(workhorse_headers) }
let(:send_rewritten_field) { true }
subject do
if method == :put
workhorse_finalize(
api(url),
method: method,
file_key: :file,
params: params,
headers: headers,
send_rewritten_field: send_rewritten_field
)
else
send method, api(url), headers: headers, params: params
end
end
end
RSpec.shared_context 'Debian repository auth headers' do |user_role, user_token, auth_method = :token|
let(:token) { user_token ? personal_access_token.token : 'wrong' }
let(:auth_headers) do
if user_role == :anonymous
{}
elsif auth_method == :token
{ 'Private-Token' => token }
else
basic_auth_header(user.username, token)
end
end
end
RSpec.shared_context 'Debian repository project access' do |project_visibility_level, user_role, user_token, auth_method|
include_context 'Debian repository auth headers', user_role, user_token, auth_method do
before do
project.update_column(:visibility_level, Gitlab::VisibilityLevel.const_get(project_visibility_level, false))
end
end
end
RSpec.shared_examples 'Debian project repository GET request' do |user_role, add_member, status, body|
context "for user type #{user_role}" do
before do
project.send("add_#{user_role}", user) if add_member && user_role != :anonymous
end
and_body = body.nil? ? '' : ' and expected body'
it "returns #{status}#{and_body}" do
subject
expect(response).to have_gitlab_http_status(status)
unless body.nil?
expect(response.body).to eq(body)
end
end
end
end
RSpec.shared_examples 'Debian project repository PUT request' do |user_role, add_member, status, body|
context "for user type #{user_role}" do
before do
project.send("add_#{user_role}", user) if add_member && user_role != :anonymous
end
and_body = body.nil? ? '' : ' and expected body'
if status == :created
it 'creates package files' do
pending "Debian package creation not implemented"
expect { subject }
.to change { project.packages.debian.count }.by(1)
expect(response).to have_gitlab_http_status(status)
unless body.nil?
expect(response.body).to eq(body)
end
end
it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
else
it "returns #{status}#{and_body}" do
subject
expect(response).to have_gitlab_http_status(status)
unless body.nil?
expect(response.body).to eq(body)
end
end
end
end
end
RSpec.shared_examples 'rejects Debian access with unknown project id' do
context 'with an unknown project' do
let(:project) { double(id: non_existing_record_id) }
context 'as anonymous' do
it_behaves_like 'Debian project repository GET request', :anonymous, true, :not_found, nil
end
context 'as authenticated user' do
subject { get api(url), headers: basic_auth_header(user.username, personal_access_token.token) }
it_behaves_like 'Debian project repository GET request', :anonymous, true, :not_found, nil
end
end
end
RSpec.shared_examples 'Debian project repository GET endpoint' do |success_status, success_body|
context 'with valid project' do
using RSpec::Parameterized::TableSyntax
where(:project_visibility_level, :user_role, :member, :user_token, :expected_status, :expected_body) do
'PUBLIC' | :developer | true | true | success_status | success_body
'PUBLIC' | :guest | true | true | success_status | success_body
'PUBLIC' | :developer | true | false | success_status | success_body
'PUBLIC' | :guest | true | false | success_status | success_body
'PUBLIC' | :developer | false | true | success_status | success_body
'PUBLIC' | :guest | false | true | success_status | success_body
'PUBLIC' | :developer | false | false | success_status | success_body
'PUBLIC' | :guest | false | false | success_status | success_body
'PUBLIC' | :anonymous | false | true | success_status | success_body
'PRIVATE' | :developer | true | true | success_status | success_body
'PRIVATE' | :guest | true | true | :forbidden | nil
'PRIVATE' | :developer | true | false | :not_found | nil
'PRIVATE' | :guest | true | false | :not_found | nil
'PRIVATE' | :developer | false | true | :not_found | nil
'PRIVATE' | :guest | false | true | :not_found | nil
'PRIVATE' | :developer | false | false | :not_found | nil
'PRIVATE' | :guest | false | false | :not_found | nil
'PRIVATE' | :anonymous | false | true | :not_found | nil
end
with_them do
include_context 'Debian repository project access', params[:project_visibility_level], params[:user_role], params[:user_token], :basic do
it_behaves_like 'Debian project repository GET request', params[:user_role], params[:member], params[:expected_status], params[:expected_body]
end
end
end
it_behaves_like 'rejects Debian access with unknown project id'
end
RSpec.shared_examples 'Debian project repository PUT endpoint' do |success_status, success_body|
context 'with valid project' do
using RSpec::Parameterized::TableSyntax
where(:project_visibility_level, :user_role, :member, :user_token, :expected_status, :expected_body) do
'PUBLIC' | :developer | true | true | success_status | nil
'PUBLIC' | :guest | true | true | :forbidden | nil
'PUBLIC' | :developer | true | false | :unauthorized | nil
'PUBLIC' | :guest | true | false | :unauthorized | nil
'PUBLIC' | :developer | false | true | :forbidden | nil
'PUBLIC' | :guest | false | true | :forbidden | nil
'PUBLIC' | :developer | false | false | :unauthorized | nil
'PUBLIC' | :guest | false | false | :unauthorized | nil
'PUBLIC' | :anonymous | false | true | :unauthorized | nil
'PRIVATE' | :developer | true | true | success_status | nil
'PRIVATE' | :guest | true | true | :forbidden | nil
'PRIVATE' | :developer | true | false | :not_found | nil
'PRIVATE' | :guest | true | false | :not_found | nil
'PRIVATE' | :developer | false | true | :not_found | nil
'PRIVATE' | :guest | false | true | :not_found | nil
'PRIVATE' | :developer | false | false | :not_found | nil
'PRIVATE' | :guest | false | false | :not_found | nil
'PRIVATE' | :anonymous | false | true | :not_found | nil
end
with_them do
include_context 'Debian repository project access', params[:project_visibility_level], params[:user_role], params[:user_token], :basic do
it_behaves_like 'Debian project repository PUT request', params[:user_role], params[:member], params[:expected_status], params[:expected_body]
end
end
end
it_behaves_like 'rejects Debian access with unknown project id'
end
RSpec.shared_context 'Debian repository group access' do |group_visibility_level, user_role, user_token, auth_method|
include_context 'Debian repository auth headers', user_role, user_token, auth_method do
before do
group.update_column(:visibility_level, Gitlab::VisibilityLevel.const_get(group_visibility_level, false))
end
end
end
RSpec.shared_examples 'Debian group repository GET request' do |user_role, add_member, status, body|
context "for user type #{user_role}" do
before do
group.send("add_#{user_role}", user) if add_member && user_role != :anonymous
end
and_body = body.nil? ? '' : ' and expected body'
it "returns #{status}#{and_body}" do
subject
expect(response).to have_gitlab_http_status(status)
unless body.nil?
expect(response.body).to eq(body)
end
end
end
end
RSpec.shared_examples 'rejects Debian access with unknown group id' do
context 'with an unknown group' do
let(:group) { double(id: non_existing_record_id) }
context 'as anonymous' do
it_behaves_like 'Debian group repository GET request', :anonymous, true, :not_found, nil
end
context 'as authenticated user' do
subject { get api(url), headers: basic_auth_header(user.username, personal_access_token.token) }
it_behaves_like 'Debian group repository GET request', :anonymous, true, :not_found, nil
end
end
end
RSpec.shared_examples 'Debian group repository GET endpoint' do |success_status, success_body|
context 'with valid group' do
using RSpec::Parameterized::TableSyntax
where(:group_visibility_level, :user_role, :member, :user_token, :expected_status, :expected_body) do
'PUBLIC' | :developer | true | true | success_status | success_body
'PUBLIC' | :guest | true | true | success_status | success_body
'PUBLIC' | :developer | true | false | success_status | success_body
'PUBLIC' | :guest | true | false | success_status | success_body
'PUBLIC' | :developer | false | true | success_status | success_body
'PUBLIC' | :guest | false | true | success_status | success_body
'PUBLIC' | :developer | false | false | success_status | success_body
'PUBLIC' | :guest | false | false | success_status | success_body
'PUBLIC' | :anonymous | false | true | success_status | success_body
'PRIVATE' | :developer | true | true | success_status | success_body
'PRIVATE' | :guest | true | true | :forbidden | nil
'PRIVATE' | :developer | true | false | :not_found | nil
'PRIVATE' | :guest | true | false | :not_found | nil
'PRIVATE' | :developer | false | true | :not_found | nil
'PRIVATE' | :guest | false | true | :not_found | nil
'PRIVATE' | :developer | false | false | :not_found | nil
'PRIVATE' | :guest | false | false | :not_found | nil
'PRIVATE' | :anonymous | false | true | :not_found | nil
end
with_them do
include_context 'Debian repository group access', params[:group_visibility_level], params[:user_role], params[:user_token], :basic do
it_behaves_like 'Debian group repository GET request', params[:user_role], params[:member], params[:expected_status], params[:expected_body]
end
end
end
it_behaves_like 'rejects Debian access with unknown group id'
end