diff --git a/app/assets/images/file_icons.svg b/app/assets/images/file_icons.svg
index 787b4da2e37..ec38020f978 100644
--- a/app/assets/images/file_icons.svg
+++ b/app/assets/images/file_icons.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 207f39340f7..dd5addbf1e3 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -18,6 +18,7 @@ import TreeList from './tree_list.vue';
import HiddenFilesWarning from './hidden_files_warning.vue';
import MergeConflictWarning from './merge_conflict_warning.vue';
+import CollapsedFilesWarning from './collapsed_files_warning.vue';
import {
TREE_LIST_WIDTH_STORAGE_KEY,
@@ -27,6 +28,9 @@ import {
TREE_HIDE_STATS_WIDTH,
MR_TREE_SHOW_KEY,
CENTERED_LIMITED_CONTAINER_CLASSES,
+ ALERT_OVERFLOW_HIDDEN,
+ ALERT_MERGE_CONFLICT,
+ ALERT_COLLAPSED_FILES,
} from '../constants';
export default {
@@ -37,6 +41,7 @@ export default {
NoChanges,
HiddenFilesWarning,
MergeConflictWarning,
+ CollapsedFilesWarning,
CommitWidget,
TreeList,
GlLoadingIcon,
@@ -45,6 +50,11 @@ export default {
GlSprintf,
},
mixins: [glFeatureFlagsMixin()],
+ alerts: {
+ ALERT_OVERFLOW_HIDDEN,
+ ALERT_MERGE_CONFLICT,
+ ALERT_COLLAPSED_FILES,
+ },
props: {
endpoint: {
type: String,
@@ -114,6 +124,7 @@ export default {
return {
treeWidth,
diffFilesLength: 0,
+ collapsedWarningDismissed: false,
};
},
computed: {
@@ -142,7 +153,7 @@ export default {
'canMerge',
'hasConflicts',
]),
- ...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']),
+ ...mapGetters('diffs', ['hasCollapsedFile', 'isParallelView', 'currentDiffIndex']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
diffs() {
if (!this.viewDiffsFileByFile) {
@@ -188,6 +199,23 @@ export default {
return currentFileNumber < diffFiles.length ? currentFileNumber + 1 : null;
},
+ visibleWarning() {
+ let visible = false;
+
+ if (this.renderOverflowWarning) {
+ visible = this.$options.alerts.ALERT_OVERFLOW_HIDDEN;
+ } else if (this.isDiffHead && this.hasConflicts) {
+ visible = this.$options.alerts.ALERT_MERGE_CONFLICT;
+ } else if (
+ this.hasCollapsedFile &&
+ !this.collapsedWarningDismissed &&
+ !this.viewDiffsFileByFile
+ ) {
+ visible = this.$options.alerts.ALERT_COLLAPSED_FILES;
+ }
+
+ return visible;
+ },
},
watch: {
commit(newCommit, oldCommit) {
@@ -401,6 +429,9 @@ export default {
this.toggleShowTreeList(false);
}
},
+ dismissCollapsedWarning() {
+ this.collapsedWarningDismissed = true;
+ },
},
minTreeWidth: MIN_TREE_WIDTH,
maxTreeWidth: MAX_TREE_WIDTH,
@@ -418,18 +449,23 @@ export default {
/>
+
+import { mapActions } from 'vuex';
+
+import { GlAlert, GlButton } from '@gitlab/ui';
+
+import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ },
+ props: {
+ limited: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ dismissed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isDismissed: this.dismissed,
+ };
+ },
+ computed: {
+ containerClasses() {
+ return {
+ [CENTERED_LIMITED_CONTAINER_CLASSES]: this.limited,
+ };
+ },
+ },
+
+ methods: {
+ ...mapActions('diffs', ['expandAllFiles']),
+ dismiss() {
+ this.isDismissed = true;
+ this.$emit('dismiss');
+ },
+ expand() {
+ this.expandAllFiles();
+ this.dismiss();
+ },
+ },
+};
+
+
+
+
+
+
+ {{ __('For a faster browsing experience, some files are collapsed by default.') }}
+
+
+
+ {{ __('Expand all files') }}
+
+
+
+
+
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 5df82242d2b..9ecb9a44443 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -49,8 +49,12 @@ export default {
...mapState({
projectPath: state => state.diffs.projectPath,
}),
- ...mapGetters('diffs', ['isInlineView', 'isParallelView']),
- ...mapGetters('diffs', ['getCommentFormForDiffFile']),
+ ...mapGetters('diffs', [
+ 'isInlineView',
+ 'isParallelView',
+ 'getCommentFormForDiffFile',
+ 'diffLines',
+ ]),
...mapGetters(['getNoteableData', 'noteableType', 'getUserData']),
diffMode() {
return getDiffMode(this.diffFile);
@@ -115,17 +119,15 @@ export default {
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index 273d7feddb4..0094b4f8707 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -67,9 +67,7 @@ export default {
computed: {
...mapState({
diffViewType(state) {
- return this.glFeatures.unifiedDiffLines
- ? PARALLEL_DIFF_VIEW_TYPE
- : state.diffs.diffViewType;
+ return this.glFeatures.unifiedDiffLines ? INLINE_DIFF_VIEW_TYPE : state.diffs.diffViewType;
},
diffFiles: state => state.diffs.diffFiles,
}),
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index e5197977bba..13805910648 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -64,99 +64,37 @@ export default {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index a79a385b9bc..dc97d9993da 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -68,6 +68,11 @@ export const DIFFS_PER_PAGE = 20;
export const DIFF_COMPARE_BASE_VERSION_INDEX = -1;
export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2;
+// Diff View Alerts
+export const ALERT_OVERFLOW_HIDDEN = 'overflow';
+export const ALERT_MERGE_CONFLICT = 'merge-conflict';
+export const ALERT_COLLAPSED_FILES = 'collapsed';
+
// State machine states
export const STATE_IDLING = 'idle';
export const STATE_LOADING = 'loading';
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index a24894b8d6b..42df5873a41 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -1,4 +1,5 @@
import { __, n__ } from '~/locale';
+import { parallelizeDiffLines } from './utils';
import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants';
export * from './getters_versions_dropdowns';
@@ -129,3 +130,11 @@ export const fileLineCoverage = state => (file, line) => {
*/
export const currentDiffIndex = state =>
Math.max(0, state.diffFiles.findIndex(diff => diff.file_hash === state.currentDiffFileId));
+
+export const diffLines = state => file => {
+ if (state.diffViewType === INLINE_DIFF_VIEW_TYPE) {
+ return null;
+ }
+
+ return parallelizeDiffLines(file.highlighted_diff_lines || []);
+};
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index e016cdeed30..7925c620c4e 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { PARALLEL_DIFF_VIEW_TYPE } from '../constants';
+import { INLINE_DIFF_VIEW_TYPE } from '../constants';
import {
findDiffFile,
addLineReferences,
@@ -152,7 +152,7 @@ export default {
inlineLines: diffFile.highlighted_diff_lines,
parallelLines: diffFile.parallel_diff_lines,
diffViewType: window.gon?.features?.unifiedDiffLines
- ? PARALLEL_DIFF_VIEW_TYPE
+ ? INLINE_DIFF_VIEW_TYPE
: state.diffViewType,
contextLines: lines,
bottom,
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 3bdb79d3899..69330ffae2f 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -350,15 +350,8 @@ function mergeTwoFiles(target, source) {
}
function ensureBasicDiffFileLines(file) {
- if (window.gon?.features?.unifiedDiffLines) {
- return Object.assign(file, {
- highlighted_diff_lines: [],
- parallel_diff_lines: parallelizeDiffLines(file.highlighted_diff_lines || []),
- });
- }
-
const missingInline = !file.highlighted_diff_lines;
- const missingParallel = !file.parallel_diff_lines;
+ const missingParallel = !file.parallel_diff_lines || window.gon?.features?.unifiedDiffLines;
Object.assign(file, {
highlighted_diff_lines: missingInline ? [] : file.highlighted_diff_lines,
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index 566efa0d7d6..6f2bd2da078 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -6,6 +6,7 @@ import UsersSelect from './users_select';
export default class IssuableContext {
constructor(currentUser) {
this.userSelect = new UsersSelect(currentUser);
+ this.reviewersSelect = new UsersSelect(currentUser, '.js-reviewer-search');
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 8f1f76a2e02..2146fb83b13 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -40,6 +40,17 @@ export default class SidebarMediator {
return this.service.update(field, data);
}
+ saveReviewers(field) {
+ const selected = this.store.reviewers.map(u => u.id);
+
+ // If there are no ids, that means we have to unassign (which is id = 0)
+ // And it only accepts an array, hence [0]
+ const reviewers = selected.length === 0 ? [0] : selected;
+ const data = { reviewer_ids: reviewers };
+
+ return this.service.update(field, data);
+ }
+
setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId);
}
@@ -55,6 +66,7 @@ export default class SidebarMediator {
processFetchedData(data) {
this.store.setAssigneeData(data);
+ this.store.setReviewerData(data);
this.store.setTimeTrackingData(data);
this.store.setParticipantsData(data);
this.store.setSubscriptionsData(data);
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 095f93b72a9..8d0d093e920 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -18,8 +18,10 @@ export default class SidebarStore {
this.humanTimeSpent = '';
this.timeTrackingLimitToHours = timeTrackingLimitToHours;
this.assignees = [];
+ this.reviewers = [];
this.isFetching = {
assignees: true,
+ reviewers: true,
participants: true,
subscriptions: true,
};
@@ -42,6 +44,13 @@ export default class SidebarStore {
}
}
+ setReviewerData(data) {
+ this.isFetching.reviewers = false;
+ if (data.reviewers) {
+ this.reviewers = data.reviewers;
+ }
+ }
+
setTimeTrackingData(data) {
this.timeEstimate = data.time_estimate;
this.totalTimeSpent = data.total_time_spent;
@@ -75,20 +84,40 @@ export default class SidebarStore {
}
}
+ addReviewer(reviewer) {
+ if (!this.findReviewer(reviewer)) {
+ this.reviewers.push(reviewer);
+ }
+ }
+
findAssignee(findAssignee) {
return this.assignees.find(assignee => assignee.id === findAssignee.id);
}
+ findReviewer(findReviewer) {
+ return this.reviewers.find(reviewer => reviewer.id === findReviewer.id);
+ }
+
removeAssignee(removeAssignee) {
if (removeAssignee) {
this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
}
}
+ removeReviewer(removeReviewer) {
+ if (removeReviewer) {
+ this.reviewers = this.reviewers.filter(reviewer => reviewer.id !== removeReviewer.id);
+ }
+ }
+
removeAllAssignees() {
this.assignees = [];
}
+ removeAllReviewers() {
+ this.reviewers = [];
+ }
+
setAssigneesFromRealtime(data) {
this.assignees = data;
}
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 5f4260f26ff..20d1a3c1fcd 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -19,6 +19,7 @@ import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
function UsersSelect(currentUser, els, options = {}) {
+ const elsClassName = els?.toString().match('.(.+$)')[1];
const $els = $(els || '.js-user-search');
this.users = this.users.bind(this);
this.user = this.user.bind(this);
@@ -127,9 +128,16 @@ function UsersSelect(currentUser, els, options = {}) {
.find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`);
firstSelected.remove();
- emitSidebarEvent('sidebar.removeAssignee', {
- id: firstSelectedId,
- });
+
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.removeReviewer', {
+ id: firstSelectedId,
+ });
+ } else {
+ emitSidebarEvent('sidebar.removeAssignee', {
+ id: firstSelectedId,
+ });
+ }
}
}
};
@@ -392,7 +400,11 @@ function UsersSelect(currentUser, els, options = {}) {
defaultLabel,
hidden() {
if ($dropdown.hasClass('js-multiselect')) {
- emitSidebarEvent('sidebar.saveAssignees');
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.saveReviewers');
+ } else {
+ emitSidebarEvent('sidebar.saveAssignees');
+ }
}
if (!$dropdown.data('alwaysShowSelectbox')) {
@@ -428,10 +440,18 @@ function UsersSelect(currentUser, els, options = {}) {
previouslySelected.each((index, element) => {
element.remove();
});
- emitSidebarEvent('sidebar.removeAllAssignees');
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.removeAllReviewers');
+ } else {
+ emitSidebarEvent('sidebar.removeAllAssignees');
+ }
} else if (isActive) {
// user selected
- emitSidebarEvent('sidebar.addAssignee', user);
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.addReviewer', user);
+ } else {
+ emitSidebarEvent('sidebar.addAssignee', user);
+ }
// Remove unassigned selection (if it was previously selected)
const unassignedSelected = $dropdown
@@ -448,7 +468,11 @@ function UsersSelect(currentUser, els, options = {}) {
}
// User unselected
- emitSidebarEvent('sidebar.removeAssignee', user);
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.removeReviewer', user);
+ } else {
+ emitSidebarEvent('sidebar.removeAssignee', user);
+ }
}
if (getSelected().find(u => u === gon.current_user_id)) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index d1d703b8c44..43ce748b41d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -7,6 +7,7 @@ import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
+import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import { deprecatedCreateFlash as createFlash } from '../flash';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import Loading from './components/loading.vue';
@@ -202,7 +203,10 @@ export default {
},
mounted() {
MRWidgetService.fetchInitialData()
- .then(({ data }) => this.initWidget(data))
+ .then(({ data, headers }) => {
+ this.startingPollInterval = Number(headers['POLL-INTERVAL']);
+ this.initWidget(data);
+ })
.catch(() =>
createFlash(__('Unable to load the merge request widget. Try reloading the page.')),
);
@@ -292,9 +296,10 @@ export default {
initPolling() {
this.pollingInterval = new SmartInterval({
callback: this.checkStatus,
- startingInterval: 10 * 1000,
- maxInterval: 240 * 1000,
- hiddenInterval: window.gon?.features?.widgetVisibilityPolling && 360 * 1000,
+ startingInterval: this.startingPollInterval,
+ maxInterval: this.startingPollInterval + secondsToMilliseconds(4 * 60),
+ hiddenInterval:
+ window.gon?.features?.widgetVisibilityPolling && secondsToMilliseconds(6 * 60),
incrementByFactorOf: 2,
});
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
index ee9e3cc6d08..2ad15f231bb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
+++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
@@ -1,3 +1,4 @@
+import { normalizeHeaders } from '~/lib/utils/common_utils';
import axios from '../../lib/utils/axios_utils';
export default class MRWidgetService {
@@ -82,6 +83,11 @@ export default class MRWidgetService {
return Promise.all([
axios.get(window.gl.mrWidgetData.merge_request_cached_widget_path),
axios.get(window.gl.mrWidgetData.merge_request_widget_path),
- ]).then(axios.spread((res, cachedRes) => ({ data: Object.assign(res.data, cachedRes.data) })));
+ ]).then(
+ axios.spread((res, cachedRes) => ({
+ data: Object.assign(res.data, cachedRes.data),
+ headers: normalizeHeaders(res.headers),
+ })),
+ );
}
}
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index b255597b18d..c85f2ceb82e 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -386,6 +386,12 @@ module IssuablesHelper
end
end
+ def reviewer_sidebar_data(reviewer, merge_request: nil)
+ { avatar_url: reviewer.avatar_url, name: reviewer.name, username: reviewer.username }.tap do |data|
+ data[:can_merge] = merge_request.can_be_merged_by?(reviewer) if merge_request
+ end
+ end
+
def issuable_squash_option?(issuable, project)
if issuable.persisted?
issuable.squash
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 4b61da461cf..1697067f633 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -35,6 +35,10 @@ module Ci
end
end
+ event :pending do
+ transition all => :pending
+ end
+
event :manual do
transition all => :manual
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 77821313e5a..47eba685afe 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -237,7 +237,6 @@ module Ci
end
after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
- next unless pipeline.bridge_triggered?
next unless pipeline.bridge_waiting?
pipeline.run_after_commit do
@@ -1072,6 +1071,18 @@ module Ci
.base_and_ancestors
end
+ # We need `base_and_ancestors` in a specific order to "break" when needed.
+ # If we use `find_each`, then the order is broken.
+ # rubocop:disable Rails/FindEach
+ def reset_ancestor_bridges!
+ base_and_ancestors.includes(:source_bridge).each do |pipeline|
+ break unless pipeline.bridge_waiting?
+
+ pipeline.source_bridge.pending!
+ end
+ end
+ # rubocop:enable Rails/FindEach
+
private
def add_message(severity, content)
diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb
index 766b4d7a865..1ce4e14d289 100644
--- a/app/models/resource_state_event.rb
+++ b/app/models/resource_state_event.rb
@@ -19,3 +19,5 @@ class ResourceStateEvent < ResourceEvent
issue || merge_request
end
end
+
+ResourceStateEvent.prepend_if_ee('EE::ResourceStateEvent')
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index be8762ce191..6b2e6c245f3 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -15,6 +15,7 @@ module Ci
reprocess!(build).tap do |new_build|
mark_subsequent_stages_as_processable(build)
+ build.pipeline.reset_ancestor_bridges!
Gitlab::OptimisticLocking.retry_lock(new_build, &:enqueue)
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index 2f52f0a39c1..45244d16393 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -26,6 +26,8 @@ module Ci
retry_optimistic_lock(skipped) { |build| build.process }
end
+ pipeline.reset_ancestor_bridges!
+
MergeRequests::AddTodoWhenBuildFailsService
.new(project, current_user)
.close_all(pipeline)
diff --git a/app/services/resource_events/change_state_service.rb b/app/services/resource_events/change_state_service.rb
index 202972c1efd..cd6d82df46f 100644
--- a/app/services/resource_events/change_state_service.rb
+++ b/app/services/resource_events/change_state_service.rb
@@ -13,14 +13,14 @@ module ResourceEvents
ResourceStateEvent.create(
user: user,
- issue: issue,
- merge_request: merge_request,
+ resource.class.underscore => resource,
source_commit: commit_id_of(mentionable_source),
source_merge_request_id: merge_request_id_of(mentionable_source),
state: ResourceStateEvent.states[state],
close_after_error_tracking_resolve: close_after_error_tracking_resolve,
close_auto_resolve_prometheus_alert: close_auto_resolve_prometheus_alert,
- created_at: Time.zone.now)
+ created_at: Time.zone.now
+ )
resource.expire_note_etag_cache
end
@@ -56,17 +56,5 @@ module ResourceEvents
mentionable_source.id
end
-
- def issue
- return unless resource.is_a?(Issue)
-
- resource
- end
-
- def merge_request
- return unless resource.is_a?(MergeRequest)
-
- resource
- end
end
end
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index b579f7510f9..9a3078991c0 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -92,7 +92,7 @@
.loading.hide
.spinner.spinner-md
-= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, source_branch: @merge_request.source_branch
+= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch
- if @merge_request.can_be_reverted?(current_user)
= render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 09ec49a040a..86484239546 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -5,6 +5,7 @@
- signed_in = !!issuable_sidebar.dig(:current_user, :id)
- can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit)
- add_page_startup_api_call "#{issuable_sidebar[:issuable_json_path]}?serializer=sidebar_extras"
+- reviewers = local_assigns.fetch(:reviewers, nil)
- if Feature.enabled?(:vue_issuable_sidebar, @project.group)
%aside#js-vue-issuable-sidebar{ data: { signed_in: signed_in,
@@ -28,6 +29,10 @@
.block.assignee.qa-assignee-block
= render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees
+ - if reviewers
+ .block.reviewer.qa-reviewer-block
+ = render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers
+
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar
- if issuable_sidebar[:supports_milestone]
diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml
new file mode 100644
index 00000000000..8b546d5e344
--- /dev/null
+++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml
@@ -0,0 +1,56 @@
+- issuable_type = issuable_sidebar[:type]
+- signed_in = !!issuable_sidebar.dig(:current_user, :id)
+
+#js-vue-sidebar-reviewers{ data: { field: issuable_type, signed_in: signed_in } }
+ .title.hide-collapsed
+ = _('Reviewer')
+ = loading_icon(css_class: 'gl-vertical-align-text-bottom')
+
+.selectbox.hide-collapsed
+ - if reviewers.none?
+ = hidden_field_tag "#{issuable_type}[reviewer_ids][]", 0, id: nil
+ - else
+ - reviewers.each do |reviewer|
+ = hidden_field_tag "#{issuable_type}[reviewer_ids][]", reviewer.id, id: nil, data: reviewer_sidebar_data(reviewer, merge_request: @merge_request)
+
+ - options = { toggle_class: 'js-reviewer-search js-author-search',
+ title: _('Request review from'),
+ filter: true,
+ dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author',
+ placeholder: _('Search users'),
+ data: { first_user: issuable_sidebar.dig(:current_user, :username),
+ current_user: true,
+ iid: issuable_sidebar[:iid],
+ issuable_type: issuable_type,
+ project_id: issuable_sidebar[:project_id],
+ author_id: issuable_sidebar[:author_id],
+ field_name: "#{issuable_type}[reviewer_ids][]",
+ issue_update: issuable_sidebar[:issuable_json_path],
+ ability_name: issuable_type,
+ null_user: true,
+ display: 'static' } }
+
+ - dropdown_options = reviewers_dropdown_options(issuable_type)
+ - title = dropdown_options[:title]
+ - options[:toggle_class] += ' js-multiselect js-save-user-data'
+ - data = { field_name: "#{issuable_type}[reviewer_ids][]" }
+ - data[:multi_select] = true
+ - data['dropdown-title'] = title
+ - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
+ - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
+ - options[:data].merge!(data)
+
+ - if experiment_enabled?(:invite_members_version_a) && can_import_members?
+ - options[:dropdown_class] += ' dropdown-extended-height'
+ - options[:footer_content] = true
+ - options[:wrapper_class] = 'js-sidebar-reviewer-dropdown'
+
+ = dropdown_tag(title, options: options) do
+ %ul.dropdown-footer-list
+ %li
+ = link_to _('Invite Members'),
+ project_project_members_path(@project),
+ title: _('Invite Members'),
+ data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': 'edit_reviewer' }
+ - else
+ = dropdown_tag(title, options: options)
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 266d9d890af..198735df5ee 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -1,4 +1,4 @@
-- if Feature.enabled?(:snippets_edit_vue)
+- if Feature.enabled?(:snippets_edit_vue, default_enabled: true)
- available_visibility_levels = available_visibility_levels(@snippet)
#js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access"), 'visibility_levels': available_visibility_levels, 'selected_level': snippets_selected_visibility_level(available_visibility_levels, @snippet.visibility_level), 'multiple_levels_restricted': multiple_visibility_levels_restricted? } }
- else
diff --git a/changelogs/unreleased/207239-snippet-vueified-by-default.yml b/changelogs/unreleased/207239-snippet-vueified-by-default.yml
new file mode 100644
index 00000000000..52d5b086967
--- /dev/null
+++ b/changelogs/unreleased/207239-snippet-vueified-by-default.yml
@@ -0,0 +1,5 @@
+---
+title: Refactored snippets edit form to Vue
+merge_request: 42412
+author:
+type: changed
diff --git a/changelogs/unreleased/212435-fix-upstream-pipeline-result.yml b/changelogs/unreleased/212435-fix-upstream-pipeline-result.yml
new file mode 100644
index 00000000000..2f123f4fc4b
--- /dev/null
+++ b/changelogs/unreleased/212435-fix-upstream-pipeline-result.yml
@@ -0,0 +1,5 @@
+---
+title: Fix upstream pipeline status when strategy dependent
+merge_request: 41930
+author:
+type: fixed
diff --git a/changelogs/unreleased/242291-gitlab-file-tree-icon-not-working-in-firefox.yml b/changelogs/unreleased/242291-gitlab-file-tree-icon-not-working-in-firefox.yml
new file mode 100644
index 00000000000..89eb99dca8c
--- /dev/null
+++ b/changelogs/unreleased/242291-gitlab-file-tree-icon-not-working-in-firefox.yml
@@ -0,0 +1,5 @@
+---
+title: Fix GitLab file icon in Firefox
+merge_request: 40786
+author:
+type: fixed
diff --git a/changelogs/unreleased/display-contributor-and-author-badges-on-notes.yml b/changelogs/unreleased/display-contributor-and-author-badges-on-notes.yml
deleted file mode 100644
index 0832052dd17..00000000000
--- a/changelogs/unreleased/display-contributor-and-author-badges-on-notes.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Display Contributor and Author badges on notes
-merge_request: 40198
-author: Mycroft Kang @TaehyeokKang
-type: added
diff --git a/changelogs/unreleased/feature-collapsed-files-warning.yml b/changelogs/unreleased/feature-collapsed-files-warning.yml
new file mode 100644
index 00000000000..8fc1cc6b701
--- /dev/null
+++ b/changelogs/unreleased/feature-collapsed-files-warning.yml
@@ -0,0 +1,5 @@
+---
+title: Add a warning when any diff files are collapsed
+merge_request: 40752
+author:
+type: added
diff --git a/changelogs/unreleased/on-demand-dast-validation-model-layer-245208.yml b/changelogs/unreleased/on-demand-dast-validation-model-layer-245208.yml
new file mode 100644
index 00000000000..c889826db23
--- /dev/null
+++ b/changelogs/unreleased/on-demand-dast-validation-model-layer-245208.yml
@@ -0,0 +1,5 @@
+---
+title: DAST Site validation - Model Layer
+merge_request: 41639
+author:
+type: added
diff --git a/changelogs/unreleased/ph-232478-updateWidgetIntervalForClosedMergeRequests.yml b/changelogs/unreleased/ph-232478-updateWidgetIntervalForClosedMergeRequests.yml
new file mode 100644
index 00000000000..dbed1b6c993
--- /dev/null
+++ b/changelogs/unreleased/ph-232478-updateWidgetIntervalForClosedMergeRequests.yml
@@ -0,0 +1,5 @@
+---
+title: Increase widget polling for closed and merged merge requests
+merge_request: 42458
+author:
+type: changed
diff --git a/config/feature_flags/development/snippets_edit_vue.yml b/config/feature_flags/development/snippets_edit_vue.yml
index c19fca9fa15..d62926a8337 100644
--- a/config/feature_flags/development/snippets_edit_vue.yml
+++ b/config/feature_flags/development/snippets_edit_vue.yml
@@ -1,7 +1,7 @@
---
name: snippets_edit_vue
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25667
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/207239
+group: group::editor
type: development
-default_enabled: false
+default_enabled: true
diff --git a/data/whats_new/202009150001_13_03.yml b/data/whats_new/202009150001_13_03.yml
new file mode 100644
index 00000000000..73c3dae5136
--- /dev/null
+++ b/data/whats_new/202009150001_13_03.yml
@@ -0,0 +1,41 @@
+---
+- title: Coverage-guided fuzz testing for Go and C/C++ applications
+ body: You can now run coverage-guided fuzz tests against your Go and C/C++ apps. This is a great way to start finding security issues and bugs that other security scanners and traditional QA may miss. Coverage-guided fuzz testing uses contextual information about your app to randomly generate inputs and find crashes or other faults that you can then fix before they affect users in production.
+ stage: Secure
+ self-managed: true
+ gitlab-com: true
+ packages: [Ultimate]
+ url: https://www.youtube.com/watch?v=3wdWMDRLdp4
+ image_url: http://i3.ytimg.com/vi/3wdWMDRLdp4/hqdefault.jpg
+ published_at: 2020-08-22
+ release: 13.3
+- title: Create a matrix of jobs using a simple syntax
+ body: GitLab’s child/parent pipelines let you write your own code to generate an entire pipeline YAML. This is a powerful way to generate custom behaviors, including generating jobs at runtime. This might not be needed for simpler scenarios where you just want to create multiple similar jobs for a defined set of cases. In this release you can find a new matrix keyword that works along with parallel to handle the creation of multiple jobs for you, each with different variables.
+ stage: Verify
+ self-managed: true
+ gitlab-com: true
+ packages: [All]
+ url: https://docs.gitlab.com/ee/ci/yaml/#parallel-matrix-jobs
+ image_url: https://about.gitlab.com/images/13_3/cartesian-matrix.png
+ published_at: 2020-08-22
+ release: 13.3
+- title: On-demand DAST scans
+ body: Dynamic Application Security Testing at GitLab has always been focused on integrating DAST into the DevOps pipeline and enabling developers to scan their review app, running website, or API for vulnerabilities as early as possible. However, there are times when it is necessary to run a DAST scan against an already deployed application when no code changes have been made and no Merge Request has been created. These scans could be needed for audit or compliance reasons, to debug and reproduce an issue that has been found, or to support teams who do not commit code, such as security analysts. Because of the need for DAST scans that are not triggered by a code change or MR, on-demand DAST testing is now available. You don’t need configuration files or code to start running on-demand scans. Configuration options for on-demand DAST scans are available within the GitLab UI.
+ stage: Secure
+ self-managed: true
+ gitlab-com: true
+ packages: [Ultimate]
+ url: https://docs.gitlab.com/ee/user/application_security/dast/#on-demand-scans
+ image_url: https://about.gitlab.com/images/13_3/dast_on_demand_v13_3.png
+ published_at: 2020-08-22
+ release: 13.3
+- title: SAST security analyzers available for all
+ body: We want to help developers write better code and worry less about common security mistakes. Static Application Security Testing (SAST) helps prevent security vulnerabilities by allowing developers to easily identify common security issues as code is being committed and mitigate proactively. As part of our community stewardship commitment we have made all 15 of our open source based SAST analyzers available in every GitLab tier. This allows ALL GitLab users developing in any of our 18 supported languages and frameworks to leverage GitLab SAST in their projects.
+ stage: Secure
+ self-managed: true
+ gitlab-com: true
+ packages: [All]
+ url: https://docs.gitlab.com/ee/user/application_security/sast/#making-sast-analyzers-available-to-all-gitlab-tiers
+ image_url: https://about.gitlab.com/images/13_3/sast-gitlab-languages.png
+ published_at: 2020-08-22
+ release: 13.3
diff --git a/db/migrate/20200907021256_create_dast_site_tokens.rb b/db/migrate/20200907021256_create_dast_site_tokens.rb
new file mode 100644
index 00000000000..a8e221aef69
--- /dev/null
+++ b/db/migrate/20200907021256_create_dast_site_tokens.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class CreateDastSiteTokens < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ unless table_exists?(:dast_site_tokens)
+ with_lock_retries do
+ create_table :dast_site_tokens do |t|
+ t.references :project, foreign_key: { on_delete: :cascade }, null: false, index: true
+
+ t.timestamps_with_timezone null: false
+ t.datetime_with_timezone :expired_at
+
+ t.text :token, null: false, unique: true
+ t.text :url, null: false
+ end
+ end
+ end
+
+ add_text_limit :dast_site_tokens, :token, 255
+ add_text_limit :dast_site_tokens, :url, 255
+ end
+
+ def down
+ with_lock_retries do
+ drop_table :dast_site_tokens
+ end
+ end
+end
diff --git a/db/migrate/20200907062101_create_dast_site_validations.rb b/db/migrate/20200907062101_create_dast_site_validations.rb
new file mode 100644
index 00000000000..5ff2084d693
--- /dev/null
+++ b/db/migrate/20200907062101_create_dast_site_validations.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class CreateDastSiteValidations < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ unless table_exists?(:dast_site_validations)
+ with_lock_retries do
+ create_table :dast_site_validations do |t|
+ t.references :dast_site_token, foreign_key: { on_delete: :cascade }, null: false, index: true
+
+ t.timestamps_with_timezone null: false
+ t.datetime_with_timezone :validation_started_at
+ t.datetime_with_timezone :validation_passed_at
+ t.datetime_with_timezone :validation_failed_at
+ t.datetime_with_timezone :validation_last_retried_at
+
+ t.integer :validation_strategy, null: false, limit: 2
+
+ t.text :url_base, null: false
+ t.text :url_path, null: false
+ end
+ end
+ end
+
+ add_concurrent_index :dast_site_validations, :url_base
+ add_text_limit :dast_site_validations, :url_base, 255
+ add_text_limit :dast_site_validations, :url_path, 255
+ end
+
+ def down
+ with_lock_retries do
+ drop_table :dast_site_validations
+ end
+ end
+end
diff --git a/db/migrate/20200908033805_add_dast_site_validation_id_to_dast_site.rb b/db/migrate/20200908033805_add_dast_site_validation_id_to_dast_site.rb
new file mode 100644
index 00000000000..3ac86fb1d9e
--- /dev/null
+++ b/db/migrate/20200908033805_add_dast_site_validation_id_to_dast_site.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class AddDastSiteValidationIdToDastSite < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ TABLE_NAME = :dast_sites
+ RELATION_NAME = :dast_site_validations
+ FK_NAME = :dast_site_validation_id
+ INDEX_NAME = "index_dast_sites_on_#{FK_NAME}"
+
+ disable_ddl_transaction!
+
+ def up
+ unless column_exists?(TABLE_NAME, FK_NAME)
+ with_lock_retries do
+ add_column TABLE_NAME, FK_NAME, :bigint
+ end
+ end
+
+ add_concurrent_index TABLE_NAME, FK_NAME, name: INDEX_NAME
+ add_concurrent_foreign_key TABLE_NAME, RELATION_NAME, column: FK_NAME, on_delete: :nullify
+ end
+
+ def down
+ remove_foreign_key_if_exists TABLE_NAME, RELATION_NAME
+ remove_concurrent_index_by_name TABLE_NAME, INDEX_NAME
+
+ with_lock_retries do
+ remove_column TABLE_NAME, FK_NAME
+ end
+ end
+end
diff --git a/db/schema_migrations/20200907021256 b/db/schema_migrations/20200907021256
new file mode 100644
index 00000000000..a8e8b257104
--- /dev/null
+++ b/db/schema_migrations/20200907021256
@@ -0,0 +1 @@
+5fba5213226186a1506f672eb3eab2d07f58b019c4ba13760663cb119f62d4e2
\ No newline at end of file
diff --git a/db/schema_migrations/20200907062101 b/db/schema_migrations/20200907062101
new file mode 100644
index 00000000000..b4bf6bbb781
--- /dev/null
+++ b/db/schema_migrations/20200907062101
@@ -0,0 +1 @@
+002c92f830762d97dcbdbcf8a0287ebbb576edc27f4f76f4bb18d043e956ba7a
\ No newline at end of file
diff --git a/db/schema_migrations/20200908033805 b/db/schema_migrations/20200908033805
new file mode 100644
index 00000000000..dc2103face3
--- /dev/null
+++ b/db/schema_migrations/20200908033805
@@ -0,0 +1 @@
+5f932b8a3503fc275ba6d09436115999b32f6438700e3b719f53730c5527a354
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 899c5de8ae6..b97ae5801f3 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -11202,12 +11202,59 @@ CREATE SEQUENCE public.dast_site_profiles_id_seq
ALTER SEQUENCE public.dast_site_profiles_id_seq OWNED BY public.dast_site_profiles.id;
+CREATE TABLE public.dast_site_tokens (
+ id bigint NOT NULL,
+ project_id bigint NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ expired_at timestamp with time zone,
+ token text NOT NULL,
+ url text NOT NULL,
+ CONSTRAINT check_02a6bf20a7 CHECK ((char_length(token) <= 255)),
+ CONSTRAINT check_69ab8622a6 CHECK ((char_length(url) <= 255))
+);
+
+CREATE SEQUENCE public.dast_site_tokens_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE public.dast_site_tokens_id_seq OWNED BY public.dast_site_tokens.id;
+
+CREATE TABLE public.dast_site_validations (
+ id bigint NOT NULL,
+ dast_site_token_id bigint NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ validation_started_at timestamp with time zone,
+ validation_passed_at timestamp with time zone,
+ validation_failed_at timestamp with time zone,
+ validation_last_retried_at timestamp with time zone,
+ validation_strategy smallint NOT NULL,
+ url_base text NOT NULL,
+ url_path text NOT NULL,
+ CONSTRAINT check_13b34efe4b CHECK ((char_length(url_path) <= 255)),
+ CONSTRAINT check_cd3b538210 CHECK ((char_length(url_base) <= 255))
+);
+
+CREATE SEQUENCE public.dast_site_validations_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE public.dast_site_validations_id_seq OWNED BY public.dast_site_validations.id;
+
CREATE TABLE public.dast_sites (
id bigint NOT NULL,
project_id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
url text NOT NULL,
+ dast_site_validation_id bigint,
CONSTRAINT check_46df8b449c CHECK ((char_length(url) <= 255))
);
@@ -17166,6 +17213,10 @@ ALTER TABLE ONLY public.dast_scanner_profiles ALTER COLUMN id SET DEFAULT nextva
ALTER TABLE ONLY public.dast_site_profiles ALTER COLUMN id SET DEFAULT nextval('public.dast_site_profiles_id_seq'::regclass);
+ALTER TABLE ONLY public.dast_site_tokens ALTER COLUMN id SET DEFAULT nextval('public.dast_site_tokens_id_seq'::regclass);
+
+ALTER TABLE ONLY public.dast_site_validations ALTER COLUMN id SET DEFAULT nextval('public.dast_site_validations_id_seq'::regclass);
+
ALTER TABLE ONLY public.dast_sites ALTER COLUMN id SET DEFAULT nextval('public.dast_sites_id_seq'::regclass);
ALTER TABLE ONLY public.dependency_proxy_blobs ALTER COLUMN id SET DEFAULT nextval('public.dependency_proxy_blobs_id_seq'::regclass);
@@ -18195,6 +18246,12 @@ ALTER TABLE ONLY public.dast_scanner_profiles
ALTER TABLE ONLY public.dast_site_profiles
ADD CONSTRAINT dast_site_profiles_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY public.dast_site_tokens
+ ADD CONSTRAINT dast_site_tokens_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.dast_site_validations
+ ADD CONSTRAINT dast_site_validations_pkey PRIMARY KEY (id);
+
ALTER TABLE ONLY public.dast_sites
ADD CONSTRAINT dast_sites_pkey PRIMARY KEY (id);
@@ -19785,6 +19842,14 @@ CREATE INDEX index_dast_site_profiles_on_dast_site_id ON public.dast_site_profil
CREATE UNIQUE INDEX index_dast_site_profiles_on_project_id_and_name ON public.dast_site_profiles USING btree (project_id, name);
+CREATE INDEX index_dast_site_tokens_on_project_id ON public.dast_site_tokens USING btree (project_id);
+
+CREATE INDEX index_dast_site_validations_on_dast_site_token_id ON public.dast_site_validations USING btree (dast_site_token_id);
+
+CREATE INDEX index_dast_site_validations_on_url_base ON public.dast_site_validations USING btree (url_base);
+
+CREATE INDEX index_dast_sites_on_dast_site_validation_id ON public.dast_sites USING btree (dast_site_validation_id);
+
CREATE UNIQUE INDEX index_dast_sites_on_project_id_and_url ON public.dast_sites USING btree (project_id, url);
CREATE INDEX index_dependency_proxy_blobs_on_group_id_and_file_name ON public.dependency_proxy_blobs USING btree (group_id, file_name);
@@ -21754,6 +21819,9 @@ ALTER TABLE ONLY public.merge_requests
ALTER TABLE ONLY public.user_interacted_projects
ADD CONSTRAINT fk_0894651f08 FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+ALTER TABLE ONLY public.dast_sites
+ ADD CONSTRAINT fk_0a57f2271b FOREIGN KEY (dast_site_validation_id) REFERENCES public.dast_site_validations(id) ON DELETE SET NULL;
+
ALTER TABLE ONLY public.web_hooks
ADD CONSTRAINT fk_0c8ca6d9d1 FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
@@ -22537,6 +22605,9 @@ ALTER TABLE ONLY public.lfs_file_locks
ALTER TABLE ONLY public.project_alerting_settings
ADD CONSTRAINT fk_rails_27a84b407d FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
+ALTER TABLE ONLY public.dast_site_validations
+ ADD CONSTRAINT fk_rails_285c617324 FOREIGN KEY (dast_site_token_id) REFERENCES public.dast_site_tokens(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY public.resource_state_events
ADD CONSTRAINT fk_rails_29af06892a FOREIGN KEY (issue_id) REFERENCES public.issues(id) ON DELETE CASCADE;
@@ -23464,6 +23535,9 @@ ALTER TABLE ONLY public.merge_request_metrics
ALTER TABLE ONLY public.draft_notes
ADD CONSTRAINT fk_rails_e753681674 FOREIGN KEY (merge_request_id) REFERENCES public.merge_requests(id) ON DELETE CASCADE;
+ALTER TABLE ONLY public.dast_site_tokens
+ ADD CONSTRAINT fk_rails_e84f721a8e FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY public.group_deploy_keys_groups
ADD CONSTRAINT fk_rails_e87145115d FOREIGN KEY (group_id) REFERENCES public.namespaces(id) ON DELETE CASCADE;
diff --git a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md
index 74b649b12af..9a23a115765 100644
--- a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md
+++ b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md
@@ -218,6 +218,17 @@ namespace = Namespace.find_by_full_path("")
::Projects::TransferService.new(p, current_user).execute(namespace)
```
+### For Removing webhooks that is getting timeout due to large webhook logs
+
+```ruby
+# ID will be the webhook_id
+WebHookLog.where(web_hook_id: ID).each_slice(ID) do |slice|
+ slice.each(&:destroy)
+end
+
+WebHook.find(ID).destroy
+```
+
### Bulk update service integration password for _all_ projects
For example, change the Jira user's password for all projects that have the Jira
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index 8f246136676..c977681748d 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -44259,59 +44259,6 @@
},
"isDeprecated": false,
"deprecationReason": null
- },
- {
- "name": "variables",
- "description": "List of supported variables",
- "args": [
- {
- "name": "after",
- "description": "Returns the elements in the list that come after the specified cursor.",
- "type": {
- "kind": "SCALAR",
- "name": "String",
- "ofType": null
- },
- "defaultValue": null
- },
- {
- "name": "before",
- "description": "Returns the elements in the list that come before the specified cursor.",
- "type": {
- "kind": "SCALAR",
- "name": "String",
- "ofType": null
- },
- "defaultValue": null
- },
- {
- "name": "first",
- "description": "Returns the first _n_ elements from the list.",
- "type": {
- "kind": "SCALAR",
- "name": "Int",
- "ofType": null
- },
- "defaultValue": null
- },
- {
- "name": "last",
- "description": "Returns the last _n_ elements from the list.",
- "type": {
- "kind": "SCALAR",
- "name": "Int",
- "ofType": null
- },
- "defaultValue": null
- }
- ],
- "type": {
- "kind": "OBJECT",
- "name": "SastCiConfigurationEntityConnection",
- "ofType": null
- },
- "isDeprecated": false,
- "deprecationReason": null
}
],
"inputFields": null,
@@ -44379,6 +44326,59 @@
},
"isDeprecated": false,
"deprecationReason": null
+ },
+ {
+ "name": "variables",
+ "description": "List of supported variables",
+ "args": [
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "SastCiConfigurationEntityConnection",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
}
],
"inputFields": null,
diff --git a/doc/user/search/advanced_global_search.md b/doc/user/search/advanced_global_search.md
index 82f026a6938..53ec8b35631 100644
--- a/doc/user/search/advanced_global_search.md
+++ b/doc/user/search/advanced_global_search.md
@@ -44,7 +44,7 @@ The Advanced Search can be useful in various scenarios.
### Faster searches
If you are dealing with huge amount of data and want to keep GitLab's search
-fast, the Advanced Search will help you achieve that.
+fast, Advanced Search will help you achieve that.
NOTE: **Note:**
Between versions 12.10 and 13.4, Advanced Search response times have improved by 80%.
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index 74c68f24483..66517ecd743 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -46,7 +46,7 @@ module Gitlab
push_frontend_feature_flag(:snippets_vue, default_enabled: true)
push_frontend_feature_flag(:monaco_blobs, default_enabled: true)
push_frontend_feature_flag(:monaco_ci, default_enabled: false)
- push_frontend_feature_flag(:snippets_edit_vue, default_enabled: false)
+ push_frontend_feature_flag(:snippets_edit_vue, default_enabled: true)
push_frontend_feature_flag(:webperf_experiment, default_enabled: false)
push_frontend_feature_flag(:snippets_binary_blob, default_enabled: false)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 0cbdd2ebf67..71a5ce627d8 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -10374,6 +10374,9 @@ msgstr ""
msgid "Expand all"
msgstr ""
+msgid "Expand all files"
+msgstr ""
+
msgid "Expand approvers"
msgstr ""
@@ -11216,6 +11219,9 @@ msgstr ""
msgid "Footer message"
msgstr ""
+msgid "For a faster browsing experience, some files are collapsed by default."
+msgstr ""
+
msgid "For help setting up the Service Desk for your instance, please contact an administrator."
msgstr ""
@@ -15385,6 +15391,9 @@ msgstr ""
msgid "Max seats used"
msgstr ""
+msgid "Max size 15 MB"
+msgstr ""
+
msgid "Maximum Users:"
msgstr ""
@@ -21456,6 +21465,9 @@ msgstr ""
msgid "Request parameter %{param} is missing."
msgstr ""
+msgid "Request review from"
+msgstr ""
+
msgid "Request to link SAML account must be authorized"
msgstr ""
@@ -21726,6 +21738,9 @@ msgstr ""
msgid "ReviewApp|Enable Review App"
msgstr ""
+msgid "Reviewer"
+msgstr ""
+
msgid "Reviewing"
msgstr ""
@@ -23559,6 +23574,9 @@ msgstr ""
msgid "Solution"
msgstr ""
+msgid "Some changes are not shown"
+msgstr ""
+
msgid "Some child epics may be hidden due to applied filters"
msgstr ""
diff --git a/spec/factories/ci/bridge.rb b/spec/factories/ci/bridge.rb
index aca743b9841..5a33a30921b 100644
--- a/spec/factories/ci/bridge.rb
+++ b/spec/factories/ci/bridge.rb
@@ -58,5 +58,9 @@ FactoryBot.define do
started
status { 'skipped' }
end
+
+ trait :strategy_depend do
+ options { { trigger: { strategy: 'depend' } } }
+ end
end
end
diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb
index 3db870f229a..503246bbdcf 100644
--- a/spec/features/projects/snippets/create_snippet_spec.rb
+++ b/spec/features/projects/snippets/create_snippet_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Projects > Snippets > Create Snippet', :js do
include DropzoneHelper
+ include Spec::Support::Helpers::Features::SnippetSpecHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) do
@@ -16,96 +17,115 @@ RSpec.describe 'Projects > Snippets > Create Snippet', :js do
let(:file_content) { 'Hello World!' }
let(:md_description) { 'My Snippet **Description**' }
let(:description) { 'My Snippet Description' }
+ let(:snippet_title_field) { 'project_snippet_title' }
- before do
- stub_feature_flags(snippets_vue: false)
- stub_feature_flags(snippets_edit_vue: false)
-
- sign_in(user)
-
- visit new_project_snippet_path(project)
- end
-
- def description_field
- find('.js-description-input').find('input,textarea')
- end
-
- def fill_form
- fill_in 'project_snippet_title', with: title
-
- # Click placeholder first to expand full description field
- description_field.click
- fill_in 'project_snippet_description', with: md_description
-
- page.within('.file-editor') do
- el = find('.inputarea')
- el.send_keys file_content
+ shared_examples 'snippet creation' do
+ def fill_form
+ snippet_fill_in_form(title: title, content: file_content, description: md_description)
end
- end
- it 'shows collapsible description input' do
- collapsed = description_field
+ it 'shows collapsible description input' do
+ collapsed = description_field
- expect(page).not_to have_field('project_snippet_description')
- expect(collapsed).to be_visible
+ expect(page).not_to have_field(snippet_description_field)
+ expect(collapsed).to be_visible
- collapsed.click
+ collapsed.click
- expect(page).to have_field('project_snippet_description')
- expect(collapsed).not_to be_visible
- end
-
- it 'creates a new snippet' do
- fill_form
- click_button('Create snippet')
- wait_for_requests
-
- expect(page).to have_content(title)
- expect(page).to have_content(file_content)
- page.within('.snippet-header .description') do
- expect(page).to have_content(description)
- expect(page).to have_selector('strong')
+ expect(page).to have_field(snippet_description_field)
+ expect(collapsed).not_to be_visible
end
- end
-
- it 'uploads a file when dragging into textarea' do
- fill_form
- dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
-
- expect(page.find_field('project_snippet_description').value).to have_content('banana_sample')
-
- click_button('Create snippet')
- wait_for_requests
-
- link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
- expect(link).to match(%r{/#{Regexp.escape(project.full_path)}/uploads/\h{32}/banana_sample\.gif\z})
- end
-
- it 'displays validation errors' do
- fill_in 'project_snippet_title', with: title
- click_button('Create snippet')
- wait_for_requests
-
- expect(page).to have_selector('#error_explanation')
- end
-
- context 'when the git operation fails' do
- let(:error) { 'Error creating the snippet' }
-
- before do
- allow_next_instance_of(Snippets::CreateService) do |instance|
- allow(instance).to receive(:create_commit).and_raise(StandardError, error)
- end
+ it 'creates a new snippet' do
fill_form
+ click_button('Create snippet')
+ wait_for_requests
+
+ expect(page).to have_content(title)
+ expect(page).to have_content(file_content)
+ page.within(snippet_description_view_selector) do
+ expect(page).to have_content(description)
+ expect(page).to have_selector('strong')
+ end
+ end
+
+ it 'uploads a file when dragging into textarea' do
+ fill_form
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+
+ expect(snippet_description_value).to have_content('banana_sample')
click_button('Create snippet')
wait_for_requests
+
+ link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/#{Regexp.escape(project.full_path)}/uploads/\h{32}/banana_sample\.gif\z})
end
- it 'renders the new page and displays the error' do
- expect(page).to have_content(error)
- expect(page).to have_content('New Snippet')
+ context 'when the git operation fails' do
+ let(:error) { 'Error creating the snippet' }
+
+ before do
+ allow_next_instance_of(Snippets::CreateService) do |instance|
+ allow(instance).to receive(:create_commit).and_raise(StandardError, error)
+ end
+
+ fill_form
+
+ click_button('Create snippet')
+ wait_for_requests
+ end
+
+ it 'renders the new page and displays the error' do
+ expect(page).to have_content(error)
+ expect(page).to have_content('New Snippet')
+ end
+ end
+ end
+
+ context 'Vue application' do
+ let(:snippet_description_field) { 'snippet-description' }
+ let(:snippet_description_view_selector) { '.snippet-header .snippet-description' }
+
+ before do
+ sign_in(user)
+
+ visit new_project_snippet_path(project)
+ end
+
+ it_behaves_like 'snippet creation'
+
+ it 'does not allow submitting the form without title and content' do
+ fill_in snippet_title_field, with: title
+
+ expect(page).not_to have_button('Create snippet')
+
+ snippet_fill_in_form(title: title, content: file_content)
+ expect(page).to have_button('Create snippet')
+ end
+ end
+
+ context 'non-Vue application' do
+ let(:snippet_description_field) { 'project_snippet_description' }
+ let(:snippet_description_view_selector) { '.snippet-header .description' }
+
+ before do
+ stub_feature_flags(snippets_vue: false)
+ stub_feature_flags(snippets_edit_vue: false)
+
+ sign_in(user)
+
+ visit new_project_snippet_path(project)
+ end
+
+ it_behaves_like 'snippet creation'
+
+ it 'displays validation errors' do
+ fill_in snippet_title_field, with: title
+ click_button('Create snippet')
+ wait_for_requests
+
+ expect(page).to have_selector('#error_explanation')
end
end
end
diff --git a/spec/features/projects/snippets/user_updates_snippet_spec.rb b/spec/features/projects/snippets/user_updates_snippet_spec.rb
index a40113bd93e..193eaa9576a 100644
--- a/spec/features/projects/snippets/user_updates_snippet_spec.rb
+++ b/spec/features/projects/snippets/user_updates_snippet_spec.rb
@@ -3,57 +3,81 @@
require 'spec_helper'
RSpec.describe 'Projects > Snippets > User updates a snippet', :js do
+ include Spec::Support::Helpers::Features::SnippetSpecHelpers
+
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, namespace: user.namespace) }
let_it_be(:snippet, reload: true) { create(:project_snippet, :repository, project: project, author: user) }
- before do
- stub_feature_flags(snippets_vue: false)
- stub_feature_flags(snippets_edit_vue: false)
+ let(:snippet_title_field) { 'project_snippet_title' }
+ def bootstrap_snippet
project.add_maintainer(user)
sign_in(user)
- visit(project_snippet_path(project, snippet))
+ page.visit(edit_project_snippet_path(project, snippet))
- page.within('.detail-page-header') do
- first(:link, 'Edit').click
- end
wait_for_all_requests
end
- it 'displays the snippet blob path and content' do
- blob = snippet.blobs.first
+ shared_examples 'snippet update' do
+ it 'displays the snippet blob path and content' do
+ blob = snippet.blobs.first
- aggregate_failures do
- expect(page.find_field('project_snippet_file_name').value).to eq blob.path
- expect(page.find('.file-content')).to have_content(blob.data.strip)
- expect(page.find('.snippet-file-content', visible: false).value).to eq blob.data
+ aggregate_failures do
+ expect(snippet_get_first_blob_path).to eq blob.path
+ expect(snippet_get_first_blob_value).to have_content(blob.data.strip)
+ end
end
- end
- it 'updates a snippet' do
- fill_in('project_snippet_title', with: 'Snippet new title')
- click_button('Save')
+ it 'updates a snippet' do
+ fill_in('project_snippet_title', with: 'Snippet new title')
+ click_button('Save')
- expect(page).to have_content('Snippet new title')
- end
+ expect(page).to have_content('Snippet new title')
+ end
- context 'when the git operation fails' do
- before do
- allow_next_instance_of(Snippets::UpdateService) do |instance|
- allow(instance).to receive(:create_commit).and_raise(StandardError, 'Error Message')
+ context 'when the git operation fails' do
+ before do
+ allow_next_instance_of(Snippets::UpdateService) do |instance|
+ allow(instance).to receive(:create_commit).and_raise(StandardError, 'Error Message')
+ end
+
+ fill_in(snippet_title_field, with: 'Snippet new title')
+ fill_in(snippet_blob_path_field, match: :first, with: 'new_file_name')
+
+ click_button('Save')
end
- fill_in('project_snippet_title', with: 'Snippet new title')
- fill_in('project_snippet_file_name', with: 'new_file_name')
+ it 'renders edit page and displays the error' do
+ expect(page.find('.flash-container')).to have_content('Error updating the snippet - Error Message')
+ expect(page).to have_content('Edit Snippet')
+ end
+ end
+ end
- click_button('Save')
+ context 'Vue application' do
+ before do
+ bootstrap_snippet
end
- it 'renders edit page and displays the error' do
- expect(page.find('.flash-container span').text).to eq('Error updating the snippet - Error Message')
- expect(page).to have_content('Edit Snippet')
+ it_behaves_like 'snippet update' do
+ let(:snippet_blob_path_field) { 'snippet_file_name' }
+ let(:snippet_blob_content_selector) { '.file-content' }
+ end
+ end
+
+ context 'non-Vue application' do
+ before do
+ stub_feature_flags(snippets_vue: false)
+ stub_feature_flags(snippets_edit_vue: false)
+
+ bootstrap_snippet
+ end
+
+ it_behaves_like 'snippet update' do
+ let(:snippet_blob_path_field) { 'project_snippet_file_name' }
+ let(:snippet_blob_content_selector) { '.file-content' }
end
end
end
diff --git a/spec/features/snippets/spam_snippets_spec.rb b/spec/features/snippets/spam_snippets_spec.rb
index e6a9467a3d7..1483ba4bf8f 100644
--- a/spec/features/snippets/spam_snippets_spec.rb
+++ b/spec/features/snippets/spam_snippets_spec.rb
@@ -11,8 +11,6 @@ RSpec.shared_examples_for 'snippet editor' do
before do
stub_feature_flags(allow_possible_spam: false)
- stub_feature_flags(snippets_vue: false)
- stub_feature_flags(snippets_edit_vue: false)
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
Gitlab::CurrentSettings.update!(
@@ -125,5 +123,20 @@ end
RSpec.describe 'User creates snippet', :js do
let_it_be(:user) { create(:user) }
- it_behaves_like "snippet editor"
+ context 'Vue application' do
+ before do
+ stub_feature_flags(snippets_edit_vue: false)
+ end
+
+ it_behaves_like "snippet editor"
+ end
+
+ context 'non-Vue application' do
+ before do
+ stub_feature_flags(snippets_vue: false)
+ stub_feature_flags(snippets_edit_vue: false)
+ end
+
+ it_behaves_like "snippet editor"
+ end
end
diff --git a/spec/features/snippets/user_creates_snippet_spec.rb b/spec/features/snippets/user_creates_snippet_spec.rb
index f4c6536d6d3..eabca028b8c 100644
--- a/spec/features/snippets/user_creates_snippet_spec.rb
+++ b/spec/features/snippets/user_creates_snippet_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'User creates snippet', :js do
include DropzoneHelper
+ include Spec::Support::Helpers::Features::SnippetSpecHelpers
let_it_be(:user) { create(:user) }
@@ -12,149 +13,163 @@ RSpec.describe 'User creates snippet', :js do
let(:md_description) { 'My Snippet **Description**' }
let(:description) { 'My Snippet Description' }
let(:created_snippet) { Snippet.last }
-
- before do
- stub_feature_flags(snippets_vue: false)
- stub_feature_flags(snippets_edit_vue: false)
- sign_in(user)
- end
+ let(:snippet_title_field) { 'personal_snippet_title' }
def description_field
find('.js-description-input').find('input,textarea')
end
- def fill_form
- fill_in 'personal_snippet_title', with: title
-
- # Click placeholder first to expand full description field
- description_field.click
- fill_in 'personal_snippet_description', with: md_description
-
- page.within('.file-editor') do
- el = find('.inputarea')
- el.send_keys file_content
+ shared_examples 'snippet creation' do
+ def fill_form
+ snippet_fill_in_form(title: title, content: file_content, description: md_description)
end
- end
- it 'Authenticated user creates a snippet' do
- visit new_snippet_path
+ it 'Authenticated user creates a snippet' do
+ fill_form
- fill_form
+ click_button('Create snippet')
+ wait_for_requests
- click_button('Create snippet')
- wait_for_requests
-
- expect(page).to have_content(title)
- page.within('.snippet-header .description') do
- expect(page).to have_content(description)
- expect(page).to have_selector('strong')
+ expect(page).to have_content(title)
+ page.within(snippet_description_view_selector) do
+ expect(page).to have_content(description)
+ expect(page).to have_selector('strong')
+ end
+ expect(page).to have_content(file_content)
end
- expect(page).to have_content(file_content)
- end
- it 'previews a snippet with file' do
- visit new_snippet_path
+ it 'uploads a file when dragging into textarea' do
+ fill_form
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
- # Click placeholder first to expand full description field
- description_field.click
- fill_in 'personal_snippet_description', with: 'My Snippet'
- dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
- find('.js-md-preview-button').click
+ expect(snippet_description_value).to have_content('banana_sample')
- page.within('#new_personal_snippet .md-preview-holder') do
- expect(page).to have_content('My Snippet')
+ click_button('Create snippet')
+ wait_for_requests
link = find('a.no-attachment-icon img.js-lazy-loaded[alt="banana_sample"]')['src']
- expect(link).to match(%r{/uploads/-/system/user/#{user.id}/\h{32}/banana_sample\.gif\z})
+ expect(link).to match(%r{/uploads/-/system/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z})
- # Adds a cache buster for checking if the image exists as Selenium is now handling the cached requests
- # not anymore as requests when they come straight from memory cache.
reqs = inspect_requests { visit("#{link}?ran=#{SecureRandom.base64(20)}") }
expect(reqs.first.status_code).to eq(200)
end
- end
- it 'uploads a file when dragging into textarea' do
- visit new_snippet_path
+ context 'when the git operation fails' do
+ let(:error) { 'Error creating the snippet' }
- fill_form
+ before do
+ allow_next_instance_of(Snippets::CreateService) do |instance|
+ allow(instance).to receive(:create_commit).and_raise(StandardError, error)
+ end
- dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
-
- expect(page.find_field("personal_snippet_description").value).to have_content('banana_sample')
-
- click_button('Create snippet')
- wait_for_requests
-
- link = find('a.no-attachment-icon img.js-lazy-loaded[alt="banana_sample"]')['src']
- expect(link).to match(%r{/uploads/-/system/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z})
-
- reqs = inspect_requests { visit("#{link}?ran=#{SecureRandom.base64(20)}") }
- expect(reqs.first.status_code).to eq(200)
- end
-
- context 'when the git operation fails' do
- let(:error) { 'Error creating the snippet' }
-
- before do
- allow_next_instance_of(Snippets::CreateService) do |instance|
- allow(instance).to receive(:create_commit).and_raise(StandardError, error)
+ fill_form
+ click_button('Create snippet')
+ wait_for_requests
end
- visit new_snippet_path
+ it 'renders the new page and displays the error' do
+ expect(page).to have_content(error)
+ expect(page).to have_content('New Snippet')
- fill_form
-
- click_button('Create snippet')
- wait_for_requests
+ action = find('form.snippet-form')['action']
+ expect(action).to include("/snippets")
+ end
end
- it 'renders the new page and displays the error' do
- expect(page).to have_content(error)
- expect(page).to have_content('New Snippet')
+ context 'when snippets default visibility level is restricted' do
+ before do
+ stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PRIVATE],
+ default_snippet_visibility: Gitlab::VisibilityLevel::PRIVATE)
+ end
- action = find('form.snippet-form')['action']
- expect(action).to match(%r{/snippets\z})
+ it 'creates a snippet using the lowest available visibility level as default' do
+ visit new_snippet_path
+
+ fill_form
+
+ click_button('Create snippet')
+ wait_for_requests
+
+ expect(find('.blob-content')).to have_content(file_content)
+ expect(Snippet.last.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL)
+ end
+ end
+
+ it_behaves_like 'personal snippet with references' do
+ let(:container) { snippet_description_view_selector }
+ let(:md_description) { references }
+
+ subject do
+ fill_form
+ click_button('Create snippet')
+
+ wait_for_requests
+ end
end
end
- it 'validation fails for the first time' do
- visit new_snippet_path
+ context 'Vue application' do
+ let(:snippet_description_field) { 'snippet-description' }
+ let(:snippet_description_view_selector) { '.snippet-header .snippet-description' }
- fill_in 'personal_snippet_title', with: title
- click_button('Create snippet')
-
- expect(page).to have_selector('#error_explanation')
- end
-
- context 'when snippets default visibility level is restricted' do
before do
- stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PRIVATE],
- default_snippet_visibility: Gitlab::VisibilityLevel::PRIVATE)
+ sign_in(user)
+
+ visit new_snippet_path
end
- it 'creates a snippet using the lowest available visibility level as default' do
- visit new_snippet_path
+ it_behaves_like 'snippet creation'
- fill_form
+ it 'validation fails for the first time' do
+ fill_in snippet_title_field, with: title
- click_button('Create snippet')
- wait_for_requests
+ expect(page).not_to have_button('Create snippet')
- expect(created_snippet.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL)
+ snippet_fill_in_form(title: title, content: file_content)
+ expect(page).to have_button('Create snippet')
end
end
- it_behaves_like 'personal snippet with references' do
- let(:container) { '.snippet-header .description' }
- let(:md_description) { references }
+ context 'non-Vue application' do
+ let(:snippet_description_field) { 'personal_snippet_description' }
+ let(:snippet_description_view_selector) { '.snippet-header .description' }
+
+ before do
+ stub_feature_flags(snippets_vue: false)
+ stub_feature_flags(snippets_edit_vue: false)
+
+ sign_in(user)
- subject do
visit new_snippet_path
- fill_form
+ end
+
+ it_behaves_like 'snippet creation'
+
+ it 'validation fails for the first time' do
+ fill_in snippet_title_field, with: title
click_button('Create snippet')
- wait_for_requests
+ expect(page).to have_selector('#error_explanation')
+ end
+
+ it 'previews a snippet with file' do
+ # Click placeholder first to expand full description field
+ description_field.click
+ fill_in snippet_description_field, with: 'My Snippet'
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+ find('.js-md-preview-button').click
+
+ page.within('.md-preview-holder') do
+ expect(page).to have_content('My Snippet')
+
+ link = find('a.no-attachment-icon img.js-lazy-loaded[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/-/system/user/#{user.id}/\h{32}/banana_sample\.gif\z})
+
+ # Adds a cache buster for checking if the image exists as Selenium is now handling the cached requests
+ # not anymore as requests when they come straight from memory cache.
+ reqs = inspect_requests { visit("#{link}?ran=#{SecureRandom.base64(20)}") }
+ expect(reqs.first.status_code).to eq(200)
+ end
end
end
end
diff --git a/spec/features/snippets/user_edits_snippet_spec.rb b/spec/features/snippets/user_edits_snippet_spec.rb
index 5773904dedf..9a83eb58b63 100644
--- a/spec/features/snippets/user_edits_snippet_spec.rb
+++ b/spec/features/snippets/user_edits_snippet_spec.rb
@@ -4,87 +4,114 @@ require 'spec_helper'
RSpec.describe 'User edits snippet', :js do
include DropzoneHelper
+ include Spec::Support::Helpers::Features::SnippetSpecHelpers
let_it_be(:file_name) { 'test.rb' }
let_it_be(:content) { 'puts "test"' }
let_it_be(:user) { create(:user) }
let_it_be(:snippet, reload: true) { create(:personal_snippet, :repository, :public, file_name: file_name, content: content, author: user) }
- before do
- stub_feature_flags(snippets_vue: false)
- stub_feature_flags(snippets_edit_vue: false)
+ let(:snippet_title_field) { 'personal_snippet_title' }
- sign_in(user)
+ shared_examples 'snippet editing' do
+ it 'displays the snippet blob path and content' do
+ blob = snippet.blobs.first
- visit edit_snippet_path(snippet)
- wait_for_all_requests
- end
-
- it 'displays the snippet blob path and content' do
- blob = snippet.blobs.first
-
- aggregate_failures do
- expect(page.find_field('personal_snippet_file_name').value).to eq blob.path
- expect(page.find('.file-content')).to have_content(blob.data.strip)
- expect(page.find('.snippet-file-content', visible: false).value).to eq blob.data
- end
- end
-
- it 'updates the snippet' do
- fill_in 'personal_snippet_title', with: 'New Snippet Title'
-
- click_button('Save changes')
- wait_for_requests
-
- expect(page).to have_content('New Snippet Title')
- end
-
- it 'updates the snippet with files attached' do
- dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
- expect(page.find_field('personal_snippet_description').value).to have_content('banana_sample')
-
- click_button('Save changes')
- wait_for_requests
-
- link = find('a.no-attachment-icon img:not(.lazy)[alt="banana_sample"]')['src']
- expect(link).to match(%r{/uploads/-/system/personal_snippet/#{snippet.id}/\h{32}/banana_sample\.gif\z})
- end
-
- it 'updates the snippet to make it internal' do
- choose 'Internal'
-
- click_button 'Save changes'
- wait_for_requests
-
- expect(page).to have_no_selector('[data-testid="lock-icon"]')
- expect(page).to have_selector('[data-testid="shield-icon"]')
- end
-
- it 'updates the snippet to make it public' do
- choose 'Public'
-
- click_button 'Save changes'
- wait_for_requests
-
- expect(page).to have_no_selector('[data-testid="lock-icon"]')
- expect(page).to have_selector('[data-testid="earth-icon"]')
- end
-
- context 'when the git operation fails' do
- before do
- allow_next_instance_of(Snippets::UpdateService) do |instance|
- allow(instance).to receive(:create_commit).and_raise(StandardError, 'Error Message')
+ aggregate_failures do
+ expect(snippet_get_first_blob_path).to eq blob.path
+ expect(snippet_get_first_blob_value).to have_content(blob.data.strip)
end
+ end
- fill_in 'personal_snippet_title', with: 'New Snippet Title'
- fill_in 'personal_snippet_file_name', with: 'new_file_name'
+ it 'updates the snippet' do
+ fill_in snippet_title_field, with: 'New Snippet Title'
click_button('Save changes')
+ wait_for_requests
+
+ expect(page).to have_content('New Snippet Title')
end
- it 'renders edit page and displays the error' do
- expect(page.find('.flash-container span').text).to eq('Error updating the snippet - Error Message')
- expect(page).to have_content('Edit Snippet')
+ it 'updates the snippet with files attached' do
+ dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+ expect(snippet_description_value).to have_content('banana_sample')
+
+ click_button('Save changes')
+ wait_for_requests
+
+ link = find('a.no-attachment-icon img:not(.lazy)[alt="banana_sample"]')['src']
+ expect(link).to match(%r{/uploads/-/system/personal_snippet/#{snippet.id}/\h{32}/banana_sample\.gif\z})
+ end
+
+ it 'updates the snippet to make it internal' do
+ choose 'Internal'
+
+ click_button 'Save changes'
+ wait_for_requests
+
+ expect(page).to have_no_selector('[data-testid="lock-icon"]')
+ expect(page).to have_selector('[data-testid="shield-icon"]')
+ end
+
+ it 'updates the snippet to make it public' do
+ choose 'Public'
+
+ click_button 'Save changes'
+ wait_for_requests
+
+ expect(page).to have_no_selector('[data-testid="lock-icon"]')
+ expect(page).to have_selector('[data-testid="earth-icon"]')
+ end
+
+ context 'when the git operation fails' do
+ before do
+ allow_next_instance_of(Snippets::UpdateService) do |instance|
+ allow(instance).to receive(:create_commit).and_raise(StandardError, 'Error Message')
+ end
+
+ fill_in snippet_title_field, with: 'New Snippet Title'
+ fill_in snippet_blob_path_field, with: 'new_file_name', match: :first
+
+ click_button('Save changes')
+ end
+
+ it 'renders edit page and displays the error' do
+ expect(page.find('.flash-container')).to have_content('Error updating the snippet - Error Message')
+ expect(page).to have_content('Edit Snippet')
+ end
+ end
+ end
+
+ context 'Vue application' do
+ it_behaves_like 'snippet editing' do
+ let(:snippet_blob_path_field) { 'snippet_file_name' }
+ let(:snippet_blob_content_selector) { '.file-content' }
+ let(:snippet_description_field) { 'snippet-description' }
+
+ before do
+ sign_in(user)
+
+ visit edit_snippet_path(snippet)
+ wait_for_all_requests
+ end
+ end
+ end
+
+ context 'non-Vue application' do
+ it_behaves_like 'snippet editing' do
+ let(:snippet_blob_path_field) { 'personal_snippet_file_name' }
+ let(:snippet_blob_content_selector) { '.file-content' }
+ let(:snippet_description_field) { 'personal_snippet_description' }
+
+ before do
+ stub_feature_flags(snippets_vue: false)
+ stub_feature_flags(snippets_edit_vue: false)
+
+ sign_in(user)
+
+ visit edit_snippet_path(snippet)
+ wait_for_all_requests
+ end
end
end
end
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index 5c2b684e165..cd3a6aa0e28 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -9,6 +9,7 @@ import NoChanges from '~/diffs/components/no_changes.vue';
import DiffFile from '~/diffs/components/diff_file.vue';
import CompareVersions from '~/diffs/components/compare_versions.vue';
import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue';
+import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue';
import CommitWidget from '~/diffs/components/commit_widget.vue';
import TreeList from '~/diffs/components/tree_list.vue';
import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants';
@@ -22,6 +23,10 @@ const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`;
const COMMIT_URL = '[BASE URL]/OLD';
const UPDATED_COMMIT_URL = '[BASE URL]/NEW';
+function getCollapsedFilesWarning(wrapper) {
+ return wrapper.find(CollapsedFilesWarning);
+}
+
describe('diffs/components/app', () => {
const oldMrTabs = window.mrTabs;
let store;
@@ -668,24 +673,51 @@ describe('diffs/components/app', () => {
);
});
- it('should render hidden files warning if render overflow warning is present', () => {
- createComponent({}, ({ state }) => {
- state.diffs.renderOverflowWarning = true;
- state.diffs.realSize = '5';
- state.diffs.plainDiffPath = 'plain diff path';
- state.diffs.emailPatchPath = 'email patch path';
- state.diffs.size = 1;
+ describe('warnings', () => {
+ describe('hidden files', () => {
+ it('should render hidden files warning if render overflow warning is present', () => {
+ createComponent({}, ({ state }) => {
+ state.diffs.renderOverflowWarning = true;
+ state.diffs.realSize = '5';
+ state.diffs.plainDiffPath = 'plain diff path';
+ state.diffs.emailPatchPath = 'email patch path';
+ state.diffs.size = 1;
+ });
+
+ expect(wrapper.find(HiddenFilesWarning).exists()).toBe(true);
+ expect(wrapper.find(HiddenFilesWarning).props()).toEqual(
+ expect.objectContaining({
+ total: '5',
+ plainDiffPath: 'plain diff path',
+ emailPatchPath: 'email patch path',
+ visible: 1,
+ }),
+ );
+ });
});
- expect(wrapper.find(HiddenFilesWarning).exists()).toBe(true);
- expect(wrapper.find(HiddenFilesWarning).props()).toEqual(
- expect.objectContaining({
- total: '5',
- plainDiffPath: 'plain diff path',
- emailPatchPath: 'email patch path',
- visible: 1,
- }),
- );
+ describe('collapsed files', () => {
+ it('should render the collapsed files warning if there are any collapsed files', () => {
+ createComponent({}, ({ state }) => {
+ state.diffs.diffFiles = [{ viewer: { collapsed: true } }];
+ });
+
+ expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
+ });
+
+ it('should not render the collapsed files warning if the user has dismissed the alert already', async () => {
+ createComponent({}, ({ state }) => {
+ state.diffs.diffFiles = [{ viewer: { collapsed: true } }];
+ });
+
+ expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
+
+ wrapper.vm.collapsedWarningDismissed = true;
+ await wrapper.vm.$nextTick();
+
+ expect(getCollapsedFilesWarning(wrapper).exists()).toBe(false);
+ });
+ });
});
it('should display commit widget if store has a commit', () => {
diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
new file mode 100644
index 00000000000..670eab5472f
--- /dev/null
+++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
@@ -0,0 +1,88 @@
+import Vuex from 'vuex';
+import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
+import createStore from '~/diffs/store/modules';
+import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue';
+import { CENTERED_LIMITED_CONTAINER_CLASSES } from '~/diffs/constants';
+
+const propsData = {
+ limited: true,
+ mergeable: true,
+ resolutionPath: 'a-path',
+};
+const limitedClasses = CENTERED_LIMITED_CONTAINER_CLASSES.split(' ');
+
+describe('CollapsedFilesWarning', () => {
+ const localVue = createLocalVue();
+ let store;
+ let wrapper;
+
+ localVue.use(Vuex);
+
+ const getAlertActionButton = () =>
+ wrapper.find(CollapsedFilesWarning).find('button.gl-alert-action:first-child');
+ const getAlertCloseButton = () => wrapper.find(CollapsedFilesWarning).find('button');
+
+ const createComponent = (props = {}, { full } = { full: false }) => {
+ const mounter = full ? mount : shallowMount;
+ store = new Vuex.Store({
+ modules: {
+ diffs: createStore(),
+ },
+ });
+
+ wrapper = mounter(CollapsedFilesWarning, {
+ propsData: { ...propsData, ...props },
+ localVue,
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ limited | containerClasses
+ ${true} | ${limitedClasses}
+ ${false} | ${[]}
+ `(
+ 'has the correct container classes when limited is $limited',
+ ({ limited, containerClasses }) => {
+ createComponent({ limited });
+
+ expect(wrapper.classes()).toEqual(containerClasses);
+ },
+ );
+
+ it.each`
+ present | dismissed
+ ${false} | ${true}
+ ${true} | ${false}
+ `('toggles the alert when dismissed is $dismissed', ({ present, dismissed }) => {
+ createComponent({ dismissed });
+
+ expect(wrapper.find('[data-testid="root"]').exists()).toBe(present);
+ });
+
+ it('dismisses the component when the alert "x" is clicked', async () => {
+ createComponent({}, { full: true });
+
+ expect(wrapper.find('[data-testid="root"]').exists()).toBe(true);
+
+ getAlertCloseButton().element.click();
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('[data-testid="root"]').exists()).toBe(false);
+ });
+
+ it('triggers the expandAllFiles action when the alert action button is clicked', () => {
+ createComponent({}, { full: true });
+
+ jest.spyOn(wrapper.vm.$store, 'dispatch').mockReturnValue(undefined);
+
+ getAlertActionButton().vm.$emit('click');
+
+ expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/expandAllFiles', undefined);
+ });
+});
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index 3c39dd2d385..79f0f6bc327 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -215,7 +215,7 @@ describe('DiffFile', () => {
it('calls handleLoadCollapsedDiff if collapsed changed & file has no lines', done => {
jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {});
- vm.file.highlighted_diff_lines = undefined;
+ vm.file.highlighted_diff_lines = [];
vm.file.parallel_diff_lines = [];
vm.isCollapsed = true;
@@ -242,8 +242,8 @@ describe('DiffFile', () => {
jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {});
- vm.file.highlighted_diff_lines = undefined;
- vm.file.parallel_diff_lines = [];
+ vm.file.highlighted_diff_lines = [];
+ vm.file.parallel_diff_lines = undefined;
vm.isCollapsed = true;
vm.$nextTick()
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 89a2a92ea57..821440db551 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -306,6 +306,38 @@ RSpec.describe IssuablesHelper do
end
end
+ describe '#reviewer_sidebar_data' do
+ let(:user) { create(:user) }
+
+ subject { helper.reviewer_sidebar_data(user, merge_request: merge_request) }
+
+ context 'without merge_request' do
+ let(:merge_request) { nil }
+
+ it 'returns hash of reviewer data' do
+ is_expected.to eql({
+ avatar_url: user.avatar_url,
+ name: user.name,
+ username: user.username
+ })
+ end
+ end
+
+ context 'with merge_request' do
+ let(:merge_request) { build(:merge_request) }
+
+ where(can_merge: [true, false])
+
+ with_them do
+ before do
+ allow(merge_request).to receive(:can_be_merged_by?).and_return(can_merge)
+ end
+
+ it { is_expected.to include({ can_merge: can_merge })}
+ end
+ end
+ end
+
describe '#issuable_squash_option?' do
using RSpec::Parameterized::TableSyntax
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index b3836e5dec7..e2612ba019f 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -482,6 +482,8 @@ project:
- dast_site_profiles
- dast_scanner_profiles
- dast_sites
+- dast_site_tokens
+- dast_site_validations
- operations_feature_flags
- operations_feature_flags_client
- operations_feature_flags_user_lists
@@ -689,6 +691,7 @@ epic:
- due_date_sourcing_epic
- events
- resource_label_events
+- resource_state_events
- user_mentions
- note_authors
- boards_epic_user_preferences
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index c0e7c016c20..228a1e8f7a2 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -1360,34 +1360,126 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
- context 'when pipeline is bridge triggered' do
- before do
- pipeline.source_bridge = create(:ci_bridge)
- end
+ def auto_devops_pipelines_completed_total(status)
+ Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines').get(status: status)
+ end
+ end
+
+ describe 'bridge triggered pipeline' do
+ shared_examples 'upstream downstream pipeline' do
+ let!(:source_pipeline) { create(:ci_sources_pipeline, pipeline: downstream_pipeline, source_job: bridge) }
+ let!(:job) { downstream_pipeline.builds.first }
context 'when source bridge is dependent on pipeline status' do
- before do
- allow(pipeline.source_bridge).to receive(:dependent?).and_return(true)
- end
+ let!(:bridge) { create(:ci_bridge, :strategy_depend, pipeline: upstream_pipeline) }
it 'schedules the pipeline bridge worker' do
- expect(::Ci::PipelineBridgeStatusWorker).to receive(:perform_async)
+ expect(::Ci::PipelineBridgeStatusWorker).to receive(:perform_async).with(downstream_pipeline.id)
- pipeline.succeed!
+ downstream_pipeline.succeed!
+ end
+
+ context 'when the downstream pipeline first fails then retries and succeeds' do
+ it 'makes the upstream pipeline successful' do
+ Sidekiq::Testing.inline! { job.drop! }
+
+ expect(downstream_pipeline.reload).to be_failed
+ expect(upstream_pipeline.reload).to be_failed
+
+ Sidekiq::Testing.inline! do
+ new_job = Ci::Build.retry(job, project.users.first)
+
+ expect(downstream_pipeline.reload).to be_running
+ expect(upstream_pipeline.reload).to be_running
+
+ new_job.success!
+ end
+
+ expect(downstream_pipeline.reload).to be_success
+ expect(upstream_pipeline.reload).to be_success
+ end
+ end
+
+ context 'when the downstream pipeline first succeeds then retries and fails' do
+ it 'makes the upstream pipeline failed' do
+ Sidekiq::Testing.inline! { job.success! }
+
+ expect(downstream_pipeline.reload).to be_success
+ expect(upstream_pipeline.reload).to be_success
+
+ Sidekiq::Testing.inline! do
+ new_job = Ci::Build.retry(job, project.users.first)
+
+ expect(downstream_pipeline.reload).to be_running
+ expect(upstream_pipeline.reload).to be_running
+
+ new_job.drop!
+ end
+
+ expect(downstream_pipeline.reload).to be_failed
+ expect(upstream_pipeline.reload).to be_failed
+ end
+ end
+
+ context 'when the upstream pipeline has another dependent upstream pipeline' do
+ let!(:upstream_of_upstream_pipeline) { create(:ci_pipeline) }
+
+ before do
+ upstream_bridge = create(:ci_bridge, :strategy_depend, pipeline: upstream_of_upstream_pipeline)
+ create(:ci_sources_pipeline, pipeline: upstream_pipeline,
+ source_job: upstream_bridge)
+ end
+
+ context 'when the downstream pipeline first fails then retries and succeeds' do
+ it 'makes upstream pipelines successful' do
+ Sidekiq::Testing.inline! { job.drop! }
+
+ expect(downstream_pipeline.reload).to be_failed
+ expect(upstream_pipeline.reload).to be_failed
+ expect(upstream_of_upstream_pipeline.reload).to be_failed
+
+ Sidekiq::Testing.inline! do
+ new_job = Ci::Build.retry(job, project.users.first)
+
+ expect(downstream_pipeline.reload).to be_running
+ expect(upstream_pipeline.reload).to be_running
+ expect(upstream_of_upstream_pipeline.reload).to be_running
+
+ new_job.success!
+ end
+
+ expect(downstream_pipeline.reload).to be_success
+ expect(upstream_pipeline.reload).to be_success
+ expect(upstream_of_upstream_pipeline.reload).to be_success
+ end
+ end
end
end
context 'when source bridge is not dependent on pipeline status' do
+ let!(:bridge) { create(:ci_bridge, pipeline: upstream_pipeline) }
+
it 'does not schedule the pipeline bridge worker' do
expect(::Ci::PipelineBridgeStatusWorker).not_to receive(:perform_async)
- pipeline.succeed!
+ downstream_pipeline.succeed!
end
end
end
- def auto_devops_pipelines_completed_total(status)
- Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines').get(status: status)
+ context 'multi-project pipelines' do
+ let!(:downstream_project) { create(:project, :repository) }
+ let!(:upstream_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:downstream_pipeline) { create(:ci_pipeline, :with_job, project: downstream_project) }
+
+ it_behaves_like 'upstream downstream pipeline'
+ end
+
+ context 'parent-child pipelines' do
+ let!(:upstream_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:downstream_pipeline) { create(:ci_pipeline, :with_job, project: project) }
+
+ it_behaves_like 'upstream downstream pipeline'
end
end
@@ -3611,4 +3703,65 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
end
+
+ describe 'reset_ancestor_bridges!' do
+ context 'when the pipeline is a child pipeline and the bridge is depended' do
+ let!(:parent_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:bridge) { create_bridge(parent_pipeline, pipeline, true) }
+
+ it 'marks source bridge as pending' do
+ pipeline.reset_ancestor_bridges!
+
+ expect(bridge.reload).to be_pending
+ end
+
+ context 'when the parent pipeline has a dependent upstream pipeline' do
+ let!(:upstream_bridge) do
+ create_bridge(create(:ci_pipeline, project: create(:project)), parent_pipeline, true)
+ end
+
+ it 'marks all source bridges as pending' do
+ pipeline.reset_ancestor_bridges!
+
+ expect(bridge.reload).to be_pending
+ expect(upstream_bridge.reload).to be_pending
+ end
+ end
+ end
+
+ context 'when the pipeline is a child pipeline and the bridge is not depended' do
+ let!(:parent_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:bridge) { create_bridge(parent_pipeline, pipeline, false) }
+
+ it 'does not touch source bridge' do
+ pipeline.reset_ancestor_bridges!
+
+ expect(bridge.reload).to be_success
+ end
+
+ context 'when the parent pipeline has a dependent upstream pipeline' do
+ let!(:upstream_bridge) do
+ create_bridge(create(:ci_pipeline, project: create(:project)), parent_pipeline, true)
+ end
+
+ it 'does not touch any source bridge' do
+ pipeline.reset_ancestor_bridges!
+
+ expect(bridge.reload).to be_success
+ expect(upstream_bridge.reload).to be_success
+ end
+ end
+ end
+
+ private
+
+ def create_bridge(upstream, downstream, depend = false)
+ options = depend ? { trigger: { strategy: 'depend' } } : {}
+
+ bridge = create(:ci_bridge, pipeline: upstream, status: 'success', options: options)
+ create(:ci_sources_pipeline, pipeline: downstream, source_job: bridge)
+
+ bridge
+ end
+ end
end
diff --git a/spec/models/resource_state_event_spec.rb b/spec/models/resource_state_event_spec.rb
index 1381b45cf9e..fc6575b2db8 100644
--- a/spec/models/resource_state_event_spec.rb
+++ b/spec/models/resource_state_event_spec.rb
@@ -11,4 +11,32 @@ RSpec.describe ResourceStateEvent, type: :model do
it_behaves_like 'a resource event'
it_behaves_like 'a resource event for issues'
it_behaves_like 'a resource event for merge requests'
+
+ describe 'validations' do
+ describe 'Issuable validation' do
+ it 'is valid if an issue is set' do
+ subject.attributes = { issue: build_stubbed(:issue), merge_request: nil }
+
+ expect(subject).to be_valid
+ end
+
+ it 'is valid if a merge request is set' do
+ subject.attributes = { issue: nil, merge_request: build_stubbed(:merge_request) }
+
+ expect(subject).to be_valid
+ end
+
+ it 'is invalid if both issue and merge request are set' do
+ subject.attributes = { issue: build_stubbed(:issue), merge_request: build_stubbed(:merge_request) }
+
+ expect(subject).not_to be_valid
+ end
+
+ it 'is invalid if there is no issuable set' do
+ subject.attributes = { issue: nil, merge_request: nil }
+
+ expect(subject).not_to be_valid
+ end
+ end
+ end
end
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb
index 2ee42c2968d..32bde5e7f2d 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_build_service_spec.rb
@@ -232,6 +232,19 @@ RSpec.describe Ci::RetryBuildService do
end
end
end
+
+ context 'when the pipeline is a child pipeline and the bridge is depended' do
+ let!(:parent_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:pipeline) { create(:ci_pipeline, project: project) }
+ let!(:bridge) { create(:ci_bridge, :strategy_depend, pipeline: parent_pipeline, status: 'success') }
+ let!(:source_pipeline) { create(:ci_sources_pipeline, pipeline: pipeline, source_job: bridge) }
+
+ it 'marks source bridge as pending' do
+ service.execute(build)
+
+ expect(bridge.reload).to be_pending
+ end
+ end
end
context 'when user does not have ability to execute build' do
diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb
index 212c8f99865..526c2f39b46 100644
--- a/spec/services/ci/retry_pipeline_service_spec.rb
+++ b/spec/services/ci/retry_pipeline_service_spec.rb
@@ -280,6 +280,20 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do
expect(build3.reload.scheduling_type).to eq('dag')
end
end
+
+ context 'when the pipeline is a downstream pipeline and the bridge is depended' do
+ let!(:bridge) { create(:ci_bridge, :strategy_depend, status: 'success') }
+
+ before do
+ create(:ci_sources_pipeline, pipeline: pipeline, source_job: bridge)
+ end
+
+ it 'marks source bridge as pending' do
+ service.execute(pipeline)
+
+ expect(bridge.reload).to be_pending
+ end
+ end
end
context 'when user is not allowed to retry pipeline' do
diff --git a/spec/support/helpers/features/snippet_helpers.rb b/spec/support/helpers/features/snippet_helpers.rb
new file mode 100644
index 00000000000..c01d179770c
--- /dev/null
+++ b/spec/support/helpers/features/snippet_helpers.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+# These helpers help you interact within the Editor Lite (single-file editor, snippets, etc.).
+#
+module Spec
+ module Support
+ module Helpers
+ module Features
+ module SnippetSpecHelpers
+ include ActionView::Helpers::JavaScriptHelper
+ include Spec::Support::Helpers::Features::EditorLiteSpecHelpers
+
+ def snippet_get_first_blob_path
+ page.find_field(snippet_blob_path_field, match: :first).value
+ end
+
+ def snippet_get_first_blob_value
+ page.find(snippet_blob_content_selector, match: :first)
+ end
+
+ def snippet_description_value
+ page.find_field(snippet_description_field).value
+ end
+
+ def snippet_fill_in_form(title:, content:, description: '')
+ # fill_in snippet_title_field, with: title
+ # editor_set_value(content)
+ fill_in snippet_title_field, with: title
+
+ if description
+ # Click placeholder first to expand full description field
+ description_field.click
+ fill_in snippet_description_field, with: description
+ end
+
+ page.within('.file-editor') do
+ el = find('.inputarea')
+ el.send_keys content
+ end
+ end
+
+ private
+
+ def description_field
+ find('.js-description-input').find('input,textarea')
+ end
+ end
+ end
+ end
+ end
+end