Commit 5410e709 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 49a2d827 fd6a8ca4
......@@ -193,6 +193,7 @@
- "config.ru"
- "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml"
.qa-patterns: &qa-patterns
- ".dockerignore"
......@@ -215,6 +216,7 @@
- "config.ru"
- "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml"
# Backstage changes
- "Dangerfile"
- "danger/**/*"
......@@ -240,6 +242,7 @@
- "config.ru"
- "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml"
# QA changes
- ".dockerignore"
- "qa/**/*"
......@@ -261,6 +264,7 @@
- "config.ru"
- "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*"
- "doc/api/graphql/reference/*" # Files in this folder are auto-generated
- "data/whats_new/*.yml"
# Backstage changes
- "Dangerfile"
- "danger/**/*"
......
import { s__ } from '~/locale';
export const CONFLICT_TYPES = {
TEXT: 'text',
TEXT_EDITOR: 'text-editor',
};
export const VIEW_TYPES = {
INLINE: 'inline',
PARALLEL: 'parallel',
};
export const EDIT_RESOLVE_MODE = 'edit';
export const INTERACTIVE_RESOLVE_MODE = 'interactive';
export const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE;
export const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes');
export const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes');
export const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours');
export const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs');
import Cookies from 'js-cookie';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '../constants';
import { decorateFiles, restoreFileLinesState, markLine } from '../utils';
import * as types from './mutation_types';
export const fetchConflictsData = async ({ commit, dispatch }, conflictsPath) => {
commit(types.SET_LOADING_STATE, true);
try {
const { data } = await axios.get(conflictsPath);
if (data.type === 'error') {
commit(types.SET_FAILED_REQUEST, data.message);
} else {
dispatch('setConflictsData', data);
}
} catch (e) {
commit(types.SET_FAILED_REQUEST);
}
commit(types.SET_LOADING_STATE, false);
};
export const setConflictsData = async ({ commit }, data) => {
const files = decorateFiles(data.files);
commit(types.SET_CONFLICTS_DATA, { ...data, files });
};
export const submitResolvedConflicts = async ({ commit, getters }, resolveConflictsPath) => {
commit(types.SET_SUBMIT_STATE, true);
try {
const { data } = await axios.post(resolveConflictsPath, getters.getCommitData);
window.location.assign(data.redirect_to);
} catch (e) {
commit(types.SET_SUBMIT_STATE, false);
createFlash({ message: __('Failed to save merge conflicts resolutions. Please try again!') });
}
};
export const setLoadingState = ({ commit }, isLoading) => {
commit(types.SET_LOADING_STATE, isLoading);
};
export const setErrorState = ({ commit }, hasError) => {
commit(types.SET_ERROR_STATE, hasError);
};
export const setFailedRequest = ({ commit }, message) => {
commit(types.SET_FAILED_REQUEST, message);
};
export const setViewType = ({ commit }, viewType) => {
commit(types.SET_VIEW_TYPE, viewType);
Cookies.set('diff_view', viewType);
};
export const setSubmitState = ({ commit }, isSubmitting) => {
commit(types.SET_SUBMIT_STATE, isSubmitting);
};
export const updateCommitMessage = ({ commit }, commitMessage) => {
commit(types.UPDATE_CONFLICTS_DATA, { commitMessage });
};
export const setFileResolveMode = ({ commit, state, getters }, { file, mode }) => {
const index = getters.getFileIndex(file);
const updated = { ...state.conflictsData.files[index] };
if (mode === INTERACTIVE_RESOLVE_MODE) {
updated.showEditor = false;
} else if (mode === EDIT_RESOLVE_MODE) {
// Restore Interactive mode when switching to Edit mode
updated.showEditor = true;
updated.loadEditor = true;
updated.resolutionData = {};
const { inlineLines, parallelLines } = restoreFileLinesState(updated);
updated.parallelLines = parallelLines;
updated.inlineLines = inlineLines;
}
updated.resolveMode = mode;
commit(types.UPDATE_FILE, { file: updated, index });
};
export const setPromptConfirmationState = (
{ commit, state, getters },
{ file, promptDiscardConfirmation },
) => {
const index = getters.getFileIndex(file);
const updated = { ...state.conflictsData.files[index], promptDiscardConfirmation };
commit(types.UPDATE_FILE, { file: updated, index });
};
export const handleSelected = ({ commit, state, getters }, { file, line: { id, section } }) => {
const index = getters.getFileIndex(file);
const updated = { ...state.conflictsData.files[index] };
updated.resolutionData = { ...updated.resolutionData, [id]: section };
updated.inlineLines = file.inlineLines.map((line) => {
if (id === line.id && (line.hasConflict || line.isHeader)) {
return markLine(line, section);
}
return line;
});
updated.parallelLines = file.parallelLines.map((lines) => {
let left = { ...lines[0] };
let right = { ...lines[1] };
const hasSameId = right.id === id || left.id === id;
const isLeftMatch = left.hasConflict || left.isHeader;
const isRightMatch = right.hasConflict || right.isHeader;
if (hasSameId && (isLeftMatch || isRightMatch)) {
left = markLine(left, section);
right = markLine(right, section);
}
return [left, right];
});
commit(types.UPDATE_FILE, { file: updated, index });
};
import { s__ } from '~/locale';
import { CONFLICT_TYPES, EDIT_RESOLVE_MODE, INTERACTIVE_RESOLVE_MODE } from '../constants';
export const getConflictsCount = (state) => {
if (!state.conflictsData.files.length) {
return 0;
}
const { files } = state.conflictsData;
let count = 0;
files.forEach((file) => {
if (file.type === CONFLICT_TYPES.TEXT) {
file.sections.forEach((section) => {
if (section.conflict) {
count += 1;
}
});
} else {
count += 1;
}
});
return count;
};
export const getConflictsCountText = (state, getters) => {
const count = getters.getConflictsCount;
const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict');
return `${count} ${text}`;
};
export const isReadyToCommit = (state) => {
const { files } = state.conflictsData;
const hasCommitMessage = state.conflictsData.commitMessage.trim().length;
let unresolved = 0;
for (let i = 0, l = files.length; i < l; i += 1) {
const file = files[i];
if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
let numberConflicts = 0;
const resolvedConflicts = Object.keys(file.resolutionData).length;
// We only check for conflicts type 'text'
// since conflicts `text_editor` can´t be resolved in interactive mode
if (file.type === CONFLICT_TYPES.TEXT) {
for (let j = 0, k = file.sections.length; j < k; j += 1) {
if (file.sections[j].conflict) {
numberConflicts += 1;
}
}
if (resolvedConflicts !== numberConflicts) {
unresolved += 1;
}
}
} else if (file.resolveMode === EDIT_RESOLVE_MODE) {
// Unlikely to happen since switching to Edit mode saves content automatically.
// Checking anyway in case the save strategy changes in the future
if (!file.content) {
unresolved += 1;
// eslint-disable-next-line no-continue
continue;
}
}
}
return !state.isSubmitting && hasCommitMessage && !unresolved;
};
export const getCommitButtonText = (state) => {
const initial = s__('MergeConflict|Commit to source branch');
const inProgress = s__('MergeConflict|Committing...');
return state.isSubmitting ? inProgress : initial;
};
export const getCommitData = (state) => {
let commitData = {};
commitData = {
commit_message: state.conflictsData.commitMessage,
files: [],
};
state.conflictsData.files.forEach((file) => {
const addFile = {
old_path: file.old_path,
new_path: file.new_path,
};
if (file.type === CONFLICT_TYPES.TEXT) {
// Submit only one data for type of editing
if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
addFile.sections = file.resolutionData;
} else if (file.resolveMode === EDIT_RESOLVE_MODE) {
addFile.content = file.content;
}
} else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) {
addFile.content = file.content;
}
commitData.files.push(addFile);
});
return commitData;
};
export const fileTextTypePresent = (state) => {
return state.conflictsData?.files.some((f) => f.type === CONFLICT_TYPES.TEXT);
};
export const getFileIndex = (state) => ({ blobPath }) => {
return state.conflictsData.files.findIndex((f) => f.blobPath === blobPath);
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
state,
getters,
actions,
mutations,
});
export const SET_LOADING_STATE = 'SET_LOADING_STATE';
export const SET_ERROR_STATE = 'SET_ERROR_STATE';
export const SET_FAILED_REQUEST = 'SET_FAILED_REQUEST';
export const SET_VIEW_TYPE = 'SET_VIEW_TYPE';
export const SET_SUBMIT_STATE = 'SET_SUBMIT_STATE';
export const SET_CONFLICTS_DATA = 'SET_CONFLICTS_DATA';
export const UPDATE_FILE = 'UPDATE_FILE';
export const UPDATE_CONFLICTS_DATA = 'UPDATE_CONFLICTS_DATA';
import { VIEW_TYPES } from '../constants';
import * as types from './mutation_types';
export default {
[types.SET_LOADING_STATE]: (state, value) => {
state.isLoading = value;
},
[types.SET_ERROR_STATE]: (state, value) => {
state.hasError = value;
},
[types.SET_FAILED_REQUEST]: (state, value) => {
state.hasError = true;
state.conflictsData.errorMessage = value;
},
[types.SET_VIEW_TYPE]: (state, value) => {
state.diffView = value;
state.isParallel = value === VIEW_TYPES.PARALLEL;
},
[types.SET_SUBMIT_STATE]: (state, value) => {
state.isSubmitting = value;
},
[types.SET_CONFLICTS_DATA]: (state, data) => {
state.conflictsData = {
files: data.files,
commitMessage: data.commit_message,
sourceBranch: data.source_branch,
targetBranch: data.target_branch,
shortCommitSha: data.commit_sha.slice(0, 7),
};
},
[types.UPDATE_CONFLICTS_DATA]: (state, payload) => {
state.conflictsData = {
...state.conflictsData,
...payload,
};
},
[types.UPDATE_FILE]: (state, { file, index }) => {
state.conflictsData.files.splice(index, 1, file);
},
};
import Cookies from 'js-cookie';
import { VIEW_TYPES } from '../constants';
const diffViewType = Cookies.get('diff_view');
export default () => ({
isLoading: true,
hasError: false,
isSubmitting: false,
isParallel: diffViewType === VIEW_TYPES.PARALLEL,
diffViewType,
conflictsData: {},
});
import {
ORIGIN_HEADER_TEXT,
ORIGIN_BUTTON_TITLE,
HEAD_HEADER_TEXT,
HEAD_BUTTON_TITLE,
DEFAULT_RESOLVE_MODE,
CONFLICT_TYPES,
} from './constants';
export const getFilePath = (file) => {
const { old_path, new_path } = file;
// eslint-disable-next-line babel/camelcase
return old_path === new_path ? new_path : `${old_path}${new_path}`;
};
export const checkLineLengths = ({ left, right }) => {
const wLeft = [...left];
const wRight = [...right];
if (left.length !== right.length) {
if (left.length > right.length) {
const diff = left.length - right.length;
for (let i = 0; i < diff; i += 1) {
wRight.push({ lineType: 'emptyLine', richText: '' });
}
} else {
const diff = right.length - left.length;
for (let i = 0; i < diff; i += 1) {
wLeft.push({ lineType: 'emptyLine', richText: '' });
}
}
}
return { left: wLeft, right: wRight };
};
export const getHeadHeaderLine = (id) => {
return {
id,
richText: HEAD_HEADER_TEXT,
buttonTitle: HEAD_BUTTON_TITLE,
type: 'new',
section: 'head',
isHeader: true,
isHead: true,
isSelected: false,
isUnselected: false,
};
};
export const decorateLineForInlineView = (line, id, conflict) => {
const { type } = line;
return {
id,
hasConflict: conflict,
isHead: type === 'new',
isOrigin: type === 'old',
hasMatch: type === 'match',
richText: line.rich_text,
isSelected: false,
isUnselected: false,
};
};
export const getLineForParallelView = (line, id, lineType, isHead) => {
const { old_line, new_line, rich_text } = line;
const hasConflict = lineType === 'conflict';
return {
id,
lineType,
hasConflict,
isHead: hasConflict && isHead,
isOrigin: hasConflict && !isHead,
hasMatch: lineType === 'match',
// eslint-disable-next-line babel/camelcase
lineNumber: isHead ? new_line : old_line,
section: isHead ? 'head' : 'origin',
richText: rich_text,
isSelected: false,
isUnselected: false,
};
};
export const getOriginHeaderLine = (id) => {
return {
id,
richText: ORIGIN_HEADER_TEXT,
buttonTitle: ORIGIN_BUTTON_TITLE,
type: 'old',
section: 'origin',
isHeader: true,
isOrigin: true,
isSelected: false,
isUnselected: false,
};
};
export const setInlineLine = (file) => {
const inlineLines = [];
file.sections.forEach((section) => {
let currentLineType = 'new';
const { conflict, lines, id } = section;
if (conflict) {
inlineLines.push(getHeadHeaderLine(id));
}
lines.forEach((line) => {
const { type } = line;
if ((type === 'new' || type === 'old') && currentLineType !== type) {
currentLineType = type;
inlineLines.push({ lineType: 'emptyLine', richText: '' });
}
const decoratedLine = decorateLineForInlineView(line, id, conflict);
inlineLines.push(decoratedLine);
});
if (conflict) {
inlineLines.push(getOriginHeaderLine(id));
}
});
return inlineLines;
};
export const setParallelLine = (file) => {
const parallelLines = [];
let linesObj = { left: [], right: [] };
file.sections.forEach((section) => {
const { conflict, lines, id } = section;
if (conflict) {
linesObj.left.push(getOriginHeaderLine(id));
linesObj.right.push(getHeadHeaderLine(id));
}
lines.forEach((line) => {
const { type } = line;
if (conflict) {
if (type === 'old') {
linesObj.left.push(getLineForParallelView(line, id, 'conflict'));
} else if (type === 'new') {
linesObj.right.push(getLineForParallelView(line, id, 'conflict', true));
}
} else {
const lineType = type || 'context';
linesObj.left.push(getLineForParallelView(line, id, lineType));
linesObj.right.push(getLineForParallelView(line, id, lineType, true));
}
});
linesObj = checkLineLengths(linesObj);
});
for (let i = 0, len = linesObj.left.length; i < len; i += 1) {
parallelLines.push([linesObj.right[i], linesObj.left[i]]);
}
return parallelLines;
};
export const decorateFiles = (files) => {
return files.map((file) => {
const f = { ...file };
f.content = '';
f.resolutionData = {};
f.promptDiscardConfirmation = false;
f.resolveMode = DEFAULT_RESOLVE_MODE;
f.filePath = getFilePath(file);
f.blobPath = f.blob_path;
if (f.type === CONFLICT_TYPES.TEXT) {
f.showEditor = false;
f.loadEditor = false;
f.inlineLines = setInlineLine(file);
f.parallelLines = setParallelLine(file);
} else if (f.type === CONFLICT_TYPES.TEXT_EDITOR) {
f.showEditor = true;
f.loadEditor = true;
}
return f;
});
};
export const restoreFileLinesState = (file) => {
const inlineLines = file.inlineLines.map((line) => {
if (line.hasConflict || line.isHeader) {
return { ...line, isSelected: false, isUnselected: false };
}
return { ...line };
});
const parallelLines = file.parallelLines.map((lines) => {
const left = { ...lines[0] };
const right = { ...lines[1] };
const isLeftMatch = left.hasConflict || left.isHeader;
const isRightMatch = right.hasConflict || right.isHeader;
if (isLeftMatch || isRightMatch) {
left.isSelected = false;
left.isUnselected = false;
right.isSelected = false;
right.isUnselected = false;
}
return [left, right];
});
return { inlineLines, parallelLines };
};
export const markLine = (line, selection) => {
const updated = { ...line };
if (selection === 'head' && line.isHead) {
updated.isSelected = true;
updated.isUnselected = false;
} else if (selection === 'origin' && updated.isOrigin) {
updated.isSelected = true;
updated.isUnselected = false;
} else {
updated.isSelected = false;
updated.isUnselected = true;
}
return updated;
};
......@@ -2,11 +2,9 @@ import DueDateSelectors from '~/due_date_select';
import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
initSettingsPanels();
// Initialize expandable settings panels
initSettingsPanels();
new DueDateSelectors(); // eslint-disable-line no-new
new DueDateSelectors(); // eslint-disable-line no-new
initSearchSettings();
});
initSearchSettings();
# frozen_string_literal: true
module Repositories
# Finder for obtaining commits between two refs, with a Git trailer set.
class CommitsWithTrailerFinder
# Finder for getting the commits to include in a changelog.
class ChangelogCommitsFinder
# The maximum number of commits to retrieve per page.
#
# This value is arbitrarily chosen. Lowering it means more Gitaly calls, but
......@@ -20,6 +20,9 @@ module Repositories
# 5-10 Gitaly calls, while keeping memory usage at a reasonable amount.
COMMITS_PER_PAGE = 1024
# The regex to use for extracting the SHA of a reverted commit.
REVERT_REGEX = /^This reverts commit (?<sha>[0-9a-f]{40})/i.freeze
# The `project` argument specifies the project for which to obtain the
# commits.
#
......@@ -44,7 +47,7 @@ module Repositories
#
# Example:
#
# CommitsWithTrailerFinder.new(...).each_page('Signed-off-by') do |commits|
# ChangelogCommitsFinder.new(...).each_page('Changelog') do |commits|
# commits.each do |commit|
# ...
# end
......@@ -53,12 +56,22 @@ module Repositories
return to_enum(__method__, trailer) unless block_given?
offset = 0
reverted = Set.new
response = fetch_commits
while response.any?
commits = []
response.each do |commit|
# If the commit is reverted in the same range (by a newer commit), we
# won't include it. This works here because commits are processed in
# reverse order (= newer first).
next if reverted.include?(commit.id)
if (sha = revert_commit_sha(commit))
reverted << sha
end
commits.push(commit) if commit.trailers.key?(trailer)
end
......@@ -78,5 +91,11 @@ module Repositories
.repository
.commits(range, limit: @per_page, offset: offset, trailers: true)
end
def revert_commit_sha(commit)
matches = commit.description.match(REVERT_REGEX)
matches[:sha] if matches
end
end
end
......@@ -73,7 +73,7 @@ module Repositories
.new(version: @version, date: @date, config: config)
commits =
CommitsWithTrailerFinder.new(project: @project, from: from, to: @to)
ChangelogCommitsFinder.new(project: @project, from: from, to: @to)
commits.each_page(@trailer) do |page|
mrs = mrs_finder.execute(page)
......
......@@ -30,7 +30,7 @@
= dropdown_content
= dropdown_loading
= render 'shared/projects/dropdown'
= link_to new_project_path, class: 'gl-button btn btn-success' do
= link_to new_project_path, class: 'gl-button btn btn-confirm' do
New Project
= button_tag "Search", class: "gl-button btn btn-confirm btn-search hide"
......
......@@ -65,7 +65,7 @@
%span.form-text.text-muted
= _("Upload a private key for your certificate")
= f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "gl-button btn btn-success js-serverless-domain-submit", disabled: @domain.persisted?
= f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "gl-button btn btn-confirm js-serverless-domain-submit", disabled: @domain.persisted?
- if @domain.persisted?
%button.gl-button.btn.btn-danger{ type: 'button', data: { toggle: 'modal', target: "#modal-delete-domain" } }
= _('Delete domain')
......
......@@ -6,4 +6,4 @@
.text-content.gl-mx-auto.gl-my-0.gl-p-5
%h4.h4= _('Deploy Keys')
%p= _('Deploy keys grant read/write access to all repositories in your instance')
= link_to _('New deploy key'), new_admin_deploy_key_path, class: 'btn btn-success btn-md gl-button'
= link_to _('New deploy key'), new_admin_deploy_key_path, class: 'gl-button btn btn-confirm btn-md'
......@@ -20,7 +20,7 @@
= _("To widen your search, change or remove filters above")
- if show_new_issue_link?(@project)
.text-center
= link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success"
= link_to _("New issue"), new_project_issue_path(@project), class: "gl-button btn btn-confirm"
- elsif is_opened_state && opened_issues_count == 0 && closed_issues_count > 0
%h4.text-center
= _("There are no open issues")
......@@ -28,7 +28,7 @@
= _("To keep this project going, create a new issue")
- if show_new_issue_link?(@project)
.text-center
= link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success"
= link_to _("New issue"), new_project_issue_path(@project), class: "gl-button btn btn-confirm"
- elsif is_closed_state && opened_issues_count > 0 && closed_issues_count == 0
%h4.text-center
= _("There are no closed issues")
......@@ -42,7 +42,7 @@
- if project_select_button
= render 'shared/new_project_item_select', path: 'issues/new', label: _('New issue'), type: :issues, with_feature_enabled: 'issues'
- else
= link_to _('New issue'), button_path, class: 'btn gl-button btn-success', id: 'new_issue_link'
= link_to _('New issue'), button_path, class: 'gl-button btn btn-confirm', id: 'new_issue_link'
- if show_import_button
= render 'projects/issues/import_csv/button', type: :text
......@@ -62,7 +62,7 @@
%p
= _("The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.")
.text-center
= link_to _('Register / Sign In'), new_user_session_path, class: 'btn btn-success'
= link_to _('Register / Sign In'), new_user_session_path, class: 'gl-button btn btn-confirm'
- if show_import_button
= render 'projects/issues/import_csv/modal'
......@@ -20,7 +20,7 @@
= _("To widen your search, change or remove filters above")
.text-center
- if can_create_merge_request
= link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request")
= link_to _("New merge request"), project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request")
- elsif is_opened_state && opened_merged_count == 0 && closed_merged_count > 0
%h4.text-center
= _("There are no open merge requests")
......@@ -28,7 +28,7 @@
= _("To keep this project going, create a new merge request")
.text-center
- if can_create_merge_request
= link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request")
= link_to _("New merge request"), project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request")
- elsif is_closed_state && opened_merged_count > 0 && closed_merged_count == 0
%h4.text-center
= _("There are no closed merge requests")
......@@ -42,4 +42,4 @@
- if project_select_button
= render 'shared/new_project_item_select', path: 'merge_requests/new', label: _('New merge request'), type: :merge_requests, with_feature_enabled: 'merge_requests'
- else
= link_to _('New merge request'), button_path, class: 'btn btn-success', title: _('New merge request'), id: 'new_merge_request_link'
= link_to _('New merge request'), button_path, class: 'gl-button btn btn-confirm', title: _('New merge request'), id: 'new_merge_request_link'
......@@ -13,9 +13,9 @@
%p= current_user_empty_message_description
- if secondary_button_link.present?
= link_to secondary_button_label, secondary_button_link, class: 'gl-button btn btn-success btn-inverted'
= link_to secondary_button_label, secondary_button_link, class: 'gl-button btn btn-confirm btn-inverted'
- if primary_button_link.present?
= link_to primary_button_label, primary_button_link, class: 'gl-button btn btn-success'
= link_to primary_button_label, primary_button_link, class: 'gl-button btn btn-confirm'
- else
%h5= visitor_empty_message
......@@ -12,7 +12,7 @@
= s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.')
.mt-2<
- if button_path
= link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn gl-button btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }
= link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn gl-button btn-confirm', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }
= link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn gl-button btn-default', title: s_('SnippetsEmptyState|Documentation')
- else
%h4.text-center= s_('SnippetsEmptyState|There are no snippets to show.')
......@@ -3,7 +3,7 @@
- if can?(current_user, :create_wiki, @wiki.container)
- create_path = wiki_page_path(@wiki, params[:id], view: 'create')
- create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn gl-button btn-success qa-create-first-page-link', title: s_('WikiEmpty|Create your first page')
- create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn gl-button btn-confirm qa-create-first-page-link', title: s_('WikiEmpty|Create your first page')
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do
%h4.text-left
......@@ -18,7 +18,7 @@
- elsif @project && can?(current_user, :read_issue, @project)
- issues_link = link_to s_('WikiEmptyIssueMessage|issue tracker'), project_issues_path(@project)
- new_issue_link = link_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), class: 'btn gl-button btn-success', title: s_('WikiEmptyIssueMessage|Suggest wiki improvement')
- new_issue_link = link_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), class: 'btn gl-button btn-confirm', title: s_('WikiEmptyIssueMessage|Suggest wiki improvement')
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do
%h4
......
---
title: Move to btn-confirm from btn-success in admin/projects directory
merge_request: 55274
author: Yogi (@yo)
type: changed
---
title: Move to btn-confirm from btn-success in admin/serverless directory
merge_request: 55275
author: Yogi (@yo)
type: changed
---
title: Move to btn-confirm in app/views/shared/empty_states directory
merge_request: 55203
author: Yogi (@yo)
type: changed
---
title: Restore accidental changes to structure.sql
merge_request: 55352
author:
type: other
---
title: Ignore reverted commits when generating changelogs
merge_request: 55537
author:
type: added
......@@ -9359,39 +9359,39 @@ CREATE TABLE application_settings (
elasticsearch_indexed_file_size_limit_kb integer DEFAULT 1024 NOT NULL,
enforce_namespace_storage_limit boolean DEFAULT false NOT NULL,
container_registry_delete_tags_service_timeout integer DEFAULT 250 NOT NULL,
kroki_url character varying,
kroki_enabled boolean,
elasticsearch_client_request_timeout integer DEFAULT 0 NOT NULL,
gitpod_enabled boolean DEFAULT false NOT NULL,
gitpod_url text DEFAULT 'https://gitpod.io/'::text,
elasticsearch_client_request_timeout integer DEFAULT 0 NOT NULL,
abuse_notification_email character varying,
require_admin_approval_after_user_signup boolean DEFAULT true NOT NULL,
help_page_documentation_base_url text,
automatic_purchased_storage_allocation boolean DEFAULT false NOT NULL,
container_registry_expiration_policies_worker_capacity integer DEFAULT 0 NOT NULL,
encrypted_ci_jwt_signing_key text,
encrypted_ci_jwt_signing_key_iv text,
secret_detection_token_revocation_enabled boolean DEFAULT false NOT NULL,
secret_detection_token_revocation_url text,
encrypted_secret_detection_token_revocation_token text,
encrypted_secret_detection_token_revocation_token_iv text,
container_registry_expiration_policies_worker_capacity integer DEFAULT 0 NOT NULL,
elasticsearch_analyzers_smartcn_enabled boolean DEFAULT false NOT NULL,
elasticsearch_analyzers_smartcn_search boolean DEFAULT false NOT NULL,
elasticsearch_analyzers_kuromoji_enabled boolean DEFAULT false NOT NULL,
elasticsearch_analyzers_kuromoji_search boolean DEFAULT false NOT NULL,
new_user_signups_cap integer,
secret_detection_token_revocation_enabled boolean DEFAULT false NOT NULL,
secret_detection_token_revocation_url text,
encrypted_secret_detection_token_revocation_token text,
encrypted_secret_detection_token_revocation_token_iv text,
domain_denylist_enabled boolean DEFAULT false,
domain_denylist text,
domain_allowlist text,
new_user_signups_cap integer,
encrypted_cloud_license_auth_token text,
encrypted_cloud_license_auth_token_iv text,
secret_detection_revocation_token_types_url text,
cloud_license_enabled boolean DEFAULT false NOT NULL,
kroki_url text,
kroki_enabled boolean DEFAULT false NOT NULL,
disable_feed_token boolean DEFAULT false NOT NULL,
personal_access_token_prefix text,
rate_limiting_response_text text,
container_registry_cleanup_tags_service_max_list_size integer DEFAULT 200 NOT NULL,
invisible_captcha_enabled boolean DEFAULT false NOT NULL,
container_registry_cleanup_tags_service_max_list_size integer DEFAULT 200 NOT NULL,
enforce_ssh_key_expiration boolean DEFAULT false NOT NULL,
git_two_factor_session_expiry integer DEFAULT 15 NOT NULL,
keep_latest_artifact boolean DEFAULT true NOT NULL,
......@@ -9402,7 +9402,7 @@ CREATE TABLE application_settings (
asset_proxy_whitelist text,
CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)),
CONSTRAINT app_settings_registry_exp_policies_worker_capacity_positive CHECK ((container_registry_expiration_policies_worker_capacity >= 0)),
CONSTRAINT check_17d9558205 CHECK ((char_length(kroki_url) <= 1024)),
CONSTRAINT check_17d9558205 CHECK ((char_length((kroki_url)::text) <= 1024)),
CONSTRAINT check_2dba05b802 CHECK ((char_length(gitpod_url) <= 255)),
CONSTRAINT check_51700b31b5 CHECK ((char_length(default_branch_name) <= 255)),
CONSTRAINT check_57123c9593 CHECK ((char_length(help_page_documentation_base_url) <= 255)),
......@@ -21,6 +21,10 @@ Contributions are welcome.
For a list of the available resources and their endpoints, see
[API resources](api_resources.md).
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For an introduction and basic steps, see
[How to make GitLab API calls](https://www.youtube.com/watch?v=0LsMC3ZiXkA).
## SCIM **(PREMIUM SAAS)**
GitLab provides an [SCIM API](scim.md) that both implements
......
......@@ -395,6 +395,26 @@ these as the changelog entries. You can enrich entries with additional data,
such as a link to the merge request or details about the commit author. You can
[customize the format of a changelog](#customize-the-changelog-output) section with a template.
### Reverted commits
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55537) in GitLab 13.10.
When generating a changelog for a range, GitLab ignores commits both added and
reverted in that range. Revert commits themselves _are_ included if they use the
Git trailer used for generating changelogs.
Imagine the following scenario: you have three commits: A, B, and C. To generate
changelogs, you use the default trailer `Changelog`. Both A and B use this
trailer. Commit C is a commit that reverts commit B. When generating a changelog
for this range, GitLab only includes commit A.
Revert commits are detected by looking for commits where the message contains
the pattern `This reverts commit SHA`, where `SHA` is the SHA of the commit that
is reverted.
If a revert commit includes the trailer used for generating changelogs
(`Changelog` in the above example), the revert commit itself _is_ included.
### Customize the changelog output
The output is customized using a YAML configuration file stored in your
......
......@@ -294,3 +294,24 @@ Strive to write many small pure functions and minimize where mutations occur
var c = pureFunction(values.foo);
```
## Export constants as primitives
Prefer exporting constant primitives with a common namespace over exporting objects. This allows for better compile-time reference checks and helps to avoid accidential `undefined`s at runtime. In addition, it helps in reducing bundle sizes.
Only export the constants as a collection (array, or object) when there is a need to iterate over them, for instance, for a prop validator.
```javascript
// bad
export const VARIANT = {
WARNING: 'warning',
ERROR: 'error',
};
// good
export const VARIANT_WARNING = 'warning';
export const VARIANT_ERROR = 'error';
// good, if the constants need to be iterated over
export const VARIANTS = [VARIANT_WARNING, VARIANT_ERROR];
```
......@@ -648,7 +648,9 @@ RSpec.describe Projects::BranchesController do
end
it 'sets active and stale branches' do
expect(assigns[:active_branches]).to eq([])
expect(assigns[:active_branches].map(&:name)).not_to include(
"feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"
)
expect(assigns[:stale_branches].map(&:name)).to eq(
["feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"]
)
......@@ -660,7 +662,9 @@ RSpec.describe Projects::BranchesController do
end
it 'sets active and stale branches' do
expect(assigns[:active_branches]).to eq([])
expect(assigns[:active_branches].map(&:name)).not_to include(
"feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"
)
expect(assigns[:stale_branches].map(&:name)).to eq(
["feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"]
)
......
......@@ -2,8 +2,8 @@
require 'spec_helper'
RSpec.describe Repositories::CommitsWithTrailerFinder do
let(:project) { create(:project, :repository) }
RSpec.describe Repositories::ChangelogCommitsFinder do
let_it_be(:project) { create(:project, :repository) }
describe '#each_page' do
it 'only yields commits with the given trailer' do
......@@ -22,6 +22,35 @@ RSpec.describe Repositories::CommitsWithTrailerFinder do
)
end
it 'ignores commits that are reverted' do
# This range of commits is found on the branch
# https://gitlab.com/gitlab-org/gitlab-test/-/commits/trailers.
finder = described_class.new(
project: project,
from: 'ddd0f15ae83993f5cb66a927a28673882e99100b',
to: '694e6c2f08cad00d183682d9dede99615998a630'
)
commits = finder.each_page('Changelog').to_a.flatten
expect(commits).to be_empty
end
it 'includes revert commits if they have a trailer' do
finder = described_class.new(
project: project,
from: 'ddd0f15ae83993f5cb66a927a28673882e99100b',
to: 'f0a5ed60d24c98ec6d00ac010c1f3f01ee0a8373'
)
initial_commit = project.commit('ed2e92bf50b3da2c7cbbab053f4977a4ecbd109a')
revert_commit = project.commit('f0a5ed60d24c98ec6d00ac010c1f3f01ee0a8373')
commits = finder.each_page('Changelog').to_a.flatten
expect(commits).to eq([revert_commit, initial_commit])
end
it 'supports paginating of commits' do
finder = described_class.new(
project: project,
......
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '~/merge_conflicts/constants';
import * as actions from '~/merge_conflicts/store/actions';
import * as types from '~/merge_conflicts/store/mutation_types';
import { restoreFileLinesState, markLine, decorateFiles } from '~/merge_conflicts/utils';
jest.mock('~/flash.js');
jest.mock('~/merge_conflicts/utils');
describe('merge conflicts actions', () => {
let mock;
const files = [
{
blobPath: 'a',
},
{ blobPath: 'b' },
];
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('fetchConflictsData', () => {
const conflictsPath = 'conflicts/path/mock';
it('on success dispatches setConflictsData', (done) => {
mock.onGet(conflictsPath).reply(200, {});
testAction(
actions.fetchConflictsData,
conflictsPath,
{},
[
{ type: types.SET_LOADING_STATE, payload: true },
{ type: types.SET_LOADING_STATE, payload: false },
],
[{ type: 'setConflictsData', payload: {} }],
done,
);
});
it('when data has type equal to error ', (done) => {
mock.onGet(conflictsPath).reply(200, { type: 'error', message: 'error message' });
testAction(
actions.fetchConflictsData,
conflictsPath,
{},
[
{ type: types.SET_LOADING_STATE, payload: true },
{ type: types.SET_FAILED_REQUEST, payload: 'error message' },
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
done,
);
});
it('when request fails ', (done) => {
mock.onGet(conflictsPath).reply(400);
testAction(
actions.fetchConflictsData,
conflictsPath,
{},
[
{ type: types.SET_LOADING_STATE, payload: true },
{ type: types.SET_FAILED_REQUEST },
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
done,
);
});
});
describe('submitResolvedConflicts', () => {
useMockLocationHelper();
const resolveConflictsPath = 'resolve/conflicts/path/mock';
it('on success reloads the page', (done) => {
mock.onPost(resolveConflictsPath).reply(200, { redirect_to: 'hrefPath' });
testAction(
actions.submitResolvedConflicts,
resolveConflictsPath,
{},
[{ type: types.SET_SUBMIT_STATE, payload: true }],
[],
() => {
expect(window.location.assign).toHaveBeenCalledWith('hrefPath');
done();
},
);
});
it('on errors shows flash', (done) => {
mock.onPost(resolveConflictsPath).reply(400);
testAction(
actions.submitResolvedConflicts,
resolveConflictsPath,
{},
[
{ type: types.SET_SUBMIT_STATE, payload: true },
{ type: types.SET_SUBMIT_STATE, payload: false },
],
[],
() => {
expect(createFlash).toHaveBeenCalledWith({
message: 'Failed to save merge conflicts resolutions. Please try again!',
});
done();
},
);
});
});
describe('setConflictsData', () => {
it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
decorateFiles.mockReturnValue([{ bar: 'baz' }]);
testAction(
actions.setConflictsData,
{ files, foo: 'bar' },
{},
[
{
type: types.SET_CONFLICTS_DATA,
payload: { foo: 'bar', files: [{ bar: 'baz' }] },
},
],
[],
done,
);
});
});
describe('setFileResolveMode', () => {
it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
testAction(
actions.setFileResolveMode,
{ file: files[0], mode: INTERACTIVE_RESOLVE_MODE },
{ conflictsData: { files }, getFileIndex: () => 0 },
[
{
type: types.UPDATE_FILE,
payload: {
file: { ...files[0], showEditor: false, resolveMode: INTERACTIVE_RESOLVE_MODE },
index: 0,
},
},
],
[],
done,
);
});
it('EDIT_RESOLVE_MODE updates the correct file ', (done) => {
restoreFileLinesState.mockReturnValue([]);
const file = {
...files[0],
showEditor: true,
loadEditor: true,
resolutionData: {},
resolveMode: EDIT_RESOLVE_MODE,
};
testAction(
actions.setFileResolveMode,
{ file: files[0], mode: EDIT_RESOLVE_MODE },
{ conflictsData: { files }, getFileIndex: () => 0 },
[
{
type: types.UPDATE_FILE,
payload: {
file,
index: 0,
},
},
],
[],
() => {
expect(restoreFileLinesState).toHaveBeenCalledWith(file);
done();
},
);
});
});
describe('setPromptConfirmationState', () => {
it('updates the correct file ', (done) => {
testAction(
actions.setPromptConfirmationState,
{ file: files[0], promptDiscardConfirmation: true },
{ conflictsData: { files }, getFileIndex: () => 0 },
[
{
type: types.UPDATE_FILE,
payload: {
file: { ...files[0], promptDiscardConfirmation: true },
index: 0,
},
},
],
[],
done,
);
});
});
describe('handleSelected', () => {
const file = {
...files[0],
inlineLines: [{ id: 1, hasConflict: true }, { id: 2 }],
parallelLines: [
[{ id: 1, hasConflict: true }, { id: 1 }],
[{ id: 2 }, { id: 3 }],
],
};
it('updates the correct file ', (done) => {
const marLikeMockReturn = { foo: 'bar' };
markLine.mockReturnValue(marLikeMockReturn);
testAction(
actions.handleSelected,
{ file, line: { id: 1, section: 'baz' } },
{ conflictsData: { files }, getFileIndex: () => 0 },
[
{
type: types.UPDATE_FILE,
payload: {
file: {
...file,
resolutionData: { 1: 'baz' },
inlineLines: [marLikeMockReturn, { id: 2 }],
parallelLines: [
[marLikeMockReturn, marLikeMockReturn],
[{ id: 2 }, { id: 3 }],
],
},
index: 0,
},
},
],
[],
() => {
expect(markLine).toHaveBeenCalledTimes(3);
done();
},
);
});
});
});
......@@ -20,7 +20,7 @@ RSpec.describe Projects::BranchesByModeService do
branches, prev_page, next_page = subject
expect(branches.size).to eq(10)
expect(branches.size).to eq(11)
expect(next_page).to be_nil
expect(prev_page).to eq("/#{project.full_path}/-/branches/all?offset=2&page=3")
end
......@@ -99,7 +99,7 @@ RSpec.describe Projects::BranchesByModeService do
it 'returns branches after the specified branch' do
branches, prev_page, next_page = subject
expect(branches.size).to eq(14)
expect(branches.size).to eq(15)
expect(next_page).to be_nil
expect(prev_page).to eq("/#{project.full_path}/-/branches/all?offset=3&page=4&sort=name_asc")
end
......
......@@ -80,15 +80,43 @@ RSpec.describe Repositories::ChangelogService do
expect(changelog).to include('Title 1', 'Title 2')
end
it 'uses the target branch when "to" is unspecified' do
allow(MergeRequestDiffCommit)
.to receive(:oldest_merge_request_id_per_commit)
.with(project.id, [commit3.id, commit2.id, commit1.id])
.and_return([
{ sha: sha2, merge_request_id: mr1.id },
{ sha: sha3, merge_request_id: mr2.id }
])
it "ignores a commit when it's both added and reverted in the same range" do
create_commit(
project,
author2,
commit_message: "Title 4\n\nThis reverts commit #{sha4}",
actions: [{ action: 'create', content: 'bar', file_path: 'd.txt' }]
)
described_class
.new(project, creator, version: '1.0.0', from: sha1)
.execute
changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data
expect(changelog).to include('Title 1', 'Title 2')
expect(changelog).not_to include('Title 3', 'Title 4')
end
it 'includes a revert commit when it has a trailer' do
create_commit(
project,
author2,
commit_message: "Title 4\n\nThis reverts commit #{sha4}\n\nChangelog: added",
actions: [{ action: 'create', content: 'bar', file_path: 'd.txt' }]
)
described_class
.new(project, creator, version: '1.0.0', from: sha1)
.execute
changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data
expect(changelog).to include('Title 1', 'Title 2', 'Title 4')
expect(changelog).not_to include('Title 3')
end
it 'uses the target branch when "to" is unspecified' do
described_class
.new(project, creator, version: '1.0.0', from: sha1)
.execute
......
......@@ -77,7 +77,8 @@ module TestEnv
'sha-starting-with-large-number' => '8426165',
'invalid-utf8-diff-paths' => '99e4853',
'compare-with-merge-head-source' => 'f20a03d',
'compare-with-merge-head-target' => '2f1e176'
'compare-with-merge-head-target' => '2f1e176',
'trailers' => 'f0a5ed6'
}.freeze
# gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment