Commit a97f4ec3 authored by Luke Bennett's avatar Luke Bennett

Merge branch 'master' into '39549-label-list-page-redesign-with-draggable-labels'

# Conflicts:
#   app/views/projects/labels/index.html.haml
parents 119b128e 760b12dc
### Description ### Problem to solve
(Include problem, use cases, benefits, and/or goals) ### Further details
(Include use cases, benefits, and/or goals)
### Proposal ### Proposal
### What does success look like, and how can we measure that?
(If no way to measure success, link to an issue that will implement a way to measure this)
### Links / references ### Links / references
/label ~"feature proposal" /label ~"feature proposal"
...@@ -2,6 +2,10 @@ ...@@ -2,6 +2,10 @@
documentation](doc/development/changelog.md) for instructions on adding your own documentation](doc/development/changelog.md) for instructions on adding your own
entry. entry.
## 10.8.4 (2018-06-06)
- No changes.
## 10.8.3 (2018-05-30) ## 10.8.3 (2018-05-30)
### Fixed (4 changes) ### Fixed (4 changes)
......
...@@ -512,7 +512,7 @@ request is as follows: ...@@ -512,7 +512,7 @@ request is as follows:
1. Write [tests](https://docs.gitlab.com/ee/development/rake_tasks.html#run-tests) and code 1. Write [tests](https://docs.gitlab.com/ee/development/rake_tasks.html#run-tests) and code
1. [Generate a changelog entry with `bin/changelog`][changelog] 1. [Generate a changelog entry with `bin/changelog`][changelog]
1. If you are writing documentation, make sure to follow the 1. If you are writing documentation, make sure to follow the
[documentation styleguide][doc-styleguide] [documentation guidelines][doc-guidelines]
1. If you have multiple commits please combine them into a few logically 1. If you have multiple commits please combine them into a few logically
organized commits by [squashing them][git-squash] organized commits by [squashing them][git-squash]
1. Push the commit(s) to your fork 1. Push the commit(s) to your fork
...@@ -727,7 +727,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor ...@@ -727,7 +727,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor
[rss-source]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#source-code-layout [rss-source]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#source-code-layout
[rss-naming]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#naming [rss-naming]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#naming
[changelog]: doc/development/changelog.md "Generate a changelog entry" [changelog]: doc/development/changelog.md "Generate a changelog entry"
[doc-styleguide]: doc/development/doc_styleguide.md "Documentation styleguide" [doc-guidelines]: doc/development/documentation/index.md "Documentation guidelines"
[js-styleguide]: doc/development/fe_guide/style_guide_js.md "JavaScript styleguide" [js-styleguide]: doc/development/fe_guide/style_guide_js.md "JavaScript styleguide"
[scss-styleguide]: doc/development/fe_guide/style_guide_scss.md "SCSS styleguide" [scss-styleguide]: doc/development/fe_guide/style_guide_scss.md "SCSS styleguide"
[newlines-styleguide]: doc/development/newlines_styleguide.md "Newlines styleguide" [newlines-styleguide]: doc/development/newlines_styleguide.md "Newlines styleguide"
......
...@@ -93,6 +93,10 @@ gem 'grape', '~> 1.0' ...@@ -93,6 +93,10 @@ gem 'grape', '~> 1.0'
gem 'grape-entity', '~> 0.7.1' gem 'grape-entity', '~> 0.7.1'
gem 'rack-cors', '~> 1.0.0', require: 'rack/cors' gem 'rack-cors', '~> 1.0.0', require: 'rack/cors'
# GraphQL API
gem 'graphql', '~> 1.8.0'
gem 'graphiql-rails', '~> 1.4.10'
# Disable strong_params so that Mash does not respond to :permitted? # Disable strong_params so that Mash does not respond to :permitted?
gem 'hashie-forbidden_attributes' gem 'hashie-forbidden_attributes'
......
...@@ -359,12 +359,16 @@ GEM ...@@ -359,12 +359,16 @@ GEM
grape-entity (0.7.1) grape-entity (0.7.1)
activesupport (>= 4.0) activesupport (>= 4.0)
multi_json (>= 1.3.2) multi_json (>= 1.3.2)
grape-path-helpers (1.0.1) grape-path-helpers (1.0.2)
activesupport (~> 4) activesupport (~> 4)
grape (~> 1.0) grape (~> 1.0)
rake (~> 12) rake (~> 12)
grape_logging (1.7.0) grape_logging (1.7.0)
grape grape
graphiql-rails (1.4.10)
railties
sprockets-rails
graphql (1.8.1)
grpc (1.11.0) grpc (1.11.0)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
googleapis-common-protos-types (~> 1.0.0) googleapis-common-protos-types (~> 1.0.0)
...@@ -1053,6 +1057,8 @@ DEPENDENCIES ...@@ -1053,6 +1057,8 @@ DEPENDENCIES
grape-entity (~> 0.7.1) grape-entity (~> 0.7.1)
grape-path-helpers (~> 1.0) grape-path-helpers (~> 1.0)
grape_logging (~> 1.7) grape_logging (~> 1.7)
graphiql-rails (~> 1.4.10)
graphql (~> 1.8.0)
grpc (~> 1.11.0) grpc (~> 1.11.0)
haml_lint (~> 0.26.0) haml_lint (~> 0.26.0)
hamlit (~> 2.6.1) hamlit (~> 2.6.1)
......
...@@ -11,17 +11,20 @@ export default { ...@@ -11,17 +11,20 @@ export default {
}, },
computed: { computed: {
...mapGetters(['currentMergeRequest']), ...mapGetters(['currentMergeRequest']),
...mapState(['viewer']), ...mapState(['viewer', 'currentMergeRequestId']),
showLatestChangesText() { showLatestChangesText() {
return !this.currentMergeRequest || this.viewer === viewerTypes.diff; return !this.currentMergeRequestId || this.viewer === viewerTypes.diff;
}, },
showMergeRequestText() { showMergeRequestText() {
return this.currentMergeRequest && this.viewer === viewerTypes.mr; return this.currentMergeRequestId && this.viewer === viewerTypes.mr;
},
mergeRequestId() {
return `!${this.currentMergeRequest.iid}`;
}, },
}, },
mounted() { mounted() {
this.$nextTick(() => { this.$nextTick(() => {
this.updateViewer(this.currentMergeRequest ? viewerTypes.mr : viewerTypes.diff); this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff);
}); });
}, },
methods: { methods: {
...@@ -54,7 +57,11 @@ export default { ...@@ -54,7 +57,11 @@ export default {
</template> </template>
<template v-else-if="showMergeRequestText"> <template v-else-if="showMergeRequestText">
{{ __('Merge request') }} {{ __('Merge request') }}
(<a :href="currentMergeRequest.web_url">!{{ currentMergeRequest.iid }}</a>) (<a
v-if="currentMergeRequest"
:href="currentMergeRequest.web_url"
v-text="mergeRequestId"
></a>)
</template> </template>
</div> </div>
</template> </template>
......
<script> <script>
import $ from 'jquery';
import { mapState, mapGetters } from 'vuex'; import { mapState, mapGetters } from 'vuex';
import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue'; import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
...@@ -13,6 +14,7 @@ import CommitSection from './repo_commit_section.vue'; ...@@ -13,6 +14,7 @@ import CommitSection from './repo_commit_section.vue';
import CommitForm from './commit_sidebar/form.vue'; import CommitForm from './commit_sidebar/form.vue';
import IdeReview from './ide_review.vue'; import IdeReview from './ide_review.vue';
import SuccessMessage from './commit_sidebar/success_message.vue'; import SuccessMessage from './commit_sidebar/success_message.vue';
import MergeRequestDropdown from './merge_requests/dropdown.vue';
import { activityBarViews } from '../constants'; import { activityBarViews } from '../constants';
export default { export default {
...@@ -32,10 +34,12 @@ export default { ...@@ -32,10 +34,12 @@ export default {
CommitForm, CommitForm,
IdeReview, IdeReview,
SuccessMessage, SuccessMessage,
MergeRequestDropdown,
}, },
data() { data() {
return { return {
showTooltip: false, showTooltip: false,
showMergeRequestsDropdown: false,
}; };
}, },
computed: { computed: {
...@@ -46,6 +50,7 @@ export default { ...@@ -46,6 +50,7 @@ export default {
'changedFiles', 'changedFiles',
'stagedFiles', 'stagedFiles',
'lastCommitMsg', 'lastCommitMsg',
'currentMergeRequestId',
]), ]),
...mapGetters(['currentProject', 'someUncommitedChanges']), ...mapGetters(['currentProject', 'someUncommitedChanges']),
showSuccessMessage() { showSuccessMessage() {
...@@ -61,9 +66,39 @@ export default { ...@@ -61,9 +66,39 @@ export default {
watch: { watch: {
currentBranchId() { currentBranchId() {
this.$nextTick(() => { this.$nextTick(() => {
if (!this.$refs.branchId) return;
this.showTooltip = this.$refs.branchId.scrollWidth > this.$refs.branchId.offsetWidth; this.showTooltip = this.$refs.branchId.scrollWidth > this.$refs.branchId.offsetWidth;
}); });
}, },
loading() {
this.$nextTick(() => {
this.addDropdownListeners();
});
},
},
mounted() {
this.addDropdownListeners();
},
beforeDestroy() {
$(this.$refs.mergeRequestDropdown)
.off('show.bs.dropdown')
.off('hide.bs.dropdown');
},
methods: {
addDropdownListeners() {
if (!this.$refs.mergeRequestDropdown) return;
$(this.$refs.mergeRequestDropdown)
.on('show.bs.dropdown', () => {
this.toggleMergeRequestDropdown();
}).on('hide.bs.dropdown', () => {
this.toggleMergeRequestDropdown();
});
},
toggleMergeRequestDropdown() {
this.showMergeRequestsDropdown = !this.showMergeRequestsDropdown;
},
}, },
}; };
</script> </script>
...@@ -88,9 +123,13 @@ export default { ...@@ -88,9 +123,13 @@ export default {
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="context-header ide-context-header"> <div
<a class="context-header ide-context-header dropdown"
:href="currentProject.web_url" ref="mergeRequestDropdown"
>
<button
type="button"
data-toggle="dropdown"
> >
<div <div
v-if="currentProject.avatar_url" v-if="currentProject.avatar_url"
...@@ -114,19 +153,41 @@ export default { ...@@ -114,19 +153,41 @@ export default {
<div class="sidebar-context-title"> <div class="sidebar-context-title">
{{ currentProject.name }} {{ currentProject.name }}
</div> </div>
<div <div class="d-flex">
class="sidebar-context-title ide-sidebar-branch-title" <div
ref="branchId" v-if="currentBranchId"
v-tooltip class="sidebar-context-title ide-sidebar-branch-title"
:title="branchTooltipTitle" ref="branchId"
> v-tooltip
<icon :title="branchTooltipTitle"
name="branch" >
css-classes="append-right-5" <icon
/>{{ currentBranchId }} name="branch"
css-classes="append-right-5"
/>{{ currentBranchId }}
</div>
<div
v-if="currentMergeRequestId"
class="sidebar-context-title ide-sidebar-branch-title"
:class="{
'prepend-left-8': currentBranchId
}"
>
<icon
name="git-merge"
css-classes="append-right-5"
/>!{{ currentMergeRequestId }}
</div>
</div> </div>
</div> </div>
</a> <icon
class="ml-auto"
name="chevron-down"
/>
</button>
<merge-request-dropdown
:show="showMergeRequestsDropdown"
/>
</div> </div>
<div class="multi-file-commit-panel-inner-scroll"> <div class="multi-file-commit-panel-inner-scroll">
<component <component
......
...@@ -35,9 +35,7 @@ export default { ...@@ -35,9 +35,7 @@ export default {
}, },
watch: { watch: {
lastCommit() { lastCommit() {
if (!this.isPollingInitialized) { this.initPipelinePolling();
this.initPipelinePolling();
}
}, },
}, },
mounted() { mounted() {
...@@ -47,9 +45,8 @@ export default { ...@@ -47,9 +45,8 @@ export default {
if (this.intervalId) { if (this.intervalId) {
clearInterval(this.intervalId); clearInterval(this.intervalId);
} }
if (this.isPollingInitialized) {
this.stopPipelinePolling(); this.stopPipelinePolling();
}
}, },
methods: { methods: {
...mapActions('pipelines', ['fetchLatestPipeline', 'stopPipelinePolling']), ...mapActions('pipelines', ['fetchLatestPipeline', 'stopPipelinePolling']),
...@@ -59,8 +56,9 @@ export default { ...@@ -59,8 +56,9 @@ export default {
}, 1000); }, 1000);
}, },
initPipelinePolling() { initPipelinePolling() {
this.fetchLatestPipeline(); if (this.lastCommit) {
this.isPollingInitialized = true; this.fetchLatestPipeline();
}
}, },
commitAgeUpdate() { commitAgeUpdate() {
if (this.lastCommit) { if (this.lastCommit) {
......
<script>
import { mapActions, mapState } from 'vuex';
import _ from 'underscore';
import { __ } from '../../../locale';
import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue';
import ScrollButton from './detail/scroll_button.vue';
import JobDescription from './detail/description.vue';
const scrollPositions = {
top: 0,
bottom: 1,
};
export default {
directives: {
tooltip,
},
components: {
Icon,
ScrollButton,
JobDescription,
},
data() {
return {
scrollPos: scrollPositions.top,
};
},
computed: {
...mapState('pipelines', ['detailJob']),
isScrolledToBottom() {
return this.scrollPos === scrollPositions.bottom;
},
isScrolledToTop() {
return this.scrollPos === scrollPositions.top;
},
jobOutput() {
return this.detailJob.output || __('No messages were logged');
},
},
mounted() {
this.getTrace();
},
methods: {
...mapActions('pipelines', ['fetchJobTrace', 'setDetailJob']),
scrollDown() {
if (this.$refs.buildTrace) {
this.$refs.buildTrace.scrollTo(0, this.$refs.buildTrace.scrollHeight);
}
},
scrollUp() {
if (this.$refs.buildTrace) {
this.$refs.buildTrace.scrollTo(0, 0);
}
},
scrollBuildLog: _.throttle(function buildLogScrollDebounce() {
const { scrollTop } = this.$refs.buildTrace;
const { offsetHeight, scrollHeight } = this.$refs.buildTrace;
if (scrollTop + offsetHeight === scrollHeight) {
this.scrollPos = scrollPositions.bottom;
} else if (scrollTop === 0) {
this.scrollPos = scrollPositions.top;
} else {
this.scrollPos = '';
}
}),
getTrace() {
return this.fetchJobTrace().then(() => this.scrollDown());
},
},
};
</script>
<template>
<div class="ide-pipeline build-page d-flex flex-column flex-fill">
<header class="ide-job-header d-flex align-items-center">
<button
class="btn btn-default btn-sm d-flex"
@click="setDetailJob(null)"
>
<icon
name="chevron-left"
/>
{{ __('View jobs') }}
</button>
</header>
<div class="top-bar d-flex border-left-0">
<job-description
:job="detailJob"
/>
<div class="controllers ml-auto">
<a
v-tooltip
:title="__('Show complete raw log')"
data-placement="top"
data-container="body"
class="controllers-buttons"
:href="detailJob.rawPath"
target="_blank"
>
<i
aria-hidden="true"
class="fa fa-file-text-o"
></i>
</a>
<scroll-button
direction="up"
:disabled="isScrolledToTop"
@click="scrollUp"
/>
<scroll-button
direction="down"
:disabled="isScrolledToBottom"
@click="scrollDown"
/>
</div>
</div>
<pre
class="build-trace mb-0 h-100"
ref="buildTrace"
@scroll="scrollBuildLog"
>
<code
class="bash"
v-html="jobOutput"
>
</code>
<div
v-show="detailJob.isLoading"
class="build-loader-animation"
>
</div>
</pre>
</div>
</template>
<script>
import Icon from '../../../../vue_shared/components/icon.vue';
import CiIcon from '../../../../vue_shared/components/ci_icon.vue';
export default {
components: {
Icon,
CiIcon,
},
props: {
job: {
type: Object,
required: true,
},
},
computed: {
jobId() {
return `#${this.job.id}`;
},
},
};
</script>
<template>
<div class="d-flex align-items-center">
<ci-icon
class="d-flex"
:status="job.status"
:borderless="true"
:size="24"
/>
<span class="prepend-left-8">
{{ job.name }}
<a
:href="job.path"
target="_blank"
class="ide-external-link"
>
{{ jobId }}
<icon
name="external-link"
:size="12"
/>
</a>
</span>
</div>
</template>
<script>
import { __ } from '../../../../locale';
import Icon from '../../../../vue_shared/components/icon.vue';
import tooltip from '../../../../vue_shared/directives/tooltip';
const directions = {
up: 'up',
down: 'down',
};
export default {
directives: {
tooltip,
},
components: {
Icon,
},
props: {
direction: {
type: String,
required: true,
validator(value) {
return Object.keys(directions).includes(value);
},
},
disabled: {
type: Boolean,
required: true,
},
},
computed: {
tooltipTitle() {
return this.direction === directions.up ? __('Scroll to top') : __('Scroll to bottom');
},
iconName() {
return `scroll_${this.direction}`;
},
},
methods: {
clickedScroll() {
this.$emit('click');
},
},
};
</script>
<template>
<div
v-tooltip
class="controllers-buttons"
data-container="body"
data-placement="top"
:title="tooltipTitle"
>
<button
class="btn-scroll btn-transparent btn-blank"
type="button"
:disabled="disabled"
@click="clickedScroll"
>
<icon
:name="iconName"
/>
</button>
</div>
</template>
<script> <script>
import Icon from '../../../vue_shared/components/icon.vue'; import JobDescription from './detail/description.vue';
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
export default { export default {
components: { components: {
Icon, JobDescription,
CiIcon,
}, },
props: { props: {
job: { job: {
...@@ -18,29 +16,29 @@ export default { ...@@ -18,29 +16,29 @@ export default {
return `#${this.job.id}`; return `#${this.job.id}`;
}, },
}, },
methods: {
clickViewLog() {
this.$emit('clickViewLog', this.job);
},
},
}; };
</script> </script>
<template> <template>
<div class="ide-job-item"> <div class="ide-job-item">
<ci-icon <job-description
:status="job.status" class="append-right-default"
:borderless="true" :job="job"
:size="24"
/> />
<span class="prepend-left-8"> <div class="ml-auto align-self-center">
{{ job.name }} <button
<a v-if="job.started"
:href="job.path" type="button"
target="_blank" class="btn btn-default btn-sm"
class="ide-external-link" @click="clickViewLog"
> >
{{ jobId }} {{ __('View log') }}
<icon </button>
name="external-link" </div>
:size="12"
/>
</a>
</span>
</div> </div>
</template> </template>
...@@ -19,7 +19,7 @@ export default { ...@@ -19,7 +19,7 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions('pipelines', ['fetchJobs', 'toggleStageCollapsed']), ...mapActions('pipelines', ['fetchJobs', 'toggleStageCollapsed', 'setDetailJob']),
}, },
}; };
</script> </script>
...@@ -38,6 +38,7 @@ export default { ...@@ -38,6 +38,7 @@ export default {
:stage="stage" :stage="stage"
@fetch="fetchJobs" @fetch="fetchJobs"
@toggleCollapsed="toggleStageCollapsed" @toggleCollapsed="toggleStageCollapsed"
@clickViewLog="setDetailJob"
/> />
</template> </template>
</div> </div>
......
...@@ -48,6 +48,9 @@ export default { ...@@ -48,6 +48,9 @@ export default {
toggleCollapsed() { toggleCollapsed() {
this.$emit('toggleCollapsed', this.stage.id); this.$emit('toggleCollapsed', this.stage.id);
}, },
clickViewLog(job) {
this.$emit('clickViewLog', job);
},
}, },
}; };
</script> </script>
...@@ -101,6 +104,7 @@ export default { ...@@ -101,6 +104,7 @@ export default {
v-for="job in stage.jobs" v-for="job in stage.jobs"
:key="job.id" :key="job.id"
:job="job" :job="job"
@clickViewLog="clickViewLog"
/> />
</template> </template>
</div> </div>
......
<script>
import { mapGetters } from 'vuex';
import Tabs from '../../../vue_shared/components/tabs/tabs';
import Tab from '../../../vue_shared/components/tabs/tab.vue';
import List from './list.vue';
export default {
components: {
Tabs,
Tab,
List,
},
props: {
show: {
type: Boolean,
required: true,
},
},
computed: {
...mapGetters('mergeRequests', ['assignedData', 'createdData']),
createdMergeRequestLength() {
return this.createdData.mergeRequests.length;
},
assignedMergeRequestLength() {
return this.assignedData.mergeRequests.length;
},
},
};
</script>
<template>
<div class="dropdown-menu ide-merge-requests-dropdown p-0">
<tabs
v-if="show"
stop-propagation
>
<tab active>
<template slot="title">
{{ __('Created by me') }}
<span class="badge badge-pill">
{{ createdMergeRequestLength }}
</span>
</template>
<list
type="created"
:empty-text="__('You have not created any merge requests')"
/>
</tab>
<tab>
<template slot="title">
{{ __('Assigned to me') }}
<span class="badge badge-pill">
{{ assignedMergeRequestLength }}
</span>
</template>
<list
type="assigned"
:empty-text="__('You do not have any assigned merge requests')"
/>
</tab>
</tabs>
</div>
</template>
<script>
import Icon from '../../../vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
props: {
item: {
type: Object,
required: true,
},
currentId: {
type: String,
required: true,
},
currentProjectId: {
type: String,
required: true,
},
},
computed: {
isActive() {
return (
this.item.iid === parseInt(this.currentId, 10) &&
this.currentProjectId === this.item.projectPathWithNamespace
);
},
pathWithID() {
return `${this.item.projectPathWithNamespace}!${this.item.iid}`;
},
},
methods: {
clickItem() {
this.$emit('click', this.item);
},
},
};
</script>
<template>
<button
type="button"
class="btn-link d-flex align-items-center"
@click="clickItem"
>
<span class="d-flex append-right-default ide-merge-request-current-icon">
<icon
v-if="isActive"
name="mobile-issue-close"
:size="18"
/>
</span>
<span>
<strong>
{{ item.title }}
</strong>
<span class="ide-merge-request-project-path d-block mt-1">
{{ pathWithID }}
</span>
</span>
</button>
</template>
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import _ from 'underscore';
import LoadingIcon from '../../../vue_shared/components/loading_icon.vue';
import Item from './item.vue';
export default {
components: {
LoadingIcon,
Item,
},
props: {
type: {
type: String,
required: true,
},
emptyText: {
type: String,
required: true,
},
},
data() {
return {
search: '',
};
},
computed: {
...mapGetters('mergeRequests', ['getData']),
...mapState(['currentMergeRequestId', 'currentProjectId']),
data() {
return this.getData(this.type);
},
isLoading() {
return this.data.isLoading;
},
mergeRequests() {
return this.data.mergeRequests;
},
hasMergeRequests() {
return this.mergeRequests.length !== 0;
},
hasNoSearchResults() {
return this.search !== '' && !this.hasMergeRequests;
},
},
watch: {
isLoading: {
handler: 'focusSearch',
},
},
mounted() {
this.loadMergeRequests();
},
methods: {
...mapActions('mergeRequests', ['fetchMergeRequests', 'openMergeRequest']),
loadMergeRequests() {
this.fetchMergeRequests({ type: this.type, search: this.search });
},
viewMergeRequest(item) {
this.openMergeRequest({
projectPath: item.projectPathWithNamespace,
id: item.iid,
});
},
searchMergeRequests: _.debounce(function debounceSearch() {
this.loadMergeRequests();
}, 250),
focusSearch() {
if (!this.isLoading) {
this.$nextTick(() => {
this.$refs.searchInput.focus();
});
}
},
},
};
</script>
<template>
<div>
<div class="dropdown-input mt-3 pb-3 mb-0 border-bottom">
<input
type="search"
class="dropdown-input-field"
:placeholder="__('Search merge requests')"
v-model="search"
@input="searchMergeRequests"
ref="searchInput"
/>
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search"
></i>
</div>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<loading-icon
class="mt-3 mb-3 align-self-center ml-auto mr-auto"
v-if="isLoading"
size="2"
/>
<ul
v-else
class="mb-3 w-100"
>
<template v-if="hasMergeRequests">
<li
v-for="item in mergeRequests"
:key="item.id"
>
<item
:item="item"
:current-id="currentMergeRequestId"
:current-project-id="currentProjectId"
@click="viewMergeRequest"
/>
</li>
</template>
<li
v-else
class="ide-merge-requests-empty d-flex align-items-center justify-content-center"
>
<template v-if="hasNoSearchResults">
{{ __('No merge requests found') }}
</template>
<template v-else>
{{ emptyText }}
</template>
</li>
</ul>
</div>
</div>
</template>
...@@ -4,6 +4,7 @@ import tooltip from '../../../vue_shared/directives/tooltip'; ...@@ -4,6 +4,7 @@ import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue'; import Icon from '../../../vue_shared/components/icon.vue';
import { rightSidebarViews } from '../../constants'; import { rightSidebarViews } from '../../constants';
import PipelinesList from '../pipelines/list.vue'; import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue';
export default { export default {
directives: { directives: {
...@@ -12,9 +13,16 @@ export default { ...@@ -12,9 +13,16 @@ export default {
components: { components: {
Icon, Icon,
PipelinesList, PipelinesList,
JobsDetail,
}, },
computed: { computed: {
...mapState(['rightPane']), ...mapState(['rightPane']),
pipelinesActive() {
return (
this.rightPane === rightSidebarViews.pipelines ||
this.rightPane === rightSidebarViews.jobsDetail
);
},
}, },
methods: { methods: {
...mapActions(['setRightPane']), ...mapActions(['setRightPane']),
...@@ -48,7 +56,7 @@ export default { ...@@ -48,7 +56,7 @@ export default {
:title="__('Pipelines')" :title="__('Pipelines')"
class="ide-sidebar-link is-right" class="ide-sidebar-link is-right"
:class="{ :class="{
active: rightPane === $options.rightSidebarViews.pipelines active: pipelinesActive
}" }"
type="button" type="button"
@click="clickTab($event, $options.rightSidebarViews.pipelines)" @click="clickTab($event, $options.rightSidebarViews.pipelines)"
......
...@@ -23,4 +23,5 @@ export const viewerTypes = { ...@@ -23,4 +23,5 @@ export const viewerTypes = {
export const rightSidebarViews = { export const rightSidebarViews = {
pipelines: 'pipelines-list', pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail',
}; };
...@@ -17,9 +17,7 @@ export const getMergeRequestData = ( ...@@ -17,9 +17,7 @@ export const getMergeRequestData = (
mergeRequestId, mergeRequestId,
mergeRequest: data, mergeRequest: data,
}); });
if (!state.currentMergeRequestId) { commit(types.SET_CURRENT_MERGE_REQUEST, mergeRequestId);
commit(types.SET_CURRENT_MERGE_REQUEST, mergeRequestId);
}
resolve(data); resolve(data);
}) })
.catch(() => { .catch(() => {
......
...@@ -13,8 +13,7 @@ export const getProjectData = ({ commit, state }, { namespace, projectId, force ...@@ -13,8 +13,7 @@ export const getProjectData = ({ commit, state }, { namespace, projectId, force
.then(data => { .then(data => {
commit(types.TOGGLE_LOADING, { entry: state }); commit(types.TOGGLE_LOADING, { entry: state });
commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data }); commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data });
if (!state.currentProjectId) commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`);
commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`);
resolve(data); resolve(data);
}) })
.catch(() => { .catch(() => {
......
import { __ } from '../../../../locale'; import { __ } from '../../../../locale';
import Api from '../../../../api'; import Api from '../../../../api';
import flash from '../../../../flash'; import flash from '../../../../flash';
import router from '../../../ide_router';
import { scopes } from './constants';
import * as types from './mutation_types'; import * as types from './mutation_types';
import * as rootTypes from '../../mutation_types';
export const requestMergeRequests = ({ commit }) => commit(types.REQUEST_MERGE_REQUESTS); export const requestMergeRequests = ({ commit }, type) =>
export const receiveMergeRequestsError = ({ commit }) => { commit(types.REQUEST_MERGE_REQUESTS, type);
export const receiveMergeRequestsError = ({ commit }, type) => {
flash(__('Error loading merge requests.')); flash(__('Error loading merge requests.'));
commit(types.RECEIVE_MERGE_REQUESTS_ERROR); commit(types.RECEIVE_MERGE_REQUESTS_ERROR, type);
}; };
export const receiveMergeRequestsSuccess = ({ commit }, data) => export const receiveMergeRequestsSuccess = ({ commit }, { type, data }) =>
commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, data); commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, { type, data });
export const fetchMergeRequests = ({ dispatch, state: { scope, state } }, search = '') => { export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, search = '' }) => {
dispatch('requestMergeRequests'); const scope = scopes[type];
dispatch('resetMergeRequests'); dispatch('requestMergeRequests', type);
dispatch('resetMergeRequests', type);
Api.mergeRequests({ scope, state, search }) Api.mergeRequests({ scope, state, search })
.then(({ data }) => dispatch('receiveMergeRequestsSuccess', data)) .then(({ data }) => dispatch('receiveMergeRequestsSuccess', { type, data }))
.catch(() => dispatch('receiveMergeRequestsError')); .catch(() => dispatch('receiveMergeRequestsError', type));
}; };
export const resetMergeRequests = ({ commit }) => commit(types.RESET_MERGE_REQUESTS); export const resetMergeRequests = ({ commit }, type) => commit(types.RESET_MERGE_REQUESTS, type);
export const openMergeRequest = ({ commit, dispatch }, { projectPath, id }) => {
commit(rootTypes.CLEAR_PROJECTS, null, { root: true });
commit(rootTypes.SET_CURRENT_MERGE_REQUEST, `${id}`, { root: true });
commit(rootTypes.RESET_OPEN_FILES, null, { root: true });
dispatch('pipelines/stopPipelinePolling', null, { root: true });
dispatch('pipelines/clearEtagPoll', null, { root: true });
dispatch('pipelines/resetLatestPipeline', null, { root: true });
dispatch('setCurrentBranchId', '', { root: true });
router.push(`/project/${projectPath}/merge_requests/${id}`);
};
export default () => {}; export default () => {};
export const scopes = { export const scopes = {
assignedToMe: 'assigned-to-me', assigned: 'assigned-to-me',
createdByMe: 'created-by-me', created: 'created-by-me',
}; };
export const states = { export const states = {
......
export const getData = state => type => state[type];
export const assignedData = state => state.assigned;
export const createdData = state => state.created;
import state from './state'; import state from './state';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
export default { export default {
...@@ -7,4 +8,5 @@ export default { ...@@ -7,4 +8,5 @@ export default {
state: state(), state: state(),
actions, actions,
mutations, mutations,
getters,
}; };
...@@ -2,15 +2,15 @@ ...@@ -2,15 +2,15 @@
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.REQUEST_MERGE_REQUESTS](state) { [types.REQUEST_MERGE_REQUESTS](state, type) {
state.isLoading = true; state[type].isLoading = true;
}, },
[types.RECEIVE_MERGE_REQUESTS_ERROR](state) { [types.RECEIVE_MERGE_REQUESTS_ERROR](state, type) {
state.isLoading = false; state[type].isLoading = false;
}, },
[types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, data) { [types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, { type, data }) {
state.isLoading = false; state[type].isLoading = false;
state.mergeRequests = data.map(mergeRequest => ({ state[type].mergeRequests = data.map(mergeRequest => ({
id: mergeRequest.id, id: mergeRequest.id,
iid: mergeRequest.iid, iid: mergeRequest.iid,
title: mergeRequest.title, title: mergeRequest.title,
...@@ -20,7 +20,7 @@ export default { ...@@ -20,7 +20,7 @@ export default {
.replace(`/merge_requests/${mergeRequest.iid}`, ''), .replace(`/merge_requests/${mergeRequest.iid}`, ''),
})); }));
}, },
[types.RESET_MERGE_REQUESTS](state) { [types.RESET_MERGE_REQUESTS](state, type) {
state.mergeRequests = []; state[type].mergeRequests = [];
}, },
}; };
import { scopes, states } from './constants'; import { states } from './constants';
export default () => ({ export default () => ({
isLoading: false, created: {
mergeRequests: [], isLoading: false,
scope: scopes.assignedToMe, mergeRequests: [],
},
assigned: {
isLoading: false,
mergeRequests: [],
},
state: states.opened, state: states.opened,
}); });
...@@ -4,6 +4,7 @@ import { __ } from '../../../../locale'; ...@@ -4,6 +4,7 @@ import { __ } from '../../../../locale';
import flash from '../../../../flash'; import flash from '../../../../flash';
import Poll from '../../../../lib/utils/poll'; import Poll from '../../../../lib/utils/poll';
import service from '../../../services'; import service from '../../../services';
import { rightSidebarViews } from '../../../constants';
import * as types from './mutation_types'; import * as types from './mutation_types';
let eTagPoll; let eTagPoll;
...@@ -77,4 +78,31 @@ export const fetchJobs = ({ dispatch }, stage) => { ...@@ -77,4 +78,31 @@ export const fetchJobs = ({ dispatch }, stage) => {
export const toggleStageCollapsed = ({ commit }, stageId) => export const toggleStageCollapsed = ({ commit }, stageId) =>
commit(types.TOGGLE_STAGE_COLLAPSE, stageId); commit(types.TOGGLE_STAGE_COLLAPSE, stageId);
export const setDetailJob = ({ commit, dispatch }, job) => {
commit(types.SET_DETAIL_JOB, job);
dispatch('setRightPane', job ? rightSidebarViews.jobsDetail : rightSidebarViews.pipelines, {
root: true,
});
};
export const requestJobTrace = ({ commit }) => commit(types.REQUEST_JOB_TRACE);
export const receiveJobTraceError = ({ commit }) => {
flash(__('Error fetching job trace'));
commit(types.RECEIVE_JOB_TRACE_ERROR);
};
export const receiveJobTraceSuccess = ({ commit }, data) =>
commit(types.RECEIVE_JOB_TRACE_SUCCESS, data);
export const fetchJobTrace = ({ dispatch, state }) => {
dispatch('requestJobTrace');
return axios
.get(`${state.detailJob.path}/trace`, { params: { format: 'json' } })
.then(({ data }) => dispatch('receiveJobTraceSuccess', data))
.catch(() => dispatch('receiveJobTraceError'));
};
export const resetLatestPipeline = ({ commit }) =>
commit(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, null);
export default () => {}; export default () => {};
...@@ -7,3 +7,9 @@ export const RECEIVE_JOBS_ERROR = 'RECEIVE_JOBS_ERROR'; ...@@ -7,3 +7,9 @@ export const RECEIVE_JOBS_ERROR = 'RECEIVE_JOBS_ERROR';
export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
export const TOGGLE_STAGE_COLLAPSE = 'TOGGLE_STAGE_COLLAPSE'; export const TOGGLE_STAGE_COLLAPSE = 'TOGGLE_STAGE_COLLAPSE';
export const SET_DETAIL_JOB = 'SET_DETAIL_JOB';
export const REQUEST_JOB_TRACE = 'REQUEST_JOB_TRACE';
export const RECEIVE_JOB_TRACE_ERROR = 'RECEIVE_JOB_TRACE_ERROR';
export const RECEIVE_JOB_TRACE_SUCCESS = 'RECEIVE_JOB_TRACE_SUCCESS';
...@@ -63,4 +63,17 @@ export default { ...@@ -63,4 +63,17 @@ export default {
isCollapsed: stage.id === id ? !stage.isCollapsed : stage.isCollapsed, isCollapsed: stage.id === id ? !stage.isCollapsed : stage.isCollapsed,
})); }));
}, },
[types.SET_DETAIL_JOB](state, job) {
state.detailJob = { ...job };
},
[types.REQUEST_JOB_TRACE](state) {
state.detailJob.isLoading = true;
},
[types.RECEIVE_JOB_TRACE_ERROR](state) {
state.detailJob.isLoading = false;
},
[types.RECEIVE_JOB_TRACE_SUCCESS](state, data) {
state.detailJob.isLoading = false;
state.detailJob.output = data.html;
},
}; };
...@@ -3,4 +3,5 @@ export default () => ({ ...@@ -3,4 +3,5 @@ export default () => ({
isLoadingJobs: false, isLoadingJobs: false,
latestPipeline: null, latestPipeline: null,
stages: [], stages: [],
detailJob: null,
}); });
...@@ -4,4 +4,8 @@ export const normalizeJob = job => ({ ...@@ -4,4 +4,8 @@ export const normalizeJob = job => ({
name: job.name, name: job.name,
status: job.status, status: job.status,
path: job.build_path, path: job.build_path,
rawPath: `${job.build_path}/raw`,
started: job.started,
output: '',
isLoading: false,
}); });
...@@ -68,3 +68,6 @@ export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER'; ...@@ -68,3 +68,6 @@ export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL'; export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL';
export const SET_RIGHT_PANE = 'SET_RIGHT_PANE'; export const SET_RIGHT_PANE = 'SET_RIGHT_PANE';
export const CLEAR_PROJECTS = 'CLEAR_PROJECTS';
export const RESET_OPEN_FILES = 'RESET_OPEN_FILES';
...@@ -157,6 +157,12 @@ export default { ...@@ -157,6 +157,12 @@ export default {
[types.SET_LINKS](state, links) { [types.SET_LINKS](state, links) {
Object.assign(state, { links }); Object.assign(state, { links });
}, },
[types.CLEAR_PROJECTS](state) {
Object.assign(state, { projects: {}, trees: {} });
},
[types.RESET_OPEN_FILES](state) {
Object.assign(state, { openFiles: [] });
},
...projectMutations, ...projectMutations,
...mergeRequestMutation, ...mergeRequestMutation,
...fileMutations, ...fileMutations,
......
...@@ -67,7 +67,15 @@ class ImporterStatus { ...@@ -67,7 +67,15 @@ class ImporterStatus {
false, false,
)); ));
}) })
.catch(() => flash(__('An error occurred while importing project'))); .catch((error) => {
let details = error;
if (error.response && error.response.data && error.response.data.errors) {
details = error.response.data.errors;
}
flash(__(`An error occurred while importing project: ${details}`));
});
} }
autoUpdate() { autoUpdate() {
......
import $ from 'jquery'; import $ from 'jquery';
import stickyMonitor from './lib/utils/sticky'; import { stickyMonitor } from './lib/utils/sticky';
export default (stickyTop) => { export default (stickyTop) => {
stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop); stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop);
......
import $ from 'jquery'; import $ from 'jquery';
import _ from 'underscore'; import _ from 'underscore';
import StickyFill from 'stickyfilljs'; import { polyfillSticky } from './lib/utils/sticky';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
import { visitUrl } from './lib/utils/url_utility'; import { visitUrl } from './lib/utils/url_utility';
import bp from './breakpoints'; import bp from './breakpoints';
import { numberToHumanSize } from './lib/utils/number_utils'; import { numberToHumanSize } from './lib/utils/number_utils';
import { setCiStatusFavicon } from './lib/utils/common_utils'; import { setCiStatusFavicon } from './lib/utils/common_utils';
import { isScrolledToBottom, scrollDown } from './lib/utils/scroll_utils';
import LogOutputBehaviours from './lib/utils/logoutput_behaviours';
export default class Job { export default class Job extends LogOutputBehaviours {
constructor(options) { constructor(options) {
super();
this.timeout = null; this.timeout = null;
this.state = null; this.state = null;
this.fetchingStatusFavicon = false; this.fetchingStatusFavicon = false;
...@@ -29,10 +32,6 @@ export default class Job { ...@@ -29,10 +32,6 @@ export default class Job {
this.$buildTraceOutput = $('.js-build-output'); this.$buildTraceOutput = $('.js-build-output');
this.$topBar = $('.js-top-bar'); this.$topBar = $('.js-top-bar');
// Scroll controllers
this.$scrollTopBtn = $('.js-scroll-up');
this.$scrollBottomBtn = $('.js-scroll-down');
clearTimeout(this.timeout); clearTimeout(this.timeout);
this.initSidebar(); this.initSidebar();
...@@ -48,23 +47,14 @@ export default class Job { ...@@ -48,23 +47,14 @@ export default class Job {
.off('click', '.stage-item') .off('click', '.stage-item')
.on('click', '.stage-item', this.updateDropdown); .on('click', '.stage-item', this.updateDropdown);
// add event listeners to the scroll buttons
this.$scrollTopBtn
.off('click')
.on('click', this.scrollToTop.bind(this));
this.$scrollBottomBtn
.off('click')
.on('click', this.scrollToBottom.bind(this));
this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100); this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
this.$window this.$window
.off('scroll') .off('scroll')
.on('scroll', () => { .on('scroll', () => {
if (!this.isScrolledToBottom()) { if (!isScrolledToBottom()) {
this.toggleScrollAnimation(false); this.toggleScrollAnimation(false);
} else if (this.isScrolledToBottom() && !this.isLogComplete) { } else if (isScrolledToBottom() && !this.isLogComplete) {
this.toggleScrollAnimation(true); this.toggleScrollAnimation(true);
} }
this.scrollThrottled(); this.scrollThrottled();
...@@ -80,70 +70,11 @@ export default class Job { ...@@ -80,70 +70,11 @@ export default class Job {
} }
initAffixTopArea() { initAffixTopArea() {
/** polyfillSticky(this.$topBar);
If the browser does not support position sticky, it returns the position as static.
If the browser does support sticky, then we allow the browser to handle it, if not
then we use a polyfill
*/
if (this.$topBar.css('position') !== 'static') return;
StickyFill.add(this.$topBar);
}
// eslint-disable-next-line class-methods-use-this
canScroll() {
return $(document).height() > $(window).height();
}
toggleScroll() {
const $document = $(document);
const currentPosition = $document.scrollTop();
const scrollHeight = $document.height();
const windowHeight = $(window).height();
if (this.canScroll()) {
if (currentPosition > 0 &&
(scrollHeight - currentPosition !== windowHeight)) {
// User is in the middle of the log
this.toggleDisableButton(this.$scrollTopBtn, false);
this.toggleDisableButton(this.$scrollBottomBtn, false);
} else if (currentPosition === 0) {
// User is at Top of Log
this.toggleDisableButton(this.$scrollTopBtn, true);
this.toggleDisableButton(this.$scrollBottomBtn, false);
} else if (this.isScrolledToBottom()) {
// User is at the bottom of the build log.
this.toggleDisableButton(this.$scrollTopBtn, false);
this.toggleDisableButton(this.$scrollBottomBtn, true);
}
} else {
this.toggleDisableButton(this.$scrollTopBtn, true);
this.toggleDisableButton(this.$scrollBottomBtn, true);
}
}
// eslint-disable-next-line class-methods-use-this
isScrolledToBottom() {
const $document = $(document);
const currentPosition = $document.scrollTop();
const scrollHeight = $document.height();
const windowHeight = $(window).height();
return scrollHeight - currentPosition === windowHeight;
}
// eslint-disable-next-line class-methods-use-this
scrollDown() {
const $document = $(document);
$document.scrollTop($document.height());
} }
scrollToBottom() { scrollToBottom() {
this.scrollDown(); scrollDown();
this.hasBeenScrolled = true; this.hasBeenScrolled = true;
this.toggleScroll(); this.toggleScroll();
} }
...@@ -154,12 +85,6 @@ export default class Job { ...@@ -154,12 +85,6 @@ export default class Job {
this.toggleScroll(); this.toggleScroll();
} }
// eslint-disable-next-line class-methods-use-this
toggleDisableButton($button, disable) {
if (disable && $button.prop('disabled')) return;
$button.prop('disabled', disable);
}
toggleScrollAnimation(toggle) { toggleScrollAnimation(toggle) {
this.$scrollBottomBtn.toggleClass('animate', toggle); this.$scrollBottomBtn.toggleClass('animate', toggle);
} }
...@@ -191,7 +116,7 @@ export default class Job { ...@@ -191,7 +116,7 @@ export default class Job {
this.state = log.state; this.state = log.state;
} }
this.isScrollInBottom = this.isScrolledToBottom(); this.isScrollInBottom = isScrolledToBottom();
if (log.append) { if (log.append) {
this.$buildTraceOutput.append(log.html); this.$buildTraceOutput.append(log.html);
...@@ -231,7 +156,7 @@ export default class Job { ...@@ -231,7 +156,7 @@ export default class Job {
}) })
.then(() => { .then(() => {
if (this.isScrollInBottom) { if (this.isScrollInBottom) {
this.scrollDown(); scrollDown();
} }
}) })
.then(() => this.toggleScroll()); .then(() => this.toggleScroll());
......
...@@ -42,6 +42,9 @@ export default { ...@@ -42,6 +42,9 @@ export default {
jobStarted() { jobStarted() {
return !this.job.started === false; return !this.job.started === false;
}, },
headerTime() {
return this.jobStarted ? this.job.started : this.job.created_at;
},
}, },
watch: { watch: {
job() { job() {
...@@ -73,7 +76,7 @@ export default { ...@@ -73,7 +76,7 @@ export default {
:status="status" :status="status"
item-name="Job" item-name="Job"
:item-id="job.id" :item-id="job.id"
:time="job.created_at" :time="headerTime"
:user="job.user" :user="job.user"
:actions="actions" :actions="actions"
:has-sidebar-button="true" :has-sidebar-button="true"
......
import $ from 'jquery';
import { canScroll, isScrolledToBottom, toggleDisableButton } from './scroll_utils';
export default class LogOutputBehaviours {
constructor() {
// Scroll buttons
this.$scrollTopBtn = $('.js-scroll-up');
this.$scrollBottomBtn = $('.js-scroll-down');
this.$scrollTopBtn.off('click').on('click', this.scrollToTop.bind(this));
this.$scrollBottomBtn.off('click').on('click', this.scrollToBottom.bind(this));
}
toggleScroll() {
const $document = $(document);
const currentPosition = $document.scrollTop();
const scrollHeight = $document.height();
const windowHeight = $(window).height();
if (canScroll()) {
if (currentPosition > 0 && scrollHeight - currentPosition !== windowHeight) {
// User is in the middle of the log
toggleDisableButton(this.$scrollTopBtn, false);
toggleDisableButton(this.$scrollBottomBtn, false);
} else if (currentPosition === 0) {
// User is at Top of Log
toggleDisableButton(this.$scrollTopBtn, true);
toggleDisableButton(this.$scrollBottomBtn, false);
} else if (isScrolledToBottom()) {
// User is at the bottom of the build log.
toggleDisableButton(this.$scrollTopBtn, false);
toggleDisableButton(this.$scrollBottomBtn, true);
}
} else {
toggleDisableButton(this.$scrollTopBtn, true);
toggleDisableButton(this.$scrollBottomBtn, true);
}
}
toggleScrollAnimation(toggle) {
this.$scrollBottomBtn.toggleClass('animate', toggle);
}
}
import $ from 'jquery';
export const canScroll = () => $(document).height() > $(window).height();
/**
* Checks if the entire page is scrolled down all the way to the bottom
*/
export const isScrolledToBottom = () => {
const $document = $(document);
const currentPosition = $document.scrollTop();
const scrollHeight = $document.height();
const windowHeight = $(window).height();
return scrollHeight - currentPosition === windowHeight;
};
export const scrollDown = () => {
const $document = $(document);
$document.scrollTop($document.height());
};
export const toggleDisableButton = ($button, disable) => {
if (disable && $button.prop('disabled')) return;
$button.prop('disabled', disable);
};
export default {};
import StickyFill from 'stickyfilljs';
export const createPlaceholder = () => { export const createPlaceholder = () => {
const placeholder = document.createElement('div'); const placeholder = document.createElement('div');
placeholder.classList.add('sticky-placeholder'); placeholder.classList.add('sticky-placeholder');
...@@ -28,7 +30,16 @@ export const isSticky = (el, scrollY, stickyTop, insertPlaceholder) => { ...@@ -28,7 +30,16 @@ export const isSticky = (el, scrollY, stickyTop, insertPlaceholder) => {
} }
}; };
export default (el, stickyTop, insertPlaceholder = true) => { /**
* Create a listener that will toggle a 'is-stuck' class, based on the current scroll position.
*
* - If the current environment does not support `position: sticky`, do nothing.
*
* @param {HTMLElement} el The `position: sticky` element.
* @param {Number} stickyTop Used to determine when an element is stuck.
* @param {Boolean} insertPlaceholder Should a placeholder element be created when element is stuck?
*/
export const stickyMonitor = (el, stickyTop, insertPlaceholder = true) => {
if (!el) return; if (!el) return;
if (typeof CSS === 'undefined' || !(CSS.supports('(position: -webkit-sticky) or (position: sticky)'))) return; if (typeof CSS === 'undefined' || !(CSS.supports('(position: -webkit-sticky) or (position: sticky)'))) return;
...@@ -37,3 +48,13 @@ export default (el, stickyTop, insertPlaceholder = true) => { ...@@ -37,3 +48,13 @@ export default (el, stickyTop, insertPlaceholder = true) => {
passive: true, passive: true,
}); });
}; };
/**
* Polyfill the `position: sticky` behavior.
*
* - If the current environment supports `position: sticky`, do nothing.
* - Can receive an iterable element list (NodeList, jQuery collection, etc.) or single HTMLElement.
*/
export const polyfillSticky = (el) => {
StickyFill.add(el);
};
...@@ -174,7 +174,10 @@ export default { ...@@ -174,7 +174,10 @@ export default {
:tags-path="tagsPath" :tags-path="tagsPath"
:show-legend="showLegend" :show-legend="showLegend"
:small-graph="forceSmallGraph" :small-graph="forceSmallGraph"
/> >
<!-- EE content -->
{{ null }}
</graph>
</graph-group> </graph-group>
</div> </div>
<empty-state <empty-state
......
...@@ -232,9 +232,14 @@ export default { ...@@ -232,9 +232,14 @@ export default {
@mouseover="showFlagContent = true" @mouseover="showFlagContent = true"
@mouseleave="showFlagContent = false" @mouseleave="showFlagContent = false"
> >
<h5 class="text-center graph-title"> <div class="prometheus-graph-header">
{{ graphData.title }} <h5 class="prometheus-graph-title">
</h5> {{ graphData.title }}
</h5>
<div class="prometheus-graph-widgets">
<slot></slot>
</div>
</div>
<div <div
class="prometheus-svg-container" class="prometheus-svg-container"
:style="paddingBottomRootSvg" :style="paddingBottomRootSvg"
......
...@@ -89,14 +89,13 @@ export default { ...@@ -89,14 +89,13 @@ export default {
<div> <div>
<div <div
class="js-gcp-machine-type-dropdown dropdown" class="js-gcp-machine-type-dropdown dropdown"
:class="{ 'gl-show-field-errors': hasErrors }"
> >
<dropdown-hidden-input <dropdown-hidden-input
:name="fieldName" :name="fieldName"
:value="selectedMachineType" :value="selectedMachineType"
/> />
<dropdown-button <dropdown-button
:class="{ 'gl-field-error-outline': hasErrors }" :class="{ 'border-danger': hasErrors }"
:is-disabled="isDisabled" :is-disabled="isDisabled"
:is-loading="isLoading" :is-loading="isLoading"
:toggle-text="toggleText" :toggle-text="toggleText"
...@@ -132,8 +131,11 @@ export default { ...@@ -132,8 +131,11 @@ export default {
</div> </div>
</div> </div>
<span <span
class="form-text text-muted" class="form-text"
:class="{ 'gl-field-error': hasErrors }" :class="{
'text-danger': hasErrors,
'text-muted': !hasErrors
}"
v-if="hasErrors" v-if="hasErrors"
> >
{{ errorMessage }} {{ errorMessage }}
......
...@@ -147,7 +147,6 @@ export default { ...@@ -147,7 +147,6 @@ export default {
<div> <div>
<div <div
class="js-gcp-project-id-dropdown dropdown" class="js-gcp-project-id-dropdown dropdown"
:class="{ 'gl-show-field-errors': hasErrors }"
> >
<dropdown-hidden-input <dropdown-hidden-input
:name="fieldName" :name="fieldName"
...@@ -155,7 +154,7 @@ export default { ...@@ -155,7 +154,7 @@ export default {
/> />
<dropdown-button <dropdown-button
:class="{ :class="{
'gl-field-error-outline': hasErrors, 'border-danger': hasErrors,
'read-only': hasOneProject 'read-only': hasOneProject
}" }"
:is-disabled="isDisabled" :is-disabled="isDisabled"
...@@ -193,8 +192,11 @@ export default { ...@@ -193,8 +192,11 @@ export default {
</div> </div>
</div> </div>
<span <span
class="form-text text-muted" class="form-text"
:class="{ 'gl-field-error': hasErrors }" :class="{
'text-danger': hasErrors,
'text-muted': !hasErrors
}"
v-html="helpText" v-html="helpText"
></span> ></span>
</div> </div>
......
...@@ -63,14 +63,13 @@ export default { ...@@ -63,14 +63,13 @@ export default {
<div> <div>
<div <div
class="js-gcp-zone-dropdown dropdown" class="js-gcp-zone-dropdown dropdown"
:class="{ 'gl-show-field-errors': hasErrors }"
> >
<dropdown-hidden-input <dropdown-hidden-input
:name="fieldName" :name="fieldName"
:value="selectedZone" :value="selectedZone"
/> />
<dropdown-button <dropdown-button
:class="{ 'gl-field-error-outline': hasErrors }" :class="{ 'border-danger': hasErrors }"
:is-disabled="isDisabled" :is-disabled="isDisabled"
:is-loading="isLoading" :is-loading="isLoading"
:toggle-text="toggleText" :toggle-text="toggleText"
...@@ -106,8 +105,11 @@ export default { ...@@ -106,8 +105,11 @@ export default {
</div> </div>
</div> </div>
<span <span
class="form-text text-muted" class="form-text"
:class="{ 'gl-field-error': hasErrors }" :class="{
'text-danger': hasErrors,
'text-muted': !hasErrors
}"
v-if="hasErrors" v-if="hasErrors"
> >
{{ errorMessage }} {{ errorMessage }}
......
...@@ -26,6 +26,11 @@ export default { ...@@ -26,6 +26,11 @@ export default {
created() { created() {
this.isTab = true; this.isTab = true;
}, },
updated() {
if (this.$parent) {
this.$parent.$forceUpdate();
}
},
}; };
</script> </script>
......
export default { export default {
props: {
stopPropagation: {
type: Boolean,
required: false,
default: false,
},
},
data() { data() {
return { return {
currentIndex: 0, currentIndex: 0,
...@@ -13,7 +20,12 @@ export default { ...@@ -13,7 +20,12 @@ export default {
this.tabs = this.$children.filter(child => child.isTab); this.tabs = this.$children.filter(child => child.isTab);
this.currentIndex = this.tabs.findIndex(tab => tab.localActive); this.currentIndex = this.tabs.findIndex(tab => tab.localActive);
}, },
setTab(index) { setTab(e, index) {
if (this.stopPropagation) {
e.stopPropagation();
e.preventDefault();
}
this.tabs[this.currentIndex].localActive = false; this.tabs[this.currentIndex].localActive = false;
this.tabs[index].localActive = true; this.tabs[index].localActive = true;
...@@ -36,7 +48,7 @@ export default { ...@@ -36,7 +48,7 @@ export default {
href: '#', href: '#',
}, },
on: { on: {
click: () => this.setTab(i), click: e => this.setTab(e, i),
}, },
}, },
tab.$slots.title || tab.title, tab.$slots.title || tab.title,
......
...@@ -251,3 +251,13 @@ table { ...@@ -251,3 +251,13 @@ table {
pre code { pre code {
white-space: pre-wrap; white-space: pre-wrap;
} }
.alert-danger {
background-color: $red-500;
border-color: $red-500;
color: $white-light;
h4 {
color: $white-light;
}
}
...@@ -26,19 +26,25 @@ ...@@ -26,19 +26,25 @@
margin-right: 2px; margin-right: 2px;
width: $contextual-sidebar-width; width: $contextual-sidebar-width;
a { > a,
> button {
transition: padding $sidebar-transition-duration; transition: padding $sidebar-transition-duration;
font-weight: $gl-font-weight-bold; font-weight: $gl-font-weight-bold;
display: flex; display: flex;
width: 100%;
align-items: center; align-items: center;
padding: 10px 16px 10px 10px; padding: 10px 16px 10px 10px;
color: $gl-text-color; color: $gl-text-color;
} background-color: transparent;
border: 0;
text-align: left;
&:hover, &:hover,
a:hover { &:focus {
background-color: $link-hover-background; background-color: $link-hover-background;
color: $gl-text-color; color: $gl-text-color;
outline: 0;
}
} }
.avatar-container { .avatar-container {
......
...@@ -299,6 +299,7 @@ ...@@ -299,6 +299,7 @@
height: 14px; height: 14px;
width: 14px; width: 14px;
vertical-align: middle; vertical-align: middle;
margin-bottom: 4px;
} }
.dropdown-toggle-text { .dropdown-toggle-text {
......
.table-holder { .table-holder {
margin: 0; margin: 0;
overflow: auto;
} }
table { table {
......
...@@ -42,6 +42,10 @@ ...@@ -42,6 +42,10 @@
background: none; background: none;
} }
&:focus {
outline: none;
}
.toggle-icon { .toggle-icon {
position: relative; position: relative;
display: block; display: block;
......
...@@ -282,9 +282,6 @@ ...@@ -282,9 +282,6 @@
box-shadow: 0 1px 2px $issue-boards-card-shadow; box-shadow: 0 1px 2px $issue-boards-card-shadow;
list-style: none; list-style: none;
// as a fallback, hide overflow content so that dragging and dropping still works
overflow: hidden;
&:not(:last-child) { &:not(:last-child) {
margin-bottom: 5px; margin-bottom: 5px;
} }
......
...@@ -75,6 +75,7 @@ ...@@ -75,6 +75,7 @@
.top-bar { .top-bar {
height: 35px; height: 35px;
min-height: 35px;
background: $gray-light; background: $gray-light;
border: 1px solid $border-color; border: 1px solid $border-color;
color: $gl-text-color; color: $gl-text-color;
......
...@@ -23,7 +23,6 @@ ...@@ -23,7 +23,6 @@
} }
.btn-group { .btn-group {
> a { > a {
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
} }
...@@ -245,6 +244,7 @@ ...@@ -245,6 +244,7 @@
.prometheus-graph { .prometheus-graph {
flex: 1 0 auto; flex: 1 0 auto;
min-width: 450px; min-width: 450px;
max-width: 100%;
padding: $gl-padding / 2; padding: $gl-padding / 2;
h5 { h5 {
...@@ -256,6 +256,17 @@ ...@@ -256,6 +256,17 @@
} }
} }
.prometheus-graph-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $gl-padding-8;
h5 {
margin: 0;
}
}
.prometheus-graph-cursor { .prometheus-graph-cursor {
position: absolute; position: absolute;
background: $theme-gray-600; background: $theme-gray-600;
......
...@@ -117,10 +117,6 @@ ...@@ -117,10 +117,6 @@
.prioritized-labels { .prioritized-labels {
margin-bottom: 30px; margin-bottom: 30px;
h5 {
font-size: $gl-font-size;
}
.add-priority { .add-priority {
display: none; display: none;
color: $gray-light; color: $gray-light;
...@@ -135,10 +131,6 @@ ...@@ -135,10 +131,6 @@
} }
.other-labels { .other-labels {
h5 {
font-size: $gl-font-size;
}
.remove-priority { .remove-priority {
display: none; display: none;
} }
......
...@@ -458,14 +458,10 @@ ...@@ -458,14 +458,10 @@
width: auto; width: auto;
margin-right: 0; margin-right: 0;
a { > a,
> button {
height: 60px; height: 60px;
} }
a:hover,
a:focus {
text-decoration: none;
}
} }
.projects-sidebar { .projects-sidebar {
...@@ -1135,6 +1131,11 @@ ...@@ -1135,6 +1131,11 @@
.avatar { .avatar {
flex: 0 0 40px; flex: 0 0 40px;
} }
.ide-merge-requests-dropdown.dropdown-menu {
width: 385px;
max-height: initial;
}
} }
.ide-sidebar-project-title { .ide-sidebar-project-title {
...@@ -1143,11 +1144,20 @@ ...@@ -1143,11 +1144,20 @@
.sidebar-context-title { .sidebar-context-title {
white-space: nowrap; white-space: nowrap;
} }
.ide-sidebar-branch-title {
min-width: 50px;
}
} }
.ide-external-link { .ide-external-link {
position: relative;
svg { svg {
display: none; display: none;
position: absolute;
top: 2px;
right: -$gl-padding;
} }
&:hover, &:hover,
...@@ -1178,6 +1188,8 @@ ...@@ -1178,6 +1188,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
margin-top: -$grid-size;
margin-bottom: -$grid-size;
.empty-state { .empty-state {
margin-top: auto; margin-top: auto;
...@@ -1194,6 +1206,17 @@ ...@@ -1194,6 +1206,17 @@
margin: 0; margin: 0;
} }
} }
.build-trace,
.top-bar {
margin-left: -$gl-padding;
}
&.build-page .top-bar {
top: 0;
font-size: 12px;
border-top-right-radius: $border-radius-default;
}
} }
.ide-pipeline-list { .ide-pipeline-list {
...@@ -1202,7 +1225,7 @@ ...@@ -1202,7 +1225,7 @@
} }
.ide-pipeline-header { .ide-pipeline-header {
min-height: 50px; min-height: 55px;
padding-left: $gl-padding; padding-left: $gl-padding;
padding-right: $gl-padding; padding-right: $gl-padding;
...@@ -1222,8 +1245,7 @@ ...@@ -1222,8 +1245,7 @@
.ci-status-icon { .ci-status-icon {
display: flex; display: flex;
justify-content: center; justify-content: center;
height: 20px; min-width: 24px;
margin-top: -2px;
overflow: hidden; overflow: hidden;
} }
} }
...@@ -1253,3 +1275,56 @@ ...@@ -1253,3 +1275,56 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.ide-job-header {
min-height: 60px;
}
.ide-merge-requests-dropdown {
.nav-links li {
width: 50%;
padding-left: 0;
padding-right: 0;
a {
text-align: center;
&:not(.active) {
background-color: $gray-light;
}
}
}
.dropdown-input {
padding-left: $gl-padding;
padding-right: $gl-padding;
.fa {
right: 26px;
}
}
.btn-link {
padding-top: $gl-padding;
padding-bottom: $gl-padding;
}
}
.ide-merge-request-current-icon {
min-width: 18px;
}
.ide-merge-requests-empty {
height: 230px;
}
.ide-merge-requests-dropdown-content {
min-height: 230px;
max-height: 470px;
}
.ide-merge-request-project-path {
font-size: 12px;
line-height: 16px;
color: $gl-text-color-secondary;
}
class GraphqlController < ApplicationController
# Unauthenticated users have access to the API for public data
skip_before_action :authenticate_user!
before_action :check_graphql_feature_flag!
def execute
variables = Gitlab::Graphql::Variables.new(params[:variables]).to_h
query = params[:query]
operation_name = params[:operationName]
context = {
current_user: current_user
}
result = GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
render json: result
end
rescue_from StandardError do |exception|
log_exception(exception)
render_error("Internal server error")
end
rescue_from Gitlab::Graphql::Variables::Invalid do |exception|
render_error(exception.message, status: :unprocessable_entity)
end
private
# Overridden from the ApplicationController to make the response look like
# a GraphQL response. That is nicely picked up in Graphiql.
def render_404
render_error("Not found!", status: :not_found)
end
def render_error(message, status: 500)
error = { errors: [message: message] }
render json: error, status: status
end
def check_graphql_feature_flag!
render_404 unless Feature.enabled?(:graphql)
end
end
...@@ -25,4 +25,8 @@ class Import::BaseController < ApplicationController ...@@ -25,4 +25,8 @@ class Import::BaseController < ApplicationController
current_user.namespace current_user.namespace
end end
def project_save_error(project)
project.errors.full_messages.join(', ')
end
end end
...@@ -55,7 +55,7 @@ class Import::BitbucketController < Import::BaseController ...@@ -55,7 +55,7 @@ class Import::BitbucketController < Import::BaseController
if project.persisted? if project.persisted?
render json: ProjectSerializer.new.represent(project) render json: ProjectSerializer.new.represent(project)
else else
render json: { errors: project.errors.full_messages }, status: :unprocessable_entity render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end end
else else
render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
......
...@@ -66,7 +66,7 @@ class Import::FogbugzController < Import::BaseController ...@@ -66,7 +66,7 @@ class Import::FogbugzController < Import::BaseController
if project.persisted? if project.persisted?
render json: ProjectSerializer.new.represent(project) render json: ProjectSerializer.new.represent(project)
else else
render json: { errors: project.errors.full_messages }, status: :unprocessable_entity render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end end
end end
......
...@@ -48,7 +48,7 @@ class Import::GithubController < Import::BaseController ...@@ -48,7 +48,7 @@ class Import::GithubController < Import::BaseController
if project.persisted? if project.persisted?
render json: ProjectSerializer.new.represent(project) render json: ProjectSerializer.new.represent(project)
else else
render json: { errors: project.errors.full_messages }, status: :unprocessable_entity render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end end
else else
render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
......
...@@ -32,7 +32,7 @@ class Import::GitlabController < Import::BaseController ...@@ -32,7 +32,7 @@ class Import::GitlabController < Import::BaseController
if project.persisted? if project.persisted?
render json: ProjectSerializer.new.represent(project) render json: ProjectSerializer.new.represent(project)
else else
render json: { errors: project.errors.full_messages }, status: :unprocessable_entity render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end end
else else
render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
......
...@@ -92,7 +92,7 @@ class Import::GoogleCodeController < Import::BaseController ...@@ -92,7 +92,7 @@ class Import::GoogleCodeController < Import::BaseController
if project.persisted? if project.persisted?
render json: ProjectSerializer.new.represent(project) render json: ProjectSerializer.new.represent(project)
else else
render json: { errors: project.errors.full_messages }, status: :unprocessable_entity render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end end
end end
......
...@@ -28,15 +28,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -28,15 +28,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end end
def show def show
validates_merge_request close_merge_request_if_no_source_project
close_merge_request_without_source_project mark_merge_request_mergeable
check_if_can_be_merged
# Return if the response has already been rendered
return if response_body
respond_to do |format| respond_to do |format|
format.html do format.html do
# use next to appease Rubocop
next render('invalid') if target_branch_missing?
# Build a note object for comment form # Build a note object for comment form
@note = @project.notes.new(noteable: @merge_request) @note = @project.notes.new(noteable: @merge_request)
...@@ -234,20 +233,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -234,20 +233,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
alias_method :issuable, :merge_request alias_method :issuable, :merge_request
alias_method :awardable, :merge_request alias_method :awardable, :merge_request
def validates_merge_request
# Show git not found page
# if there is no saved commits between source & target branch
if @merge_request.has_no_commits?
# and if target branch doesn't exist
return invalid_mr unless @merge_request.target_branch_exists?
end
end
def invalid_mr
# Render special view for MR with removed target branch
render 'invalid'
end
def merge_params def merge_params
params.permit(merge_params_attributes) params.permit(merge_params_attributes)
end end
...@@ -261,7 +246,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -261,7 +246,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@merge_request.head_pipeline && @merge_request.head_pipeline.active? @merge_request.head_pipeline && @merge_request.head_pipeline.active?
end end
def close_merge_request_without_source_project def close_merge_request_if_no_source_project
if !@merge_request.source_project && @merge_request.open? if !@merge_request.source_project && @merge_request.open?
@merge_request.close @merge_request.close
end end
...@@ -269,7 +254,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -269,7 +254,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
private private
def check_if_can_be_merged def target_branch_missing?
@merge_request.has_no_commits? && !@merge_request.target_branch_exists?
end
def mark_merge_request_mergeable
@merge_request.check_if_can_be_merged @merge_request.check_if_can_be_merged
end end
......
module Functions
class BaseFunction < GraphQL::Function
end
end
module Functions
class Echo < BaseFunction
argument :text, GraphQL::STRING_TYPE
description "Testing endpoint to validate the API with"
def call(obj, args, ctx)
username = ctx[:current_user]&.username
"#{username.inspect} says: #{args[:text]}"
end
end
end
class GitlabSchema < GraphQL::Schema
use BatchLoader::GraphQL
use Gitlab::Graphql::Authorize
use Gitlab::Graphql::Present
query(Types::QueryType)
# mutation(Types::MutationType)
end
module Resolvers
class BaseResolver < GraphQL::Schema::Resolver
end
end
module Resolvers
module FullPathResolver
extend ActiveSupport::Concern
prepended do
argument :full_path, GraphQL::ID_TYPE,
required: true,
description: 'The full path of the project or namespace, e.g., "gitlab-org/gitlab-ce"'
end
def model_by_full_path(model, full_path)
BatchLoader.for(full_path).batch(key: "#{model.model_name.param_key}:full_path") do |full_paths, loader|
# `with_route` avoids an N+1 calculating full_path
results = model.where_full_path_in(full_paths).with_route
results.each { |project| loader.call(project.full_path, project) }
end
end
end
end
module Resolvers
class MergeRequestResolver < BaseResolver
prepend FullPathResolver
type Types::ProjectType, null: true
argument :iid, GraphQL::ID_TYPE,
required: true,
description: 'The IID of the merge request, e.g., "1"'
def resolve(full_path:, iid:)
project = model_by_full_path(Project, full_path)
return unless project.present?
BatchLoader.for(iid.to_s).batch(key: project.id) do |iids, loader|
results = project.merge_requests.where(iid: iids)
results.each { |mr| loader.call(mr.iid.to_s, mr) }
end
end
end
end
module Resolvers
class ProjectResolver < BaseResolver
prepend FullPathResolver
type Types::ProjectType, null: true
def resolve(full_path:)
model_by_full_path(Project, full_path)
end
end
end
module Types
class BaseEnum < GraphQL::Schema::Enum
end
end
module Types
class BaseField < GraphQL::Schema::Field
prepend Gitlab::Graphql::Authorize
end
end
module Types
class BaseInputObject < GraphQL::Schema::InputObject
end
end
module Types
module BaseInterface
include GraphQL::Schema::Interface
end
end
module Types
class BaseObject < GraphQL::Schema::Object
prepend Gitlab::Graphql::Present
field_class Types::BaseField
end
end
module Types
class BaseScalar < GraphQL::Schema::Scalar
end
end
module Types
class BaseUnion < GraphQL::Schema::Union
end
end
module Types
class MergeRequestType < BaseObject
present_using MergeRequestPresenter
graphql_name 'MergeRequest'
field :id, GraphQL::ID_TYPE, null: false
field :iid, GraphQL::ID_TYPE, null: false
field :title, GraphQL::STRING_TYPE, null: false
field :description, GraphQL::STRING_TYPE, null: true
field :state, GraphQL::STRING_TYPE, null: true
field :created_at, Types::TimeType, null: false
field :updated_at, Types::TimeType, null: false
field :source_project, Types::ProjectType, null: true
field :target_project, Types::ProjectType, null: false
# Alias for target_project
field :project, Types::ProjectType, null: false
field :project_id, GraphQL::INT_TYPE, null: false, method: :target_project_id
field :source_project_id, GraphQL::INT_TYPE, null: true
field :target_project_id, GraphQL::INT_TYPE, null: false
field :source_branch, GraphQL::STRING_TYPE, null: false
field :target_branch, GraphQL::STRING_TYPE, null: false
field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false
field :merge_when_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true
field :diff_head_sha, GraphQL::STRING_TYPE, null: true
field :merge_commit_sha, GraphQL::STRING_TYPE, null: true
field :user_notes_count, GraphQL::INT_TYPE, null: true
field :should_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :should_remove_source_branch?, null: true
field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true
field :merge_status, GraphQL::STRING_TYPE, null: true
field :in_progress_merge_commit_sha, GraphQL::STRING_TYPE, null: true
field :merge_error, GraphQL::STRING_TYPE, null: true
field :allow_collaboration, GraphQL::BOOLEAN_TYPE, null: true
field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false
field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true
field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false
field :diff_head_sha, GraphQL::STRING_TYPE, null: true
field :merge_commit_message, GraphQL::STRING_TYPE, null: true
field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false
field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false
field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true
field :web_url, GraphQL::STRING_TYPE, null: true
field :upvotes, GraphQL::INT_TYPE, null: false
field :downvotes, GraphQL::INT_TYPE, null: false
field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false
end
end
module Types
class MutationType < BaseObject
graphql_name "Mutation"
# TODO: Add Mutations as fields
end
end
module Types
class ProjectType < BaseObject
graphql_name 'Project'
field :id, GraphQL::ID_TYPE, null: false
field :full_path, GraphQL::ID_TYPE, null: false
field :path, GraphQL::STRING_TYPE, null: false
field :name_with_namespace, GraphQL::STRING_TYPE, null: false
field :name, GraphQL::STRING_TYPE, null: false
field :description, GraphQL::STRING_TYPE, null: true
field :default_branch, GraphQL::STRING_TYPE, null: true
field :tag_list, GraphQL::STRING_TYPE, null: true
field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true
field :http_url_to_repo, GraphQL::STRING_TYPE, null: true
field :web_url, GraphQL::STRING_TYPE, null: true
field :star_count, GraphQL::INT_TYPE, null: false
field :forks_count, GraphQL::INT_TYPE, null: false
field :created_at, Types::TimeType, null: true
field :last_activity_at, Types::TimeType, null: true
field :archived, GraphQL::BOOLEAN_TYPE, null: true
field :visibility, GraphQL::STRING_TYPE, null: true
field :container_registry_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :shared_runners_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :merge_requests_ff_only_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :avatar_url, GraphQL::STRING_TYPE, null: true, resolve: -> (project, args, ctx) do
project.avatar_url(only_path: false)
end
%i[issues merge_requests wiki snippets].each do |feature|
field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do
project.feature_available?(feature, ctx[:current_user])
end
end
field :jobs_enabled, GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do
project.feature_available?(:builds, ctx[:current_user])
end
field :public_jobs, GraphQL::BOOLEAN_TYPE, method: :public_builds, null: true
field :open_issues_count, GraphQL::INT_TYPE, null: true, resolve: -> (project, args, ctx) do
project.open_issues_count if project.feature_available?(:issues, ctx[:current_user])
end
field :import_status, GraphQL::STRING_TYPE, null: true
field :ci_config_path, GraphQL::STRING_TYPE, null: true
field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true
field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true
field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true
end
end
module Types
class QueryType < BaseObject
graphql_name 'Query'
field :project, Types::ProjectType,
null: true,
resolver: Resolvers::ProjectResolver,
description: "Find a project" do
authorize :read_project
end
field :merge_request, Types::MergeRequestType,
null: true,
resolver: Resolvers::MergeRequestResolver,
description: "Find a merge request" do
authorize :read_merge_request
end
field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new
end
end
module Types
class TimeType < BaseScalar
graphql_name 'Time'
description 'Time represented in ISO 8601'
def self.coerce_input(value, ctx)
Time.parse(value)
end
def self.coerce_result(value, ctx)
value.iso8601
end
end
end
...@@ -66,6 +66,7 @@ module Ci ...@@ -66,6 +66,7 @@ module Ci
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) } scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) }
scope :ref_protected, -> { where(protected: true) } scope :ref_protected, -> { where(protected: true) }
scope :with_live_trace, -> { where('EXISTS (?)', Ci::BuildTraceChunk.where('ci_builds.id = ci_build_trace_chunks.build_id').select(1)) }
scope :matches_tag_ids, -> (tag_ids) do scope :matches_tag_ids, -> (tag_ids) do
matcher = ::ActsAsTaggableOn::Tagging matcher = ::ActsAsTaggableOn::Tagging
......
...@@ -8,8 +8,8 @@ module ProtectedRefAccess ...@@ -8,8 +8,8 @@ module ProtectedRefAccess
].freeze ].freeze
HUMAN_ACCESS_LEVELS = { HUMAN_ACCESS_LEVELS = {
Gitlab::Access::MASTER => "Masters".freeze, Gitlab::Access::MASTER => "Maintainers".freeze,
Gitlab::Access::DEVELOPER => "Developers + Masters".freeze, Gitlab::Access::DEVELOPER => "Developers + Maintainers".freeze,
Gitlab::Access::NO_ACCESS => "No one".freeze Gitlab::Access::NO_ACCESS => "No one".freeze
}.freeze }.freeze
......
...@@ -5,7 +5,7 @@ class ProtectedBranch < ActiveRecord::Base ...@@ -5,7 +5,7 @@ class ProtectedBranch < ActiveRecord::Base
protected_ref_access_levels :merge, :push protected_ref_access_levels :merge, :push
def self.protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil) def self.protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil)
# Masters, owners and admins are allowed to create the default branch # Maintainers, owners and admins are allowed to create the default branch
if default_branch_protected? && project.empty_repo? if default_branch_protected? && project.empty_repo?
return true if user.admin? || project.team.max_member_access(user.id) > Gitlab::Access::DEVELOPER return true if user.admin? || project.team.max_member_access(user.id) > Gitlab::Access::DEVELOPER
end end
......
...@@ -956,6 +956,10 @@ class Repository ...@@ -956,6 +956,10 @@ class Repository
blob_data_at(sha, path) blob_data_at(sha, path)
end end
def lfsconfig_for(sha)
blob_data_at(sha, '.lfsconfig')
end
def fetch_ref(source_repository, source_ref:, target_ref:) def fetch_ref(source_repository, source_ref:, target_ref:)
raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref) raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref)
end end
......
...@@ -45,7 +45,7 @@ class ProjectPolicy < BasePolicy ...@@ -45,7 +45,7 @@ class ProjectPolicy < BasePolicy
desc "User has developer access" desc "User has developer access"
condition(:developer) { team_access_level >= Gitlab::Access::DEVELOPER } condition(:developer) { team_access_level >= Gitlab::Access::DEVELOPER }
desc "User has master access" desc "User has maintainer access"
condition(:master) { team_access_level >= Gitlab::Access::MASTER } condition(:master) { team_access_level >= Gitlab::Access::MASTER }
desc "Project is public" desc "Project is public"
......
...@@ -179,6 +179,25 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated ...@@ -179,6 +179,25 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
.can_push_to_branch?(source_branch) .can_push_to_branch?(source_branch)
end end
def mergeable_discussions_state
# This avoids calling MergeRequest#mergeable_discussions_state without
# considering the state of the MR first. If a MR isn't mergeable, we can
# safely short-circuit it.
if merge_request.mergeable_state?(skip_ci_check: true, skip_discussions_check: true)
merge_request.mergeable_discussions_state?
else
false
end
end
def web_url
Gitlab::UrlBuilder.build(merge_request)
end
def subscribed?
merge_request.subscribed?(current_user, merge_request.target_project)
end
private private
def cached_can_be_reverted? def cached_can_be_reverted?
......
...@@ -3,7 +3,7 @@ class BaseService ...@@ -3,7 +3,7 @@ class BaseService
attr_accessor :project, :current_user, :params attr_accessor :project, :current_user, :params
def initialize(project, user, params = {}) def initialize(project, user = nil, params = {})
@project, @current_user, @params = project, user, params.dup @project, @current_user, @params = project, user, params.dup
end end
......
...@@ -47,6 +47,6 @@ module ExclusiveLeaseGuard ...@@ -47,6 +47,6 @@ module ExclusiveLeaseGuard
end end
def log_error(message, extra_args = {}) def log_error(message, extra_args = {})
logger.error(message) Rails.logger.error(message)
end end
end end
...@@ -24,7 +24,7 @@ module Lfs ...@@ -24,7 +24,7 @@ module Lfs
success(lock: lock, http_status: :ok) success(lock: lock, http_status: :ok)
elsif forced elsif forced
error(_('You must have master access to force delete a lock'), 403) error(_('You must have maintainer access to force delete a lock'), 403)
else else
error(_("%{lock_path} is locked by GitLab User %{lock_user_id}") % { lock_path: lock.path, lock_user_id: lock.user_id }, 403) error(_("%{lock_path} is locked by GitLab User %{lock_user_id}") % { lock_path: lock.path, lock_user_id: lock.user_id }, 403)
end end
......
...@@ -46,6 +46,9 @@ module Projects ...@@ -46,6 +46,9 @@ module Projects
yield(@project) if block_given? yield(@project) if block_given?
# If the block added errors, don't try to save the project
return @project if @project.errors.any?
@project.creator = current_user @project.creator = current_user
if forked_from_project_id if forked_from_project_id
...@@ -63,6 +66,7 @@ module Projects ...@@ -63,6 +66,7 @@ module Projects
message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} " message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} "
fail(error: message) fail(error: message)
rescue => e rescue => e
@project.errors.add(:base, e.message) if @project
fail(error: e.message) fail(error: e.message)
end end
...@@ -141,7 +145,6 @@ module Projects ...@@ -141,7 +145,6 @@ module Projects
Rails.logger.error(log_message) Rails.logger.error(log_message)
if @project if @project
@project.errors.add(:base, message)
@project.mark_import_as_failed(message) if @project.persisted? && @project.import? @project.mark_import_as_failed(message) if @project.persisted? && @project.import?
end end
......
...@@ -17,6 +17,8 @@ module Projects ...@@ -17,6 +17,8 @@ module Projects
def execute def execute
add_repository_to_project add_repository_to_project
download_lfs_objects
import_data import_data
success success
...@@ -37,7 +39,7 @@ module Projects ...@@ -37,7 +39,7 @@ module Projects
# We should skip the repository for a GitHub import or GitLab project import, # We should skip the repository for a GitHub import or GitLab project import,
# because these importers fetch the project repositories for us. # because these importers fetch the project repositories for us.
return if has_importer? && importer_class.try(:imports_repository?) return if importer_imports_repository?
if unknown_url? if unknown_url?
# In this case, we only want to import issues, not a repository. # In this case, we only want to import issues, not a repository.
...@@ -73,6 +75,27 @@ module Projects ...@@ -73,6 +75,27 @@ module Projects
end end
end end
def download_lfs_objects
# In this case, we only want to import issues
return if unknown_url?
# If it has its own repository importer, it has to implements its own lfs import download
return if importer_imports_repository?
return unless project.lfs_enabled?
oids_to_download = Projects::LfsPointers::LfsImportService.new(project).execute
download_service = Projects::LfsPointers::LfsDownloadService.new(project)
oids_to_download.each do |oid, link|
download_service.execute(oid, link)
end
rescue => e
# Right now, to avoid aborting the importing process, we silently fail
# if any exception raises.
Rails.logger.error("The Lfs import process failed. #{e.message}")
end
def import_data def import_data
return unless has_importer? return unless has_importer?
...@@ -98,5 +121,9 @@ module Projects ...@@ -98,5 +121,9 @@ module Projects
def unknown_url? def unknown_url?
project.import_url == Project::UNKNOWN_IMPORT_URL project.import_url == Project::UNKNOWN_IMPORT_URL
end end
def importer_imports_repository?
has_importer? && importer_class.try(:imports_repository?)
end
end end
end end
# This service lists the download link from a remote source based on the
# oids provided
module Projects
module LfsPointers
class LfsDownloadLinkListService < BaseService
DOWNLOAD_ACTION = 'download'.freeze
DownloadLinksError = Class.new(StandardError)
DownloadLinkNotFound = Class.new(StandardError)
attr_reader :remote_uri
def initialize(project, remote_uri: nil)
super(project)
@remote_uri = remote_uri
end
# This method accepts two parameters:
# - oids: hash of oids to query. The structure is { lfs_file_oid => lfs_file_size }
#
# Returns a hash with the structure { lfs_file_oids => download_link }
def execute(oids)
return {} unless project&.lfs_enabled? && remote_uri && oids.present?
get_download_links(oids)
end
private
def get_download_links(oids)
response = Gitlab::HTTP.post(remote_uri,
body: request_body(oids),
headers: headers)
raise DownloadLinksError, response.message unless response.success?
parse_response_links(response['objects'])
end
def parse_response_links(objects_response)
objects_response.each_with_object({}) do |entry, link_list|
begin
oid = entry['oid']
link = entry.dig('actions', DOWNLOAD_ACTION, 'href')
raise DownloadLinkNotFound unless link
link_list[oid] = add_credentials(link)
rescue DownloadLinkNotFound, URI::InvalidURIError
Rails.logger.error("Link for Lfs Object with oid #{oid} not found or invalid.")
end
end
end
def request_body(oids)
{
operation: DOWNLOAD_ACTION,
objects: oids.map { |oid, size| { oid: oid, size: size } }
}.to_json
end
def headers
{
'Accept' => LfsRequest::CONTENT_TYPE,
'Content-Type' => LfsRequest::CONTENT_TYPE
}.freeze
end
def add_credentials(link)
uri = URI.parse(link)
if should_add_credentials?(uri)
uri.user = remote_uri.user
uri.password = remote_uri.password
end
uri.to_s
end
# The download link can be a local url or an object storage url
# If the download link has the some host as the import url then
# we add the same credentials because we may need them
def should_add_credentials?(link_uri)
url_credentials? && link_uri.host == remote_uri.host
end
def url_credentials?
remote_uri.user.present? || remote_uri.password.present?
end
end
end
end
# This service downloads and links lfs objects from a remote URL
module Projects
module LfsPointers
class LfsDownloadService < BaseService
def execute(oid, url)
return unless project&.lfs_enabled? && oid.present? && url.present?
return if LfsObject.exists?(oid: oid)
sanitized_uri = Gitlab::UrlSanitizer.new(url)
with_tmp_file(oid) do |file|
size = download_and_save_file(file, sanitized_uri)
lfs_object = LfsObject.new(oid: oid, size: size, file: file)
project.all_lfs_objects << lfs_object
end
rescue StandardError => e
Rails.logger.error("LFS file with oid #{oid} could't be downloaded from #{sanitized_uri.sanitized_url}: #{e.message}")
end
private
def download_and_save_file(file, sanitized_uri)
IO.copy_stream(open(sanitized_uri.sanitized_url, headers(sanitized_uri)), file)
end
def headers(sanitized_uri)
{}.tap do |headers|
credentials = sanitized_uri.credentials
if credentials[:user].present? || credentials[:password].present?
# Using authentication headers in the request
headers[:http_basic_authentication] = [credentials[:user], credentials[:password]]
end
end
end
def with_tmp_file(oid)
create_tmp_storage_dir
File.open(File.join(tmp_storage_dir, oid), 'w') { |file| yield file }
end
def create_tmp_storage_dir
FileUtils.makedirs(tmp_storage_dir) unless Dir.exist?(tmp_storage_dir)
end
def tmp_storage_dir
@tmp_storage_dir ||= File.join(storage_dir, 'tmp', 'download')
end
def storage_dir
@storage_dir ||= Gitlab.config.lfs.storage_path
end
end
end
end
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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