Commit e957ea4a authored by Kamil Trzcinski's avatar Kamil Trzcinski

Merge branch '38464-k8s-apps' of https://gitlab.com/gitlab-org/gitlab-ce into...

Merge branch '38464-k8s-apps' of https://gitlab.com/gitlab-org/gitlab-ce into add-ingress-to-cluster-applications
parents 0a459db0 18ac8acb
<script>
import { mapActions, mapGetters } from 'vuex';
import timeAgoMixin from '../../vue_shared/mixins/timeago';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
export default {
mixins: [
timeAgoMixin,
],
components: {
skeletonLoadingContainer,
},
props: {
file: {
type: Object,
......@@ -16,6 +20,9 @@
...mapGetters([
'isCollapsed',
]),
isSubmodule() {
return this.file.type === 'submodule';
},
fileIcon() {
return {
'fa-spinner fa-spin': this.file.loading,
......@@ -31,6 +38,9 @@
shortId() {
return this.file.id.substr(0, 8);
},
submoduleColSpan() {
return !this.isCollapsed && this.isSubmodule ? 3 : 1;
},
},
methods: {
...mapActions([
......@@ -44,7 +54,7 @@
<tr
class="file"
@click.prevent="clickedTreeRow(file)">
<td>
<td :colspan="submoduleColSpan">
<i
class="fa fa-fw file-icon"
:class="fileIcon"
......@@ -58,7 +68,7 @@
>
{{ file.name }}
</a>
<template v-if="file.type === 'submodule' && file.id">
<template v-if="isSubmodule && file.id">
@
<span class="commit-sha">
<a
......@@ -71,15 +81,20 @@
</template>
</td>
<template v-if="!isCollapsed">
<template v-if="!isCollapsed && !isSubmodule">
<td class="hidden-sm hidden-xs">
<a
v-if="file.lastCommit.message"
@click.stop
:href="file.lastCommit.url"
class="commit-message"
>
{{ file.lastCommit.message }}
</a>
<skeleton-loading-container
v-else
:small="true"
/>
</td>
<td class="commit-update hidden-xs text-right">
......@@ -89,6 +104,11 @@
>
{{ timeFormated(file.lastCommit.updatedAt) }}
</span>
<skeleton-loading-container
v-else
class="animation-container-right"
:small="true"
/>
</td>
</template>
</tr>
......
<script>
import { mapGetters } from 'vuex';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
export default {
components: {
skeletonLoadingContainer,
},
computed: {
...mapGetters([
'isCollapsed',
]),
},
methods: {
lineOfCode(n) {
return `skeleton-line-${n}`;
},
},
};
</script>
......@@ -21,36 +20,24 @@
aria-label="Loading files"
>
<td>
<div
class="animation-container animation-container-small">
<div
v-for="n in 6"
:key="n"
:class="lineOfCode(n)">
</div>
</div>
<skeleton-loading-container
:small="true"
/>
</td>
<template v-if="!isCollapsed">
<td
class="hidden-sm hidden-xs">
<div class="animation-container">
<div
v-for="n in 6"
:key="n"
:class="lineOfCode(n)">
</div>
</div>
<skeleton-loading-container
:small="true"
/>
</td>
<td
class="hidden-xs">
<div class="animation-container animation-container-small animation-container-right">
<div
v-for="n in 6"
:key="n"
:class="lineOfCode(n)">
</div>
</div>
<skeleton-loading-container
class="animation-container-right"
:small="true"
/>
</td>
</template>
</tr>
......
......@@ -80,7 +80,7 @@ export default {
/>
<repo-file
v-for="(file, index) in treeList"
:key="index"
:key="file.key"
:file="file"
/>
</tbody>
......
......@@ -30,4 +30,11 @@ export default {
commit(projectId, payload) {
return Api.commitMultiple(projectId, payload);
},
getTreeLastCommit(endpoint) {
return Vue.http.get(endpoint, {
params: {
format: 'json',
},
});
},
};
......@@ -64,7 +64,7 @@ export const checkCommitStatus = ({ state }) => service.getBranchData(
})
.catch(() => flash('Error checking branch data. Please try again.'));
export const commitChanges = ({ commit, state, dispatch }, { payload, newMr }) =>
export const commitChanges = ({ commit, state, dispatch, getters }, { payload, newMr }) =>
service.commit(state.project.id, payload)
.then((data) => {
const { branch } = payload;
......@@ -73,12 +73,28 @@ export const commitChanges = ({ commit, state, dispatch }, { payload, newMr }) =
return;
}
const lastCommit = {
commit_path: `${state.project.url}/commit/${data.id}`,
commit: {
message: data.message,
authored_date: data.committed_date,
},
};
flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice');
if (newMr) {
redirectToUrl(`${state.endpoints.newMergeRequestUrl}${branch}`);
} else {
commit(types.SET_COMMIT_REF, data.id);
getters.changedFiles.forEach((entry) => {
commit(types.SET_LAST_COMMIT_DATA, {
entry,
lastCommit,
});
});
dispatch('discardAllChanges');
dispatch('closeAllFiles');
dispatch('toggleEditMode');
......
......@@ -27,6 +27,8 @@ export const closeFile = ({ commit, state, dispatch }, { file, force = false })
} else if (!state.openFiles.length) {
pushState(file.parentTreeUrl);
}
dispatch('getLastCommitData');
};
export const setFileActive = ({ commit, state, getters, dispatch }, file) => {
......
......@@ -7,10 +7,11 @@ import {
setPageTitle,
findEntry,
createTemp,
createOrMergeEntry,
} from '../utils';
export const getTreeData = (
{ commit, state },
{ commit, state, dispatch },
{ endpoint = state.endpoints.rootEndpoint, tree = state } = {},
) => {
commit(types.TOGGLE_LOADING, tree);
......@@ -24,14 +25,20 @@ export const getTreeData = (
return res.json();
})
.then((data) => {
const prevLastCommitPath = tree.lastCommitPath;
if (!state.isInitialRoot) {
commit(types.SET_ROOT, data.path === '/');
}
commit(types.SET_DIRECTORY_DATA, { data, tree });
dispatch('updateDirectoryData', { data, tree });
commit(types.SET_PARENT_TREE_URL, data.parent_tree_url);
commit(types.SET_LAST_COMMIT_URL, { tree, url: data.last_commit_path });
commit(types.TOGGLE_LOADING, tree);
if (prevLastCommitPath !== null) {
dispatch('getLastCommitData', tree);
}
pushState(endpoint);
})
.catch(() => {
......@@ -48,7 +55,7 @@ export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => {
pushState(tree.parentTreeUrl);
commit(types.SET_PREVIOUS_URL, tree.parentTreeUrl);
commit(types.SET_DIRECTORY_DATA, { data, tree });
dispatch('updateDirectoryData', { data, tree });
} else {
commit(types.SET_PREVIOUS_URL, endpoint);
dispatch('getTreeData', { endpoint, tree });
......@@ -108,3 +115,48 @@ export const createTempTree = ({ state, commit, dispatch }, name) => {
});
}
};
export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => {
if (tree.lastCommitPath === null || getters.isCollapsed) return;
service.getTreeLastCommit(tree.lastCommitPath)
.then((res) => {
const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null;
commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath });
return res.json();
})
.then((data) => {
data.forEach((lastCommit) => {
const entry = findEntry(tree, lastCommit.type, lastCommit.file_name);
if (entry) {
commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit });
}
});
dispatch('getLastCommitData', tree);
})
.catch(() => flash('Error fetching log data.'));
};
export const updateDirectoryData = ({ commit, state }, { data, tree }) => {
const level = tree.level !== undefined ? tree.level + 1 : 0;
const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl;
const createEntry = (entry, type) => createOrMergeEntry({
tree,
entry,
level,
type,
parentTreeUrl,
});
const formattedData = [
...data.trees.map(t => createEntry(t, 'tree')),
...data.submodules.map(m => createEntry(m, 'submodule')),
...data.blobs.map(b => createEntry(b, 'blob')),
];
commit(types.SET_DIRECTORY_DATA, { tree, data: formattedData });
};
......@@ -4,11 +4,13 @@ export const SET_COMMIT_REF = 'SET_COMMIT_REF';
export const SET_PARENT_TREE_URL = 'SET_PARENT_TREE_URL';
export const SET_ROOT = 'SET_ROOT';
export const SET_PREVIOUS_URL = 'SET_PREVIOUS_URL';
export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA';
// Tree mutation types
export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA';
export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN';
export const CREATE_TMP_TREE = 'CREATE_TMP_TREE';
export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL';
// File mutation types
export const SET_FILE_DATA = 'SET_FILE_DATA';
......
......@@ -48,6 +48,13 @@ export default {
previousUrl,
});
},
[types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) {
Object.assign(entry.lastCommit, {
url: lastCommit.commit_path,
message: lastCommit.commit.message,
updatedAt: lastCommit.commit.authored_date,
});
},
...fileMutations,
...treeMutations,
...branchMutations,
......
import * as types from '../mutation_types';
import * as utils from '../utils';
export default {
[types.TOGGLE_TREE_OPEN](state, tree) {
......@@ -8,30 +7,8 @@ export default {
});
},
[types.SET_DIRECTORY_DATA](state, { data, tree }) {
const level = tree.level !== undefined ? tree.level + 1 : 0;
const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl;
Object.assign(tree, {
tree: [
...data.trees.map(t => utils.decorateData({
...t,
type: 'tree',
parentTreeUrl,
level,
}, state.project.url)),
...data.submodules.map(m => utils.decorateData({
...m,
type: 'submodule',
parentTreeUrl,
level,
}, state.project.url)),
...data.blobs.map(b => utils.decorateData({
...b,
type: 'blob',
parentTreeUrl,
level,
}, state.project.url)),
],
tree: data,
});
},
[types.SET_PARENT_TREE_URL](state, url) {
......@@ -39,6 +16,11 @@ export default {
parentTreeUrl: url,
});
},
[types.SET_LAST_COMMIT_URL](state, { tree = state, url }) {
Object.assign(tree, {
lastCommitPath: url,
});
},
[types.CREATE_TMP_TREE](state, { parent, tmpEntry }) {
parent.tree.push(tmpEntry);
},
......
......@@ -8,6 +8,7 @@ export default () => ({
endpoints: {},
isRoot: false,
isInitialRoot: false,
lastCommitPath: '',
loading: false,
onTopOfBranch: false,
openFiles: [],
......
export const dataStructure = () => ({
id: '',
key: '',
type: '',
name: '',
url: '',
......@@ -12,7 +13,12 @@ export const dataStructure = () => ({
opened: false,
active: false,
changed: false,
lastCommit: {},
lastCommitPath: '',
lastCommit: {
url: '',
message: '',
updatedAt: '',
},
tree_url: '',
blamePath: '',
commitsPath: '',
......@@ -27,14 +33,13 @@ export const dataStructure = () => ({
base64: false,
});
export const decorateData = (entity, projectUrl = '') => {
export const decorateData = (entity) => {
const {
id,
type,
url,
name,
icon,
last_commit,
tree_url,
path,
renderError,
......@@ -51,6 +56,7 @@ export const decorateData = (entity, projectUrl = '') => {
return {
...dataStructure(),
id,
key: `${name}-${type}-${id}`,
type,
name,
url,
......@@ -66,12 +72,6 @@ export const decorateData = (entity, projectUrl = '') => {
renderError,
content,
base64,
// eslint-disable-next-line camelcase
lastCommit: last_commit ? {
url: `${projectUrl}/commit/${last_commit.id}`,
message: last_commit.message,
updatedAt: last_commit.committed_date,
} : {},
};
};
......@@ -106,3 +106,22 @@ export const createTemp = ({ name, path, type, level, changed, content, base64 }
renderError: base64,
});
};
export const createOrMergeEntry = ({ tree, entry, type, parentTreeUrl, level }) => {
const found = findEntry(tree, type, entry.name);
if (found) {
return Object.assign({}, found, {
id: entry.id,
url: entry.url,
tempFile: false,
});
}
return decorateData({
...entry,
type,
parentTreeUrl,
level,
});
};
......@@ -3,9 +3,10 @@
* and controllable by a public API.
*/
class SmartInterval {
export default class SmartInterval {
/**
* @param { function } opts.callback Function to be called on each iteration (required)
* @param { function } opts.callback Function that returns a promise, called on each iteration
* unless still in progress (required)
* @param { milliseconds } opts.startingInterval `currentInterval` is set to this initially
* @param { milliseconds } opts.maxInterval `currentInterval` will be incremented to this
* @param { milliseconds } opts.hiddenInterval `currentInterval` is set to this
......@@ -42,13 +43,16 @@ class SmartInterval {
const cfg = this.cfg;
const state = this.state;
if (cfg.immediateExecution) {
if (cfg.immediateExecution && !this.isLoading) {
cfg.immediateExecution = false;
cfg.callback();
this.triggerCallback();
}
state.intervalId = window.setInterval(() => {
cfg.callback();
if (this.isLoading) {
return;
}
this.triggerCallback();
if (this.getCurrentInterval() === cfg.maxInterval) {
return;
......@@ -76,7 +80,7 @@ class SmartInterval {
// start a timer, using the existing interval
resume() {
this.stopTimer(); // stop exsiting timer, in case timer was not previously stopped
this.stopTimer(); // stop existing timer, in case timer was not previously stopped
this.start();
}
......@@ -104,6 +108,18 @@ class SmartInterval {
this.initPageUnloadHandling();
}
triggerCallback() {
this.isLoading = true;
this.cfg.callback()
.then(() => {
this.isLoading = false;
})
.catch((err) => {
this.isLoading = false;
throw err;
});
}
initVisibilityChangeHandling() {
// cancel interval when tab no longer shown (prevents cached pages from polling)
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
......@@ -154,4 +170,3 @@ class SmartInterval {
}
}
window.gl.SmartInterval = SmartInterval;
import SmartInterval from '~/smart_interval';
import Flash from '../flash';
import {
WidgetHeader,
......@@ -81,7 +82,7 @@ export default {
return new MRWidgetService(endpoints);
},
checkStatus(cb) {
this.service.checkStatus()
return this.service.checkStatus()
.then(res => res.json())
.then((res) => {
this.handleNotification(res);
......@@ -97,7 +98,7 @@ export default {
});
},
initPolling() {
this.pollingInterval = new gl.SmartInterval({
this.pollingInterval = new SmartInterval({
callback: this.checkStatus,
startingInterval: 10000,
maxInterval: 30000,
......@@ -106,7 +107,7 @@ export default {
});
},
initDeploymentsPolling() {
this.deploymentsInterval = new gl.SmartInterval({
this.deploymentsInterval = new SmartInterval({
callback: this.fetchDeployments,
startingInterval: 30000,
maxInterval: 120000,
......@@ -121,7 +122,7 @@ export default {
}
},
fetchDeployments() {
this.service.fetchDeployments()
return this.service.fetchDeployments()
.then(res => res.json())
.then((res) => {
if (res.length) {
......
<script>
export default {
props: {
small: {
type: Boolean,
required: false,
default: false,
},
lines: {
type: Number,
required: false,
default: 6,
},
},
computed: {
lineClasses() {
return new Array(this.lines).fill().map((_, i) => `skeleton-line-${i + 1}`);
},
},
};
</script>
<template>
<div
class="animation-container"
:class="{
'animation-container-small': small,
}"
>
<div
v-for="(css, index) in lineClasses"
:key="index"
:class="css"
>
</div>
</div>
</template>
......@@ -9,9 +9,7 @@ module IssuableActions
def show
respond_to do |format|
format.html do
render show_view
end
format.html
format.json do
render json: serializer.represent(issuable, serializer: params[:serializer])
end
......@@ -152,10 +150,6 @@ module IssuableActions
end
end
def show_view
'show'
end
def serializer
raise NotImplementedError
end
......
......@@ -4,58 +4,44 @@ module IssuableCollections
include Gitlab::IssuableMetadata
included do
helper_method :issues_finder
helper_method :merge_requests_finder
helper_method :finder
end
private
def set_issues_index
@collection_type = "Issue"
@issues = issues_collection
@issues = @issues.page(params[:page])
@issuable_meta_data = issuable_meta_data(@issues, @collection_type)
@total_pages = issues_page_count(@issues)
def set_issuables_index
@issuables = issuables_collection
@issuables = @issuables.page(params[:page])
@issuable_meta_data = issuable_meta_data(@issuables, collection_type)
@total_pages = issuable_page_count
return if redirect_out_of_range(@issues, @total_pages)
return if redirect_out_of_range(@total_pages)
if params[:label_name].present?
@labels = LabelsFinder.new(current_user, project_id: @project.id, title: params[:label_name]).execute
labels_params = { project_id: @project.id, title: params[:label_name] }
@labels = LabelsFinder.new(current_user, labels_params).execute
end
@users = []
if params[:assignee_id].present?
assignee = User.find_by_id(params[:assignee_id])
@users.push(assignee) if assignee
end
def issues_collection
issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace)
if params[:author_id].present?
author = User.find_by_id(params[:author_id])
@users.push(author) if author
end
def merge_requests_collection
merge_requests_finder.execute.preload(
:source_project,
:target_project,
:author,
:assignee,
:labels,
:milestone,
head_pipeline: :project,
target_project: :namespace,
merge_request_diff: :merge_request_diff_commits
)
end
def issues_finder
@issues_finder ||= issuable_finder_for(IssuesFinder)
end
def merge_requests_finder
@merge_requests_finder ||= issuable_finder_for(MergeRequestsFinder)
def issuables_collection
finder.execute.preload(preload_for_collection)
end
def redirect_out_of_range(relation, total_pages)
def redirect_out_of_range(total_pages)
return false if total_pages.zero?
out_of_range = relation.current_page > total_pages
out_of_range = @issuables.current_page > total_pages
if out_of_range
redirect_to(url_for(params.merge(page: total_pages, only_path: true)))
......@@ -64,12 +50,8 @@ module IssuableCollections
out_of_range
end
def issues_page_count(relation)
page_count_for_relation(relation, issues_finder.row_count)
end
def merge_requests_page_count(relation)
page_count_for_relation(relation, merge_requests_finder.row_count)
def issuable_page_count
page_count_for_relation(@issuables, finder.row_count)
end
def page_count_for_relation(relation, row_count)
......@@ -145,4 +127,31 @@ module IssuableCollections
else value
end
end
def finder
return @finder if defined?(@finder)
@finder = issuable_finder_for(@finder_type)
end
def collection_type
@collection_type ||= case finder
when IssuesFinder
'Issue'
when MergeRequestsFinder
'MergeRequest'
end
end
def preload_for_collection
@preload_for_collection ||= case collection_type
when 'Issue'
[:project, :author, :assignees, :labels, :milestone, project: :namespace]
when 'MergeRequest'
[
:source_project, :target_project, :author, :assignee, :labels, :milestone,
head_pipeline: :project, target_project: :namespace, merge_request_diff: :merge_request_diff_commits
]
end
end
end
......@@ -3,14 +3,14 @@ module IssuesAction
include IssuableCollections
def issues
@label = issues_finder.labels.first
@finder_type = IssuesFinder
@label = finder.labels.first
@issues = issues_collection
@issues = issuables_collection
.non_archived
.page(params[:page])
@collection_type = "Issue"
@issuable_meta_data = issuable_meta_data(@issues, @collection_type)
@issuable_meta_data = issuable_meta_data(@issues, collection_type)
respond_to do |format|
format.html
......
......@@ -3,13 +3,12 @@ module MergeRequestsAction
include IssuableCollections
def merge_requests
@label = merge_requests_finder.labels.first
@finder_type = MergeRequestsFinder
@label = finder.labels.first
@merge_requests = merge_requests_collection
.page(params[:page])
@merge_requests = issuables_collection.page(params[:page])
@collection_type = "MergeRequest"
@issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type)
@issuable_meta_data = issuable_meta_data(@merge_requests, collection_type)
end
private
......
......@@ -10,7 +10,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :check_issues_available!
before_action :issue, except: [:index, :new, :create, :bulk_update]
before_action :set_issues_index, only: [:index]
before_action :set_issuables_index, only: [:index]
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
......@@ -24,15 +24,7 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to :html
def index
if params[:assignee_id].present?
assignee = User.find_by_id(params[:assignee_id])
@users.push(assignee) if assignee
end
if params[:author_id].present?
author = User.find_by_id(params[:author_id])
@users.push(author) if author
end
@issues = @issuables
respond_to do |format|
format.html
......@@ -252,4 +244,9 @@ class Projects::IssuesController < Projects::ApplicationController
update_params = issue_params.merge(spammable_params)
Issues::UpdateService.new(project, current_user, update_params)
end
def set_issuables_index
@finder_type = IssuesFinder
super
end
end
......@@ -10,33 +10,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort]
before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues]
def index
@collection_type = "MergeRequest"
@merge_requests = merge_requests_collection
@merge_requests = @merge_requests.page(params[:page])
@merge_requests = @merge_requests.preload(merge_request_diff: :merge_request)
@issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type)
@total_pages = merge_requests_page_count(@merge_requests)
return if redirect_out_of_range(@merge_requests, @total_pages)
if params[:label_name].present?
labels_params = { project_id: @project.id, title: params[:label_name] }
@labels = LabelsFinder.new(current_user, labels_params).execute
end
@users = []
if params[:assignee_id].present?
assignee = User.find_by_id(params[:assignee_id])
@users.push(assignee) if assignee
end
if params[:author_id].present?
author = User.find_by_id(params[:author_id])
@users.push(author) if author
end
@merge_requests = @issuables
respond_to do |format|
format.html
......@@ -338,4 +317,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@target_project = @merge_request.target_project
@target_branches = @merge_request.target_project.repository.branch_names
end
def set_issuables_index
@finder_type = MergeRequestsFinder
super
end
end
......@@ -56,9 +56,12 @@ class Projects::RefsController < Projects::ApplicationController
contents[@offset, @limit].to_a.map do |content|
file = @path ? File.join(@path, content.name) : content.name
last_commit = @repo.last_commit_for_path(@commit.id, file)
commit_path = project_commit_path(@project, last_commit) if last_commit
{
file_name: content.name,
commit: last_commit
commit: last_commit,
type: content.type,
commit_path: commit_path
}
end
end
......@@ -70,6 +73,11 @@ class Projects::RefsController < Projects::ApplicationController
respond_to do |format|
format.html { render_404 }
format.json do
response.headers["More-Logs-Url"] = @more_log_url
render json: @logs
end
format.js
end
end
......
......@@ -275,7 +275,8 @@ class ProjectsController < Projects::ApplicationController
@project_wiki = @project.wiki
@wiki_home = @project_wiki.find_page('home', params[:version_id])
elsif @project.feature_available?(:issues, current_user)
@issues = issues_collection.page(params[:page])
@finder_type = IssuesFinder
@issues = issuables_collection.page(params[:page])
@collection_type = 'Issue'
@issuable_meta_data = issuable_meta_data(@issues, @collection_type)
end
......
......@@ -249,8 +249,6 @@ module IssuablesHelper
end
def issuables_count_for_state(issuable_type, state)
finder = public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend
Gitlab::IssuablesCountForState.new(finder)[state]
end
......
......@@ -7,7 +7,7 @@ module Clusters
default_value_for :zone, 'us-central1-a'
default_value_for :num_nodes, 3
default_value_for :machine_type, 'n1-standard-4'
default_value_for :machine_type, 'n1-standard-2'
attr_encrypted :access_token,
mode: :per_attribute_iv,
......
......@@ -14,7 +14,6 @@ class CommitStatus < ActiveRecord::Base
delegate :sha, :short_sha, to: :pipeline
validates :pipeline, presence: true, unless: :importing?
validates :name, presence: true, unless: :importing?
alias_attribute :author, :user
......@@ -46,6 +45,17 @@ class CommitStatus < ActiveRecord::Base
runner_system_failure: 4
}
##
# We still create some CommitStatuses outside of CreatePipelineService.
#
# These are pages deployments and external statuses.
#
before_create unless: :importing? do
Ci::EnsureStageService.new(project, user).execute(self) do |stage|
self.run_after_commit { StageUpdateWorker.perform_async(stage.id) }
end
end
state_machine :status do
event :process do
transition [:skipped, :manual] => :created
......
......@@ -17,6 +17,8 @@ module Issuable
include Importable
include Editable
include AfterCommitQueue
include Sortable
include CreatedAtFilterable
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
......
......@@ -5,11 +5,9 @@ class Issue < ActiveRecord::Base
include Issuable
include Noteable
include Referable
include Sortable
include Spammable
include FasterCacheKeys
include RelativePositioning
include CreatedAtFilterable
include TimeTrackable
DueDateStruct = Struct.new(:title, :name).freeze
......
......@@ -3,9 +3,7 @@ class MergeRequest < ActiveRecord::Base
include Issuable
include Noteable
include Referable
include Sortable
include IgnorableColumn
include CreatedAtFilterable
include TimeTrackable
ignore_column :locked_at,
......
......@@ -3,10 +3,6 @@ class BlobEntity < Grape::Entity
expose :id, :path, :name, :mode
expose :last_commit do |blob|
request.project.repository.last_commit_for_path(blob.commit_id, blob.path)
end
expose :icon do |blob|
IconsHelper.file_type_icon_class('file', blob.mode, blob.name)
end
......
......@@ -3,10 +3,6 @@ class TreeEntity < Grape::Entity
expose :id, :path, :name, :mode
expose :last_commit do |tree|
request.project.repository.last_commit_for_path(tree.commit_id, tree.path)
end
expose :icon do |tree|
IconsHelper.file_type_icon_class('folder', tree.mode, tree.name)
end
......
......@@ -18,4 +18,8 @@ class TreeRootEntity < Grape::Entity
project_tree_path(request.project, File.join(request.ref, parent_tree_path))
end
expose :last_commit_path do |tree|
logs_file_project_ref_path(request.project, request.ref, tree.path)
end
end
module Ci
##
# We call this service everytime we persist a CI/CD job.
#
# In most cases a job should already have a stage assigned, but in cases it
# doesn't have we need to either find existing one or create a brand new
# stage.
#
class EnsureStageService < BaseService
def execute(build)
@build = build
return if build.stage_id.present?
return if build.invalid?
ensure_stage.tap do |stage|
build.stage_id = stage.id
yield stage if block_given?
end
end
private
def ensure_stage
find_stage || create_stage
end
def find_stage
@build.pipeline.stages.find_by(name: @build.stage)
end
def create_stage
Ci::Stage.create!(name: @build.stage,
pipeline: @build.pipeline,
project: @build.project)
end
end
end
......@@ -29,7 +29,7 @@
.form-group
= provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type')
= link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer')
= provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-4'
= provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-2'
.form-group
= field.submit s_('ClusterIntegration|Create cluster'), class: 'btn btn-save'
......@@ -67,7 +67,7 @@
- if @commit.last_pipeline
- last_pipeline = @commit.last_pipeline
.well-segment.pipeline-info
.status-icon-container{ class: "ci-status-icon-#{@commit.status}" }
.status-icon-container{ class: "ci-status-icon-#{last_pipeline.status}" }
= link_to project_pipeline_path(@project, last_pipeline.id) do
= ci_icon_for_status(last_pipeline.status)
#{ _('Pipeline') }
......
......@@ -3,8 +3,8 @@
- if @can_bulk_update
= button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
= link_to "New issue", new_project_issue_path(@project,
issue: { assignee_id: issues_finder.assignee.try(:id),
milestone_id: issues_finder.milestones.first.try(:id) }),
issue: { assignee_id: finder.assignee.try(:id),
milestone_id: finder.milestones.first.try(:id) }),
class: "btn btn-new",
title: "New issue",
id: "new_issue_link"
- finder = controller.controller_name == 'issues' ? issues_finder : merge_requests_finder
- boards_page = controller.controller_name == 'boards'
.issues-filters
......
---
title: Update Merge Request polling so there is only one request at a time
merge_request: 15032
author:
type: fixed
---
title: Change default cluster size to n1-default-2
merge_request: 39649
author: Fabio Busatto
type: changed
---
title: Fix commit pipeline showing wrong status
merge_request:
author:
type: fixed
---
title: Fix arguments Import/Export error importing project merge requests
merge_request:
author:
type: fixed
---
title: Fix TRIGGER checks for MySQL
merge_request:
author:
type: fixed
......@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20171106101200) do
ActiveRecord::Schema.define(version: 20171101134435) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......
......@@ -166,12 +166,26 @@ board itself.
![Remove issue from list](img/issue_boards_remove_issue.png)
## Re-ordering an issue in a list
> Introduced in GitLab 9.0.
Issues can be re-ordered inside of lists. This is as simple as dragging and dropping
an issue into the order you want.
## Issue ordering in a list
When visiting a board, issues appear ordered in any list. You are able to change
that order simply by dragging and dropping the issues. The changed order will be saved
to the system so that anybody who visits the same board later will see the reordering,
with some exceptions.
The first time a given issue appears in any board (i.e. the first time a user
loads a board containing that issue), it will be ordered with
respect to other issues in that list according to [Priority order][label-priority].
At that point, that issue will be assigned a relative order value by the system
representing its relative order with respect to the other issues in the list. Any time
you drag-and-drop reorder that issue, its relative order value will change accordingly.
Also, any time that issue appears in any board when it is loaded by a user,
the updated relative order value will be used for the ordering. (It's only the first
time an issue appears that it takes from the Priority order mentioned above.) This means that
if issue `A` is drag-and-drop reordered to be above issue `B` by any user in
a given board inside your GitLab instance, any time those two issues are subsequently
loaded in any board in the same instance (could be a different project board or a different group board, for example),
that ordering will be maintained.
## Filtering issues
......
......@@ -8,7 +8,7 @@ module Gitlab
end
def icon
'warning'
'status_warning'
end
def group
......
......@@ -6,15 +6,7 @@ module Gitlab
if Database.postgresql?
'information_schema.role_table_grants'
else
'mysql.user'
end
def self.scope_to_current_user
if Database.postgresql?
where('grantee = user')
else
where("CONCAT(User, '@', Host) = current_user()")
end
'information_schema.schema_privileges'
end
# Returns true if the current user can create and execute triggers on the
......@@ -23,11 +15,27 @@ module Gitlab
priv =
if Database.postgresql?
where(privilege_type: 'TRIGGER', table_name: table)
.where('grantee = user')
else
where(Trigger_priv: 'Y')
queries = [
Grant.select(1)
.from('information_schema.user_privileges')
.where("PRIVILEGE_TYPE = 'SUPER'")
.where("GRANTEE = CONCAT('\\'', REPLACE(CURRENT_USER(), '@', '\\'@\\''), '\\'')"),
Grant.select(1)
.from('information_schema.schema_privileges')
.where("PRIVILEGE_TYPE = 'TRIGGER'")
.where('TABLE_SCHEMA = ?', Gitlab::Database.database_name)
.where("GRANTEE = CONCAT('\\'', REPLACE(CURRENT_USER(), '@', '\\'@\\''), '\\'')")
]
union = SQL::Union.new(queries).to_sql
Grant.from("(#{union}) privs")
end
priv.scope_to_current_user.any?
priv.any?
end
end
end
......
......@@ -26,7 +26,7 @@ module Gitlab
end
def fetch_ref
@project.repository.fetch_ref(@project.repository.path, @diff_head_sha, @merge_request.source_branch)
@project.repository.fetch_ref(@project.repository, source_ref: @diff_head_sha, target_ref: @merge_request.source_branch)
end
def branch_exists?(branch_name)
......
......@@ -4,7 +4,7 @@ module Gitlab
attr_accessor :name
def initialize(name, client)
self.name = name
@name = name
@client = client
end
......
......@@ -33,9 +33,10 @@ namespace :gitlab do
backup.unpack
unless backup.skipped?('db')
begin
unless ENV['force'] == 'yes'
warning = <<-MSG.strip_heredoc
Before restoring the database we recommend removing all existing
Before restoring the database, we will remove all existing
tables to avoid future upgrade problems. Be aware that if you have
custom tables in the GitLab database these tables and all data will be
removed.
......@@ -51,6 +52,10 @@ namespace :gitlab do
Rake::Task['gitlab:db:drop_tables'].invoke
$progress.puts 'done'.color(:green)
Rake::Task['gitlab:backup:db:restore'].invoke
rescue Gitlab::TaskAbortedByUserError
puts "Quitting...".color(:red)
exit 1
end
end
Rake::Task['gitlab:backup:repo:restore'].invoke unless backup.skipped?('repositories')
......
......@@ -17,60 +17,6 @@ describe IssuableCollections do
controller
end
describe '#redirect_out_of_range' do
before do
allow(controller).to receive(:url_for)
end
it 'returns true and redirects if the offset is out of range' do
relation = double(:relation, current_page: 10)
expect(controller).to receive(:redirect_to)
expect(controller.send(:redirect_out_of_range, relation, 2)).to eq(true)
end
it 'returns false if the offset is not out of range' do
relation = double(:relation, current_page: 1)
expect(controller).not_to receive(:redirect_to)
expect(controller.send(:redirect_out_of_range, relation, 2)).to eq(false)
end
end
describe '#issues_page_count' do
it 'returns the number of issue pages' do
project = create(:project, :public)
create(:issue, project: project)
finder = IssuesFinder.new(user)
issues = finder.execute
allow(controller).to receive(:issues_finder)
.and_return(finder)
expect(controller.send(:issues_page_count, issues)).to eq(1)
end
end
describe '#merge_requests_page_count' do
it 'returns the number of merge request pages' do
project = create(:project, :public)
create(:merge_request, source_project: project, target_project: project)
finder = MergeRequestsFinder.new(user)
merge_requests = finder.execute
allow(controller).to receive(:merge_requests_finder)
.and_return(finder)
pages = controller.send(:merge_requests_page_count, merge_requests)
expect(pages).to eq(1)
end
end
describe '#page_count_for_relation' do
it 'returns the number of pages' do
relation = double(:relation, limit_value: 20)
......
......@@ -23,12 +23,15 @@ describe Projects::RefsController do
xhr :get,
:logs_tree,
namespace_id: project.namespace.to_param,
project_id: project, id: 'master',
path: 'foo/bar/baz.html', format: format
project_id: project,
id: 'master',
path: 'foo/bar/baz.html',
format: format
end
it 'never throws MissingTemplate' do
expect { default_get }.not_to raise_error
expect { xhr_get(:json) }.not_to raise_error
expect { xhr_get }.not_to raise_error
end
......@@ -42,5 +45,12 @@ describe Projects::RefsController do
xhr_get(:js)
expect(response).to be_success
end
it 'renders JSON' do
xhr_get(:json)
expect(response).to be_success
expect(json_response).to be_kind_of(Array)
end
end
end
FactoryGirl.define do
factory :commit_status, class: CommitStatus do
name 'default'
stage 'test'
status 'success'
description 'commit status'
pipeline factory: :ci_pipeline_with_one_job
......
......@@ -12,6 +12,13 @@ feature 'Mini Pipeline Graph in Commit View', :js do
end
let(:build) { create(:ci_build, pipeline: pipeline) }
it 'display icon with status' do
build.run
visit project_commit_path(project, project.commit.id)
expect(page).to have_selector('.ci-status-icon-running')
end
it 'displays a mini pipeline graph' do
build.run
visit project_commit_path(project, project.commit.id)
......
......@@ -31,10 +31,5 @@ feature 'Multi-file editor new directory', :js do
click_button('Commit 1 file')
expect(page).to have_selector('td', text: 'commit message')
click_link('foldername')
expect(page).to have_selector('td', text: 'commit message', count: 2)
expect(page).to have_selector('td', text: '.gitkeep')
end
end
......@@ -20,7 +20,7 @@ describe('RepoFile', () => {
resetStore(vm.$store);
});
it('renders link, icon, name and last commit details', () => {
it('renders link, icon and name', () => {
const RepoFile = Vue.extend(repoFile);
vm = new RepoFile({
store,
......@@ -37,10 +37,9 @@ describe('RepoFile', () => {
expect(vm.$el.querySelector(`.${vm.file.icon}`).style.marginLeft).toEqual('0px');
expect(name.href).toMatch(`/${vm.file.url}`);
expect(name.textContent.trim()).toEqual(vm.file.name);
expect(vm.$el.querySelector('.commit-message').textContent.trim()).toBe(vm.file.lastCommit.message);
expect(vm.$el.querySelector('.commit-update').textContent.trim()).toBe(updated);
expect(fileIcon.classList.contains(vm.file.icon)).toBeTruthy();
expect(fileIcon.style.marginLeft).toEqual(`${vm.file.level * 10}px`);
expect(vm.$el.querySelectorAll('.animation-container').length).toBe(2);
});
it('does render if hasFiles is true and is loading tree', () => {
......
import '~/smart_interval';
import SmartInterval from '~/smart_interval';
(() => {
describe('SmartInterval', function () {
const DEFAULT_MAX_INTERVAL = 100;
const DEFAULT_STARTING_INTERVAL = 5;
const DEFAULT_SHORT_TIMEOUT = 75;
......@@ -9,7 +9,7 @@ import '~/smart_interval';
function createDefaultSmartInterval(config) {
const defaultParams = {
callback: () => {},
callback: () => Promise.resolve(),
startingInterval: DEFAULT_STARTING_INTERVAL,
maxInterval: DEFAULT_MAX_INTERVAL,
incrementByFactorOf: DEFAULT_INCREMENT_FACTOR,
......@@ -22,10 +22,9 @@ import '~/smart_interval';
_.extend(defaultParams, config);
}
return new gl.SmartInterval(defaultParams);
return new SmartInterval(defaultParams);
}
describe('SmartInterval', function () {
describe('Increment Interval', function () {
beforeEach(function () {
this.smartInterval = createDefaultSmartInterval();
......@@ -58,6 +57,21 @@ import '~/smart_interval';
done();
}, DEFAULT_LONG_TIMEOUT);
});
it('does not increment while waiting for callback', function () {
jasmine.clock().install();
const smartInterval = createDefaultSmartInterval({
callback: () => new Promise($.noop),
});
jasmine.clock().tick(DEFAULT_SHORT_TIMEOUT);
const oneInterval = smartInterval.cfg.startingInterval * DEFAULT_INCREMENT_FACTOR;
expect(smartInterval.getCurrentInterval()).toEqual(oneInterval);
jasmine.clock().uninstall();
});
});
describe('Public methods', function () {
......@@ -175,5 +189,4 @@ import '~/smart_interval';
expect(interval.cfg.immediateExecution).toBeFalsy();
});
});
});
})(window.gl || (window.gl = {}));
});
......@@ -121,24 +121,28 @@ describe('mrWidgetOptions', () => {
describe('initPolling', () => {
it('should call SmartInterval', () => {
spyOn(gl, 'SmartInterval').and.returnValue({
resume() {},
stopTimer() {},
});
spyOn(vm, 'checkStatus').and.returnValue(Promise.resolve());
jasmine.clock().install();
vm.initPolling();
expect(vm.checkStatus).not.toHaveBeenCalled();
jasmine.clock().tick(10000);
expect(vm.pollingInterval).toBeDefined();
expect(gl.SmartInterval).toHaveBeenCalled();
expect(vm.checkStatus).toHaveBeenCalled();
jasmine.clock().uninstall();
});
});
describe('initDeploymentsPolling', () => {
it('should call SmartInterval', () => {
spyOn(gl, 'SmartInterval');
spyOn(vm, 'fetchDeployments').and.returnValue(Promise.resolve());
vm.initDeploymentsPolling();
expect(vm.deploymentsInterval).toBeDefined();
expect(gl.SmartInterval).toHaveBeenCalled();
expect(vm.fetchDeployments).toHaveBeenCalled();
});
});
......
import Vue from 'vue';
import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('Skeleton loading container', () => {
let vm;
beforeEach(() => {
const component = Vue.extend(skeletonLoadingContainer);
vm = mountComponent(component);
});
afterEach(() => {
vm.$destroy();
});
it('renders 6 skeleton lines by default', () => {
expect(vm.$el.querySelector('.skeleton-line-6')).not.toBeNull();
});
it('renders in full mode by default', () => {
expect(vm.$el.classList.contains('animation-container-small')).toBeFalsy();
});
describe('small', () => {
beforeEach((done) => {
vm.small = true;
Vue.nextTick(done);
});
it('renders in small mode', () => {
expect(vm.$el.classList.contains('animation-container-small')).toBeTruthy();
});
});
describe('lines', () => {
beforeEach((done) => {
vm.lines = 5;
Vue.nextTick(done);
});
it('renders 5 lines', () => {
expect(vm.$el.querySelector('.skeleton-line-5')).not.toBeNull();
expect(vm.$el.querySelector('.skeleton-line-6')).toBeNull();
});
});
});
......@@ -84,7 +84,7 @@ describe Gitlab::Ci::Status::Build::Factory do
it 'fabricates status with correct details' do
expect(status.text).to eq 'failed'
expect(status.icon).to eq 'warning'
expect(status.icon).to eq 'status_warning'
expect(status.favicon).to eq 'favicon_status_failed'
expect(status.label).to eq 'failed (allowed to fail)'
expect(status).to have_details
......
......@@ -18,7 +18,7 @@ describe Gitlab::Ci::Status::Build::FailedAllowed do
describe '#icon' do
it 'returns a warning icon' do
expect(subject.icon).to eq 'warning'
expect(subject.icon).to eq 'status_warning'
end
end
......
require 'spec_helper'
describe Gitlab::Database::Grant do
describe '.scope_to_current_user' do
it 'scopes the relation to the current user' do
user = Gitlab::Database.username
column = Gitlab::Database.postgresql? ? :grantee : :User
names = described_class.scope_to_current_user.pluck(column).uniq
expect(names).to eq([user])
end
end
describe '.create_and_execute_trigger' do
it 'returns true when the user can create and execute a trigger' do
# We assume the DB/user is set up correctly so that triggers can be
......@@ -18,13 +8,11 @@ describe Gitlab::Database::Grant do
expect(described_class.create_and_execute_trigger?('users')).to eq(true)
end
it 'returns false when the user can not create and/or execute a trigger' do
allow(described_class).to receive(:scope_to_current_user)
.and_return(described_class.none)
result = described_class.create_and_execute_trigger?('kittens')
expect(result).to eq(false)
it 'returns false when the user can not create and/or execute a trigger', :postgresql do
# In case of MySQL the user may have SUPER permissions, making it
# impossible to have `false` returned when running tests; hence we only
# run these tests on PostgreSQL.
expect(described_class.create_and_execute_trigger?('foo')).to eq(false)
end
end
end
......@@ -13,7 +13,7 @@ describe Gitlab::ImportExport::MergeRequestParser do
let(:parsed_merge_request) do
described_class.new(project,
merge_request.diff_head_sha,
'abcd',
merge_request,
merge_request.as_json).parse!
end
......@@ -29,4 +29,14 @@ describe Gitlab::ImportExport::MergeRequestParser do
it 'has a target branch' do
expect(project.repository.branch_exists?(parsed_merge_request.target_branch)).to be true
end
it 'parses a MR that has no source branch' do
allow_any_instance_of(described_class).to receive(:branch_exists?).and_call_original
allow_any_instance_of(described_class).to receive(:branch_exists?).with(merge_request.source_branch).and_return(false)
allow_any_instance_of(described_class).to receive(:fork_merge_request?).and_return(true)
allow(Gitlab::GitalyClient).to receive(:migrate).and_call_original
allow(Gitlab::GitalyClient).to receive(:migrate).with(:fetch_ref).and_return([nil, 0])
expect(parsed_merge_request).to eq(merge_request)
end
end
......@@ -69,7 +69,7 @@ describe GoogleApi::CloudPlatform::Client do
let(:cluster_name) { 'test-cluster' }
let(:cluster_size) { 1 }
let(:machine_type) { 'n1-standard-4' }
let(:machine_type) { 'n1-standard-2' }
let(:operation) { double }
before do
......
......@@ -10,7 +10,7 @@ describe Clusters::Providers::Gcp do
it "has default value" do
expect(gcp.zone).to eq('us-central1-a')
expect(gcp.num_nodes).to eq(3)
expect(gcp.machine_type).to eq('n1-standard-4')
expect(gcp.machine_type).to eq('n1-standard-2')
end
end
......
require 'spec_helper'
describe CommitStatus do
let(:project) { create(:project, :repository) }
set(:project) { create(:project, :repository) }
let(:pipeline) do
set(:pipeline) do
create(:ci_pipeline, project: project, sha: project.commit.id)
end
......@@ -464,4 +464,73 @@ describe CommitStatus do
it { is_expected.to be_script_failure }
end
end
describe 'ensure stage assignment' do
context 'when commit status has a stage_id assigned' do
let!(:stage) do
create(:ci_stage_entity, project: project, pipeline: pipeline)
end
let(:commit_status) do
create(:commit_status, stage_id: stage.id, name: 'rspec', stage: 'test')
end
it 'does not create a new stage' do
expect { commit_status }.not_to change { Ci::Stage.count }
expect(commit_status.stage_id).to eq stage.id
end
end
context 'when commit status does not have a stage_id assigned' do
let(:commit_status) do
create(:commit_status, name: 'rspec', stage: 'test', status: :success)
end
let(:stage) { Ci::Stage.first }
it 'creates a new stage' do
expect { commit_status }.to change { Ci::Stage.count }.by(1)
expect(stage.name).to eq 'test'
expect(stage.project).to eq commit_status.project
expect(stage.pipeline).to eq commit_status.pipeline
expect(stage.status).to eq commit_status.status
expect(commit_status.stage_id).to eq stage.id
end
end
context 'when commit status does not have stage but it exists' do
let!(:stage) do
create(:ci_stage_entity, project: project,
pipeline: pipeline,
name: 'test')
end
let(:commit_status) do
create(:commit_status, project: project,
pipeline: pipeline,
name: 'rspec',
stage: 'test',
status: :success)
end
it 'uses existing stage' do
expect { commit_status }.not_to change { Ci::Stage.count }
expect(commit_status.stage_id).to eq stage.id
expect(stage.reload.status).to eq commit_status.status
end
end
context 'when commit status is being imported' do
let(:commit_status) do
create(:commit_status, name: 'rspec', stage: 'test', importing: true)
end
it 'does not create a new stage' do
expect { commit_status }.not_to change { Ci::Stage.count }
expect(commit_status.stage_id).not_to be_present
end
end
end
end
......@@ -107,7 +107,7 @@ describe PipelineDetailsEntity do
it 'contains stages' do
expect(subject).to include(:details)
expect(subject[:details]).to include(:stages)
expect(subject[:details][:stages].first).to include(name: 'external')
expect(subject[:details][:stages].first).to include(name: 'test')
end
end
......
......@@ -94,6 +94,7 @@ module CycleAnalyticsHelpers
ref: 'master',
tag: false,
name: 'dummy',
stage: 'dummy',
pipeline: dummy_pipeline,
protected: false)
end
......
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