diff options
176 files changed, 3766 insertions, 831 deletions
diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 58af062e75e..9d53a48409a 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -20,6 +20,12 @@ Please remove this notice if you're confident your issue isn't a duplicate. (How one can reproduce the issue - this is very important) +### Example Project + +(If possible, please create an example project here on GitLab.com that exhibits the problematic behaviour, and link to it here in the bug report) + +(If you are using an older version of GitLab, this will also determine whether the bug has been fixed in a more recent version) + ### What is the current *bug* behavior? (What actually happens) @@ -109,7 +109,7 @@ gem 'seed-fu', '~> 2.3.5' # Markdown and HTML processing gem 'html-pipeline', '~> 1.11.0' -gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie' +gem 'deckar01-task_list', '2.0.0' gem 'gitlab-markup', '~> 1.5.1' gem 'redcarpet', '~> 3.4' gem 'RedCloth', '~> 4.3.2' diff --git a/Gemfile.lock b/Gemfile.lock index 273a69792ef..b4252a2bdc9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -141,10 +141,8 @@ GEM database_cleaner (1.5.3) debug_inspector (0.0.2) debugger-ruby_core_source (1.3.8) - deckar01-task_list (1.0.6) - activesupport (~> 4.0) + deckar01-task_list (2.0.0) html-pipeline - rack (~> 1.0) default_value_for (3.0.2) activerecord (>= 3.2.0, < 5.1) descendants_tracker (0.0.4) @@ -895,7 +893,7 @@ DEPENDENCIES creole (~> 0.5.0) d3_rails (~> 3.5.0) database_cleaner (~> 1.5.0) - deckar01-task_list (= 1.0.6) + deckar01-task_list (= 2.0.0) default_value_for (~> 3.0.0) devise (~> 4.2) devise-two-factor (~> 3.0.0) diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 6e2f06112dd..53b25da18e5 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -118,7 +118,7 @@ import ShortcutsBlob from './shortcuts_blob'; shortcut_handler = new ShortcutsNavigation(); new UsersSelect(); break; - case 'projects:builds:show': + case 'projects:jobs:show': new Build(); break; case 'projects:merge_requests:index': diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 266cd3966c6..111449bb8f7 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -194,6 +194,7 @@ window.DropzoneInput = (function() { $(child).val(beforeSelection + formattedText + afterSelection); textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length); textarea.style.height = `${textarea.scrollHeight}px`; + formTextarea.get(0).dispatchEvent(new Event('input')); return formTextarea.trigger('input'); }; diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 770a0dcd27e..800bb9f1fe8 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -1,10 +1,14 @@ <script> +/* global Flash */ import Visibility from 'visibilityjs'; import Poll from '../../lib/utils/poll'; +import eventHub from '../event_hub'; import Service from '../services/index'; import Store from '../stores'; import titleComponent from './title.vue'; import descriptionComponent from './description.vue'; +import formComponent from './form.vue'; +import '../../lib/utils/url_utility'; export default { props: { @@ -12,15 +16,27 @@ export default { required: true, type: String, }, + canMove: { + required: true, + type: Boolean, + }, canUpdate: { required: true, type: Boolean, }, + canDestroy: { + required: true, + type: Boolean, + }, issuableRef: { type: String, required: true, }, - initialTitle: { + initialTitleHtml: { + type: String, + required: true, + }, + initialTitleText: { type: String, required: true, }, @@ -34,10 +50,40 @@ export default { required: false, default: '', }, + issuableTemplates: { + type: Array, + required: false, + default: () => [], + }, + isConfidential: { + type: Boolean, + required: true, + }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + projectsAutocompleteUrl: { + type: String, + required: true, + }, }, data() { const store = new Store({ - titleHtml: this.initialTitle, + titleHtml: this.initialTitleHtml, + titleText: this.initialTitleText, descriptionHtml: this.initialDescriptionHtml, descriptionText: this.initialDescriptionText, }); @@ -45,19 +91,97 @@ export default { return { store, state: store.state, + showForm: false, }; }, + computed: { + formState() { + return this.store.formState; + }, + }, components: { descriptionComponent, titleComponent, + formComponent, + }, + methods: { + openForm() { + if (!this.showForm) { + this.showForm = true; + this.store.setFormState({ + title: this.state.titleText, + confidential: this.isConfidential, + description: this.state.descriptionText, + lockedWarningVisible: false, + move_to_project_id: 0, + updateLoading: false, + }); + } + }, + closeForm() { + this.showForm = false; + }, + updateIssuable() { + const canPostUpdate = this.store.formState.move_to_project_id !== 0 ? + confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert + + if (!canPostUpdate) { + this.store.setFormState({ + updateLoading: false, + }); + return; + } + + this.service.updateIssuable(this.store.formState) + .then(res => res.json()) + .then((data) => { + if (location.pathname !== data.web_url) { + gl.utils.visitUrl(data.web_url); + } else if (data.confidential !== this.isConfidential) { + gl.utils.visitUrl(location.pathname); + } + + return this.service.getData(); + }) + .then(res => res.json()) + .then((data) => { + this.store.updateState(data); + eventHub.$emit('close.form'); + }) + .catch(() => { + eventHub.$emit('close.form'); + return new Flash('Error updating issue'); + }); + }, + deleteIssuable() { + this.service.deleteIssuable() + .then(res => res.json()) + .then((data) => { + // Stop the poll so we don't get 404's with the issue not existing + this.poll.stop(); + + gl.utils.visitUrl(data.web_url); + }) + .catch(() => { + eventHub.$emit('close.form'); + return new Flash('Error deleting issue'); + }); + }, }, created() { - const resource = new Service(this.endpoint); - const poll = new Poll({ - resource, + this.service = new Service(this.endpoint); + this.poll = new Poll({ + resource: this.service, method: 'getData', successCallback: (res) => { - this.store.updateState(res.json()); + const data = res.json(); + const shouldUpdate = this.store.stateShouldUpdate(data); + + this.store.updateState(data); + + if (this.showForm && (shouldUpdate.title || shouldUpdate.description)) { + this.store.formState.lockedWarningVisible = true; + } }, errorCallback(err) { throw new Error(err); @@ -65,32 +189,57 @@ export default { }); if (!Visibility.hidden()) { - poll.makeRequest(); + this.poll.makeRequest(); } Visibility.change(() => { if (!Visibility.hidden()) { - poll.restart(); + this.poll.restart(); } else { - poll.stop(); + this.poll.stop(); } }); + + eventHub.$on('delete.issuable', this.deleteIssuable); + eventHub.$on('update.issuable', this.updateIssuable); + eventHub.$on('close.form', this.closeForm); + eventHub.$on('open.form', this.openForm); + }, + beforeDestroy() { + eventHub.$off('delete.issuable', this.deleteIssuable); + eventHub.$off('update.issuable', this.updateIssuable); + eventHub.$off('close.form', this.closeForm); + eventHub.$off('open.form', this.openForm); }, }; </script> <template> <div> - <title-component - :issuable-ref="issuableRef" - :title-html="state.titleHtml" - :title-text="state.titleText" /> - <description-component - v-if="state.descriptionHtml" - :can-update="canUpdate" - :description-html="state.descriptionHtml" - :description-text="state.descriptionText" - :updated-at="state.updatedAt" - :task-status="state.taskStatus" /> + <form-component + v-if="canUpdate && showForm" + :form-state="formState" + :can-move="canMove" + :can-destroy="canDestroy" + :issuable-templates="issuableTemplates" + :markdown-docs="markdownDocs" + :markdown-preview-url="markdownPreviewUrl" + :project-path="projectPath" + :project-namespace="projectNamespace" + :projects-autocomplete-url="projectsAutocompleteUrl" + /> + <div v-else> + <title-component + :issuable-ref="issuableRef" + :title-html="state.titleHtml" + :title-text="state.titleText" /> + <description-component + v-if="state.descriptionHtml" + :can-update="canUpdate" + :description-html="state.descriptionHtml" + :description-text="state.descriptionText" + :updated-at="state.updatedAt" + :task-status="state.taskStatus" /> + </div> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 4ad3eb7dfd7..3281ec6b172 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -18,11 +18,13 @@ }, updatedAt: { type: String, - required: true, + required: false, + default: '', }, taskStatus: { type: String, - required: true, + required: false, + default: '', }, }, data() { @@ -83,6 +85,7 @@ <template> <div + v-if="descriptionHtml" class="description" :class="{ 'js-task-list-container': canUpdate diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue new file mode 100644 index 00000000000..8c81575fe6f --- /dev/null +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -0,0 +1,79 @@ +<script> + import updateMixin from '../mixins/update'; + import eventHub from '../event_hub'; + + export default { + mixins: [updateMixin], + props: { + canDestroy: { + type: Boolean, + required: true, + }, + formState: { + type: Object, + required: true, + }, + }, + data() { + return { + deleteLoading: false, + }; + }, + computed: { + isSubmitEnabled() { + return this.formState.title.trim() !== ''; + }, + }, + methods: { + closeForm() { + eventHub.$emit('close.form'); + }, + deleteIssuable() { + // eslint-disable-next-line no-alert + if (confirm('Issue will be removed! Are you sure?')) { + this.deleteLoading = true; + + eventHub.$emit('delete.issuable'); + } + }, + }, + }; +</script> + +<template> + <div class="prepend-top-default append-bottom-default clearfix"> + <button + class="btn btn-save pull-left" + :class="{ disabled: formState.updateLoading || !isSubmitEnabled }" + type="submit" + :disabled="formState.updateLoading || !isSubmitEnabled" + @click.prevent="updateIssuable"> + Save changes + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" + v-if="formState.updateLoading"> + </i> + </button> + <button + class="btn btn-default pull-right" + type="button" + @click="closeForm"> + Cancel + </button> + <button + v-if="canDestroy" + class="btn btn-danger pull-right append-right-default" + :class="{ disabled: deleteLoading }" + type="button" + :disabled="deleteLoading" + @click="deleteIssuable"> + Delete + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" + v-if="deleteLoading"> + </i> + </button> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue new file mode 100644 index 00000000000..a0ff08e9111 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue @@ -0,0 +1,23 @@ +<script> + export default { + props: { + formState: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <fieldset class="checkbox"> + <label for="issue-confidential"> + <input + type="checkbox" + value="1" + id="issue-confidential" + v-model="formState.confidential" /> + This issue is confidential and should only be visible to team members with at least Reporter access. + </label> + </fieldset> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue new file mode 100644 index 00000000000..30a1be5cb50 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -0,0 +1,54 @@ +<script> + /* global Flash */ + import updateMixin from '../../mixins/update'; + import markdownField from '../../../vue_shared/components/markdown/field.vue'; + + export default { + mixins: [updateMixin], + props: { + formState: { + type: Object, + required: true, + }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, + }, + components: { + markdownField, + }, + mounted() { + this.$refs.textarea.focus(); + }, + }; +</script> + +<template> + <div class="common-note-form"> + <label + class="sr-only" + for="issue-description"> + Description + </label> + <markdown-field + :markdown-preview-url="markdownPreviewUrl" + :markdown-docs="markdownDocs"> + <textarea + id="issue-description" + class="note-textarea js-gfm-input js-autosize markdown-area" + data-supports-slash-commands="false" + aria-label="Description" + v-model="formState.description" + ref="textarea" + slot="textarea" + placeholder="Write a comment or drag your files here..." + @keydown.meta.enter="updateIssuable"> + </textarea> + </markdown-field> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue new file mode 100644 index 00000000000..1c40b286513 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -0,0 +1,111 @@ +<script> + export default { + props: { + formState: { + type: Object, + required: true, + }, + issuableTemplates: { + type: Array, + required: false, + default: () => [], + }, + projectPath: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + }, + computed: { + issuableTemplatesJson() { + return JSON.stringify(this.issuableTemplates); + }, + }, + mounted() { + // Create the editor for the template + const editor = document.querySelector('.detail-page-description .note-textarea') || {}; + editor.setValue = (val) => { + this.formState.description = val; + }; + editor.getValue = () => this.formState.description; + + this.issuableTemplate = new gl.IssuableTemplateSelectors({ + $dropdowns: $(this.$refs.toggle), + editor, + }); + }, + }; +</script> + +<template> + <div + class="dropdown js-issuable-selector-wrap" + data-issuable-type="issue"> + <button + class="dropdown-menu-toggle js-issuable-selector" + type="button" + ref="toggle" + data-field-name="issuable_template" + data-selected="null" + data-toggle="dropdown" + :data-namespace-path="projectNamespace" + :data-project-path="projectPath" + :data-data="issuableTemplatesJson"> + <span class="dropdown-toggle-text"> + Choose a template + </span> + <i + aria-hidden="true" + class="fa fa-chevron-down"> + </i> + </button> + <div class="dropdown-menu dropdown-select"> + <div class="dropdown-title"> + Choose a template + <button + class="dropdown-title-button dropdown-menu-close" + aria-label="Close" + type="button"> + <i + aria-hidden="true" + class="fa fa-times dropdown-menu-close-icon"> + </i> + </button> + </div> + <div class="dropdown-input"> + <input + type="search" + class="dropdown-input-field" + placeholder="Filter" + autocomplete="off" /> + <i + aria-hidden="true" + class="fa fa-search dropdown-input-search"> + </i> + <i + role="button" + aria-label="Clear templates search input" + class="fa fa-times dropdown-input-clear js-dropdown-input-clear"> + </i> + </div> + <div class="dropdown-content"></div> + <div class="dropdown-footer"> + <ul class="dropdown-footer-list"> + <li> + <a class="no-template"> + No template + </a> + </li> + <li> + <a class="reset-template"> + Reset template + </a> + </li> + </ul> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue new file mode 100644 index 00000000000..f811fb0de24 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue @@ -0,0 +1,83 @@ +<script> + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + export default { + mixins: [ + tooltipMixin, + ], + props: { + formState: { + type: Object, + required: true, + }, + projectsAutocompleteUrl: { + type: String, + required: true, + }, + }, + mounted() { + const $moveDropdown = $(this.$refs['move-dropdown']); + + $moveDropdown.select2({ + ajax: { + url: this.projectsAutocompleteUrl, + quietMillis: 125, + data(term, page, context) { + return { + search: term, + offset_id: context, + }; + }, + results(data) { + const more = data.length >= 50; + const context = data[data.length - 1] ? data[data.length - 1].id : null; + + return { + results: data, + more, + context, + }; + }, + }, + formatResult(project) { + return project.name_with_namespace; + }, + formatSelection(project) { + return project.name_with_namespace; + }, + }) + .on('change', (e) => { + this.formState.move_to_project_id = parseInt(e.target.value, 10); + }); + }, + beforeDestroy() { + $(this.$refs['move-dropdown']).select2('destroy'); + }, + }; +</script> + +<template> + <fieldset> + <label + for="issuable-move" + class="sr-only"> + Move + </label> + <div class="issuable-form-select-holder append-right-5"> + <input + ref="move-dropdown" + type="hidden" + id="issuable-move" + data-placeholder="Move to a different project" /> + </div> + <span + data-placement="auto top" + title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location." + ref="tooltip"> + <i + class="fa fa-question-circle" + aria-hidden="true"> + </i> + </span> + </fieldset> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue new file mode 100644 index 00000000000..6556bf117e2 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/title.vue @@ -0,0 +1,31 @@ +<script> + import updateMixin from '../../mixins/update'; + + export default { + mixins: [updateMixin], + props: { + formState: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <fieldset> + <label + class="sr-only" + for="issue-title"> + Title + </label> + <input + id="issue-title" + class="form-control" + type="text" + placeholder="Issue title" + aria-label="Issue title" + v-model="formState.title" + @keydown.meta.enter="updateIssuable" /> + </fieldset> +</template> diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue new file mode 100644 index 00000000000..76ec3dc9a5d --- /dev/null +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -0,0 +1,104 @@ +<script> + import lockedWarning from './locked_warning.vue'; + import titleField from './fields/title.vue'; + import descriptionField from './fields/description.vue'; + import editActions from './edit_actions.vue'; + import descriptionTemplate from './fields/description_template.vue'; + import projectMove from './fields/project_move.vue'; + import confidentialCheckbox from './fields/confidential_checkbox.vue'; + + export default { + props: { + canMove: { + type: Boolean, + required: true, + }, + canDestroy: { + type: Boolean, + required: true, + }, + formState: { + type: Object, + required: true, + }, + issuableTemplates: { + type: Array, + required: false, + default: () => [], + }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + projectsAutocompleteUrl: { + type: String, + required: true, + }, + }, + components: { + lockedWarning, + titleField, + descriptionField, + descriptionTemplate, + editActions, + projectMove, + confidentialCheckbox, + }, + computed: { + hasIssuableTemplates() { + return this.issuableTemplates.length; + }, + }, + }; +</script> + +<template> + <form> + <locked-warning v-if="formState.lockedWarningVisible" /> + <div class="row"> + <div + class="col-sm-4 col-lg-3" + v-if="hasIssuableTemplates"> + <description-template + :form-state="formState" + :issuable-templates="issuableTemplates" + :project-path="projectPath" + :project-namespace="projectNamespace" /> + </div> + <div + :class="{ + 'col-sm-8 col-lg-9': hasIssuableTemplates, + 'col-xs-12': !hasIssuableTemplates, + }"> + <title-field + :form-state="formState" + :issuable-templates="issuableTemplates" /> + </div> + </div> + <description-field + :form-state="formState" + :markdown-preview-url="markdownPreviewUrl" + :markdown-docs="markdownDocs" /> + <confidential-checkbox + :form-state="formState" /> + <project-move + v-if="canMove" + :form-state="formState" + :projects-autocomplete-url="projectsAutocompleteUrl" /> + <edit-actions + :form-state="formState" + :can-destroy="canDestroy" /> + </form> +</template> diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue new file mode 100644 index 00000000000..1c2789f154a --- /dev/null +++ b/app/assets/javascripts/issue_show/components/locked_warning.vue @@ -0,0 +1,20 @@ +<script> + export default { + computed: { + currentPath() { + return location.pathname; + }, + }, + }; +</script> + +<template> + <div class="alert alert-danger"> + Someone edited the issue at the same time you did. Please check out + <a + :href="currentPath" + target="_blank" + rel="nofollow">the issue</a> + and make sure your changes will not unintentionally remove theirs. + </div> +</template> diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/issue_show/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/issue_show/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index f06e33dee60..faf79471946 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,42 +1,49 @@ import Vue from 'vue'; +import eventHub from './event_hub'; import issuableApp from './components/app.vue'; import '../vue_shared/vue_resource_interceptor'; -document.addEventListener('DOMContentLoaded', () => new Vue({ - el: document.getElementById('js-issuable-app'), - components: { - issuableApp, - }, - data() { - const issuableElement = this.$options.el; - const issuableTitleElement = issuableElement.querySelector('.title'); - const issuableDescriptionElement = issuableElement.querySelector('.wiki'); - const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field'); - const { - canUpdate, - endpoint, - issuableRef, - } = issuableElement.dataset; +document.addEventListener('DOMContentLoaded', () => { + const initialDataEl = document.getElementById('js-issuable-app-initial-data'); + const initialData = JSON.parse(initialDataEl.innerHTML.replace(/"/g, '"')); - return { - canUpdate: gl.utils.convertPermissionToBoolean(canUpdate), - endpoint, - issuableRef, - initialTitle: issuableTitleElement.innerHTML, - initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '', - initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '', - }; - }, - render(createElement) { - return createElement('issuable-app', { - props: { - canUpdate: this.canUpdate, - endpoint: this.endpoint, - issuableRef: this.issuableRef, - initialTitle: this.initialTitle, - initialDescriptionHtml: this.initialDescriptionHtml, - initialDescriptionText: this.initialDescriptionText, - }, - }); - }, -})); + $('.issuable-edit').on('click', (e) => { + e.preventDefault(); + + eventHub.$emit('open.form'); + }); + + return new Vue({ + el: document.getElementById('js-issuable-app'), + components: { + issuableApp, + }, + data() { + return { + ...initialData, + }; + }, + render(createElement) { + return createElement('issuable-app', { + props: { + canUpdate: this.canUpdate, + canDestroy: this.canDestroy, + canMove: this.canMove, + endpoint: this.endpoint, + issuableRef: this.issuableRef, + initialTitleHtml: this.initialTitleHtml, + initialTitleText: this.initialTitleText, + initialDescriptionHtml: this.initialDescriptionHtml, + initialDescriptionText: this.initialDescriptionText, + issuableTemplates: this.issuableTemplates, + isConfidential: this.isConfidential, + markdownPreviewUrl: this.markdownPreviewUrl, + markdownDocs: this.markdownDocs, + projectPath: this.projectPath, + projectNamespace: this.projectNamespace, + projectsAutocompleteUrl: this.projectsAutocompleteUrl, + }, + }); + }, + }); +}); diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issue_show/mixins/animate.js index eda6302aa8b..4816393da1f 100644 --- a/app/assets/javascripts/issue_show/mixins/animate.js +++ b/app/assets/javascripts/issue_show/mixins/animate.js @@ -4,7 +4,7 @@ export default { this.preAnimation = true; this.pulseAnimation = false; - this.$nextTick(() => { + setTimeout(() => { this.preAnimation = false; this.pulseAnimation = true; }); diff --git a/app/assets/javascripts/issue_show/mixins/update.js b/app/assets/javascripts/issue_show/mixins/update.js new file mode 100644 index 00000000000..72be65b426f --- /dev/null +++ b/app/assets/javascripts/issue_show/mixins/update.js @@ -0,0 +1,10 @@ +import eventHub from '../event_hub'; + +export default { + methods: { + updateIssuable() { + this.formState.updateLoading = true; + eventHub.$emit('update.issuable'); + }, + }, +}; diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js index 348ad8d6813..6f0fd0b1768 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issue_show/services/index.js @@ -7,10 +7,23 @@ export default class Service { constructor(endpoint) { this.endpoint = endpoint; - this.resource = Vue.resource(this.endpoint); + this.resource = Vue.resource(`${this.endpoint}.json`, {}, { + realtimeChanges: { + method: 'GET', + url: `${this.endpoint}/realtime_changes`, + }, + }); } getData() { - return this.resource.get(); + return this.resource.realtimeChanges(); + } + + deleteIssuable() { + return this.resource.delete(); + } + + updateIssuable(data) { + return this.resource.update(data); } } diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 8e89a2b7730..4a16c3cb4dc 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -1,17 +1,26 @@ export default class Store { constructor({ titleHtml, + titleText, descriptionHtml, descriptionText, }) { this.state = { titleHtml, - titleText: '', + titleText, descriptionHtml, descriptionText, taskStatus: '', updatedAt: '', }; + this.formState = { + title: '', + confidential: false, + description: '', + lockedWarningVisible: false, + move_to_project_id: 0, + updateLoading: false, + }; } updateState(data) { @@ -22,4 +31,15 @@ export default class Store { this.state.taskStatus = data.task_status; this.state.updatedAt = data.updated_at; } + + stateShouldUpdate(data) { + return { + title: this.state.titleText !== data.title_text, + description: this.state.descriptionText !== data.description_text, + }; + } + + setFormState(state) { + this.formState = Object.assign(this.formState, state); + } } diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index b43c1c3aac6..601d01e1be1 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -170,7 +170,7 @@ gl.text.init = function(form) { }); }; gl.text.removeListeners = function(form) { - return $('.js-md', form).off(); + return $('.js-md', form).off('click'); }; gl.text.humanize = function(string) { return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index dace03554e8..51448252c0f 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -77,7 +77,9 @@ import './shortcuts_navigation'; ShortcutsIssuable.prototype.editIssue = function() { var $editBtn; $editBtn = $('.issuable-edit'); - return gl.utils.visitUrl($editBtn.attr('href')); + // Need to click the element as on issues, editing is inline + // on merge request, editing is on a different page + $editBtn.get(0).click(); }; ShortcutsIssuable.prototype.openSidebarDropdown = function(name) { diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index 3392cb9da29..419c458ff34 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -1,6 +1,6 @@ /* global Flash */ -import 'vendor/task_list'; +import 'deckar01-task_list'; class TaskList { constructor(options = {}) { diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue new file mode 100644 index 00000000000..e6977681e96 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -0,0 +1,107 @@ +<script> + /* global Flash */ + import markdownHeader from './header.vue'; + import markdownToolbar from './toolbar.vue'; + + export default { + props: { + markdownPreviewUrl: { + type: String, + required: false, + default: '', + }, + markdownDocs: { + type: String, + required: true, + }, + }, + data() { + return { + markdownPreview: '', + markdownPreviewLoading: false, + previewMarkdown: false, + }; + }, + components: { + markdownHeader, + markdownToolbar, + }, + methods: { + toggleMarkdownPreview() { + this.previewMarkdown = !this.previewMarkdown; + + if (!this.previewMarkdown) { + this.markdownPreview = ''; + } else { + this.markdownPreviewLoading = true; + this.$http.post( + this.markdownPreviewUrl, + { + /* + Can't use `$refs` as the component is technically in the parent component + so we access the VNode & then get the element + */ + text: this.$slots.textarea[0].elm.value, + }, + ) + .then((res) => { + const data = res.json(); + + this.markdownPreviewLoading = false; + this.markdownPreview = data.body; + + this.$nextTick(() => { + $(this.$refs['markdown-preview']).renderGFM(); + }); + }) + .catch(() => new Flash('Error loading markdown preview')); + } + }, + }, + mounted() { + /* + GLForm class handles all the toolbar buttons + */ + return new gl.GLForm($(this.$refs['gl-form']), true); + }, + }; +</script> + +<template> + <div + class="md-area prepend-top-default append-bottom-default js-vue-markdown-field" + ref="gl-form"> + <markdown-header + :preview-markdown="previewMarkdown" + @toggle-markdown="toggleMarkdownPreview" /> + <div + class="md-write-holder" + v-show="!previewMarkdown"> + <div class="zen-backdrop"> + <slot name="textarea"></slot> + <a + class="zen-control zen-control-leave js-zen-leave" + href="#" + aria-label="Enter zen mode"> + <i + class="fa fa-compress" + aria-hidden="true"> + </i> + </a> + <markdown-toolbar + :markdown-docs="markdownDocs" /> + </div> + </div> + <div + class="md md-preview-holder md-preview" + v-show="previewMarkdown"> + <div + ref="markdown-preview" + v-html="markdownPreview"> + </div> + <span v-if="markdownPreviewLoading"> + Loading... + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue new file mode 100644 index 00000000000..1a11f493b7f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -0,0 +1,113 @@ +<script> + import tooltipMixin from '../../mixins/tooltip'; + import toolbarButton from './toolbar_button.vue'; + + export default { + mixins: [ + tooltipMixin, + ], + props: { + previewMarkdown: { + type: Boolean, + required: true, + }, + }, + components: { + toolbarButton, + }, + methods: { + toggleMarkdownPreview(e, form) { + if (form && !form.find('.js-vue-markdown-field').length) { + return; + } else if (e.target.blur) { + e.target.blur(); + } + + this.$emit('toggle-markdown'); + }, + }, + mounted() { + $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview); + $(document).on('markdown-preview:hide.vue', this.toggleMarkdownPreview); + }, + beforeDestroy() { + $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview); + $(document).off('markdown-preview:hide.vue', this.toggleMarkdownPreview); + }, + }; +</script> + +<template> + <div class="md-header"> + <ul class="nav-links clearfix"> + <li :class="{ active: !previewMarkdown }"> + <a + href="#md-write-holder" + tabindex="-1" + @click.prevent="toggleMarkdownPreview($event)"> + Write + </a> + </li> + <li :class="{ active: previewMarkdown }"> + <a + href="#md-preview-holder" + tabindex="-1" + @click.prevent="toggleMarkdownPreview($event)"> + Preview + </a> + </li> + <li class="pull-right"> + <div class="toolbar-group"> + <toolbar-button + tag="**" + button-title="Add bold text" + icon="bold" /> + <toolbar-button + tag="*" + button-title="Add italic text" + icon="italic" /> + <toolbar-button + tag="> " + :prepend="true" + button-title="Insert a quote" + icon="quote-right" /> + <toolbar-button + tag="`" + tag-block="```" + button-title="Insert code" + icon="code" /> + <toolbar-button + tag="* " + :prepend="true" + button-title="Add a bullet list" + icon="list-ul" /> + <toolbar-button + tag="1. " + :prepend="true" + button-title="Add a numbered list" + icon="list-ol" /> + <toolbar-button + tag="* [ ] " + :prepend="true" + button-title="Add a task list" + icon="check-square-o" /> + </div> + <div class="toolbar-group"> + <button + aria-label="Go full screen" + class="toolbar-btn js-zen-enter" + data-container="body" + tabindex="-1" + title="Go full screen" + type="button" + ref="tooltip"> + <i + aria-hidden="true" + class="fa fa-arrows-alt fa-fw"> + </i> + </button> + </div> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue new file mode 100644 index 00000000000..93252293ba6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -0,0 +1,33 @@ +<script> + export default { + props: { + markdownDocs: { + type: String, + required: true, + }, + }, + }; +</script> + +<template> + <div class="comment-toolbar clearfix"> + <div class="toolbar-text"> + <a + :href="markdownDocs" + target="_blank" + tabindex="-1"> + Markdown is supported + </a> + </div> + <button + class="toolbar-button markdown-selector" + type="button" + tabindex="-1"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"> + </i> + Attach a file + </button> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue new file mode 100644 index 00000000000..096be507625 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -0,0 +1,58 @@ +<script> + import tooltipMixin from '../../mixins/tooltip'; + + export default { + mixins: [ + tooltipMixin, + ], + props: { + buttonTitle: { + type: String, + required: true, + }, + icon: { + type: String, + required: true, + }, + tag: { + type: String, + required: true, + }, + tagBlock: { + type: String, + required: false, + default: '', + }, + prepend: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + iconClass() { + return `fa-${this.icon}`; + }, + }, + }; +</script> + +<template> + <button + type="button" + class="toolbar-btn js-md hidden-xs" + tabindex="-1" + ref="tooltip" + data-container="body" + :data-md-tag="tag" + :data-md-block="tagBlock" + :data-md-prepend="prepend" + :title="buttonTitle" + :aria-label="buttonTitle"> + <i + aria-hidden="true" + class="fa fa-fw" + :class="iconClass"> + </i> + </button> +</template> diff --git a/app/assets/javascripts/vue_shared/mixins/tooltip.js b/app/assets/javascripts/vue_shared/mixins/tooltip.js index 9bb948bff66..995c0c98505 100644 --- a/app/assets/javascripts/vue_shared/mixins/tooltip.js +++ b/app/assets/javascripts/vue_shared/mixins/tooltip.js @@ -6,4 +6,8 @@ export default { updated() { $(this.$refs.tooltip).tooltip('fixTitle'); }, + + beforeDestroy() { + $(this.$refs.tooltip).tooltip('destroy'); + }, }; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index d191bbb226c..90051ffe753 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -475,4 +475,5 @@ .filter-dropdown-loading { padding: 8px 16px; + text-align: center; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 4db77752c0c..975a4b40383 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -293,7 +293,7 @@ $btn-white-active: #848484; /* * Badges */ -$badge-bg: #eee; +$badge-bg: rgba(0, 0, 0, 0.07); $badge-color: $gl-text-color-secondary; /* diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index cffd3b6060d..f956e3757bf 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -550,13 +550,13 @@ ul.notes { position: relative; top: -2px; display: inline-block; - padding-left: 4px; - padding-right: 4px; + padding-left: 7px; + padding-right: 7px; color: $notes-role-color; font-size: 12px; line-height: 20px; border: 1px solid $border-color; - border-radius: $border-radius-base; + border-radius: $label-border-radius; } diff --git a/app/controllers/admin/builds_controller.rb b/app/controllers/admin/jobs_controller.rb index 88f3c0e2fd4..5162273ef8a 100644 --- a/app/controllers/admin/builds_controller.rb +++ b/app/controllers/admin/jobs_controller.rb @@ -1,4 +1,4 @@ -class Admin::BuildsController < Admin::ApplicationController +class Admin::JobsController < Admin::ApplicationController def index @scope = params[:scope] @all_builds = Ci::Build @@ -20,6 +20,6 @@ class Admin::BuildsController < Admin::ApplicationController def cancel_all Ci::Build.running_or_pending.each(&:cancel) - redirect_to admin_builds_path + redirect_to admin_jobs_path end end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 4cf645d6341..0c3b68a7ac3 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -14,7 +14,16 @@ module IssuableActions name = issuable.human_class_name flash[:notice] = "The #{name} was successfully deleted." - redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) + index_path = polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) + + respond_to do |format| + format.html { redirect_to index_path } + format.json do + render json: { + web_url: index_path + } + end + end end def bulk_update diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 6195121b931..f9c31920302 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -24,7 +24,7 @@ class DashboardController < Dashboard::ApplicationController def load_events projects = if params[:filter] == "starred" - current_user.viewable_starred_projects + ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute else current_user.authorized_projects end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 1224e9503c9..b46a33604ff 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -46,7 +46,7 @@ class Projects::ArtifactsController < Projects::ApplicationController def keep build.keep_artifacts! - redirect_to namespace_project_build_path(project.namespace, project, build) + redirect_to namespace_project_job_path(project.namespace, project, build) end def latest_succeeded @@ -79,7 +79,7 @@ class Projects::ArtifactsController < Projects::ApplicationController end def build_from_id - project.builds.find_by(id: params[:build_id]) if params[:build_id] + project.builds.find_by(id: params[:job_id]) if params[:job_id] end def build_from_ref diff --git a/app/controllers/projects/build_artifacts_controller.rb b/app/controllers/projects/build_artifacts_controller.rb new file mode 100644 index 00000000000..f34a198634e --- /dev/null +++ b/app/controllers/projects/build_artifacts_controller.rb @@ -0,0 +1,55 @@ +class Projects::BuildArtifactsController < Projects::ApplicationController + include ExtractsPath + include RendersBlob + + before_action :authorize_read_build! + before_action :extract_ref_name_and_path + before_action :validate_artifacts! + + def download + redirect_to download_namespace_project_job_artifacts_path(project.namespace, project, job) + end + + def browse + redirect_to browse_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path]) + end + + def file + redirect_to file_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path]) + end + + def raw + redirect_to raw_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path]) + end + + def latest_succeeded + redirect_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, job, ref_name_and_path: params[:ref_name_and_path], job: params[:job]) + end + + private + + def validate_artifacts! + render_404 unless job && job.artifacts? + end + + def extract_ref_name_and_path + return unless params[:ref_name_and_path] + + @ref_name, @path = extract_ref(params[:ref_name_and_path]) + end + + def job + @job ||= job_from_id || job_from_ref + end + + def job_from_id + project.builds.find_by(id: params[:build_id]) if params[:build_id] + end + + def job_from_ref + return unless @ref_name + + jobs = project.latest_successful_builds_for(@ref_name) + jobs.find_by(name: params[:job]) + end +end diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index dfaaea71b9c..1334a231788 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -1,131 +1,21 @@ class Projects::BuildsController < Projects::ApplicationController - before_action :build, except: [:index, :cancel_all] - - before_action :authorize_read_build!, - only: [:index, :show, :status, :raw, :trace] - before_action :authorize_update_build!, - except: [:index, :show, :status, :raw, :trace, :cancel_all] - - layout 'project' + before_action :authorize_read_build! def index - @scope = params[:scope] - @all_builds = project.builds.relevant - @builds = @all_builds.order('created_at DESC') - @builds = - case @scope - when 'pending' - @builds.pending.reverse_order - when 'running' - @builds.running.reverse_order - when 'finished' - @builds.finished - else - @builds - end - @builds = @builds.includes([ - { pipeline: :project }, - :project, - :tags - ]) - @builds = @builds.page(params[:page]).per(30) - end - - def cancel_all - return access_denied! unless can?(current_user, :update_build, project) - - @project.builds.running_or_pending.each do |build| - build.cancel if can?(current_user, :update_build, build) - end - - redirect_to namespace_project_builds_path(project.namespace, project) + redirect_to namespace_project_jobs_path(project.namespace, project) end def show - @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') - @builds = @builds.where("id not in (?)", @build.id) - @pipeline = @build.pipeline - end - - def trace - build.trace.read do |stream| - respond_to do |format| - format.json do - result = { - id: @build.id, status: @build.status, complete: @build.complete? - } - - if stream.valid? - stream.limit - state = params[:state].presence - trace = stream.html_with_state(state) - result.merge!(trace.to_h) - end - - render json: result - end - end - end - end - - def retry - return respond_422 unless @build.retryable? - - build = Ci::Build.retry(@build, current_user) - redirect_to build_path(build) - end - - def play - return respond_422 unless @build.playable? - - build = @build.play(current_user) - redirect_to build_path(build) - end - - def cancel - return respond_422 unless @build.cancelable? - - @build.cancel - redirect_to build_path(@build) - end - - def status - render json: BuildSerializer - .new(project: @project, current_user: @current_user) - .represent_status(@build) - end - - def erase - if @build.erase(erased_by: current_user) - redirect_to namespace_project_build_path(project.namespace, project, @build), - notice: "Build has been successfully erased!" - else - respond_422 - end + redirect_to namespace_project_job_path(project.namespace, project, job) end def raw - build.trace.read do |stream| - if stream.file? - send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' - else - render_404 - end - end + redirect_to raw_namespace_project_job_path(project.namespace, project, job) end private - def authorize_update_build! - return access_denied! unless can?(current_user, :update_build, build) - end - - def build - @build ||= project.builds.find(params[:id]) - .present(current_user: current_user) - end - - def build_path(build) - namespace_project_build_path(build.project.namespace, build.project, build) + def job + @job ||= project.builds.find(params[:id]) end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index cbef8fa94d4..59df1e7b86a 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -148,10 +148,7 @@ class Projects::IssuesController < Projects::ApplicationController format.json do if @issue.valid? - render json: @issue.to_json(methods: [:task_status, :task_status_short], - include: { milestone: {}, - assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, - labels: { methods: :text_color } }) + render json: IssueSerializer.new.represent(@issue) else render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity end diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb new file mode 100644 index 00000000000..d2cd1cfdab8 --- /dev/null +++ b/app/controllers/projects/jobs_controller.rb @@ -0,0 +1,131 @@ +class Projects::JobsController < Projects::ApplicationController + before_action :build, except: [:index, :cancel_all] + + before_action :authorize_read_build!, + only: [:index, :show, :status, :raw, :trace] + before_action :authorize_update_build!, + except: [:index, :show, :status, :raw, :trace, :cancel_all] + + layout 'project' + + def index + @scope = params[:scope] + @all_builds = project.builds.relevant + @builds = @all_builds.order('created_at DESC') + @builds = + case @scope + when 'pending' + @builds.pending.reverse_order + when 'running' + @builds.running.reverse_order + when 'finished' + @builds.finished + else + @builds + end + @builds = @builds.includes([ + { pipeline: :project }, + :project, + :tags + ]) + @builds = @builds.page(params[:page]).per(30) + end + + def cancel_all + return access_denied! unless can?(current_user, :update_build, project) + + @project.builds.running_or_pending.each do |build| + build.cancel if can?(current_user, :update_build, build) + end + + redirect_to namespace_project_jobs_path(project.namespace, project) + end + + def show + @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') + @builds = @builds.where("id not in (?)", @build.id) + @pipeline = @build.pipeline + end + + def trace + build.trace.read do |stream| + respond_to do |format| + format.json do + result = { + id: @build.id, status: @build.status, complete: @build.complete? + } + + if stream.valid? + stream.limit + state = params[:state].presence + trace = stream.html_with_state(state) + result.merge!(trace.to_h) + end + + render json: result + end + end + end + end + + def retry + return respond_422 unless @build.retryable? + + build = Ci::Build.retry(@build, current_user) + redirect_to build_path(build) + end + + def play + return respond_422 unless @build.playable? + + build = @build.play(current_user) + redirect_to build_path(build) + end + + def cancel + return respond_422 unless @build.cancelable? + + @build.cancel + redirect_to build_path(@build) + end + + def status + render json: BuildSerializer + .new(project: @project, current_user: @current_user) + .represent_status(@build) + end + + def erase + if @build.erase(erased_by: current_user) + redirect_to namespace_project_job_path(project.namespace, project, @build), + notice: "Build has been successfully erased!" + else + respond_422 + end + end + + def raw + build.trace.read do |stream| + if stream.file? + send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' + else + render_404 + end + end + end + + private + + def authorize_update_build! + return access_denied! unless can?(current_user, :update_build, build) + end + + def build + @build ||= project.builds.find(params[:id]) + .present(current_user: current_user) + end + + def build_path(build) + namespace_project_job_path(build.project.namespace, build.project, build) + end +end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index f6d8226bf3f..5bf722d1ec6 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -7,6 +7,7 @@ # project_ids_relation: int[] - project ids to use # params: # trending: boolean +# owned: boolean # non_public: boolean # starred: boolean # sort: string @@ -28,13 +29,17 @@ class ProjectsFinder < UnionFinder def execute items = init_collection - items = by_ids(items) + items = items.map do |item| + item = by_ids(item) + item = by_personal(item) + item = by_starred(item) + item = by_trending(item) + item = by_visibilty_level(item) + item = by_tags(item) + item = by_search(item) + by_archived(item) + end items = union(items) - items = by_personal(items) - items = by_visibilty_level(items) - items = by_tags(items) - items = by_search(items) - items = by_archived(items) sort(items) end @@ -43,10 +48,8 @@ class ProjectsFinder < UnionFinder def init_collection projects = [] - if params[:trending].present? - projects << Project.trending - elsif params[:starred].present? && current_user - projects << current_user.viewable_starred_projects + if params[:owned].present? + projects << current_user.owned_projects if current_user else projects << current_user.authorized_projects if current_user projects << Project.unscoped.public_to_user(current_user) unless params[:non_public].present? @@ -56,7 +59,7 @@ class ProjectsFinder < UnionFinder end def by_ids(items) - project_ids_relation ? items.map { |item| item.where(id: project_ids_relation) } : items + project_ids_relation ? items.where(id: project_ids_relation) : items end def union(items) @@ -67,6 +70,14 @@ class ProjectsFinder < UnionFinder (params[:personal].present? && current_user) ? items.personal(current_user) : items end + def by_starred(items) + (params[:starred].present? && current_user) ? items.starred_by(current_user) : items + end + + def by_trending(items) + params[:trending].present? ? items.trending : items + end + def by_visibilty_level(items) params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e5e64650708..36d9090b3ae 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -276,7 +276,7 @@ module ApplicationHelper end def show_user_callout? - cookies[:user_callout_dismissed] == 'true' + cookies[:user_callout_dismissed].nil? end def linkedin_url(user) diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 622e14e21ff..11c972c6563 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -120,7 +120,7 @@ module BlobHelper def blob_raw_url if @build && @entry - raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path) + raw_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: @entry.path) elsif @snippet if @snippet.project_id raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 2eb2c6c7389..f0a0d245dc0 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -2,7 +2,7 @@ module BuildsHelper def build_summary(build, skip: false) if build.has_trace? if skip - link_to "View job trace", pipeline_build_url(build.pipeline, build) + link_to "View job trace", pipeline_job_url(build.pipeline, build) else build.trace.html(last_lines: 10).html_safe end @@ -20,8 +20,8 @@ module BuildsHelper def javascript_build_options { - page_url: namespace_project_build_url(@project.namespace, @project, @build), - build_url: namespace_project_build_url(@project.namespace, @project, @build, :json), + page_url: namespace_project_job_url(@project.namespace, @project, @build), + build_url: namespace_project_job_url(@project.namespace, @project, @build, :json), build_status: @build.status, build_stage: @build.stage, log_state: '' @@ -31,7 +31,7 @@ module BuildsHelper def build_failed_issue_options { title: "Build Failed ##{@build.id}", - description: namespace_project_build_url(@project.namespace, @project, @build) + description: namespace_project_job_url(@project.namespace, @project, @build) } end end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index fc308b3960e..40864bed0ff 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -50,8 +50,8 @@ module GitlabRoutingHelper namespace_project_cycle_analytics_path(project.namespace, project, *args) end - def project_builds_path(project, *args) - namespace_project_builds_path(project.namespace, project, *args) + def project_jobs_path(project, *args) + namespace_project_jobs_path(project.namespace, project, *args) end def project_ref_path(project, ref_name, *args) @@ -110,8 +110,8 @@ module GitlabRoutingHelper namespace_project_pipeline_url(pipeline.project.namespace, pipeline.project, pipeline.id, *args) end - def pipeline_build_url(pipeline, build, *args) - namespace_project_build_url(pipeline.project.namespace, pipeline.project, build.id, *args) + def pipeline_job_url(pipeline, build, *args) + namespace_project_job_url(pipeline.project.namespace, pipeline.project, build.id, *args) end def commits_url(entity, *args) @@ -215,13 +215,13 @@ module GitlabRoutingHelper case action when 'download' - download_namespace_project_build_artifacts_path(*args) + download_namespace_project_job_artifacts_path(*args) when 'browse' - browse_namespace_project_build_artifacts_path(*args) + browse_namespace_project_job_artifacts_path(*args) when 'file' - file_namespace_project_build_artifacts_path(*args) + file_namespace_project_job_artifacts_path(*args) when 'raw' - raw_namespace_project_build_artifacts_path(*args) + raw_namespace_project_job_artifacts_path(*args) end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 9290e4ec133..c380a10c82d 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -199,6 +199,27 @@ module IssuablesHelper issuable_filter_params.any? { |k| params.key?(k) } end + def issuable_initial_data(issuable) + { + endpoint: namespace_project_issue_path(@project.namespace, @project, issuable), + canUpdate: can?(current_user, :update_issue, issuable), + canDestroy: can?(current_user, :destroy_issue, issuable), + canMove: current_user ? issuable.can_move?(current_user) : false, + issuableRef: issuable.to_reference, + isConfidential: issuable.confidential, + markdownPreviewUrl: preview_markdown_path(@project), + markdownDocs: help_page_path('user/markdown'), + projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id), + issuableTemplates: issuable_templates(issuable), + projectPath: ref_project.path, + projectNamespace: ref_project.namespace.full_path, + initialTitleHtml: markdown_field(issuable, :title), + initialTitleText: issuable.title, + initialDescriptionHtml: markdown_field(issuable, :description), + initialDescriptionText: issuable.description + }.to_json + end + private def sidebar_gutter_collapsed? diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index b83068467ec..c7565c93811 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -51,6 +51,12 @@ module Ci after_destroy :update_project_statistics class << self + # This is needed for url_for to work, + # as the controller is JobsController + def model_name + ActiveModel::Name.new(self, nil, 'job') + end + def first_pending pending.unstarted.order('created_at ASC').first end diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 07213ca608a..45d8cd34359 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -24,6 +24,10 @@ module Ci owner == current_user end + def own!(user) + update(owner: user) + end + def inactive? !active? end diff --git a/app/models/project.rb b/app/models/project.rb index 2922bebbaa7..d741af355fd 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -242,6 +242,7 @@ class Project < ActiveRecord::Base scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) } scope :personal, ->(user) { where(namespace_id: user.namespace_id) } scope :joined, ->(user) { where('namespace_id != ?', user.namespace_id) } + scope :starred_by, ->(user) { joins(:users_star_projects).where('users_star_projects.user_id': user.id) } scope :visible_to_user, ->(user) { where(id: user.authorized_projects.select(:id).reorder(nil)) } scope :non_archived, -> { where(archived: false) } scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct } @@ -350,10 +351,6 @@ class Project < ActiveRecord::Base where("projects.id IN (#{union.to_sql})") end - def search_by_visibility(level) - where(visibility_level: Gitlab::VisibilityLevel.string_options[level]) - end - def search_by_title(query) pattern = "%#{query}%" table = Project.arel_table diff --git a/app/models/user.rb b/app/models/user.rb index 3f816a250c2..9aad327b592 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -557,12 +557,6 @@ class User < ActiveRecord::Base authorized_projects(Gitlab::Access::REPORTER).where(id: projects) end - def viewable_starred_projects - starred_projects.where("projects.visibility_level IN (?) OR projects.id IN (?)", - [Project::PUBLIC, Project::INTERNAL], - authorized_projects.select(:project_id)) - end - def owned_projects @owned_projects ||= Project.where('namespace_id IN (?) OR namespace_id = ?', diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index d4af4490608..2d7405dc240 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -23,7 +23,7 @@ module Ci !::Gitlab::UserAccess .new(user, project: build.project) - .can_push_to_branch?(build.ref) + .can_merge_to_branch?(build.ref) end end end diff --git a/app/serializers/analytics_build_entity.rb b/app/serializers/analytics_build_entity.rb index a0db5b8f0f4..ad7ad020b03 100644 --- a/app/serializers/analytics_build_entity.rb +++ b/app/serializers/analytics_build_entity.rb @@ -25,7 +25,7 @@ class AnalyticsBuildEntity < Grape::Entity end expose :url do |build| - url_to(:namespace_project_build, build) + url_to(:namespace_project_job, build) end expose :commit_url do |build| diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 5e99204c658..301b718d060 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -6,7 +6,7 @@ class BuildActionEntity < Grape::Entity end expose :path do |build| - play_namespace_project_build_path( + play_namespace_project_job_path( build.project.namespace, build.project, build) diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb index 8b643d8e783..dde17aa68b8 100644 --- a/app/serializers/build_artifact_entity.rb +++ b/app/serializers/build_artifact_entity.rb @@ -6,7 +6,7 @@ class BuildArtifactEntity < Grape::Entity end expose :path do |build| - download_namespace_project_build_artifacts_path( + download_namespace_project_job_artifacts_path( build.project.namespace, build.project, build) diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index e2276808b90..05dd8270e92 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -5,15 +5,15 @@ class BuildEntity < Grape::Entity expose :name expose :build_path do |build| - path_to(:namespace_project_build, build) + path_to(:namespace_project_job, build) end expose :retry_path do |build| - path_to(:retry_namespace_project_build, build) + path_to(:retry_namespace_project_job, build) end expose :play_path, if: -> (*) { playable? } do |build| - path_to(:play_namespace_project_build, build) + path_to(:play_namespace_project_job, build) end expose :playable?, as: :playable diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index bc4f68710b2..35df95549b7 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -1,4 +1,6 @@ class IssueEntity < IssuableEntity + include RequestAwareEntity + expose :branch_name expose :confidential expose :assignees, using: API::Entities::UserBasic @@ -7,4 +9,8 @@ class IssueEntity < IssuableEntity expose :project_id expose :milestone, using: API::Entities::Milestone expose :labels, using: LabelEntity + + expose :web_url do |issue| + namespace_project_issue_path(issue.project.namespace, issue.project, issue) + end end diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index 163bd5662b0..dff549f502c 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -20,7 +20,7 @@ %span Groups = nav_link path: 'builds#index' do - = link_to admin_builds_path, title: 'Jobs' do + = link_to admin_jobs_path, title: 'Jobs' do %span Jobs = nav_link path: ['runners#index', 'runners#show'] do diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/jobs/index.html.haml index 66d633119c2..09be17f07be 100644 --- a/app/views/admin/builds/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -4,15 +4,15 @@ %div{ class: container_class } .top-area - - build_path_proc = ->(scope) { admin_builds_path(scope: scope) } + - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) } = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope .nav-controls - if @all_builds.running_or_pending.any? - = link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post + = link_to 'Cancel all', cancel_all_admin_jobs_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post .row-content-block.second-block #{(@scope || 'all').capitalize} jobs %ul.content-list.builds-content-list.admin-builds-table - = render "projects/builds/table", builds: @builds, admin: true + = render "projects/jobs/table", builds: @builds, admin: true diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index dc4116e1ce0..801430e525e 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -85,7 +85,7 @@ %tr.build %td.id - if project - = link_to namespace_project_build_path(project.namespace, project, build) do + = link_to namespace_project_job_path(project.namespace, project, build) do %strong ##{build.id} - else %strong ##{build.id} diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 3b2a555a143..2890ae7173b 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -9,7 +9,7 @@ = render "projects/last_push" %div{ class: container_class } - - unless show_user_callout? + - if show_user_callout? = render 'shared/user_callout' - if @projects.any? || params[:name] diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index e4dfe0c8c08..29658da7792 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -92,7 +92,7 @@ -# Shortcut to Pipelines > Jobs - if project_nav_tab? :builds %li.hidden - = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do + = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do Jobs -# Shortcut to commits page diff --git a/app/views/notify/links/ci/builds/_build.html.haml b/app/views/notify/links/ci/builds/_build.html.haml index d35b3839171..644cf506eff 100644 --- a/app/views/notify/links/ci/builds/_build.html.haml +++ b/app/views/notify/links/ci/builds/_build.html.haml @@ -1,2 +1,2 @@ -%a{ href: pipeline_build_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" } +%a{ href: pipeline_job_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" } = build.name diff --git a/app/views/notify/links/ci/builds/_build.text.erb b/app/views/notify/links/ci/builds/_build.text.erb index 741c7f344c8..773ae8174e9 100644 --- a/app/views/notify/links/ci/builds/_build.text.erb +++ b/app/views/notify/links/ci/builds/_build.text.erb @@ -1 +1 @@ -Job #<%= build.id %> ( <%= pipeline_build_url(pipeline, build) %> ) +Job #<%= build.id %> ( <%= pipeline_job_url(pipeline, build) %> ) diff --git a/app/views/projects/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml index 34d5c3b7285..e2966ec33c2 100644 --- a/app/views/projects/artifacts/_tree_directory.html.haml +++ b/app/views/projects/artifacts/_tree_directory.html.haml @@ -1,4 +1,4 @@ -- path_to_directory = browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: directory.path) +- path_to_directory = browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: directory.path) %tr.tree-item{ 'data-link' => path_to_directory } %td.tree-item-file-name diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index ce7e25d774b..ea0b43b85cf 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,4 +1,4 @@ -- path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path) +- path_to_file = file_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: file.path) %tr.tree-item{ 'data-link' => path_to_file } - blob = file.blob diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index 9fbb30f7c7c..961c805dc7c 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -1,22 +1,22 @@ - page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' = render "projects/pipelines/head" -= render "projects/builds/header", show_controls: false += render "projects/jobs/header", show_controls: false .tree-holder .nav-block .tree-controls - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), + = link_to download_namespace_project_job_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-default download' do = icon('download') Download artifacts archive %ul.breadcrumb.repo-breadcrumb %li - = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + = link_to 'Artifacts', browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build) - path_breadcrumbs do |title, path| %li - = link_to truncate(title, length: 40), browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + = link_to truncate(title, length: 40), browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path) .tree-content-holder %table.table.tree-table diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml index d8da83b9a80..b25c7c95196 100644 --- a/app/views/projects/artifacts/file.html.haml +++ b/app/views/projects/artifacts/file.html.haml @@ -1,21 +1,21 @@ - page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' = render "projects/pipelines/head" -= render "projects/builds/header", show_controls: false += render "projects/jobs/header", show_controls: false #tree-holder.tree-holder .nav-block %ul.breadcrumb.repo-breadcrumb %li - = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + = link_to 'Artifacts', browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build) - path_breadcrumbs do |title, path| - title = truncate(title, length: 40) %li - if path == @path - = link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do + = link_to file_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path) do %strong= title - else - = link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + = link_to title, browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path) %article.file-holder diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index a190a8760ef..d9f28d66b66 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -14,7 +14,7 @@ %td.branch-commit - if can?(current_user, :read_build, job) - = link_to namespace_project_build_url(job.project.namespace, job.project, job) do + = link_to namespace_project_job_url(job.project.namespace, job.project, job) do %span.build-link ##{job.id} - else %span.build-link ##{job.id} @@ -95,16 +95,16 @@ %td .pull-right - if can?(current_user, :read_build, job) && job.artifacts? - = link_to download_namespace_project_build_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do + = link_to download_namespace_project_job_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do = icon('download') - if can?(current_user, :update_build, job) - if job.active? - = link_to cancel_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do + = link_to cancel_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do = icon('remove', class: 'cred') - elsif allow_retry - if job.playable? && !admin && can?(current_user, :update_build, job) - = link_to play_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do + = link_to play_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do = custom_icon('icon_play') - elsif job.retryable? - = link_to retry_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do + = link_to retry_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do = icon('repeat') diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml index 506246f2ee6..e2baaa625ae 100644 --- a/app/views/projects/deployments/_actions.haml +++ b/app/views/projects/deployments/_actions.haml @@ -8,6 +8,7 @@ = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right - actions.each do |action| + - next unless can?(current_user, :update_build, action) %li = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do = custom_icon('icon_play') diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 7315e671056..9e221240cf2 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -13,7 +13,7 @@ = render 'projects/environments/metrics_button', environment: @environment - if can?(current_user, :update_environment, @environment) = link_to 'Edit', edit_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn' - - if can?(current_user, :create_deployment, @environment) && @environment.can_stop? + - if can?(current_user, :stop_environment, @environment) = link_to 'Stop', stop_namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure you want to stop this environment?' }, class: 'btn btn-danger', method: :post .environments-container diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 67403c36d7f..7bf271c2fc5 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -31,7 +31,7 @@ %ul - if can_update_issue %li - = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'issuable-edit' %li = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %li @@ -55,10 +55,8 @@ .issue-details.issuable-details .detail-page-description.content-block - #js-issuable-app{ "data" => { "endpoint" => realtime_changes_namespace_project_issue_path(@project.namespace, @project, @issue), - "can-update" => can?(current_user, :update_issue, @issue).to_s, - "issuable-ref" => @issue.to_reference, - } } + %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue) + #js-issuable-app %h2.title= markdown_field(@issue, :title) - if @issue.description.present? .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/jobs/_header.html.haml index d4cdb709b97..ad72ab5b199 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/jobs/_header.html.haml @@ -6,7 +6,7 @@ = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title %strong Job - = link_to "##{@build.id}", namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id' + = link_to "##{@build.id}", namespace_project_job_path(@project.namespace, @project, @build), class: 'js-build-id' in pipeline %strong = link_to "##{pipeline.id}", pipeline_path(pipeline) @@ -17,7 +17,7 @@ %strong = link_to @build.ref, project_ref_path(@project, @build.ref), class: 'ref-name' - = render "projects/builds/user" if @build.user + = render "projects/jobs/user" if @build.user = time_ago_with_tooltip(@build.created_at) @@ -26,6 +26,6 @@ - if can?(current_user, :create_issue, @project) && @build.failed? = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted' - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post + = link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } = icon('angle-double-left') diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index b3abc0e3da1..3e83142377b 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -30,21 +30,21 @@ - if @build.artifacts? .btn-group.btn-group-justified{ role: :group } - if @build.has_expiring_artifacts? && can?(current_user, :update_build, @build) - = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do + = link_to keep_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do Keep - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do + = link_to download_namespace_project_job_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do Download - if @build.artifacts_metadata? - = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + = link_to browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do Browse .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } .title Job details - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post + = link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post - if @build.merge_request %p.build-detail-row %span.build-light-text Merge Request: @@ -69,7 +69,7 @@ \##{@build.runner.id} .btn-group.btn-group-justified{ role: :group } - if @build.active? - = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post + = link_to "Cancel", cancel_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post - if @build.trigger_request .build-widget @@ -119,7 +119,7 @@ - HasStatus::ORDERED_STATUSES.each do |build_status| - builds.select{|build| build.status == build_status}.each do |build| .build-job{ class: sidebar_build_class(build, @build), data: { stage: build.stage } } - = link_to namespace_project_build_path(@project.namespace, @project, build) do + = link_to namespace_project_job_path(@project.namespace, @project, build) do = icon('arrow-right') %span{ class: "ci-status-icon-#{build.status}" } = ci_icon_for_status(build.status) diff --git a/app/views/projects/builds/_table.html.haml b/app/views/projects/jobs/_table.html.haml index 82806f022ee..82806f022ee 100644 --- a/app/views/projects/builds/_table.html.haml +++ b/app/views/projects/jobs/_table.html.haml diff --git a/app/views/projects/builds/_user.html.haml b/app/views/projects/jobs/_user.html.haml index 83f299da651..83f299da651 100644 --- a/app/views/projects/builds/_user.html.haml +++ b/app/views/projects/jobs/_user.html.haml diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/jobs/index.html.haml index a8c8afe2695..a33e3978ee1 100644 --- a/app/views/projects/builds/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -4,13 +4,13 @@ %div{ class: container_class } .top-area - - build_path_proc = ->(scope) { project_builds_path(@project, scope: scope) } + - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) } = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope .nav-controls - if can?(current_user, :update_build, @project) - if @all_builds.running_or_pending.any? - = link_to 'Cancel running', cancel_all_namespace_project_builds_path(@project.namespace, @project), + = link_to 'Cancel running', cancel_all_namespace_project_jobs_path(@project.namespace, @project), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post - unless @repository.gitlab_ci_yml diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/jobs/show.html.haml index a5a9a6435e3..0d10dfcef70 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -62,17 +62,17 @@ Showing last %span.js-truncated-info-size.truncated-info-size>< KiB of log - - %a.js-raw-link.raw-link{ href: raw_namespace_project_build_path(@project.namespace, @project, @build) }>< Complete Raw + %a.js-raw-link.raw-link{ href: raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw .controllers - if @build.has_trace? - = link_to raw_namespace_project_build_path(@project.namespace, @project, @build), + = link_to raw_namespace_project_job_path(@project.namespace, @project, @build), title: 'Open raw trace', data: { placement: 'top', container: 'body' }, class: 'js-raw-link-controller has-tooltip' do = icon('download') - if can?(current_user, :update_build, @project) && @build.erasable? - = link_to erase_namespace_project_build_path(@project.namespace, @project, @build), + = link_to erase_namespace_project_job_path(@project.namespace, @project, @build), method: :post, data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' }, title: 'Erase Build', diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index db9d77dba16..a33da149c62 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -11,7 +11,7 @@ - if project_nav_tab? :builds = nav_link(controller: [:builds, :artifacts]) do - = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do + = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do %span Jobs diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index aea8d13b7c5..01cf2cc80e5 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -51,5 +51,5 @@ %span.stage = build.stage.titleize %span.build-name - = link_to build.name, pipeline_build_url(pipeline, build) + = link_to build.name, pipeline_job_url(pipeline, build) %pre.build-log= build_summary(build, skip: index >= 10) diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index c587155bc4f..c239253c8d5 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -100,7 +100,7 @@ Snippets %div{ class: container_class } - - if @user == current_user && !show_user_callout? + - if @user == current_user && show_user_callout? = render 'shared/user_callout' .tab-content #activity.tab-pane diff --git a/changelogs/unreleased/30892-add-api-support-for-pipeline-schedule.yml b/changelogs/unreleased/30892-add-api-support-for-pipeline-schedule.yml new file mode 100644 index 00000000000..26ce84697d0 --- /dev/null +++ b/changelogs/unreleased/30892-add-api-support-for-pipeline-schedule.yml @@ -0,0 +1,4 @@ +--- +title: Add API support for pipeline schedule +merge_request: 11307 +author: dosuken123 diff --git a/changelogs/unreleased/33000-tag-list-in-project-create-api.yml b/changelogs/unreleased/33000-tag-list-in-project-create-api.yml new file mode 100644 index 00000000000..b0d0d3cbeba --- /dev/null +++ b/changelogs/unreleased/33000-tag-list-in-project-create-api.yml @@ -0,0 +1,4 @@ +--- +title: Add tag_list param to project api +merge_request: 11799 +author: Ivan Chernov diff --git a/changelogs/unreleased/UI-improvements-for-count-badges-and-permission-badges.yml b/changelogs/unreleased/UI-improvements-for-count-badges-and-permission-badges.yml new file mode 100644 index 00000000000..374f643faa7 --- /dev/null +++ b/changelogs/unreleased/UI-improvements-for-count-badges-and-permission-badges.yml @@ -0,0 +1,5 @@ +--- +title: Count badges depend on translucent color to better adjust to different background + colors and permission badges now feature a pill shaped design similar to labels +merge_request: +author: diff --git a/changelogs/unreleased/fix-gb-use-merge-ability-for-protected-manual-actions.yml b/changelogs/unreleased/fix-gb-use-merge-ability-for-protected-manual-actions.yml new file mode 100644 index 00000000000..43c18502cd6 --- /dev/null +++ b/changelogs/unreleased/fix-gb-use-merge-ability-for-protected-manual-actions.yml @@ -0,0 +1,4 @@ +--- +title: Respect merge, instead of push, permissions for protected actions +merge_request: 11648 +author: diff --git a/changelogs/unreleased/issue-edit-inline.yml b/changelogs/unreleased/issue-edit-inline.yml new file mode 100644 index 00000000000..db03d1bdac4 --- /dev/null +++ b/changelogs/unreleased/issue-edit-inline.yml @@ -0,0 +1,4 @@ +--- +title: Enables inline editing for an issues title & description +merge_request: +author: diff --git a/changelogs/unreleased/issue-template-reproduce-in-example-project.yml b/changelogs/unreleased/issue-template-reproduce-in-example-project.yml new file mode 100644 index 00000000000..8116007b459 --- /dev/null +++ b/changelogs/unreleased/issue-template-reproduce-in-example-project.yml @@ -0,0 +1,4 @@ +--- +title: Ask for an example project for bug reports +merge_request: +author: diff --git a/changelogs/unreleased/rename-builds-controller.yml b/changelogs/unreleased/rename-builds-controller.yml new file mode 100644 index 00000000000..7f6872ccf95 --- /dev/null +++ b/changelogs/unreleased/rename-builds-controller.yml @@ -0,0 +1,4 @@ +--- +title: Change /builds in the URL to /-/jobs. Backward URLs were also added +merge_request: 11407 +author: diff --git a/changelogs/unreleased/task-list-2.yml b/changelogs/unreleased/task-list-2.yml new file mode 100644 index 00000000000..cbae8178081 --- /dev/null +++ b/changelogs/unreleased/task-list-2.yml @@ -0,0 +1,4 @@ +--- +title: Update task_list to version 2.0.0 +merge_request: 11525 +author: Jared Deckard <jared.deckard@gmail.com> diff --git a/changelogs/unreleased/tc-improve-project-api-perf.yml b/changelogs/unreleased/tc-improve-project-api-perf.yml new file mode 100644 index 00000000000..7e88466c058 --- /dev/null +++ b/changelogs/unreleased/tc-improve-project-api-perf.yml @@ -0,0 +1,4 @@ +--- +title: Improve performance of ProjectFinder used in /projects API endpoint +merge_request: 11666 +author: diff --git a/config/initializers/relative_naming_ci_namespace.rb b/config/initializers/relative_naming_ci_namespace.rb index 59abe1b9b91..03ac55be0b6 100644 --- a/config/initializers/relative_naming_ci_namespace.rb +++ b/config/initializers/relative_naming_ci_namespace.rb @@ -4,7 +4,7 @@ # - [project.namespace, project, build] # # instead of: -# - namespace_project_build_path(project.namespace, project, build) +# - namespace_project_job_path(project.namespace, project, build) # # Without that, Ci:: namespace is used for resolving routes: # - namespace_project_ci_build_path(project.namespace, project, build) diff --git a/config/routes/admin.rb b/config/routes/admin.rb index c20581b1333..ccfd85aed63 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -118,7 +118,7 @@ namespace :admin do resources :cohorts, only: :index - resources :builds, only: :index do + resources :jobs, only: :index do collection do post :cancel_all end diff --git a/config/routes/project.rb b/config/routes/project.rb index bec1f04d1f9..5aac44fce10 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -1,4 +1,5 @@ require 'constraints/project_url_constrainer' +require 'gitlab/routes/legacy_builds' resources :projects, only: [:index, :new, :create] @@ -180,38 +181,42 @@ constraints(ProjectUrlConstrainer.new) do end end - resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do - collection do - post :cancel_all - - resources :artifacts, only: [] do - collection do - get :latest_succeeded, - path: '*ref_name_and_path', - format: false + scope '-' do + resources :jobs, only: [:index, :show], constraints: { id: /\d+/ } do + collection do + post :cancel_all + + resources :artifacts, only: [] do + collection do + get :latest_succeeded, + path: '*ref_name_and_path', + format: false + end end end - end - member do - get :status - post :cancel - post :retry - post :play - post :erase - get :trace, defaults: { format: 'json' } - get :raw - end + member do + get :status + post :cancel + post :retry + post :play + post :erase + get :trace, defaults: { format: 'json' } + get :raw + end - resource :artifacts, only: [] do - get :download - get :browse, path: 'browse(/*path)', format: false - get :file, path: 'file/*path', format: false - get :raw, path: 'raw/*path', format: false - post :keep + resource :artifacts, only: [] do + get :download + get :browse, path: 'browse(/*path)', format: false + get :file, path: 'file/*path', format: false + get :raw, path: 'raw/*path', format: false + post :keep + end end end + Gitlab::Routes::LegacyBuilds.new(self).draw + resources :hooks, only: [:index, :create, :edit, :update, :destroy], constraints: { id: /\d+/ } do member do get :test diff --git a/doc/api/README.md b/doc/api/README.md index 1b0f6470b13..45579ccac4e 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -33,6 +33,7 @@ following locations: - [Notification settings](notification_settings.md) - [Pipelines](pipelines.md) - [Pipeline Triggers](pipeline_triggers.md) +- [Pipeline Schedules](pipeline_schedules.md) - [Projects](projects.md) including setting Webhooks - [Project Access Requests](access_requests.md) - [Project Members](members.md) diff --git a/doc/api/pipeline_schedules.md b/doc/api/pipeline_schedules.md new file mode 100644 index 00000000000..433654c18cc --- /dev/null +++ b/doc/api/pipeline_schedules.md @@ -0,0 +1,273 @@ +# Pipeline schedules + +You can read more about [pipeline schedules](../user/project/pipelines/schedules.md). + +## Get all pipeline schedules + +Get a list of the pipeline schedules of a project. + +``` +GET /projects/:id/pipeline_schedules +``` + +| Attribute | Type | required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `scope` | string | no | The scope of pipeline schedules, one of: `active`, `inactive` | + +```sh +curl --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules" +``` + +```json +[ + { + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "* * * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T13:41:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:40:17.727Z", + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } + } +] +``` + +## Get a single pipeline schedule + +Get the pipeline schedule of a project. + +``` +GET /projects/:id/pipeline_schedules/:pipeline_schedule_id +``` + +| Attribute | Type | required | Description | +|--------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | + +```sh +curl --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "* * * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T13:41:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:40:17.727Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } +} +``` + +## Create a new pipeline schedule + +Create a new pipeline schedule of a project. + +``` +POST /projects/:id/pipeline_schedules +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `description` | string | yes | The description of pipeline schedule | +| `ref` | string | yes | The branch/tag name will be triggered | +| `cron ` | string | yes | The cron (e.g. `0 1 * * *`) ([Cron syntax](https://en.wikipedia.org/wiki/Cron)) | +| `cron_timezone ` | string | no | The timezone supproted by `ActiveSupport::TimeZone` (e.g. `Pacific Time (US & Canada)`) (default: `'UTC'`) | +| `active ` | boolean | no | The activation of pipeline schedule. If false is set, the pipeline schedule will deactivated initially (default: `true`) | + +```sh +curl --request POST --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form description="Build packages" --form ref="master" --form cron="0 1 * * 5" --form cron_timezone="UTC" --form active="true" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules" +``` + +```json +{ + "id": 14, + "description": "Build packages", + "ref": "master", + "cron": "0 1 * * 5", + "cron_timezone": "UTC", + "next_run_at": "2017-05-26T01:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:43:08.169Z", + "updated_at": "2017-05-19T13:43:08.169Z", + "last_pipeline": null, + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } +} +``` + +## Edit a pipeline schedule + +Updates the pipeline schedule of a project. Once the update is done, it will be rescheduled automatically. + +``` +PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | +| `description` | string | no | The description of pipeline schedule | +| `ref` | string | no | The branch/tag name will be triggered | +| `cron ` | string | no | The cron (e.g. `0 1 * * *`) ([Cron syntax](https://en.wikipedia.org/wiki/Cron)) | +| `cron_timezone ` | string | no | The timezone supproted by `ActiveSupport::TimeZone` (e.g. `Pacific Time (US & Canada)`) or `TZInfo::Timezone` (e.g. `America/Los_Angeles`) | +| `active ` | boolean | no | The activation of pipeline schedule. If false is set, the pipeline schedule will deactivated initially. | + +```sh +curl --request PUT --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form cron="0 2 * * *" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "0 2 * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T17:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:44:16.135Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } +} +``` + +## Take ownership of a pipeline schedule + +Update the owner of the pipeline schedule of a project. + +``` +POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/take_ownership +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | + +```sh +curl --request POST --header "PRIVATE-TOKEN: hf2CvZXB9w8Uc5pZKpSB" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13/take_ownership" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "0 2 * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T17:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:46:37.468Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "shinya", + "username": "maeda", + "id": 50, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/8ca0a796a679c292e3a11da50f99e801?s=80&d=identicon", + "web_url": "https://gitlab.example.com/maeda" + } +} +``` + +## Delete a pipeline schedule + +Delete the pipeline schedule of a project. + +``` +DELETE /projects/:id/pipeline_schedules/:pipeline_schedule_id +``` + +| Attribute | Type | required | Description | +|----------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | + +```sh +curl --request DELETE --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "0 2 * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T17:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:46:37.468Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "shinya", + "username": "maeda", + "id": 50, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/8ca0a796a679c292e3a11da50f99e801?s=80&d=identicon", + "web_url": "https://gitlab.example.com/maeda" + } +} +``` diff --git a/doc/api/projects.md b/doc/api/projects.md index 345f93a6017..62c78ddc32e 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -473,6 +473,7 @@ Parameters: | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | +| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | ### Create project for user @@ -506,6 +507,7 @@ Parameters: | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | +| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | ### Edit project @@ -538,6 +540,7 @@ Parameters: | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | +| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | ### Fork project diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index da20076da52..fab5d14ac54 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -591,7 +591,7 @@ Optional manual actions have `allow_failure: true` set by default. **Manual actions are considered to be write actions, so permissions for protected branches are used when user wants to trigger an action. In other words, in order to trigger a manual action assigned to a branch that the -pipeline is running for, user needs to have ability to push to this branch.** +pipeline is running for, user needs to have ability to merge to this branch.** ### environment @@ -1105,6 +1105,36 @@ variables: GIT_STRATEGY: none ``` +## Git Checkout + +> Introduced in GitLab Runner 9.3 + +The `GIT_CHECKOUT` variable can be used when the `GIT_STRATEGY` is set to either +`clone` or `fetch` to specify whether a `git checkout` should be run. If not +specified, it defaults to true. Like `GIT_STRATEGY`, it can be set in either the +global [`variables`](#variables) section or the [`variables`](#job-variables) +section for individual jobs. + +If set to `false`, the Runner will: + +- when doing `fetch` - update the repository and leave working copy on + the current revision, +- when doing `clone` - clone the repository and leave working copy on the + default branch. + +Having this setting set to `true` will mean that for both `clone` and `fetch` +strategies the Runner will checkout the working copy to a revision related +to the CI pipeline: + +```yaml +variables: + GIT_STRATEGY: clone + GIT_CHECKOUT: false +script: + - git checkout master + - git merge $CI_BUILD_REF_NAME +``` + ## Git Submodule Strategy > Requires GitLab Runner v1.10+. diff --git a/features/steps/shared/builds.rb b/features/steps/shared/builds.rb index 5549fc25525..624f1a7858b 100644 --- a/features/steps/shared/builds.rb +++ b/features/steps/shared/builds.rb @@ -27,11 +27,11 @@ module SharedBuilds end step 'I visit recent build details page' do - visit namespace_project_build_path(@project.namespace, @project, @build) + visit namespace_project_job_path(@project.namespace, @project, @build) end step 'I visit project builds page' do - visit namespace_project_builds_path(@project.namespace, @project) + visit namespace_project_jobs_path(@project.namespace, @project) end step 'recent build has artifacts available' do @@ -56,7 +56,7 @@ module SharedBuilds end step 'I access artifacts download page' do - visit download_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + visit download_namespace_project_job_artifacts_path(@project.namespace, @project, @build) end step 'I see details of a build' do diff --git a/features/steps/shared/markdown.rb b/features/steps/shared/markdown.rb index 6610b97ecb2..c2bec2a6320 100644 --- a/features/steps/shared/markdown.rb +++ b/features/steps/shared/markdown.rb @@ -30,7 +30,7 @@ module SharedMarkdown end step 'I should see the Markdown write tab' do - expect(find('.gfm-form')).to have_css('.js-md-write-button', visible: true) + expect(first('.gfm-form')).to have_link('Write', visible: true) end step 'I should see the Markdown preview' do @@ -49,9 +49,9 @@ module SharedMarkdown end step 'I preview a description text like "Bug fixed :smile:"' do - page.within('.gfm-form') do + page.within(first('.gfm-form')) do fill_in 'Description', with: 'Bug fixed :smile:' - find('.js-md-preview-button').click + click_link 'Preview' end end diff --git a/lib/api/api.rb b/lib/api/api.rb index ac113c5200d..bbdd2039f43 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -110,6 +110,7 @@ module API mount ::API::Notes mount ::API::NotificationSettings mount ::API::Pipelines + mount ::API::PipelineSchedules mount ::API::ProjectHooks mount ::API::Projects mount ::API::ProjectSnippets diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 2e2b95b7994..620e3409d6f 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -687,6 +687,17 @@ module API expose :coverage end + class PipelineSchedule < Grape::Entity + expose :id + expose :description, :ref, :cron, :cron_timezone, :next_run_at, :active + expose :created_at, :updated_at + expose :owner, using: Entities::UserBasic + end + + class PipelineScheduleDetails < PipelineSchedule + expose :last_pipeline, using: Entities::PipelineBasic + end + class EnvironmentBasic < Grape::Entity expose :id, :name, :slug, :external_url end diff --git a/lib/api/groups.rb b/lib/api/groups.rb index ee85b777aff..e14a988a153 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -151,8 +151,8 @@ module API end get ":id/projects" do group = find_group!(params[:id]) - projects = GroupProjectsFinder.new(group: group, current_user: current_user).execute - projects = filter_projects(projects) + projects = GroupProjectsFinder.new(group: group, current_user: current_user, params: project_finder_params).execute + projects = reorder_projects(projects) entity = params[:simple] ? Entities::BasicProjectDetails : Entities::Project present paginate(projects), with: entity, current_user: current_user end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 226a7ddd50e..d61450f8258 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -256,31 +256,21 @@ module API # project helpers - def filter_projects(projects) - if params[:membership] - projects = projects.merge(current_user.authorized_projects) - end - - if params[:owned] - projects = projects.merge(current_user.owned_projects) - end - - if params[:starred] - projects = projects.merge(current_user.starred_projects) - end - - if params[:search].present? - projects = projects.search(params[:search]) - end - - if params[:visibility].present? - projects = projects.search_by_visibility(params[:visibility]) - end - - projects = projects.where(archived: params[:archived]) + def reorder_projects(projects) projects.reorder(params[:order_by] => params[:sort]) end + def project_finder_params + finder_params = {} + finder_params[:owned] = true if params[:owned].present? + finder_params[:non_public] = true if params[:membership].present? + finder_params[:starred] = true if params[:starred].present? + finder_params[:visibility_level] = Gitlab::VisibilityLevel.level_value(params[:visibility]) if params[:visibility] + finder_params[:archived] = params[:archived] + finder_params[:search] = params[:search] if params[:search] + finder_params + end + # file helpers def uploaded_file(field, uploads_path) diff --git a/lib/api/pipeline_schedules.rb b/lib/api/pipeline_schedules.rb new file mode 100644 index 00000000000..93d89209934 --- /dev/null +++ b/lib/api/pipeline_schedules.rb @@ -0,0 +1,131 @@ +module API + class PipelineSchedules < Grape::API + include PaginationParams + + before { authenticate! } + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: { id: %r{[^/]+} } do + desc 'Get all pipeline schedules' do + success Entities::PipelineSchedule + end + params do + use :pagination + optional :scope, type: String, values: %w[active inactive], + desc: 'The scope of pipeline schedules' + end + get ':id/pipeline_schedules' do + authorize! :read_pipeline_schedule, user_project + + schedules = PipelineSchedulesFinder.new(user_project).execute(scope: params[:scope]) + .preload([:owner, :last_pipeline]) + present paginate(schedules), with: Entities::PipelineSchedule + end + + desc 'Get a single pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + end + get ':id/pipeline_schedules/:pipeline_schedule_id' do + authorize! :read_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + present pipeline_schedule, with: Entities::PipelineScheduleDetails + end + + desc 'Create a new pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :description, type: String, desc: 'The description of pipeline schedule' + requires :ref, type: String, desc: 'The branch/tag name will be triggered' + requires :cron, type: String, desc: 'The cron' + optional :cron_timezone, type: String, default: 'UTC', desc: 'The timezone' + optional :active, type: Boolean, default: true, desc: 'The activation of pipeline schedule' + end + post ':id/pipeline_schedules' do + authorize! :create_pipeline_schedule, user_project + + pipeline_schedule = Ci::CreatePipelineScheduleService + .new(user_project, current_user, declared_params(include_missing: false)) + .execute + + if pipeline_schedule.persisted? + present pipeline_schedule, with: Entities::PipelineScheduleDetails + else + render_validation_error!(pipeline_schedule) + end + end + + desc 'Edit a pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + optional :description, type: String, desc: 'The description of pipeline schedule' + optional :ref, type: String, desc: 'The branch/tag name will be triggered' + optional :cron, type: String, desc: 'The cron' + optional :cron_timezone, type: String, desc: 'The timezone' + optional :active, type: Boolean, desc: 'The activation of pipeline schedule' + end + put ':id/pipeline_schedules/:pipeline_schedule_id' do + authorize! :update_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + if pipeline_schedule.update(declared_params(include_missing: false)) + present pipeline_schedule, with: Entities::PipelineScheduleDetails + else + render_validation_error!(pipeline_schedule) + end + end + + desc 'Take ownership of a pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + end + post ':id/pipeline_schedules/:pipeline_schedule_id/take_ownership' do + authorize! :update_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + if pipeline_schedule.own!(current_user) + present pipeline_schedule, with: Entities::PipelineScheduleDetails + else + render_validation_error!(pipeline_schedule) + end + end + + desc 'Delete a pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + end + delete ':id/pipeline_schedules/:pipeline_schedule_id' do + authorize! :admin_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + status :accepted + present pipeline_schedule.destroy, with: Entities::PipelineScheduleDetails + end + end + + helpers do + def pipeline_schedule + @pipeline_schedule ||= + user_project.pipeline_schedules + .preload(:owner, :last_pipeline) + .find_by(id: params.delete(:pipeline_schedule_id)) + end + end + end +end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index d4fe5c023bf..d00d4fe1737 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -21,6 +21,7 @@ module API optional :request_access_enabled, type: Boolean, desc: 'Allow users to request member access' optional :only_allow_merge_if_pipeline_succeeds, type: Boolean, desc: 'Only allow to merge if builds succeed' optional :only_allow_merge_if_all_discussions_are_resolved, type: Boolean, desc: 'Only allow to merge if all discussions are resolved' + optional :tag_list, type: Array[String], desc: 'The list of tags for a project' end params :optional_params do @@ -67,20 +68,19 @@ module API optional :import_url, type: String, desc: 'URL from which the project is imported' end - def present_projects(projects, options = {}) + def present_projects(options = {}) + projects = ProjectsFinder.new(current_user: current_user, params: project_finder_params).execute + projects = reorder_projects(projects) + projects = projects.with_statistics if params[:statistics] + projects = projects.with_issues_enabled if params[:with_issues_enabled] + projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled] + options = options.reverse_merge( - with: Entities::Project, - current_user: current_user, - simple: params[:simple], - with_issues_enabled: params[:with_issues_enabled], - with_merge_requests_enabled: params[:with_merge_requests_enabled] + with: current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails, + statistics: params[:statistics], + current_user: current_user ) - - projects = filter_projects(projects) - projects = projects.with_statistics if options[:statistics] - projects = projects.with_issues_enabled if options[:with_issues_enabled] - projects = projects.with_merge_requests_enabled if options[:with_merge_requests_enabled] - options[:with] = Entities::BasicProjectDetails if options[:simple] + options[:with] = Entities::BasicProjectDetails if params[:simple] present paginate(projects), options end @@ -94,8 +94,7 @@ module API use :statistics_params end get do - entity = current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails - present_projects ProjectsFinder.new(current_user: current_user).execute, with: entity, statistics: params[:statistics] + present_projects end desc 'Create new project' do @@ -231,6 +230,7 @@ module API :request_access_enabled, :shared_runners_enabled, :snippets_enabled, + :tag_list, :visibility, :wiki_enabled ] diff --git a/lib/api/v3/helpers.rb b/lib/api/v3/helpers.rb index 0f234d4cdad..d9e76560d03 100644 --- a/lib/api/v3/helpers.rb +++ b/lib/api/v3/helpers.rb @@ -14,6 +14,33 @@ module API authorize! access_level, merge_request merge_request end + + # project helpers + + def filter_projects(projects) + if params[:membership] + projects = projects.merge(current_user.authorized_projects) + end + + if params[:owned] + projects = projects.merge(current_user.owned_projects) + end + + if params[:starred] + projects = projects.merge(current_user.starred_projects) + end + + if params[:search].present? + projects = projects.search(params[:search]) + end + + if params[:visibility].present? + projects = projects.where(visibility_level: Gitlab::VisibilityLevel.level_value(params[:visibility])) + end + + projects = projects.where(archived: params[:archived]) + projects.reorder(params[:order_by] => params[:sort]) + end end end end diff --git a/lib/api/v3/projects.rb b/lib/api/v3/projects.rb index 164612cb8dd..896c00b88e7 100644 --- a/lib/api/v3/projects.rb +++ b/lib/api/v3/projects.rb @@ -147,7 +147,7 @@ module API get '/starred' do authenticate! - present_projects current_user.viewable_starred_projects + present_projects ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute end desc 'Get all projects for admin user' do diff --git a/lib/gitlab/ci/status/build/cancelable.rb b/lib/gitlab/ci/status/build/cancelable.rb index 57b533bad99..439ef0ce015 100644 --- a/lib/gitlab/ci/status/build/cancelable.rb +++ b/lib/gitlab/ci/status/build/cancelable.rb @@ -12,7 +12,7 @@ module Gitlab end def action_path - cancel_namespace_project_build_path(subject.project.namespace, + cancel_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/common.rb b/lib/gitlab/ci/status/build/common.rb index 3fec2c5d4db..b173c23fba4 100644 --- a/lib/gitlab/ci/status/build/common.rb +++ b/lib/gitlab/ci/status/build/common.rb @@ -8,7 +8,7 @@ module Gitlab end def details_path - namespace_project_build_path(subject.project.namespace, + namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb index c6139f1b716..e80f3263794 100644 --- a/lib/gitlab/ci/status/build/play.rb +++ b/lib/gitlab/ci/status/build/play.rb @@ -20,7 +20,7 @@ module Gitlab end def action_path - play_namespace_project_build_path(subject.project.namespace, + play_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/retryable.rb b/lib/gitlab/ci/status/build/retryable.rb index 505f80848b2..56303e4cb17 100644 --- a/lib/gitlab/ci/status/build/retryable.rb +++ b/lib/gitlab/ci/status/build/retryable.rb @@ -16,7 +16,7 @@ module Gitlab end def action_path - retry_namespace_project_build_path(subject.project.namespace, + retry_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/stop.rb b/lib/gitlab/ci/status/build/stop.rb index 0b5199e5483..2778d6f3b52 100644 --- a/lib/gitlab/ci/status/build/stop.rb +++ b/lib/gitlab/ci/status/build/stop.rb @@ -20,7 +20,7 @@ module Gitlab end def action_path - play_namespace_project_build_path(subject.project.namespace, + play_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb index cc285162b44..d137cc1bae6 100644 --- a/lib/gitlab/etag_caching/router.rb +++ b/lib/gitlab/etag_caching/router.rb @@ -11,7 +11,7 @@ module Gitlab USED_IN_ROUTES = %w[noteable issue notes issues realtime_changes commit pipelines merge_requests new].freeze RESERVED_WORDS = Gitlab::PathRegex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES - RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS) + RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS.map(&Regexp.method(:escape))) ROUTES = [ Gitlab::EtagCaching::Router::Route.new( %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/noteable/issue/\d+/notes\z), diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb index 1c0abc9f7cf..9ff6829cd49 100644 --- a/lib/gitlab/path_regex.rb +++ b/lib/gitlab/path_regex.rb @@ -80,6 +80,7 @@ module Gitlab # By rejecting `badges` the router can _count_ on the fact that `badges` will # be preceded by the `namespace/project`. PROJECT_WILDCARD_ROUTES = %w[ + - badges blame blob diff --git a/lib/gitlab/routes/legacy_builds.rb b/lib/gitlab/routes/legacy_builds.rb new file mode 100644 index 00000000000..36d1a8a6f64 --- /dev/null +++ b/lib/gitlab/routes/legacy_builds.rb @@ -0,0 +1,36 @@ +module Gitlab + module Routes + class LegacyBuilds + def initialize(map) + @map = map + end + + def draw + @map.instance_eval do + resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do + collection do + resources :artifacts, only: [], controller: 'build_artifacts' do + collection do + get :latest_succeeded, + path: '*ref_name_and_path', + format: false + end + end + end + + member do + get :raw + end + + resource :artifacts, only: [], controller: 'build_artifacts' do + get :download + get :browse, path: 'browse(/*path)', format: false + get :file, path: 'file/*path', format: false + get :raw, path: 'raw/*path', format: false + end + end + end + end + end + end +end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index fe37e4da94f..18d8b4f4744 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -31,8 +31,7 @@ module Gitlab feature_enabled = case action.to_s when 'git_receive_pack' - # Disabled for now, see https://gitlab.com/gitlab-org/gitaly/issues/172 - false + Gitlab::GitalyClient.feature_enabled?(:post_receive_pack) when 'git_upload_pack' Gitlab::GitalyClient.feature_enabled?(:post_upload_pack) when 'info_refs' diff --git a/package.json b/package.json index 8e280e77d67..29165fd4182 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "core-js": "^2.4.1", "css-loader": "^0.28.0", "d3": "^3.5.11", + "deckar01-task_list": "^2.0.0", "document-register-element": "^1.3.0", "dropzone": "^4.2.0", "emoji-unicode-version": "^0.2.1", diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb index eff9fab8da2..428bc45b842 100644 --- a/spec/controllers/projects/artifacts_controller_spec.rb +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -12,7 +12,7 @@ describe Projects::ArtifactsController do status: 'success') end - let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } before do project.team << [user, :developer] @@ -22,16 +22,16 @@ describe Projects::ArtifactsController do describe 'GET download' do it 'sends the artifacts file' do - expect(controller).to receive(:send_file).with(build.artifacts_file.path, disposition: 'attachment').and_call_original + expect(controller).to receive(:send_file).with(job.artifacts_file.path, disposition: 'attachment').and_call_original - get :download, namespace_id: project.namespace, project_id: project, build_id: build + get :download, namespace_id: project.namespace, project_id: project, job_id: job end end describe 'GET browse' do context 'when the directory exists' do it 'renders the browse view' do - get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'other_artifacts_0.1.2' + get :browse, namespace_id: project.namespace, project_id: project, job_id: job, path: 'other_artifacts_0.1.2' expect(response).to render_template('projects/artifacts/browse') end @@ -39,7 +39,7 @@ describe Projects::ArtifactsController do context 'when the directory does not exist' do it 'responds Not Found' do - get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + get :browse, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown' expect(response).to be_not_found end @@ -49,7 +49,7 @@ describe Projects::ArtifactsController do describe 'GET file' do context 'when the file exists' do it 'renders the file view' do - get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + get :file, namespace_id: project.namespace, project_id: project, job_id: job, path: 'ci_artifacts.txt' expect(response).to render_template('projects/artifacts/file') end @@ -57,7 +57,7 @@ describe Projects::ArtifactsController do context 'when the file does not exist' do it 'responds Not Found' do - get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + get :file, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown' expect(response).to be_not_found end @@ -67,7 +67,7 @@ describe Projects::ArtifactsController do describe 'GET raw' do context 'when the file exists' do it 'serves the file using workhorse' do - get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + get :raw, namespace_id: project.namespace, project_id: project, job_id: job, path: 'ci_artifacts.txt' send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER] @@ -84,7 +84,7 @@ describe Projects::ArtifactsController do context 'when the file does not exist' do it 'responds Not Found' do - get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + get :raw, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown' expect(response).to be_not_found end @@ -92,29 +92,29 @@ describe Projects::ArtifactsController do end describe 'GET latest_succeeded' do - def params_from_ref(ref = pipeline.ref, job = build.name, path = 'browse') + def params_from_ref(ref = pipeline.ref, job_name = job.name, path = 'browse') { namespace_id: project.namespace, project_id: project, ref_name_and_path: File.join(ref, path), - job: job + job: job_name } end - context 'cannot find the build' do + context 'cannot find the job' do shared_examples 'not found' do it { expect(response).to have_http_status(:not_found) } end context 'has no such ref' do before do - get :latest_succeeded, params_from_ref('TAIL', build.name) + get :latest_succeeded, params_from_ref('TAIL', job.name) end it_behaves_like 'not found' end - context 'has no such build' do + context 'has no such job' do before do get :latest_succeeded, params_from_ref(pipeline.ref, 'NOBUILD') end @@ -124,20 +124,20 @@ describe Projects::ArtifactsController do context 'has no path' do before do - get :latest_succeeded, params_from_ref(pipeline.sha, build.name, '') + get :latest_succeeded, params_from_ref(pipeline.sha, job.name, '') end it_behaves_like 'not found' end end - context 'found the build and redirect' do - shared_examples 'redirect to the build' do + context 'found the job and redirect' do + shared_examples 'redirect to the job' do it 'redirects' do - path = browse_namespace_project_build_artifacts_path( + path = browse_namespace_project_job_artifacts_path( project.namespace, project, - build) + job) expect(response).to redirect_to(path) end @@ -151,7 +151,7 @@ describe Projects::ArtifactsController do get :latest_succeeded, params_from_ref('master') end - it_behaves_like 'redirect to the build' + it_behaves_like 'redirect to the job' end context 'with branch name containing slash' do @@ -162,7 +162,7 @@ describe Projects::ArtifactsController do get :latest_succeeded, params_from_ref('improve/awesome') end - it_behaves_like 'redirect to the build' + it_behaves_like 'redirect to the job' end context 'with branch name and path containing slashes' do @@ -170,14 +170,14 @@ describe Projects::ArtifactsController do pipeline.update(ref: 'improve/awesome', sha: project.commit('improve/awesome').sha) - get :latest_succeeded, params_from_ref('improve/awesome', build.name, 'file/README.md') + get :latest_succeeded, params_from_ref('improve/awesome', job.name, 'file/README.md') end it 'redirects' do - path = file_namespace_project_build_artifacts_path( + path = file_namespace_project_job_artifacts_path( project.namespace, project, - build, + job, 'README.md') expect(response).to redirect_to(path) diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 20f99b209eb..de13f17012b 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -177,7 +177,7 @@ describe Projects::EnvironmentsController do expect(response).to have_http_status(200) expect(json_response).to eq( { 'redirect_url' => - "http://test.host/#{project.path_with_namespace}/builds/#{action.id}" }) + namespace_project_job_url(project.namespace, project, action) }) end end @@ -191,7 +191,7 @@ describe Projects::EnvironmentsController do expect(response).to have_http_status(200) expect(json_response).to eq( { 'redirect_url' => - "http://test.host/#{project.path_with_namespace}/environments/#{environment.id}" }) + namespace_project_environment_url(project.namespace, project, environment) }) end end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 04afd07c59e..a38ae2eb990 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -204,7 +204,7 @@ describe Projects::IssuesController do body = JSON.parse(response.body) expect(body['assignees'].first.keys) - .to match_array(%w(id name username avatar_url)) + .to match_array(%w(id name username avatar_url state web_url)) end end diff --git a/spec/controllers/projects/builds_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index f41503fd34e..838bdae1445 100644 --- a/spec/controllers/projects/builds_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Projects::BuildsController do +describe Projects::JobsController do include ApiHelpers let(:project) { create(:empty_project, :public) } @@ -213,7 +213,7 @@ describe Projects::BuildsController do it 'redirects to the retried build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: Ci::Build.last.id)) + expect(response).to redirect_to(namespace_project_job_path(id: Ci::Build.last.id)) end end @@ -234,7 +234,11 @@ describe Projects::BuildsController do describe 'POST play' do before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) + sign_in(user) post_play @@ -245,7 +249,7 @@ describe Projects::BuildsController do it 'redirects to the played build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: build.id)) + expect(response).to redirect_to(namespace_project_job_path(id: build.id)) end it 'transits to pending' do @@ -281,7 +285,7 @@ describe Projects::BuildsController do it 'redirects to the canceled build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: build.id)) + expect(response).to redirect_to(namespace_project_job_path(id: build.id)) end it 'transits to canceled' do @@ -319,7 +323,7 @@ describe Projects::BuildsController do it 'redirects to a index page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_builds_path) + expect(response).to redirect_to(namespace_project_jobs_path) end it 'transits to canceled' do @@ -336,7 +340,7 @@ describe Projects::BuildsController do it 'redirects to a index page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_builds_path) + expect(response).to redirect_to(namespace_project_jobs_path) end end @@ -359,7 +363,7 @@ describe Projects::BuildsController do it 'redirects to the erased build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: build.id)) + expect(response).to redirect_to(namespace_project_job_path(id: build.id)) end it 'erases artifacts' do diff --git a/spec/features/admin/admin_builds_spec.rb b/spec/features/admin/admin_builds_spec.rb index 9d5ce876c29..999ce3611b5 100644 --- a/spec/features/admin/admin_builds_spec.rb +++ b/spec/features/admin/admin_builds_spec.rb @@ -16,7 +16,7 @@ describe 'Admin Builds' do create(:ci_build, pipeline: pipeline, status: :success) create(:ci_build, pipeline: pipeline, status: :failed) - visit admin_builds_path + visit admin_jobs_path expect(page).to have_selector('.nav-links li.active', text: 'All') expect(page).to have_selector('.row-content-block', text: 'All jobs') @@ -27,7 +27,7 @@ describe 'Admin Builds' do context 'when have no jobs' do it 'shows a message' do - visit admin_builds_path + visit admin_jobs_path expect(page).to have_selector('.nav-links li.active', text: 'All') expect(page).to have_content 'No jobs to show' @@ -44,7 +44,7 @@ describe 'Admin Builds' do build3 = create(:ci_build, pipeline: pipeline, status: :success) build4 = create(:ci_build, pipeline: pipeline, status: :failed) - visit admin_builds_path(scope: :pending) + visit admin_jobs_path(scope: :pending) expect(page).to have_selector('.nav-links li.active', text: 'Pending') expect(page.find('.build-link')).to have_content(build1.id) @@ -59,7 +59,7 @@ describe 'Admin Builds' do it 'shows a message' do create(:ci_build, pipeline: pipeline, status: :success) - visit admin_builds_path(scope: :pending) + visit admin_jobs_path(scope: :pending) expect(page).to have_selector('.nav-links li.active', text: 'Pending') expect(page).to have_content 'No jobs to show' @@ -76,7 +76,7 @@ describe 'Admin Builds' do build3 = create(:ci_build, pipeline: pipeline, status: :failed) build4 = create(:ci_build, pipeline: pipeline, status: :pending) - visit admin_builds_path(scope: :running) + visit admin_jobs_path(scope: :running) expect(page).to have_selector('.nav-links li.active', text: 'Running') expect(page.find('.build-link')).to have_content(build1.id) @@ -91,7 +91,7 @@ describe 'Admin Builds' do it 'shows a message' do create(:ci_build, pipeline: pipeline, status: :success) - visit admin_builds_path(scope: :running) + visit admin_jobs_path(scope: :running) expect(page).to have_selector('.nav-links li.active', text: 'Running') expect(page).to have_content 'No jobs to show' @@ -107,7 +107,7 @@ describe 'Admin Builds' do build2 = create(:ci_build, pipeline: pipeline, status: :running) build3 = create(:ci_build, pipeline: pipeline, status: :success) - visit admin_builds_path(scope: :finished) + visit admin_jobs_path(scope: :finished) expect(page).to have_selector('.nav-links li.active', text: 'Finished') expect(page.find('.build-link')).not_to have_content(build1.id) @@ -121,7 +121,7 @@ describe 'Admin Builds' do it 'shows a message' do create(:ci_build, pipeline: pipeline, status: :running) - visit admin_builds_path(scope: :finished) + visit admin_jobs_path(scope: :finished) expect(page).to have_selector('.nav-links li.active', text: 'Finished') expect(page).to have_content 'No jobs to show' diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb index 6c09903a2f6..e75bf059218 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -38,9 +38,11 @@ feature 'issue move to another project' do end scenario 'moving issue to another project', js: true do - find('#move_to_project_id', visible: false).set(new_project.id) + find('#issuable-move', visible: false).set(new_project.id) click_button('Save changes') + wait_for_requests + expect(current_url).to include project_path(new_project) expect(page).to have_content("Text with #{cross_reference}#{mr.to_reference}") @@ -51,7 +53,7 @@ feature 'issue move to another project' do scenario 'searching project dropdown', js: true do new_project_search.team << [user, :reporter] - page.within '.js-move-dropdown' do + page.within '.detail-page-description' do first('.select2-choice').click end @@ -69,7 +71,7 @@ feature 'issue move to another project' do background { another_project.team << [user, :guest] } scenario 'browsing projects in projects select' do - click_link 'Select project' + click_link 'Move to a different project' page.within '.select2-results' do expect(page).to have_content 'No project' diff --git a/spec/features/merge_requests/mini_pipeline_graph_spec.rb b/spec/features/merge_requests/mini_pipeline_graph_spec.rb index 51e7467c14c..3ceb91d951d 100644 --- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb +++ b/spec/features/merge_requests/mini_pipeline_graph_spec.rb @@ -85,7 +85,7 @@ feature 'Mini Pipeline Graph', :js, :feature do build_item.click find('.build-page') - expect(current_path).to eql(namespace_project_build_path(project.namespace, project, build)) + expect(current_path).to eql(namespace_project_job_path(project.namespace, project, build)) end it 'should show tooltip when hovered' do diff --git a/spec/features/projects/artifacts/browse_spec.rb b/spec/features/projects/artifacts/browse_spec.rb new file mode 100644 index 00000000000..68375956273 --- /dev/null +++ b/spec/features/projects/artifacts/browse_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +feature 'Browse artifact', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } + + def browse_path(path) + browse_namespace_project_job_artifacts_path(project.namespace, project, job, path) + end + + context 'when visiting old URL' do + let(:browse_url) do + browse_path('other_artifacts_0.1.2') + end + + before do + visit browse_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(browse_url) + end + end +end diff --git a/spec/features/projects/artifacts/download_spec.rb b/spec/features/projects/artifacts/download_spec.rb new file mode 100644 index 00000000000..dd9454840ee --- /dev/null +++ b/spec/features/projects/artifacts/download_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +feature 'Download artifact', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project, sha: project.commit.sha, ref: 'master') } + let(:job) { create(:ci_build, :artifacts, :success, pipeline: pipeline) } + + shared_examples 'downloading' do + it 'downloads the zip' do + expect(page.response_headers['Content-Disposition']) + .to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"}) + + # Check the content does match, but don't print this as error message + expect(page.source.b == job.artifacts_file.file.read.b) + end + end + + context 'when downloading' do + before do + visit download_url + end + + context 'via job id' do + let(:download_url) do + download_namespace_project_job_artifacts_path(project.namespace, project, job) + end + + it_behaves_like 'downloading' + end + + context 'via branch name and job name' do + let(:download_url) do + latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{pipeline.ref}/download", job: job.name) + end + + it_behaves_like 'downloading' + end + end + + context 'when visiting old URL' do + before do + visit download_url.sub('/-/jobs', '/builds') + end + + context 'via job id' do + let(:download_url) do + download_namespace_project_job_artifacts_path(project.namespace, project, job) + end + + it_behaves_like 'downloading' + end + + context 'via branch name and job name' do + let(:download_url) do + latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{pipeline.ref}/download", job: job.name) + end + + it_behaves_like 'downloading' + end + end +end diff --git a/spec/features/projects/artifacts/file_spec.rb b/spec/features/projects/artifacts/file_spec.rb index 25db908d917..25c4f3c87a2 100644 --- a/spec/features/projects/artifacts/file_spec.rb +++ b/spec/features/projects/artifacts/file_spec.rb @@ -6,7 +6,11 @@ feature 'Artifact file', :js, feature: true do let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } def visit_file(path) - visit file_namespace_project_build_artifacts_path(project.namespace, project, build, path) + visit file_path(path) + end + + def file_path(path) + file_namespace_project_job_artifacts_path(project.namespace, project, build, path) end context 'Text file' do @@ -56,4 +60,18 @@ feature 'Artifact file', :js, feature: true do end end end + + context 'when visiting old URL' do + let(:file_url) do + file_path('other_artifacts_0.1.2/doc_sample.txt') + end + + before do + visit file_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(file_url) + end + end end diff --git a/spec/features/projects/artifacts/raw_spec.rb b/spec/features/projects/artifacts/raw_spec.rb new file mode 100644 index 00000000000..b589701729d --- /dev/null +++ b/spec/features/projects/artifacts/raw_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +feature 'Raw artifact', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } + + def raw_path(path) + raw_namespace_project_job_artifacts_path(project.namespace, project, job, path) + end + + context 'when visiting old URL' do + let(:raw_url) do + raw_path('other_artifacts_0.1.2/doc_sample.txt') + end + + before do + visit raw_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(raw_url) + end + end +end diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 86ce50c976f..18b608c863e 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -12,6 +12,7 @@ feature 'Environment', :feature do feature 'environment details page' do given!(:environment) { create(:environment, project: project) } + given!(:permissions) { } given!(:deployment) { } given!(:action) { } @@ -62,20 +63,31 @@ feature 'Environment', :feature do name: 'deploy to production') end - given(:role) { :master } + context 'when user has ability to trigger deployment' do + given(:permissions) do + create(:protected_branch, :developers_can_merge, + name: action.ref, project: project) + end - scenario 'does show a play button' do - expect(page).to have_link(action.name.humanize) - end + it 'does show a play button' do + expect(page).to have_link(action.name.humanize) + end + + it 'does allow to play manual action' do + expect(action).to be_manual - scenario 'does allow to play manual action' do - expect(action).to be_manual + expect { click_link(action.name.humanize) } + .not_to change { Ci::Pipeline.count } - expect { click_link(action.name.humanize) } - .not_to change { Ci::Pipeline.count } + expect(page).to have_content(action.name) + expect(action.reload).to be_pending + end + end - expect(page).to have_content(action.name) - expect(action.reload).to be_pending + context 'when user has no ability to trigger a deployment' do + it 'does not show a play button' do + expect(page).not_to have_link(action.name.humanize) + end end context 'with external_url' do @@ -134,12 +146,23 @@ feature 'Environment', :feature do on_stop: 'close_app') end - given(:role) { :master } + context 'when user has ability to stop environment' do + given(:permissions) do + create(:protected_branch, :developers_can_merge, + name: action.ref, project: project) + end - scenario 'does allow to stop environment' do - click_link('Stop') + it 'allows to stop environment' do + click_link('Stop') - expect(page).to have_content('close_app') + expect(page).to have_content('close_app') + end + end + + context 'when user has no ability to stop environment' do + it 'does not allow to stop environment' do + expect(page).to have_no_link('Stop') + end end context 'for reporter' do @@ -150,12 +173,6 @@ feature 'Environment', :feature do end end end - - context 'without stop action' do - scenario 'does allow to stop environment' do - click_link('Stop') - end - end end context 'when environment is stopped' do diff --git a/spec/features/projects/builds_spec.rb b/spec/features/projects/jobs_spec.rb index 8f4dfa7c48b..0eda46649db 100644 --- a/spec/features/projects/builds_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' require 'tempfile' -feature 'Builds', :feature do +feature 'Jobs', :feature do let(:user) { create(:user) } let(:user_access_level) { :developer } let(:project) { create(:project) } @@ -19,12 +19,12 @@ feature 'Builds', :feature do login_as(user) end - describe "GET /:project/builds" do + describe "GET /:project/jobs" do let!(:build) { create(:ci_build, pipeline: pipeline) } context "Pending scope" do before do - visit namespace_project_builds_path(project.namespace, project, scope: :pending) + visit namespace_project_jobs_path(project.namespace, project, scope: :pending) end it "shows Pending tab jobs" do @@ -39,7 +39,7 @@ feature 'Builds', :feature do context "Running scope" do before do build.run! - visit namespace_project_builds_path(project.namespace, project, scope: :running) + visit namespace_project_jobs_path(project.namespace, project, scope: :running) end it "shows Running tab jobs" do @@ -54,7 +54,7 @@ feature 'Builds', :feature do context "Finished scope" do before do build.run! - visit namespace_project_builds_path(project.namespace, project, scope: :finished) + visit namespace_project_jobs_path(project.namespace, project, scope: :finished) end it "shows Finished tab jobs" do @@ -67,7 +67,7 @@ feature 'Builds', :feature do context "All jobs" do before do project.builds.running_or_pending.each(&:success) - visit namespace_project_builds_path(project.namespace, project) + visit namespace_project_jobs_path(project.namespace, project) end it "shows All tab jobs" do @@ -78,12 +78,26 @@ feature 'Builds', :feature do expect(page).not_to have_link 'Cancel running' end end + + context "when visiting old URL" do + let(:jobs_url) do + namespace_project_jobs_path(project.namespace, project) + end + + before do + visit jobs_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(jobs_url) + end + end end - describe "POST /:project/builds/:id/cancel_all" do + describe "POST /:project/jobs/:id/cancel_all" do before do build.run! - visit namespace_project_builds_path(project.namespace, project) + visit namespace_project_jobs_path(project.namespace, project) click_link "Cancel running" end @@ -97,10 +111,10 @@ feature 'Builds', :feature do end end - describe "GET /:project/builds/:id" do + describe "GET /:project/jobs/:id" do context "Job from project" do before do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'shows commit`s data' do @@ -117,7 +131,7 @@ feature 'Builds', :feature do context "Job from other project" do before do - visit namespace_project_build_path(project.namespace, project, build2) + visit namespace_project_job_path(project.namespace, project, build2) end it { expect(page.status_code).to eq(404) } @@ -126,7 +140,7 @@ feature 'Builds', :feature do context "Download artifacts" do before do build.update_attributes(artifacts_file: artifacts_file) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'has button to download artifacts' do @@ -139,7 +153,7 @@ feature 'Builds', :feature do build.update_attributes(artifacts_file: artifacts_file, artifacts_expire_at: expire_at) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end context 'no expire date defined' do @@ -183,10 +197,25 @@ feature 'Builds', :feature do end end + context "when visiting old URL" do + let(:job_url) do + namespace_project_job_path(project.namespace, project, build) + end + + before do + visit job_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(job_url) + end + end + feature 'Raw trace' do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + + visit namespace_project_job_path(project.namespace, project, build) end it do @@ -198,7 +227,7 @@ feature 'Builds', :feature do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end context 'when job has an initial trace' do @@ -222,7 +251,7 @@ feature 'Builds', :feature do end before do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'shows variable key and value after click', js: true do @@ -247,17 +276,17 @@ feature 'Builds', :feature do let(:build) { create(:ci_build, :success, environment: environment.name, deployments: [deployment], pipeline: pipeline) } it 'shows a link for the job' do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) expect(page).to have_link environment.name end end - context 'job is complete and not successfull' do + context 'job is complete and not successful' do let(:build) { create(:ci_build, :failed, environment: environment.name, pipeline: pipeline) } it 'shows a link for the job' do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) expect(page).to have_link environment.name end @@ -268,7 +297,7 @@ feature 'Builds', :feature do let(:build) { create(:ci_build, :success, environment: environment.name, pipeline: pipeline) } it 'shows a link to latest deployment' do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) expect(page).to have_link('latest deployment') end @@ -276,11 +305,11 @@ feature 'Builds', :feature do end end - describe "POST /:project/builds/:id/cancel" do + describe "POST /:project/jobs/:id/cancel" do context "Job from project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link "Cancel" end @@ -294,19 +323,19 @@ feature 'Builds', :feature do context "Job from other project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) - page.driver.post(cancel_namespace_project_build_path(project.namespace, project, build2)) + visit namespace_project_job_path(project.namespace, project, build) + page.driver.post(cancel_namespace_project_job_path(project.namespace, project, build2)) end it { expect(page.status_code).to eq(404) } end end - describe "POST /:project/builds/:id/retry" do + describe "POST /:project/jobs/:id/retry" do context "Job from project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link 'Cancel' page.within('.build-header') do click_link 'Retry job' @@ -322,18 +351,18 @@ feature 'Builds', :feature do end end - context "Build from other project" do + context "Job from other project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link 'Cancel' - page.driver.post(retry_namespace_project_build_path(project.namespace, project, build2)) + page.driver.post(retry_namespace_project_job_path(project.namespace, project, build2)) end it { expect(page).to have_http_status(404) } end - context "Build that current user is not allowed to retry" do + context "Job that current user is not allowed to retry" do before do build.run! build.cancel! @@ -341,7 +370,7 @@ feature 'Builds', :feature do logout_direct login_with(create(:user)) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'does not show the Retry button' do @@ -352,30 +381,30 @@ feature 'Builds', :feature do end end - describe "GET /:project/builds/:id/download" do + describe "GET /:project/jobs/:id/download" do before do build.update_attributes(artifacts_file: artifacts_file) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link 'Download' end context "Build from other project" do before do build2.update_attributes(artifacts_file: artifacts_file) - visit download_namespace_project_build_artifacts_path(project.namespace, project, build2) + visit download_namespace_project_job_artifacts_path(project.namespace, project, build2) end it { expect(page.status_code).to eq(404) } end end - describe 'GET /:project/builds/:id/raw', :js do + describe 'GET /:project/jobs/:id/raw', :js do context 'access source' do - context 'build from project' do + context 'job from project' do before do Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) find('.js-raw-link-controller').click() end @@ -386,11 +415,11 @@ feature 'Builds', :feature do end end - context 'build from other project' do + context 'job from other project' do before do Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } build2.run! - visit raw_namespace_project_build_path(project.namespace, project, build2) + visit raw_namespace_project_job_path(project.namespace, project, build2) end it 'sends the right headers' do @@ -410,7 +439,7 @@ feature 'Builds', :feature do allow_any_instance_of(Gitlab::Ci::Trace).to receive(:paths) .and_return(paths) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end context 'when build has trace in file', :js do @@ -429,7 +458,7 @@ feature 'Builds', :feature do end end - context 'when build has trace in DB' do + context 'when job has trace in DB' do let(:paths) { [] } it 'sends the right headers' do @@ -437,38 +466,52 @@ feature 'Builds', :feature do end end end + + context "when visiting old URL" do + let(:raw_job_url) do + raw_namespace_project_job_path(project.namespace, project, build) + end + + before do + visit raw_job_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(raw_job_url) + end + end end - describe "GET /:project/builds/:id/trace.json" do - context "Build from project" do + describe "GET /:project/jobs/:id/trace.json" do + context "Job from project" do before do - visit trace_namespace_project_build_path(project.namespace, project, build, format: :json) + visit trace_namespace_project_job_path(project.namespace, project, build, format: :json) end it { expect(page.status_code).to eq(200) } end - context "Build from other project" do + context "Job from other project" do before do - visit trace_namespace_project_build_path(project.namespace, project, build2, format: :json) + visit trace_namespace_project_job_path(project.namespace, project, build2, format: :json) end it { expect(page.status_code).to eq(404) } end end - describe "GET /:project/builds/:id/status" do - context "Build from project" do + describe "GET /:project/jobs/:id/status" do + context "Job from project" do before do - visit status_namespace_project_build_path(project.namespace, project, build) + visit status_namespace_project_job_path(project.namespace, project, build) end it { expect(page.status_code).to eq(200) } end - context "Build from other project" do + context "Job from other project" do before do - visit status_namespace_project_build_path(project.namespace, project, build2) + visit status_namespace_project_job_path(project.namespace, project, build2) end it { expect(page.status_code).to eq(404) } diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 78a76d9c112..2a2655bbdb5 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -334,7 +334,7 @@ describe "Internal Project Access", feature: true do end describe "GET /:project_path/builds" do - subject { namespace_project_builds_path(project.namespace, project) } + subject { namespace_project_jobs_path(project.namespace, project) } context "when allowed for public and internal" do before { project.update(public_builds: true) } @@ -368,7 +368,7 @@ describe "Internal Project Access", feature: true do describe "GET /:project_path/builds/:id" do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { namespace_project_build_path(project.namespace, project, build.id) } + subject { namespace_project_job_path(project.namespace, project, build.id) } context "when allowed for public and internal" do before { project.update(public_builds: true) } @@ -402,7 +402,7 @@ describe "Internal Project Access", feature: true do describe 'GET /:project_path/builds/:id/trace' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { trace_namespace_project_build_path(project.namespace, project, build.id) } + subject { trace_namespace_project_job_path(project.namespace, project, build.id) } context 'when allowed for public and internal' do before do diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index a66f6e09055..b676c236758 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -330,7 +330,7 @@ describe "Private Project Access", feature: true do end describe "GET /:project_path/builds" do - subject { namespace_project_builds_path(project.namespace, project) } + subject { namespace_project_jobs_path(project.namespace, project) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } @@ -358,7 +358,7 @@ describe "Private Project Access", feature: true do describe "GET /:project_path/builds/:id" do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { namespace_project_build_path(project.namespace, project, build.id) } + subject { namespace_project_job_path(project.namespace, project, build.id) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } @@ -391,7 +391,7 @@ describe "Private Project Access", feature: true do describe 'GET /:project_path/builds/:id/trace' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { trace_namespace_project_build_path(project.namespace, project, build.id) } + subject { trace_namespace_project_job_path(project.namespace, project, build.id) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index 5cd575500c3..35d5163941e 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -154,7 +154,7 @@ describe "Public Project Access", feature: true do end describe "GET /:project_path/builds" do - subject { namespace_project_builds_path(project.namespace, project) } + subject { namespace_project_jobs_path(project.namespace, project) } context "when allowed for public" do before { project.update(public_builds: true) } @@ -188,7 +188,7 @@ describe "Public Project Access", feature: true do describe "GET /:project_path/builds/:id" do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { namespace_project_build_path(project.namespace, project, build.id) } + subject { namespace_project_job_path(project.namespace, project, build.id) } context "when allowed for public" do before { project.update(public_builds: true) } @@ -222,7 +222,7 @@ describe "Public Project Access", feature: true do describe 'GET /:project_path/builds/:id/trace' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { trace_namespace_project_build_path(project.namespace, project, build.id) } + subject { trace_namespace_project_job_path(project.namespace, project, build.id) } context 'when allowed for public' do before do diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index 148adcffe3b..03d98459e8c 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -137,6 +137,13 @@ describe ProjectsFinder do it { is_expected.to eq([public_project]) } end + describe 'filter by owned' do + let(:params) { { owned: true } } + let!(:owned_project) { create(:empty_project, :private, namespace: current_user.namespace) } + + it { is_expected.to eq([owned_project]) } + end + describe 'filter by non_public' do let(:params) { { non_public: true } } before do @@ -146,13 +153,19 @@ describe ProjectsFinder do it { is_expected.to eq([private_project]) } end - describe 'filter by viewable_starred_projects' do + describe 'filter by starred' do let(:params) { { starred: true } } before do current_user.toggle_star(public_project) end it { is_expected.to eq([public_project]) } + + it 'returns only projects the user has access to' do + current_user.toggle_star(private_project) + + is_expected.to eq([public_project]) + end end describe 'sorting' do diff --git a/spec/fixtures/api/schemas/pipeline_schedule.json b/spec/fixtures/api/schemas/pipeline_schedule.json new file mode 100644 index 00000000000..f6346bd0fb6 --- /dev/null +++ b/spec/fixtures/api/schemas/pipeline_schedule.json @@ -0,0 +1,41 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "description": { "type": "string" }, + "ref": { "type": "string" }, + "cron": { "type": "string" }, + "cron_timezone": { "type": "string" }, + "next_run_at": { "type": "date" }, + "active": { "type": "boolean" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "last_pipeline": { + "type": ["object", "null"], + "properties": { + "id": { "type": "integer" }, + "sha": { "type": "string" }, + "ref": { "type": "string" }, + "status": { "type": "string" } + }, + "additionalProperties": false + }, + "owner": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + } + }, + "required": [ + "id", "description", "ref", "cron", "cron_timezone", "next_run_at", + "active", "created_at", "updated_at", "owner" + ], + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/pipeline_schedules.json b/spec/fixtures/api/schemas/pipeline_schedules.json new file mode 100644 index 00000000000..173a28d2505 --- /dev/null +++ b/spec/fixtures/api/schemas/pipeline_schedules.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "pipeline_schedule.json" } +} diff --git a/spec/javascripts/build_spec.js b/spec/javascripts/build_spec.js index 278bd1f9179..461908f3fde 100644 --- a/spec/javascripts/build_spec.js +++ b/spec/javascripts/build_spec.js @@ -8,7 +8,7 @@ import '~/breakpoints'; import 'vendor/jquery.nicescroll'; describe('Build', () => { - const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/builds/1`; + const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1`; preloadFixtures('builds/build-with-artifacts.html.raw'); diff --git a/spec/javascripts/fixtures/builds.rb b/spec/javascripts/fixtures/jobs.rb index 320de791b08..dc7dde1138c 100644 --- a/spec/javascripts/fixtures/builds.rb +++ b/spec/javascripts/fixtures/jobs.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Projects::BuildsController, '(JavaScript fixtures)', type: :controller do +describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers let(:admin) { create(:admin) } diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index ee456869c53..0030a953119 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import '~/render_math'; import '~/render_gfm'; import issuableApp from '~/issue_show/components/app.vue'; +import eventHub from '~/issue_show/event_hub'; import issueShowData from '../mock_data'; const issueShowInterceptor = data => (request, next) => { @@ -22,14 +23,25 @@ describe('Issuable output', () => { const IssuableDescriptionComponent = Vue.extend(issuableApp); Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + spyOn(eventHub, '$emit'); + vm = new IssuableDescriptionComponent({ propsData: { canUpdate: true, + canDestroy: true, + canMove: true, endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes', issuableRef: '#1', - initialTitle: '', + initialTitleHtml: '', + initialTitleText: '', initialDescriptionHtml: '', initialDescriptionText: '', + markdownPreviewUrl: '/', + markdownDocs: '/', + projectsAutocompleteUrl: '/', + isConfidential: false, + projectNamespace: '/', + projectPath: '/', }, }).$mount(); }); @@ -57,4 +69,296 @@ describe('Issuable output', () => { }); }); }); + + it('shows actions if permissions are correct', (done) => { + vm.showForm = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn'), + ).not.toBeNull(); + + done(); + }); + }); + + it('does not show actions if permissions are incorrect', (done) => { + vm.showForm = true; + vm.canUpdate = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn'), + ).toBeNull(); + + done(); + }); + }); + + it('does not update formState if form is already open', (done) => { + vm.openForm(); + + vm.state.titleText = 'testing 123'; + + vm.openForm(); + + Vue.nextTick(() => { + expect( + vm.store.formState.title, + ).not.toBe('testing 123'); + + done(); + }); + }); + + describe('updateIssuable', () => { + it('fetches new data after update', (done) => { + spyOn(vm.service, 'getData'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + confidential: false, + web_url: location.pathname, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + vm.service.getData, + ).toHaveBeenCalled(); + + done(); + }); + }); + + it('reloads the page if the confidential status has changed', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + confidential: true, + web_url: location.pathname, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).toHaveBeenCalledWith(location.pathname); + + done(); + }); + }); + + it('correctly updates issuable data', (done) => { + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve(); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + vm.service.updateIssuable, + ).toHaveBeenCalledWith(vm.formState); + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + + done(); + }); + }); + + it('does not redirect if issue has not moved', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + web_url: location.pathname, + confidential: vm.isConfidential, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).not.toHaveBeenCalled(); + + done(); + }); + }); + + it('redirects if issue is moved', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + web_url: '/testing-issue-move', + confidential: vm.isConfidential, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).toHaveBeenCalledWith('/testing-issue-move'); + + done(); + }); + }); + + it('does not update issuable if project move confirm is false', (done) => { + spyOn(window, 'confirm').and.returnValue(false); + spyOn(vm.service, 'updateIssuable'); + + vm.store.formState.move_to_project_id = 1; + + vm.updateIssuable(); + + setTimeout(() => { + expect( + vm.service.updateIssuable, + ).not.toHaveBeenCalled(); + + done(); + }); + }); + + it('closes form on error', (done) => { + spyOn(window, 'Flash').and.callThrough(); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve, reject) => { + reject(); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + expect( + window.Flash, + ).toHaveBeenCalledWith('Error updating issue'); + + done(); + }); + }); + }); + + describe('deleteIssuable', () => { + it('changes URL when deleted', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { web_url: '/test' }; + }, + }); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).toHaveBeenCalledWith('/test'); + + done(); + }); + }); + + it('stops polling when deleting', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.poll, 'stop'); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { web_url: '/test' }; + }, + }); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + vm.poll.stop, + ).toHaveBeenCalledWith(); + + done(); + }); + }); + + it('closes form on error', (done) => { + spyOn(window, 'Flash').and.callThrough(); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve, reject) => { + reject(); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + expect( + window.Flash, + ).toHaveBeenCalledWith('Error deleting issue'); + + done(); + }); + }); + }); + + describe('open form', () => { + it('shows locked warning if form is open & data is different', (done) => { + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + + Vue.nextTick() + .then(() => new Promise((resolve) => { + setTimeout(resolve); + })) + .then(() => { + vm.openForm(); + + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); + + return new Promise((resolve) => { + setTimeout(resolve); + }); + }) + .then(() => { + expect( + vm.formState.lockedWarningVisible, + ).toBeTruthy(); + + expect( + vm.$el.querySelector('.alert'), + ).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/issue_show/components/edit_actions_spec.js b/spec/javascripts/issue_show/components/edit_actions_spec.js new file mode 100644 index 00000000000..f6625b748b6 --- /dev/null +++ b/spec/javascripts/issue_show/components/edit_actions_spec.js @@ -0,0 +1,147 @@ +import Vue from 'vue'; +import editActions from '~/issue_show/components/edit_actions.vue'; +import eventHub from '~/issue_show/event_hub'; +import Store from '~/issue_show/stores'; + +describe('Edit Actions components', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(editActions); + const store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.title = 'test'; + + spyOn(eventHub, '$emit'); + + vm = new Component({ + propsData: { + canDestroy: true, + formState: store.formState, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders all buttons as enabled', () => { + expect( + vm.$el.querySelectorAll('.disabled').length, + ).toBe(0); + + expect( + vm.$el.querySelectorAll('[disabled]').length, + ).toBe(0); + }); + + it('does not render delete button if canUpdate is false', (done) => { + vm.canDestroy = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-danger'), + ).toBeNull(); + + done(); + }); + }); + + it('disables submit button when title is blank', (done) => { + vm.formState.title = ''; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-save').getAttribute('disabled'), + ).toBe('disabled'); + + done(); + }); + }); + + describe('updateIssuable', () => { + it('sends update.issauble event when clicking save button', () => { + vm.$el.querySelector('.btn-save').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('update.issuable'); + }); + + it('shows loading icon after clicking save button', (done) => { + vm.$el.querySelector('.btn-save').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-save .fa'), + ).not.toBeNull(); + + done(); + }); + }); + + it('disabled button after clicking save button', (done) => { + vm.$el.querySelector('.btn-save').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-save').getAttribute('disabled'), + ).toBe('disabled'); + + done(); + }); + }); + }); + + describe('closeForm', () => { + it('emits close.form when clicking cancel', () => { + vm.$el.querySelector('.btn-default').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + }); + }); + + describe('deleteIssuable', () => { + it('sends delete.issuable event when clicking save button', () => { + spyOn(window, 'confirm').and.returnValue(true); + vm.$el.querySelector('.btn-danger').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('delete.issuable'); + }); + + it('shows loading icon after clicking delete button', (done) => { + spyOn(window, 'confirm').and.returnValue(true); + vm.$el.querySelector('.btn-danger').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-danger .fa'), + ).not.toBeNull(); + + done(); + }); + }); + + it('does no actions when confirm is false', (done) => { + spyOn(window, 'confirm').and.returnValue(false); + vm.$el.querySelector('.btn-danger').click(); + + Vue.nextTick(() => { + expect( + eventHub.$emit, + ).not.toHaveBeenCalledWith('delete.issuable'); + expect( + vm.$el.querySelector('.btn-danger .fa'), + ).toBeNull(); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/description_spec.js b/spec/javascripts/issue_show/components/fields/description_spec.js new file mode 100644 index 00000000000..f5b35b1e8b0 --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/description_spec.js @@ -0,0 +1,56 @@ +import Vue from 'vue'; +import Store from '~/issue_show/stores'; +import descriptionField from '~/issue_show/components/fields/description.vue'; + +describe('Description field component', () => { + let vm; + let store; + + beforeEach((done) => { + const Component = Vue.extend(descriptionField); + const el = document.createElement('div'); + store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.description = 'test'; + + document.body.appendChild(el); + + vm = new Component({ + el, + propsData: { + markdownPreviewUrl: '/', + markdownDocs: '/', + formState: store.formState, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders markdown field with description', () => { + expect( + vm.$el.querySelector('.md-area textarea').value, + ).toBe('test'); + }); + + it('renders markdown field with a markdown description', (done) => { + store.formState.description = '**test**'; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.md-area textarea').value, + ).toBe('**test**'); + + done(); + }); + }); + + it('focuses field when mounted', () => { + expect( + document.activeElement, + ).toBe(vm.$refs.textarea); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/description_template_spec.js b/spec/javascripts/issue_show/components/fields/description_template_spec.js new file mode 100644 index 00000000000..2b7ee65094b --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/description_template_spec.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import descriptionTemplate from '~/issue_show/components/fields/description_template.vue'; +import '~/templates/issuable_template_selector'; +import '~/templates/issuable_template_selectors'; + +describe('Issue description template component', () => { + let vm; + let formState; + + beforeEach((done) => { + const Component = Vue.extend(descriptionTemplate); + formState = { + description: 'test', + }; + + vm = new Component({ + propsData: { + formState, + issuableTemplates: [{ name: 'test' }], + projectPath: '/', + projectNamespace: '/', + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders templates as JSON array in data attribute', () => { + expect( + vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data'), + ).toBe('[{"name":"test"}]'); + }); + + it('updates formState when changing template', () => { + vm.issuableTemplate.editor.setValue('test new template'); + + expect( + formState.description, + ).toBe('test new template'); + }); + + it('returns formState description with editor getValue', () => { + formState.description = 'testing new template'; + + expect( + vm.issuableTemplate.editor.getValue(), + ).toBe('testing new template'); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/project_move_spec.js b/spec/javascripts/issue_show/components/fields/project_move_spec.js new file mode 100644 index 00000000000..86d35c33ff4 --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/project_move_spec.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import projectMove from '~/issue_show/components/fields/project_move.vue'; + +describe('Project move field component', () => { + let vm; + let formState; + + beforeEach((done) => { + const Component = Vue.extend(projectMove); + + formState = { + move_to_project_id: 0, + }; + + vm = new Component({ + propsData: { + formState, + projectsAutocompleteUrl: '/autocomplete', + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('mounts select2 element', () => { + expect( + vm.$el.querySelector('.select2-container'), + ).not.toBeNull(); + }); + + it('updates formState on change', () => { + $(vm.$refs['move-dropdown']).val(2).trigger('change'); + + expect( + formState.move_to_project_id, + ).toBe(2); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/title_spec.js b/spec/javascripts/issue_show/components/fields/title_spec.js new file mode 100644 index 00000000000..53ae038a6a2 --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/title_spec.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import Store from '~/issue_show/stores'; +import titleField from '~/issue_show/components/fields/title.vue'; + +describe('Title field component', () => { + let vm; + let store; + + beforeEach(() => { + const Component = Vue.extend(titleField); + store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.title = 'test'; + + vm = new Component({ + propsData: { + formState: store.formState, + }, + }).$mount(); + }); + + it('renders form control with formState title', () => { + expect( + vm.$el.querySelector('.form-control').value, + ).toBe('test'); + }); +}); diff --git a/spec/javascripts/issue_show/components/form_spec.js b/spec/javascripts/issue_show/components/form_spec.js new file mode 100644 index 00000000000..9a85223208c --- /dev/null +++ b/spec/javascripts/issue_show/components/form_spec.js @@ -0,0 +1,68 @@ +import Vue from 'vue'; +import formComponent from '~/issue_show/components/form.vue'; +import '~/templates/issuable_template_selector'; +import '~/templates/issuable_template_selectors'; + +describe('Inline edit form component', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(formComponent); + + vm = new Component({ + propsData: { + canDestroy: true, + canMove: true, + formState: { + title: 'b', + description: 'a', + lockedWarningVisible: false, + }, + markdownPreviewUrl: '/', + markdownDocs: '/', + projectsAutocompleteUrl: '/', + projectPath: '/', + projectNamespace: '/', + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('does not render template selector if no templates exist', () => { + expect( + vm.$el.querySelector('.js-issuable-selector-wrap'), + ).toBeNull(); + }); + + it('renders template selector when templates exists', (done) => { + spyOn(gl, 'IssuableTemplateSelectors'); + vm.issuableTemplates = ['test']; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.js-issuable-selector-wrap'), + ).not.toBeNull(); + + done(); + }); + }); + + it('hides locked warning by default', () => { + expect( + vm.$el.querySelector('.alert'), + ).toBeNull(); + }); + + it('shows locked warning if formState is different', (done) => { + vm.formState.lockedWarningVisible = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.alert'), + ).not.toBeNull(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/title_spec.js b/spec/javascripts/issue_show/components/title_spec.js index 2f953e7e92e..a2d90a9b9f5 100644 --- a/spec/javascripts/issue_show/components/title_spec.js +++ b/spec/javascripts/issue_show/components/title_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import Store from '~/issue_show/stores'; import titleComponent from '~/issue_show/components/title.vue'; describe('Title component', () => { @@ -6,11 +7,18 @@ describe('Title component', () => { beforeEach(() => { const Component = Vue.extend(titleComponent); + const store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); vm = new Component({ propsData: { issuableRef: '#1', titleHtml: 'Testing <img />', titleText: 'Testing', + showForm: false, + formState: store.formState, }, }).$mount(); }); diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index e9bffd74d90..e3938a77680 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -356,7 +356,7 @@ import '~/lib/utils/common_utils'; describe('gl.utils.setCiStatusFavicon', () => { it('should set page favicon to CI status favicon based on provided status', () => { - const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/builds/1/status.json`; + const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1/status.json`; const FAVICON_PATH = '//icon_status_success'; const spySetFavicon = spyOn(gl.utils, 'setFavicon').and.stub(); const spyResetFavicon = spyOn(gl.utils, 'resetFavicon').and.stub(); diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js index 1173fa40947..f444bcaf847 100644 --- a/spec/javascripts/merge_request_spec.js +++ b/spec/javascripts/merge_request_spec.js @@ -13,7 +13,9 @@ import '~/merge_request'; }); it('modifies the Markdown field', function() { spyOn(jQuery, 'ajax').and.stub(); - $('input[type=checkbox]').attr('checked', true).trigger('change'); + const changeEvent = document.createEvent('HTMLEvents'); + changeEvent.initEvent('change', true, true); + $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent); return expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); }); return it('submits an ajax request on tasklist:changed', function() { diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 04cf0fe2bf8..17aa70ff3f1 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -34,7 +34,9 @@ import '~/notes'; }); it('modifies the Markdown field', function() { - $('input[type=checkbox]').attr('checked', true).trigger('change'); + const changeEvent = document.createEvent('HTMLEvents'); + changeEvent.initEvent('change', true, true); + $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent); expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); }); diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js index f033956c071..85bd87318db 100644 --- a/spec/javascripts/pipelines/graph/action_component_spec.js +++ b/spec/javascripts/pipelines/graph/action_component_spec.js @@ -4,7 +4,7 @@ import actionComponent from '~/pipelines/components/graph/action_component.vue'; describe('pipeline graph action component', () => { let component; - beforeEach(() => { + beforeEach((done) => { const ActionComponent = Vue.extend(actionComponent); component = new ActionComponent({ propsData: { @@ -14,6 +14,8 @@ describe('pipeline graph action component', () => { actionIcon: 'icon_action_cancel', }, }).$mount(); + + Vue.nextTick(done); }); it('should render a link', () => { @@ -27,7 +29,7 @@ describe('pipeline graph action component', () => { it('should update bootstrap tooltip when title changes', (done) => { component.tooltipText = 'changed'; - Vue.nextTick(() => { + setTimeout(() => { expect(component.$el.getAttribute('data-original-title')).toBe('changed'); done(); }); diff --git a/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js index 14ff1b0d25c..25fd18b197e 100644 --- a/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js +++ b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js @@ -4,7 +4,7 @@ import dropdownActionComponent from '~/pipelines/components/graph/dropdown_actio describe('action component', () => { let component; - beforeEach(() => { + beforeEach((done) => { const DropdownActionComponent = Vue.extend(dropdownActionComponent); component = new DropdownActionComponent({ propsData: { @@ -14,6 +14,8 @@ describe('action component', () => { actionIcon: 'icon_action_cancel', }, }).$mount(); + + Vue.nextTick(done); }); it('should render a link', () => { diff --git a/spec/javascripts/pipelines/graph/job_component_spec.js b/spec/javascripts/pipelines/graph/job_component_spec.js index 63986b6c0db..e90593e0f40 100644 --- a/spec/javascripts/pipelines/graph/job_component_spec.js +++ b/spec/javascripts/pipelines/graph/job_component_spec.js @@ -27,26 +27,30 @@ describe('pipeline graph job component', () => { }); describe('name with link', () => { - it('should render the job name and status with a link', () => { + it('should render the job name and status with a link', (done) => { const component = new JobComponent({ propsData: { job: mockJob, }, }).$mount(); - const link = component.$el.querySelector('a'); + Vue.nextTick(() => { + const link = component.$el.querySelector('a'); - expect(link.getAttribute('href')).toEqual(mockJob.status.details_path); + expect(link.getAttribute('href')).toEqual(mockJob.status.details_path); - expect( - link.getAttribute('data-original-title'), - ).toEqual(`${mockJob.name} - ${mockJob.status.label}`); + expect( + link.getAttribute('data-original-title'), + ).toEqual(`${mockJob.name} - ${mockJob.status.label}`); - expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined(); + expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined(); - expect( - component.$el.querySelector('.ci-status-text').textContent.trim(), - ).toEqual(mockJob.name); + expect( + component.$el.querySelector('.ci-status-text').textContent.trim(), + ).toEqual(mockJob.name); + + done(); + }); }); }); diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js new file mode 100644 index 00000000000..4bbaff561fc --- /dev/null +++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js @@ -0,0 +1,121 @@ +import Vue from 'vue'; +import fieldComponent from '~/vue_shared/components/markdown/field.vue'; + +describe('Markdown field component', () => { + let vm; + + beforeEach(() => { + vm = new Vue({ + render(createElement) { + return createElement( + fieldComponent, + { + props: { + markdownPreviewUrl: '/preview', + markdownDocs: '/docs', + }, + }, + [ + createElement('textarea', { + slot: 'textarea', + }), + ], + ); + }, + }); + }); + + it('creates a new instance of GL form', (done) => { + spyOn(gl, 'GLForm'); + vm.$mount(); + + Vue.nextTick(() => { + expect( + gl.GLForm, + ).toHaveBeenCalled(); + + done(); + }); + }); + + describe('mounted', () => { + beforeEach((done) => { + vm.$mount(); + + Vue.nextTick(done); + }); + + it('renders textarea inside backdrop', () => { + expect( + vm.$el.querySelector('.zen-backdrop textarea'), + ).not.toBeNull(); + }); + + describe('markdown preview', () => { + let previewLink; + + beforeEach(() => { + spyOn(Vue.http, 'post').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + body: '<p>markdown preview</p>', + }; + }, + }); + })); + + previewLink = vm.$el.querySelector('.nav-links li:nth-child(2) a'); + }); + + it('sets preview link as active', (done) => { + previewLink.click(); + + Vue.nextTick(() => { + expect( + previewLink.parentNode.classList.contains('active'), + ).toBeTruthy(); + + done(); + }); + }); + + it('shows preview loading text', (done) => { + previewLink.click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.md-preview').textContent.trim(), + ).toContain('Loading...'); + + done(); + }); + }); + + it('renders markdown preview', (done) => { + previewLink.click(); + + setTimeout(() => { + expect( + vm.$el.querySelector('.md-preview').innerHTML, + ).toContain('<p>markdown preview</p>'); + + done(); + }); + }); + + it('renders GFM with jQuery', (done) => { + spyOn($.fn, 'renderGFM'); + previewLink.click(); + + setTimeout(() => { + expect( + $.fn.renderGFM, + ).toHaveBeenCalled(); + + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/markdown/header_spec.js b/spec/javascripts/vue_shared/components/markdown/header_spec.js new file mode 100644 index 00000000000..7110ff36937 --- /dev/null +++ b/spec/javascripts/vue_shared/components/markdown/header_spec.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import headerComponent from '~/vue_shared/components/markdown/header.vue'; + +describe('Markdown field header component', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(headerComponent); + + vm = new Component({ + propsData: { + previewMarkdown: false, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders markdown buttons', () => { + expect( + vm.$el.querySelectorAll('.js-md').length, + ).toBe(7); + }); + + it('renders `write` link as active when previewMarkdown is false', () => { + expect( + vm.$el.querySelector('li:nth-child(1)').classList.contains('active'), + ).toBeTruthy(); + }); + + it('renders `preview` link as active when previewMarkdown is true', (done) => { + vm.previewMarkdown = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('li:nth-child(2)').classList.contains('active'), + ).toBeTruthy(); + + done(); + }); + }); + + it('emits toggle markdown event when clicking preview', () => { + spyOn(vm, '$emit'); + + vm.$el.querySelector('li:nth-child(2) a').click(); + + expect( + vm.$emit, + ).toHaveBeenCalledWith('toggle-markdown'); + }); + + it('blurs preview link after click', (done) => { + const link = vm.$el.querySelector('li:nth-child(2) a'); + spyOn(HTMLElement.prototype, 'blur'); + + link.click(); + + setTimeout(() => { + expect( + link.blur, + ).toHaveBeenCalled(); + + done(); + }); + }); +}); diff --git a/spec/lib/gitlab/chat_commands/command_spec.rb b/spec/lib/gitlab/chat_commands/command_spec.rb index eb4f06b371c..13e6953147b 100644 --- a/spec/lib/gitlab/chat_commands/command_spec.rb +++ b/spec/lib/gitlab/chat_commands/command_spec.rb @@ -58,9 +58,12 @@ describe Gitlab::ChatCommands::Command, service: true do end end - context 'and user does have deployment permission' do + context 'and user has deployment permission' do before do - build.project.add_master(user) + build.project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'returns action' do diff --git a/spec/lib/gitlab/chat_commands/deploy_spec.rb b/spec/lib/gitlab/chat_commands/deploy_spec.rb index b33389d959e..46dbdeae37c 100644 --- a/spec/lib/gitlab/chat_commands/deploy_spec.rb +++ b/spec/lib/gitlab/chat_commands/deploy_spec.rb @@ -7,7 +7,12 @@ describe Gitlab::ChatCommands::Deploy, service: true do let(:regex_match) { described_class.match('deploy staging to production') } before do - project.add_master(user) + # Make it possible to trigger protected manual actions for developers. + # + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end subject do diff --git a/spec/lib/gitlab/ci/status/build/common_spec.rb b/spec/lib/gitlab/ci/status/build/common_spec.rb index 40b96b1807b..72bd7c4eb93 100644 --- a/spec/lib/gitlab/ci/status/build/common_spec.rb +++ b/spec/lib/gitlab/ci/status/build/common_spec.rb @@ -31,7 +31,7 @@ describe Gitlab::Ci::Status::Build::Common do describe '#details_path' do it 'links to the build details page' do - expect(subject.details_path).to include "builds/#{build.id}" + expect(subject.details_path).to include "jobs/#{build.id}" end end end diff --git a/spec/lib/gitlab/ci/status/build/factory_spec.rb b/spec/lib/gitlab/ci/status/build/factory_spec.rb index 185bb9098da..3f30b2c38f2 100644 --- a/spec/lib/gitlab/ci/status/build/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb @@ -224,7 +224,10 @@ describe Gitlab::Ci::Status::Build::Factory do context 'when user has ability to play action' do before do - build.project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'fabricates status that has action' do diff --git a/spec/lib/gitlab/ci/status/build/play_spec.rb b/spec/lib/gitlab/ci/status/build/play_spec.rb index f5d0f977768..0e15a5f3c6b 100644 --- a/spec/lib/gitlab/ci/status/build/play_spec.rb +++ b/spec/lib/gitlab/ci/status/build/play_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Gitlab::Ci::Status::Build::Play do let(:user) { create(:user) } + let(:project) { build.project } let(:build) { create(:ci_build, :manual) } let(:status) { Gitlab::Ci::Status::Core.new(build, user) } @@ -15,8 +16,13 @@ describe Gitlab::Ci::Status::Build::Play do describe '#has_action?' do context 'when user is allowed to update build' do - context 'when user can push to branch' do - before { build.project.add_master(user) } + context 'when user is allowed to trigger protected action' do + before do + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) + end it { is_expected.to have_action } end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index fdbb55fc874..b1999409170 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -244,7 +244,7 @@ describe Gitlab::Workhorse, lib: true do context "when git_receive_pack action is passed" do let(:action) { 'git_receive_pack' } - it { expect(subject).not_to include(gitaly_params) } + it { expect(subject).to include(gitaly_params) } end context "when info_refs action is passed" do diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 12519de8636..9fbe19b04d5 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -227,7 +227,10 @@ describe Environment, models: true do context 'when user is allowed to stop environment' do before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end context 'when action did not yet finish' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index a953faaaedf..360fcae29a5 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -948,6 +948,20 @@ describe Project, models: true do end end + describe '.starred_by' do + it 'returns only projects starred by the given user' do + user1 = create(:user) + user2 = create(:user) + project1 = create(:empty_project) + project2 = create(:empty_project) + create(:empty_project) + user1.toggle_star(project1) + user2.toggle_star(project2) + + expect(Project.starred_by(user1)).to contain_exactly(project1) + end + end + describe '.visible_to_user' do let!(:project) { create(:empty_project, :private) } let!(:user) { create(:user) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 9edf34b05ad..fe9df3360ff 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1496,25 +1496,6 @@ describe User, models: true do end end - describe '#viewable_starred_projects' do - let(:user) { create(:user) } - let(:public_project) { create(:empty_project, :public) } - let(:private_project) { create(:empty_project, :private) } - let(:private_viewable_project) { create(:empty_project, :private) } - - before do - private_viewable_project.team << [user, Gitlab::Access::MASTER] - - [public_project, private_project, private_viewable_project].each do |project| - user.toggle_star(project) - end - end - - it 'returns only starred projects the user can view' do - expect(user.viewable_starred_projects).not_to include(private_project) - end - end - describe '#projects_with_reporter_access_limited_to' do let(:project1) { create(:empty_project) } let(:project2) { create(:empty_project) } diff --git a/spec/requests/api/pipeline_schedules_spec.rb b/spec/requests/api/pipeline_schedules_spec.rb new file mode 100644 index 00000000000..85d11deb26f --- /dev/null +++ b/spec/requests/api/pipeline_schedules_spec.rb @@ -0,0 +1,297 @@ +require 'spec_helper' + +describe API::PipelineSchedules do + set(:developer) { create(:user) } + set(:user) { create(:user) } + set(:project) { create(:project) } + + before do + project.add_developer(developer) + end + + describe 'GET /projects/:id/pipeline_schedules' do + context 'authenticated user with valid permissions' do + let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer) } + + before do + pipeline_schedule.pipelines << build(:ci_pipeline, project: project) + end + + it 'returns list of pipeline_schedules' do + get api("/projects/#{project.id}/pipeline_schedules", developer) + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(response).to match_response_schema('pipeline_schedules') + end + + it 'avoids N + 1 queries' do + control_count = ActiveRecord::QueryRecorder.new do + get api("/projects/#{project.id}/pipeline_schedules", developer) + end.count + + create_list(:ci_pipeline_schedule, 10, project: project) + .each do |pipeline_schedule| + create(:user).tap do |user| + project.add_developer(user) + pipeline_schedule.update_attributes(owner: user) + end + pipeline_schedule.pipelines << build(:ci_pipeline, project: project) + end + + expect do + get api("/projects/#{project.id}/pipeline_schedules", developer) + end.not_to exceed_query_limit(control_count) + end + + %w[active inactive].each do |target| + context "when scope is #{target}" do + before do + create(:ci_pipeline_schedule, project: project, active: active?(target)) + end + + it 'returns matched pipeline schedules' do + get api("/projects/#{project.id}/pipeline_schedules", developer), scope: target + + expect(json_response.map{ |r| r['active'] }).to all(eq(active?(target))) + end + end + + def active?(str) + (str == 'active') ? true : false + end + end + end + + context 'authenticated user with invalid permissions' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /projects/:id/pipeline_schedules/:pipeline_schedule_id' do + let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer) } + + before do + pipeline_schedule.pipelines << build(:ci_pipeline, project: project) + end + + context 'authenticated user with valid permissions' do + it 'returns pipeline_schedule details' do + get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer) + + expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('pipeline_schedule') + end + + it 'responds with 404 Not Found if requesting non-existing pipeline_schedule' do + get api("/projects/#{project.id}/pipeline_schedules/-5", developer) + + expect(response).to have_http_status(:not_found) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /projects/:id/pipeline_schedules' do + let(:params) { attributes_for(:ci_pipeline_schedule) } + + context 'authenticated user with valid permissions' do + context 'with required parameters' do + it 'creates pipeline_schedule' do + expect do + post api("/projects/#{project.id}/pipeline_schedules", developer), + params + end.to change { project.pipeline_schedules.count }.by(1) + + expect(response).to have_http_status(:created) + expect(response).to match_response_schema('pipeline_schedule') + expect(json_response['description']).to eq(params[:description]) + expect(json_response['ref']).to eq(params[:ref]) + expect(json_response['cron']).to eq(params[:cron]) + expect(json_response['cron_timezone']).to eq(params[:cron_timezone]) + expect(json_response['owner']['id']).to eq(developer.id) + end + end + + context 'without required parameters' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules", developer) + + expect(response).to have_http_status(:bad_request) + end + end + + context 'when cron has validation error' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules", developer), + params.merge('cron' => 'invalid-cron') + + expect(response).to have_http_status(:bad_request) + expect(json_response['message']).to have_key('cron') + end + end + end + + context 'authenticated user with invalid permissions' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules", user), params + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules"), params + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id' do + let(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + context 'authenticated user with valid permissions' do + it 'updates cron' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer), + cron: '1 2 3 4 *' + + expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('pipeline_schedule') + expect(json_response['cron']).to eq('1 2 3 4 *') + end + + context 'when cron has validation error' do + it 'does not update pipeline_schedule' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer), + cron: 'invalid-cron' + + expect(response).to have_http_status(:bad_request) + expect(json_response['message']).to have_key('cron') + end + end + end + + context 'authenticated user with invalid permissions' do + it 'does not update pipeline_schedule' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not update pipeline_schedule' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/take_ownership' do + let(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + context 'authenticated user with valid permissions' do + it 'updates owner' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership", developer) + + expect(response).to have_http_status(:created) + expect(response).to match_response_schema('pipeline_schedule') + end + end + + context 'authenticated user with invalid permissions' do + it 'does not update owner' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not update owner' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE /projects/:id/pipeline_schedules/:pipeline_schedule_id' do + let(:master) { create(:user) } + + let!(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + before do + project.add_master(master) + end + + context 'authenticated user with valid permissions' do + it 'deletes pipeline_schedule' do + expect do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", master) + end.to change { project.pipeline_schedules.count }.by(-1) + + expect(response).to have_http_status(:accepted) + expect(response).to match_response_schema('pipeline_schedule') + end + + it 'responds with 404 Not Found if requesting non-existing pipeline_schedule' do + delete api("/projects/#{project.id}/pipeline_schedules/-5", master) + + expect(response).to have_http_status(:not_found) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not delete pipeline_schedule' do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer) + + expect(response).to have_http_status(:forbidden) + end + end + + context 'unauthenticated user' do + it 'does not delete pipeline_schedule' do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}") + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index f95a287a184..3d98551628b 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -390,6 +390,14 @@ describe API::Projects do expect(json_response['visibility']).to eq('private') end + it 'sets tag list to a project' do + project = attributes_for(:project, tag_list: %w[tagFirst tagSecond]) + + post api('/projects', user), project + + expect(json_response['tag_list']).to eq(%w[tagFirst tagSecond]) + end + it 'sets a project as allowing merge even if build fails' do project = attributes_for(:project, { only_allow_merge_if_pipeline_succeeds: false }) post api('/projects', user), project diff --git a/spec/serializers/build_action_entity_spec.rb b/spec/serializers/build_action_entity_spec.rb index 059deba5416..15720d86583 100644 --- a/spec/serializers/build_action_entity_spec.rb +++ b/spec/serializers/build_action_entity_spec.rb @@ -1,26 +1,26 @@ require 'spec_helper' describe BuildActionEntity do - let(:build) { create(:ci_build, name: 'test_build') } + let(:job) { create(:ci_build, name: 'test_job') } let(:request) { double('request') } let(:entity) do - described_class.new(build, request: spy('request')) + described_class.new(job, request: spy('request')) end describe '#as_json' do subject { entity.as_json } - it 'contains original build name' do - expect(subject[:name]).to eq 'test_build' + it 'contains original job name' do + expect(subject[:name]).to eq 'test_job' end it 'contains path to the action play' do - expect(subject[:path]).to include "builds/#{build.id}/play" + expect(subject[:path]).to include "jobs/#{job.id}/play" end it 'contains whether it is playable' do - expect(subject[:playable]).to eq build.playable? + expect(subject[:playable]).to eq job.playable? end end end diff --git a/spec/serializers/build_artifact_entity_spec.rb b/spec/serializers/build_artifact_entity_spec.rb index 2fc60aa9de6..b4eef20d6a6 100644 --- a/spec/serializers/build_artifact_entity_spec.rb +++ b/spec/serializers/build_artifact_entity_spec.rb @@ -1,22 +1,22 @@ require 'spec_helper' describe BuildArtifactEntity do - let(:build) { create(:ci_build, name: 'test:build') } + let(:job) { create(:ci_build, name: 'test:job') } let(:entity) do - described_class.new(build, request: double) + described_class.new(job, request: double) end describe '#as_json' do subject { entity.as_json } - it 'contains build name' do - expect(subject[:name]).to eq 'test:build' + it 'contains job name' do + expect(subject[:name]).to eq 'test:job' end it 'contains path to the artifacts' do expect(subject[:path]) - .to include "builds/#{build.id}/artifacts/download" + .to include "jobs/#{job.id}/artifacts/download" end end end diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb index b5eb84ae43b..6d5e1046e86 100644 --- a/spec/serializers/build_entity_spec.rb +++ b/spec/serializers/build_entity_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe BuildEntity do let(:user) { create(:user) } let(:build) { create(:ci_build) } + let(:project) { build.project } let(:request) { double('request') } before do @@ -52,7 +53,10 @@ describe BuildEntity do context 'when user is allowed to trigger action' do before do - build.project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end it 'contains path to play action' do diff --git a/spec/services/ci/play_build_service_spec.rb b/spec/services/ci/play_build_service_spec.rb index d6f9fa42045..ea211de1f82 100644 --- a/spec/services/ci/play_build_service_spec.rb +++ b/spec/services/ci/play_build_service_spec.rb @@ -13,8 +13,11 @@ describe Ci::PlayBuildService, '#execute', :services do context 'when project does not have repository yet' do let(:project) { create(:empty_project) } - it 'allows user with master role to play build' do - project.add_master(user) + it 'allows user to play build if protected branch rules are met' do + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) service.execute(build) @@ -45,7 +48,10 @@ describe Ci::PlayBuildService, '#execute', :services do let(:build) { create(:ci_build, :manual, pipeline: pipeline) } before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'enqueues the build' do @@ -64,7 +70,10 @@ describe Ci::PlayBuildService, '#execute', :services do let(:build) { create(:ci_build, when: :manual, pipeline: pipeline) } before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'duplicates the build' do diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index fc5de5d069a..1557cb3c938 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -333,10 +333,11 @@ describe Ci::ProcessPipelineService, '#execute', :services do context 'when pipeline is promoted sequentially up to the end' do before do - # We are using create(:empty_project), and users has to be master in - # order to execute manual action when repository does not exist. + # Users need ability to merge into a branch in order to trigger + # protected manual actions. # - project.add_master(user) + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end it 'properly processes entire pipeline' do diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb index d941d56c0d8..3e860203063 100644 --- a/spec/services/ci/retry_pipeline_service_spec.rb +++ b/spec/services/ci/retry_pipeline_service_spec.rb @@ -6,9 +6,12 @@ describe Ci::RetryPipelineService, '#execute', :services do let(:pipeline) { create(:ci_pipeline, project: project) } let(:service) { described_class.new(project, user) } - context 'when user has ability to modify pipeline' do + context 'when user has full ability to modify pipeline' do before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: pipeline.ref, project: project) end context 'when there are already retried jobs present' do diff --git a/spec/views/ci/status/_badge.html.haml_spec.rb b/spec/views/ci/status/_badge.html.haml_spec.rb index c62450fb8e2..72323da2838 100644 --- a/spec/views/ci/status/_badge.html.haml_spec.rb +++ b/spec/views/ci/status/_badge.html.haml_spec.rb @@ -16,7 +16,7 @@ describe 'ci/status/_badge', :view do end it 'has link to build details page' do - details_path = namespace_project_build_path( + details_path = namespace_project_job_path( project.namespace, project, build) render_status(build) diff --git a/spec/views/projects/builds/_build.html.haml_spec.rb b/spec/views/projects/jobs/_build.html.haml_spec.rb index 751482cac42..1d58891036e 100644 --- a/spec/views/projects/builds/_build.html.haml_spec.rb +++ b/spec/views/projects/jobs/_build.html.haml_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'projects/ci/builds/_build' do +describe 'projects/ci/jobs/_build' do include Devise::Test::ControllerHelpers let(:project) { create(:project, :repository) } diff --git a/spec/views/projects/builds/_generic_commit_status.html.haml_spec.rb b/spec/views/projects/jobs/_generic_commit_status.html.haml_spec.rb index dc2ffc9dc47..dc2ffc9dc47 100644 --- a/spec/views/projects/builds/_generic_commit_status.html.haml_spec.rb +++ b/spec/views/projects/jobs/_generic_commit_status.html.haml_spec.rb diff --git a/spec/views/projects/builds/show.html.haml_spec.rb b/spec/views/projects/jobs/show.html.haml_spec.rb index 0f39df0f250..8f2822f5dc5 100644 --- a/spec/views/projects/builds/show.html.haml_spec.rb +++ b/spec/views/projects/jobs/show.html.haml_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'projects/builds/show', :view do +describe 'projects/jobs/show', :view do let(:project) { create(:project, :repository) } let(:build) { create(:ci_build, pipeline: pipeline) } @@ -278,7 +278,7 @@ describe 'projects/builds/show', :view do it 'links to issues/new with the title and description filled in' do title = "Build Failed ##{build.id}" - build_url = namespace_project_build_url(project.namespace, project, build) + build_url = namespace_project_job_url(project.namespace, project, build) href = new_namespace_project_issue_path( project.namespace, project, diff --git a/vendor/assets/javascripts/task_list.js b/vendor/assets/javascripts/task_list.js deleted file mode 100644 index 9fbfef03f6d..00000000000 --- a/vendor/assets/javascripts/task_list.js +++ /dev/null @@ -1,258 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2014 GitHub, Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// TaskList Behavior -// -/*= provides tasklist:enabled */ -/*= provides tasklist:disabled */ -/*= provides tasklist:change */ -/*= provides tasklist:changed */ -// -// -// Enables Task List update behavior. -// -// ### Example Markup -// -// <div class="js-task-list-container"> -// <ul class="task-list"> -// <li class="task-list-item"> -// <input type="checkbox" class="js-task-list-item-checkbox" disabled /> -// text -// </li> -// </ul> -// <form> -// <textarea class="js-task-list-field">- [ ] text</textarea> -// </form> -// </div> -// -// ### Specification -// -// TaskLists MUST be contained in a `(div).js-task-list-container`. -// -// TaskList Items SHOULD be an a list (`UL`/`OL`) element. -// -// Task list items MUST match `(input).task-list-item-checkbox` and MUST be -// `disabled` by default. -// -// TaskLists MUST have a `(textarea).js-task-list-field` form element whose -// `value` attribute is the source (Markdown) to be udpated. The source MUST -// follow the syntax guidelines. -// -// TaskList updates trigger `tasklist:change` events. If the change is -// successful, `tasklist:changed` is fired. The change can be canceled. -// -// jQuery is required. -// -// ### Methods -// -// `.taskList('enable')` or `.taskList()` -// -// Enables TaskList updates for the container. -// -// `.taskList('disable')` -// -// Disables TaskList updates for the container. -// -//# ### Events -// -// `tasklist:enabled` -// -// Fired when the TaskList is enabled. -// -// * **Synchronicity** Sync -// * **Bubbles** Yes -// * **Cancelable** No -// * **Target** `.js-task-list-container` -// -// `tasklist:disabled` -// -// Fired when the TaskList is disabled. -// -// * **Synchronicity** Sync -// * **Bubbles** Yes -// * **Cancelable** No -// * **Target** `.js-task-list-container` -// -// `tasklist:change` -// -// Fired before the TaskList item change takes affect. -// -// * **Synchronicity** Sync -// * **Bubbles** Yes -// * **Cancelable** Yes -// * **Target** `.js-task-list-field` -// -// `tasklist:changed` -// -// Fired once the TaskList item change has taken affect. -// -// * **Synchronicity** Sync -// * **Bubbles** Yes -// * **Cancelable** No -// * **Target** `.js-task-list-field` -// -// ### NOTE -// -// Task list checkboxes are rendered as disabled by default because rendered -// user content is cached without regard for the viewer. -(function() { - var codeFencesPattern, complete, completePattern, disableTaskList, disableTaskLists, enableTaskList, enableTaskLists, escapePattern, incomplete, incompletePattern, itemPattern, itemsInParasPattern, updateTaskList, updateTaskListItem, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; - - incomplete = "[ ]"; - - complete = "[x]"; - - // Escapes the String for regular expression matching. - escapePattern = function(str) { - return str.replace(/([\[\]])/g, "\\$1").replace(/\s/, "\\s").replace("x", "[xX]"); - }; - - incompletePattern = RegExp("" + (escapePattern(incomplete))); // escape square brackets - // match all white space - completePattern = RegExp("" + (escapePattern(complete))); // match all cases - - // Pattern used to identify all task list items. - // Useful when you need iterate over all items. - itemPattern = RegExp("^(?:\\s*(?:>\\s*)*(?:[-+*]|(?:\\d+\\.)))\\s*(" + (escapePattern(complete)) + "|" + (escapePattern(incomplete)) + ")\\s+(?!\\(.*?\\))(?=(?:\\[.*?\\]\\s*(?:\\[.*?\\]|\\(.*?\\))\\s*)*(?:[^\\[]|$))"); - - // prefix, consisting of - // optional leading whitespace - // zero or more blockquotes - // list item indicator - // optional whitespace prefix - // checkbox - // is followed by whitespace - // is not part of a [foo](url) link - // and is followed by zero or more links - // and either a non-link or the end of the string - // Used to filter out code fences from the source for comparison only. - // http://rubular.com/r/x5EwZVrloI - // Modified slightly due to issues with JS - codeFencesPattern = /^`{3}(?:\s*\w+)?[\S\s].*[\S\s]^`{3}$/mg; - - // ``` - // followed by optional language - // whitespace - // code - // whitespace - // ``` - // Used to filter out potential mismatches (items not in lists). - // http://rubular.com/r/OInl6CiePy - itemsInParasPattern = RegExp("^(" + (escapePattern(complete)) + "|" + (escapePattern(incomplete)) + ").+$", "g"); - - // Given the source text, updates the appropriate task list item to match the - // given checked value. - // - // Returns the updated String text. - updateTaskListItem = function(source, itemIndex, checked) { - var clean, index, line, result; - clean = source.replace(/\r/g, '').replace(codeFencesPattern, '').replace(itemsInParasPattern, '').split("\n"); - index = 0; - result = (function() { - var i, len, ref, results; - ref = source.split("\n"); - results = []; - for (i = 0, len = ref.length; i < len; i++) { - line = ref[i]; - if (indexOf.call(clean, line) >= 0 && line.match(itemPattern)) { - index += 1; - if (index === itemIndex) { - line = checked ? line.replace(incompletePattern, complete) : line.replace(completePattern, incomplete); - } - } - results.push(line); - } - return results; - })(); - return result.join("\n"); - }; - - // Updates the $field value to reflect the state of $item. - // Triggers the `tasklist:change` event before the value has changed, and fires - // a `tasklist:changed` event once the value has changed. - updateTaskList = function($item) { - var $container, $field, checked, event, index; - $container = $item.closest('.js-task-list-container'); - $field = $container.find('.js-task-list-field'); - index = 1 + $container.find('.task-list-item-checkbox').index($item); - checked = $item.prop('checked'); - event = $.Event('tasklist:change'); - $field.trigger(event, [index, checked]); - if (!event.isDefaultPrevented()) { - $field.val(updateTaskListItem($field.val(), index, checked)); - $field.trigger('change'); - return $field.trigger('tasklist:changed', [index, checked]); - } - }; - - // When the task list item checkbox is updated, submit the change - $(document).on('change', '.task-list-item-checkbox', function() { - return updateTaskList($(this)); - }); - - // Enables TaskList item changes. - enableTaskList = function($container) { - if ($container.find('.js-task-list-field').length > 0) { - $container.find('.task-list-item').addClass('enabled').find('.task-list-item-checkbox').attr('disabled', null); - return $container.addClass('is-task-list-enabled').trigger('tasklist:enabled'); - } - }; - - // Enables a collection of TaskList containers. - enableTaskLists = function($containers) { - var container, i, len, results; - results = []; - for (i = 0, len = $containers.length; i < len; i++) { - container = $containers[i]; - results.push(enableTaskList($(container))); - } - return results; - }; - - // Disable TaskList item changes. - disableTaskList = function($container) { - $container.find('.task-list-item').removeClass('enabled').find('.task-list-item-checkbox').attr('disabled', 'disabled'); - return $container.removeClass('is-task-list-enabled').trigger('tasklist:disabled'); - }; - - // Disables a collection of TaskList containers. - disableTaskLists = function($containers) { - var container, i, len, results; - results = []; - for (i = 0, len = $containers.length; i < len; i++) { - container = $containers[i]; - results.push(disableTaskList($(container))); - } - return results; - }; - - $.fn.taskList = function(method) { - var $container, methods; - $container = $(this).closest('.js-task-list-container'); - methods = { - enable: enableTaskLists, - disable: disableTaskLists - }; - return methods[method || 'enable']($container); - }; - -}).call(this); diff --git a/yarn.lock b/yarn.lock index 8221711960d..1db64aead8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1570,6 +1570,10 @@ decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" +deckar01-task_list@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.0.0.tgz#7f7a595430d21b3036ed5dfbf97d6b65de18e2c9" + deep-extend@~0.4.0: version "0.4.1" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.1.tgz#efe4113d08085f4e6f9687759810f807469e2253" |