summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/api.js3
-rw-r--r--app/assets/javascripts/commons/index.js1
-rw-r--r--app/assets/javascripts/commons/vue.js (renamed from app/assets/javascripts/vue_shared/common_vue.js)1
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js8
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js4
-rw-r--r--app/assets/javascripts/main.js6
-rw-r--r--app/assets/javascripts/notes.js8
-rw-r--r--app/assets/javascripts/projects_dropdown/components/app.vue157
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue57
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_item.vue96
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_search.vue63
-rw-r--r--app/assets/javascripts/projects_dropdown/components/search.vue64
-rw-r--r--app/assets/javascripts/projects_dropdown/constants.js10
-rw-r--r--app/assets/javascripts/projects_dropdown/event_hub.js3
-rw-r--r--app/assets/javascripts/projects_dropdown/index.js68
-rw-r--r--app/assets/javascripts/projects_dropdown/service/projects_service.js132
-rw-r--r--app/assets/javascripts/projects_dropdown/store/projects_store.js33
-rw-r--r--app/assets/javascripts/vue_shared/components/identicon.vue8
-rw-r--r--app/assets/stylesheets/framework/common.scss1
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss149
-rw-r--r--app/assets/stylesheets/framework/selects.scss1
-rw-r--r--app/assets/stylesheets/pages/issuable.scss2
-rw-r--r--app/models/commit.rb2
-rw-r--r--app/models/gpg_key.rb23
-rw-r--r--app/models/gpg_signature.rb14
-rw-r--r--app/models/repository.rb28
-rw-r--r--app/models/user.rb4
-rw-r--r--app/views/layouts/nav/_new_dashboard.html.haml12
-rw-r--r--app/views/layouts/nav/projects_dropdown/_show.html.haml15
-rw-r--r--app/views/projects/commit/_invalid_signature_badge.html.haml9
-rw-r--r--app/views/projects/commit/_other_user_signature_badge.html.haml6
-rw-r--r--app/views/projects/commit/_same_user_different_email_signature_badge.html.haml7
-rw-r--r--app/views/projects/commit/_signature.html.haml5
-rw-r--r--app/views/projects/commit/_signature_badge.html.haml20
-rw-r--r--app/views/projects/commit/_signature_badge_user.html.haml21
-rw-r--r--app/views/projects/commit/_unknown_key_signature_badge.html.haml1
-rw-r--r--app/views/projects/commit/_unverified_key_signature_badge.html.haml1
-rw-r--r--app/views/projects/commit/_unverified_signature_badge.html.haml6
-rw-r--r--app/views/projects/commit/_valid_signature_badge.html.haml32
-rw-r--r--app/views/projects/commit/_verified_signature_badge.html.haml7
-rw-r--r--app/views/projects/notes/_actions.html.haml2
-rw-r--r--app/views/shared/icons/_icon_resolve_discussion.svg1
-rwxr-xr-xapp/views/shared/icons/_icon_status_success.svg2
-rw-r--r--app/workers/create_gpg_signature_worker.rb6
-rw-r--r--changelogs/unreleased/35010-projects-nav-dropdown.yml5
-rw-r--r--changelogs/unreleased/37406-success-status-icon.yml5
-rw-r--r--changelogs/unreleased/api_branches_head.yml5
-rw-r--r--changelogs/unreleased/dont-remove-add-diff-btn-on-post.yml5
-rw-r--r--changelogs/unreleased/feature-gpg-verification-status.yml6
-rw-r--r--config/webpack.config.js2
-rw-r--r--db/migrate/20170817123339_add_verification_status_to_gpg_signatures.rb20
-rw-r--r--db/post_migrate/20170830084744_destroy_gpg_signatures.rb10
-rw-r--r--db/post_migrate/20170831195038_remove_valid_signature_from_gpg_signatures.rb11
-rw-r--r--db/schema.rb4
-rw-r--r--doc/README.md1
-rw-r--r--doc/administration/integration/koding.md6
-rw-r--r--doc/ci/environments.md4
-rw-r--r--doc/ci/runners/README.md19
-rw-r--r--doc/integration/README.md1
-rw-r--r--doc/user/project/index.md2
-rw-r--r--doc/user/project/koding.md5
-rw-r--r--doc/user/project/repository/gpg_signed_commits/img/project_signed_and_unsigned_commits.pngbin41193 -> 113801 bytes
-rw-r--r--doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_unverified_signature.pngbin9542 -> 12924 bytes
-rw-r--r--doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_verified_signature.pngbin14029 -> 20652 bytes
-rw-r--r--doc/user/project/repository/gpg_signed_commits/index.md3
-rw-r--r--lib/api/branches.rb25
-rw-r--r--lib/gitlab/git/repository.rb43
-rw-r--r--lib/gitlab/gpg.rb2
-rw-r--r--lib/gitlab/gpg/commit.rb34
-rw-r--r--lib/gitlab/gpg/invalid_gpg_signature_updater.rb2
-rw-r--r--lib/tasks/gettext.rake3
-rw-r--r--locale/gitlab.pot34
-rw-r--r--spec/factories/gpg_signature.rb2
-rw-r--r--spec/features/commits_spec.rb101
-rw-r--r--spec/features/merge_requests/user_posts_diff_notes_spec.rb10
-rw-r--r--spec/features/profiles/gpg_keys_spec.rb4
-rw-r--r--spec/features/signed_commits_spec.rb179
-rw-r--r--spec/javascripts/api_spec.js6
-rw-r--r--spec/javascripts/project_title_spec.js3
-rw-r--r--spec/javascripts/projects_dropdown/components/app_spec.js348
-rw-r--r--spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js72
-rw-r--r--spec/javascripts/projects_dropdown/components/projects_list_item_spec.js65
-rw-r--r--spec/javascripts/projects_dropdown/components/projects_list_search_spec.js84
-rw-r--r--spec/javascripts/projects_dropdown/components/search_spec.js101
-rw-r--r--spec/javascripts/projects_dropdown/mock_data.js96
-rw-r--r--spec/javascripts/projects_dropdown/service/projects_service_spec.js178
-rw-r--r--spec/javascripts/projects_dropdown/store/projects_store_spec.js41
-rw-r--r--spec/javascripts/vue_shared/components/identicon_spec.js28
-rw-r--r--spec/lib/gitlab/gpg/commit_spec.rb232
-rw-r--r--spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb43
-rw-r--r--spec/lib/gitlab/gpg_spec.rb15
-rw-r--r--spec/models/gpg_key_spec.rb38
-rw-r--r--spec/models/user_spec.rb14
-rw-r--r--spec/requests/api/branches_spec.rb16
-rw-r--r--spec/support/test_env.rb2
-rw-r--r--spec/workers/create_gpg_signature_worker_spec.rb9
96 files changed, 2697 insertions, 346 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 78cb3def879..8acddd6194c 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -5,7 +5,7 @@ const Api = {
groupPath: '/api/:version/groups/:id.json',
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
- projectsPath: '/api/:version/projects.json?simple=true',
+ projectsPath: '/api/:version/projects.json',
labelsPath: '/:namespace_path/:project_path/labels',
licensePath: '/api/:version/templates/licenses/:key',
gitignorePath: '/api/:version/templates/gitignores/:key',
@@ -58,6 +58,7 @@ const Api = {
const defaults = {
search: query,
per_page: 20,
+ simple: true,
};
if (gon.current_user_id) {
diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js
index 6db8b3afbef..768453b28f1 100644
--- a/app/assets/javascripts/commons/index.js
+++ b/app/assets/javascripts/commons/index.js
@@ -2,3 +2,4 @@ import 'underscore';
import './polyfills';
import './jquery';
import './bootstrap';
+import './vue';
diff --git a/app/assets/javascripts/vue_shared/common_vue.js b/app/assets/javascripts/commons/vue.js
index eb2a6071fda..8b62d78c043 100644
--- a/app/assets/javascripts/vue_shared/common_vue.js
+++ b/app/assets/javascripts/commons/vue.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import './vue_resource_interceptor';
if (process.env.NODE_ENV !== 'production') {
Vue.config.productionTip = false;
diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
index c37249c060a..06ce84d7599 100644
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
@@ -21,11 +21,13 @@ const DiffNoteAvatars = Vue.extend({
},
template: `
<div class="diff-comment-avatar-holders"
+ :class="discussionClassName"
v-show="notesCount !== 0">
<div v-if="!isVisible">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image
v-for="note in notesSubset"
+ :key="note.id"
class="diff-comment-avatar js-diff-comment-avatar"
@click.native="clickedAvatar($event)"
:img-src="note.authorAvatar"
@@ -68,7 +70,8 @@ const DiffNoteAvatars = Vue.extend({
});
});
},
- destroyed() {
+ beforeDestroy() {
+ this.addNoCommentClass();
$(document).off('toggle.comments');
},
watch: {
@@ -85,6 +88,9 @@ const DiffNoteAvatars = Vue.extend({
},
},
computed: {
+ discussionClassName() {
+ return `js-diff-avatars-${this.discussionId}`;
+ },
notesSubset() {
let notes = [];
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
index 5decfc1dc01..0863c3406bd 100644
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js
+++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
@@ -32,6 +32,10 @@ $(() => {
const tmpApp = new tmp().$mount();
$(this).replaceWith(tmpApp.$el);
+ $(tmpApp.$el).one('remove.vue', () => {
+ tmpApp.$destroy();
+ tmpApp.$el.remove();
+ });
});
const $components = $(COMPONENT_SELECTOR).filter(function () {
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 69a6e131b59..f14458c8d41 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -132,6 +132,7 @@ import './project_new';
import './project_select';
import './project_show';
import './project_variables';
+import './projects_dropdown';
import './projects_list';
import './syntax_highlight';
import './render_math';
@@ -249,7 +250,10 @@ $(function () {
// Initialize popovers
$body.popover({
selector: '[data-toggle="popover"]',
- trigger: 'focus'
+ trigger: 'focus',
+ // set the viewport to the main content, excluding the navigation bar, so
+ // the navigation can't overlap the popover
+ viewport: '.page-with-sidebar'
});
$('.trigger-submit').on('change', function () {
return $(this).parents('form').submit();
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index b38a6abc8d1..a09270d6d24 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -464,7 +464,6 @@ export default class Notes {
}
renderDiscussionAvatar(diffAvatarContainer, noteEntity) {
- var commentButton = diffAvatarContainer.find('.js-add-diff-note-button');
var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
if (!avatarHolder.length) {
@@ -475,10 +474,6 @@ export default class Notes {
gl.diffNotesCompileComponents();
}
-
- if (commentButton.length) {
- commentButton.remove();
- }
}
/**
@@ -767,6 +762,7 @@ export default class Notes {
var $note, $notes;
$note = $(el);
$notes = $note.closest('.discussion-notes');
+ const discussionId = $('.notes', $notes).data('discussion-id');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) {
@@ -783,6 +779,8 @@ export default class Notes {
// "Discussions" tab
$notes.closest('.timeline-entry').remove();
+ $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
+
// The notes tr can contain multiple lists of notes, like on the parallel diff
if (notesTr.find('.discussion-notes').length > 1) {
$notes.remove();
diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue
new file mode 100644
index 00000000000..7606605be32
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/app.vue
@@ -0,0 +1,157 @@
+<script>
+import bs from '../../breakpoints';
+import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+import projectsListFrequent from './projects_list_frequent.vue';
+import projectsListSearch from './projects_list_search.vue';
+
+import search from './search.vue';
+
+export default {
+ components: {
+ search,
+ loadingIcon,
+ projectsListFrequent,
+ projectsListSearch,
+ },
+ props: {
+ currentProject: {
+ type: Object,
+ required: true,
+ },
+ store: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoadingProjects: false,
+ isFrequentsListVisible: false,
+ isSearchListVisible: false,
+ isLocalStorageFailed: false,
+ isSearchFailed: false,
+ searchQuery: '',
+ };
+ },
+ computed: {
+ frequentProjects() {
+ return this.store.getFrequentProjects();
+ },
+ searchProjects() {
+ return this.store.getSearchedProjects();
+ },
+ },
+ methods: {
+ toggleFrequentProjectsList(state) {
+ this.isLoadingProjects = !state;
+ this.isSearchListVisible = !state;
+ this.isFrequentsListVisible = state;
+ },
+ toggleSearchProjectsList(state) {
+ this.isLoadingProjects = !state;
+ this.isFrequentsListVisible = !state;
+ this.isSearchListVisible = state;
+ },
+ toggleLoader(state) {
+ this.isFrequentsListVisible = !state;
+ this.isSearchListVisible = !state;
+ this.isLoadingProjects = state;
+ },
+ fetchFrequentProjects() {
+ const screenSize = bs.getBreakpointSize();
+ if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) {
+ this.toggleSearchProjectsList(true);
+ } else {
+ this.toggleLoader(true);
+ this.isLocalStorageFailed = false;
+ const projects = this.service.getFrequentProjects();
+ if (projects) {
+ this.toggleFrequentProjectsList(true);
+ this.store.setFrequentProjects(projects);
+ } else {
+ this.isLocalStorageFailed = true;
+ this.toggleFrequentProjectsList(true);
+ this.store.setFrequentProjects([]);
+ }
+ }
+ },
+ fetchSearchedProjects(searchQuery) {
+ this.searchQuery = searchQuery;
+ this.toggleLoader(true);
+ this.service.getSearchedProjects(this.searchQuery)
+ .then(res => res.json())
+ .then((results) => {
+ this.toggleSearchProjectsList(true);
+ this.store.setSearchedProjects(results);
+ })
+ .catch(() => {
+ this.isSearchFailed = true;
+ this.toggleSearchProjectsList(true);
+ });
+ },
+ logCurrentProjectAccess() {
+ this.service.logProjectAccess(this.currentProject);
+ },
+ handleSearchClear() {
+ this.searchQuery = '';
+ this.toggleFrequentProjectsList(true);
+ this.store.clearSearchedProjects();
+ },
+ handleSearchFailure() {
+ this.isSearchFailed = true;
+ this.toggleSearchProjectsList(true);
+ },
+ },
+ created() {
+ if (this.currentProject.id) {
+ this.logCurrentProjectAccess();
+ }
+
+ eventHub.$on('dropdownOpen', this.fetchFrequentProjects);
+ eventHub.$on('searchProjects', this.fetchSearchedProjects);
+ eventHub.$on('searchCleared', this.handleSearchClear);
+ eventHub.$on('searchFailed', this.handleSearchFailure);
+ },
+ beforeDestroy() {
+ eventHub.$off('dropdownOpen', this.fetchFrequentProjects);
+ eventHub.$off('searchProjects', this.fetchSearchedProjects);
+ eventHub.$off('searchCleared', this.handleSearchClear);
+ eventHub.$off('searchFailed', this.handleSearchFailure);
+ },
+};
+</script>
+
+<template>
+ <div>
+ <search/>
+ <loading-icon
+ class="loading-animation prepend-top-20"
+ size="2"
+ v-if="isLoadingProjects"
+ :label="s__('ProjectsDropdown|Loading projects')"
+ />
+ <div
+ class="section-header"
+ v-if="isFrequentsListVisible"
+ >
+ {{ s__('ProjectsDropdown|Frequently visited') }}
+ </div>
+ <projects-list-frequent
+ v-if="isFrequentsListVisible"
+ :local-storage-failed="isLocalStorageFailed"
+ :projects="frequentProjects"
+ />
+ <projects-list-search
+ v-if="isSearchListVisible"
+ :search-failed="isSearchFailed"
+ :matcher="searchQuery"
+ :projects="searchProjects"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
new file mode 100644
index 00000000000..093554cd0bc
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
@@ -0,0 +1,57 @@
+<script>
+import { s__ } from '../../locale';
+import projectsListItem from './projects_list_item.vue';
+
+export default {
+ components: {
+ projectsListItem,
+ },
+ props: {
+ projects: {
+ type: Array,
+ required: true,
+ },
+ localStorageFailed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ isListEmpty() {
+ return this.projects.length === 0;
+ },
+ listEmptyMessage() {
+ return this.localStorageFailed ?
+ s__('ProjectsDropdown|This feature requires browser localStorage support') :
+ s__('ProjectsDropdown|Projects you visit often will appear here');
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="projects-list-frequent-container"
+ >
+ <ul
+ class="list-unstyled"
+ >
+ <li
+ class="section-empty"
+ v-if="isListEmpty"
+ >
+ {{listEmptyMessage}}
+ </li>
+ <projects-list-item
+ v-else
+ v-for="(project, index) in projects"
+ :key="index"
+ :project-id="project.id"
+ :project-name="project.name"
+ :namespace="project.namespace"
+ :web-url="project.webUrl"
+ :avatar-url="project.avatarUrl"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
new file mode 100644
index 00000000000..fe5179de206
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
@@ -0,0 +1,96 @@
+<script>
+import identicon from '../../vue_shared/components/identicon.vue';
+
+export default {
+ components: {
+ identicon,
+ },
+ props: {
+ matcher: {
+ type: String,
+ required: false,
+ },
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ projectName: {
+ type: String,
+ required: true,
+ },
+ namespace: {
+ type: String,
+ required: true,
+ },
+ webUrl: {
+ type: String,
+ required: true,
+ },
+ avatarUrl: {
+ required: true,
+ validator(value) {
+ return value === null || typeof value === 'string';
+ },
+ },
+ },
+ computed: {
+ hasAvatar() {
+ return this.avatarUrl !== null;
+ },
+ highlightedProjectName() {
+ if (this.matcher) {
+ const matcherRegEx = new RegExp(this.matcher, 'gi');
+ const matches = this.projectName.match(matcherRegEx);
+
+ if (matches && matches.length > 0) {
+ return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`);
+ }
+ }
+ return this.projectName;
+ },
+ },
+};
+</script>
+
+<template>
+ <li
+ class="projects-list-item-container"
+ >
+ <a
+ class="clearfix"
+ :href="webUrl"
+ >
+ <div
+ class="project-item-avatar-container"
+ >
+ <img
+ v-if="hasAvatar"
+ class="avatar s32"
+ :src="avatarUrl"
+ />
+ <identicon
+ v-else
+ size-class="s32"
+ :entity-id=projectId
+ :entity-name="projectName"
+ />
+ </div>
+ <div
+ class="project-item-metadata-container"
+ >
+ <div
+ class="project-title"
+ :title="projectName"
+ v-html="highlightedProjectName"
+ >
+ </div>
+ <div
+ class="project-namespace"
+ :title="namespace"
+ >
+ {{namespace}}
+ </div>
+ </div>
+ </a>
+ </li>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue
new file mode 100644
index 00000000000..fa5efef2919
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue
@@ -0,0 +1,63 @@
+<script>
+import { s__ } from '../../locale';
+import projectsListItem from './projects_list_item.vue';
+
+export default {
+ components: {
+ projectsListItem,
+ },
+ props: {
+ matcher: {
+ type: String,
+ required: true,
+ },
+ projects: {
+ type: Array,
+ required: true,
+ },
+ searchFailed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ isListEmpty() {
+ return this.projects.length === 0;
+ },
+ listEmptyMessage() {
+ return this.searchFailed ?
+ s__('ProjectsDropdown|Something went wrong on our end.') :
+ s__('ProjectsDropdown|No projects matched your query');
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="projects-list-search-container"
+ >
+ <ul
+ class="list-unstyled"
+ >
+ <li
+ v-if="isListEmpty"
+ :class="{ 'section-failure': searchFailed }"
+ class="section-empty"
+ >
+ {{ listEmptyMessage }}
+ </li>
+ <projects-list-item
+ v-else
+ v-for="(project, index) in projects"
+ :key="index"
+ :project-id="project.id"
+ :project-name="project.name"
+ :namespace="project.namespace"
+ :web-url="project.webUrl"
+ :avatar-url="project.avatarUrl"
+ :matcher="matcher"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue
new file mode 100644
index 00000000000..b71997234e5
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/search.vue
@@ -0,0 +1,64 @@
+<script>
+import _ from 'underscore';
+import eventHub from '../event_hub';
+
+export default {
+ data() {
+ return {
+ searchQuery: '',
+ };
+ },
+ watch: {
+ searchQuery() {
+ this.handleInput();
+ },
+ },
+ methods: {
+ setFocus() {
+ this.$refs.search.focus();
+ },
+ emitSearchEvents() {
+ if (this.searchQuery) {
+ eventHub.$emit('searchProjects', this.searchQuery);
+ } else {
+ eventHub.$emit('searchCleared');
+ }
+ },
+ /**
+ * Callback function within _.debounce is intentionally
+ * kept as ES5 `function() {}` instead of ES6 `() => {}`
+ * as it otherwise messes up function context
+ * and component reference is no longer accessible via `this`
+ */
+ // eslint-disable-next-line func-names
+ handleInput: _.debounce(function () {
+ this.emitSearchEvents();
+ }, 500),
+ },
+ mounted() {
+ eventHub.$on('dropdownOpen', this.setFocus);
+ },
+ beforeDestroy() {
+ eventHub.$off('dropdownOpen', this.setFocus);
+ },
+};
+</script>
+
+<template>
+ <div
+ class="search-input-container hidden-xs"
+ >
+ <input
+ type="search"
+ class="form-control"
+ ref="search"
+ v-model="searchQuery"
+ :placeholder="s__('ProjectsDropdown|Search projects')"
+ />
+ <i
+ v-if="!searchQuery"
+ class="search-icon fa fa-fw fa-search"
+ aria-hidden="true"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/constants.js b/app/assets/javascripts/projects_dropdown/constants.js
new file mode 100644
index 00000000000..8937097184c
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/constants.js
@@ -0,0 +1,10 @@
+export const FREQUENT_PROJECTS = {
+ MAX_COUNT: 20,
+ LIST_COUNT_DESKTOP: 5,
+ LIST_COUNT_MOBILE: 3,
+ ELIGIBLE_FREQUENCY: 3,
+};
+
+export const HOUR_IN_MS = 3600000;
+
+export const STORAGE_KEY = 'frequent-projects';
diff --git a/app/assets/javascripts/projects_dropdown/event_hub.js b/app/assets/javascripts/projects_dropdown/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/projects_dropdown/index.js b/app/assets/javascripts/projects_dropdown/index.js
new file mode 100644
index 00000000000..2660da3c558
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/index.js
@@ -0,0 +1,68 @@
+import Vue from 'vue';
+
+import Translate from '../vue_shared/translate';
+import eventHub from './event_hub';
+import ProjectsService from './service/projects_service';
+import ProjectsStore from './store/projects_store';
+
+import projectsDropdownApp from './components/app.vue';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('js-projects-dropdown');
+ const navEl = document.getElementById('nav-projects-dropdown');
+
+ // Don't do anything if element doesn't exist (No projects dropdown)
+ // This is for when the user accesses GitLab without logging in
+ if (!el || !navEl) {
+ return;
+ }
+
+ $(navEl).on('show.bs.dropdown', (e) => {
+ const dropdownEl = $(e.currentTarget).find('.projects-dropdown-menu');
+ dropdownEl.one('transitionend', () => {
+ eventHub.$emit('dropdownOpen');
+ });
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ projectsDropdownApp,
+ },
+ data() {
+ const dataset = this.$options.el.dataset;
+ const store = new ProjectsStore();
+ const service = new ProjectsService(dataset.userName);
+
+ const project = {
+ id: Number(dataset.projectId),
+ name: dataset.projectName,
+ namespace: dataset.projectNamespace,
+ webUrl: dataset.projectWebUrl,
+ avatarUrl: dataset.projectAvatarUrl || null,
+ lastAccessedOn: Date.now(),
+ };
+
+ return {
+ store,
+ service,
+ state: store.state,
+ currentUserName: dataset.userName,
+ currentProject: project,
+ };
+ },
+ render(createElement) {
+ return createElement('projects-dropdown-app', {
+ props: {
+ currentUserName: this.currentUserName,
+ currentProject: this.currentProject,
+ store: this.store,
+ service: this.service,
+ },
+ });
+ },
+ });
+});
diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js
new file mode 100644
index 00000000000..fad956b4c26
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/service/projects_service.js
@@ -0,0 +1,132 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+import bp from '../../breakpoints';
+import Api from '../../api';
+import AccessorUtilities from '../../lib/utils/accessor';
+
+import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants';
+
+Vue.use(VueResource);
+
+export default class ProjectsService {
+ constructor(currentUserName) {
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+ this.currentUserName = currentUserName;
+ this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`;
+ this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath));
+ }
+
+ getSearchedProjects(searchQuery) {
+ return this.projectsPath.get({
+ simple: false,
+ per_page: 20,
+ membership: !!gon.current_user_id,
+ order_by: 'last_activity_at',
+ search: searchQuery,
+ });
+ }
+
+ getFrequentProjects() {
+ if (this.isLocalStorageAvailable) {
+ return this.getTopFrequentProjects();
+ }
+ return null;
+ }
+
+ logProjectAccess(project) {
+ let matchFound = false;
+ let storedFrequentProjects;
+
+ if (this.isLocalStorageAvailable) {
+ const storedRawProjects = localStorage.getItem(this.storageKey);
+
+ // Check if there's any frequent projects list set
+ if (!storedRawProjects) {
+ // No frequent projects list set, set one up.
+ storedFrequentProjects = [];
+ storedFrequentProjects.push({ ...project, frequency: 1 });
+ } else {
+ // Check if project is already present in frequents list
+ // When found, update metadata of it.
+ storedFrequentProjects = JSON.parse(storedRawProjects).map((projectItem) => {
+ if (projectItem.id === project.id) {
+ matchFound = true;
+ const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS;
+ const updatedProject = {
+ ...project,
+ frequency: projectItem.frequency,
+ lastAccessedOn: projectItem.lastAccessedOn,
+ };
+
+ // Check if duration since last access of this project
+ // is over an hour
+ if (diff > 1) {
+ return {
+ ...updatedProject,
+ frequency: updatedProject.frequency + 1,
+ lastAccessedOn: Date.now(),
+ };
+ }
+
+ return {
+ ...updatedProject,
+ };
+ }
+
+ return projectItem;
+ });
+
+ // Check whether currently logged project is present in frequents list
+ if (!matchFound) {
+ // We always keep size of frequents collection to 20 projects
+ // out of which only 5 projects with
+ // highest value of `frequency` and most recent `lastAccessedOn`
+ // are shown in projects dropdown
+ if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) {
+ storedFrequentProjects.shift(); // Remove an item from head of array
+ }
+
+ storedFrequentProjects.push({ ...project, frequency: 1 });
+ }
+ }
+
+ localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects));
+ }
+ }
+
+ getTopFrequentProjects() {
+ const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey));
+ let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP;
+
+ if (!storedFrequentProjects) {
+ return [];
+ }
+
+ if (bp.getBreakpointSize() === 'sm' ||
+ bp.getBreakpointSize() === 'xs') {
+ frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE;
+ }
+
+ const frequentProjects = storedFrequentProjects
+ .filter(project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY);
+
+ // Sort all frequent projects in decending order of frequency
+ // and then by lastAccessedOn with recent most first
+ frequentProjects.sort((projectA, projectB) => {
+ if (projectA.frequency < projectB.frequency) {
+ return 1;
+ } else if (projectA.frequency > projectB.frequency) {
+ return -1;
+ } else if (projectA.lastAccessedOn < projectB.lastAccessedOn) {
+ return 1;
+ } else if (projectA.lastAccessedOn > projectB.lastAccessedOn) {
+ return -1;
+ }
+
+ return 0;
+ });
+
+ return _.first(frequentProjects, frequentProjectsCount);
+ }
+}
diff --git a/app/assets/javascripts/projects_dropdown/store/projects_store.js b/app/assets/javascripts/projects_dropdown/store/projects_store.js
new file mode 100644
index 00000000000..ffefbe693f4
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/store/projects_store.js
@@ -0,0 +1,33 @@
+export default class ProjectsStore {
+ constructor() {
+ this.state = {};
+ this.state.frequentProjects = [];
+ this.state.searchedProjects = [];
+ }
+
+ setFrequentProjects(rawProjects) {
+ this.state.frequentProjects = rawProjects;
+ }
+
+ getFrequentProjects() {
+ return this.state.frequentProjects;
+ }
+
+ setSearchedProjects(rawProjects) {
+ this.state.searchedProjects = rawProjects.map(rawProject => ({
+ id: rawProject.id,
+ name: rawProject.name,
+ namespace: rawProject.name_with_namespace,
+ webUrl: rawProject.web_url,
+ avatarUrl: rawProject.avatar_url,
+ }));
+ }
+
+ getSearchedProjects() {
+ return this.state.searchedProjects;
+ }
+
+ clearSearchedProjects() {
+ this.state.searchedProjects = [];
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue
index 0edd820743f..7cf2e029cf6 100644
--- a/app/assets/javascripts/vue_shared/components/identicon.vue
+++ b/app/assets/javascripts/vue_shared/components/identicon.vue
@@ -9,6 +9,11 @@ export default {
type: String,
required: true,
},
+ sizeClass: {
+ type: String,
+ required: false,
+ default: 's40',
+ },
},
computed: {
/**
@@ -38,7 +43,8 @@ export default {
<template>
<div
- class="avatar s40 identicon"
+ class="avatar identicon"
+ :class="sizeClass"
:style="identiconStyles">
{{identiconTitle}}
</div>
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 68a51c5a461..a85051642dd 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -21,6 +21,7 @@
.append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; }
.append-bottom-0 { margin-bottom: 0; }
+.append-bottom-5 { margin-bottom: 5px; }
.append-bottom-10 { margin-bottom: 10px; }
.append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; }
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 336ac096d60..e65a78b8dc3 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -829,3 +829,152 @@
}
@include new-style-dropdown('.js-namespace-select + ');
+
+header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu {
+ padding: 0;
+
+ @media (max-width: $screen-xs-max) {
+ display: table;
+ left: -50px;
+ min-width: 300px;
+ }
+}
+
+.projects-dropdown-container {
+ display: flex;
+ flex-direction: row;
+ width: 500px;
+ height: 334px;
+
+ .project-dropdown-sidebar,
+ .project-dropdown-content {
+ padding: 8px 0;
+ }
+
+ .loading-animation {
+ color: $almost-black;
+ }
+
+ .project-dropdown-sidebar {
+ width: 30%;
+ border-right: 1px solid $border-color;
+ }
+
+ .project-dropdown-content {
+ position: relative;
+ width: 70%;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ flex-direction: column;
+ width: 100%;
+ height: auto;
+ flex: 1;
+
+ .project-dropdown-sidebar,
+ .project-dropdown-content {
+ width: 100%;
+ }
+
+ .project-dropdown-sidebar {
+ border-bottom: 1px solid $border-color;
+ border-right: 0;
+ }
+ }
+}
+
+.projects-dropdown-container {
+ .projects-list-frequent-container,
+ .projects-list-search-container, {
+ padding: 8px 0;
+ overflow-y: auto;
+ }
+
+ .section-header,
+ .projects-list-frequent-container li.section-empty,
+ .projects-list-search-container li.section-empty {
+ padding: 0 15px;
+ }
+
+ .section-header,
+ .projects-list-frequent-container li.section-empty,
+ .projects-list-search-container li.section-empty {
+ color: $gl-text-color-secondary;
+ font-size: $gl-font-size;
+ }
+
+ .projects-list-frequent-container,
+ .projects-list-search-container {
+ li.section-empty.section-failure {
+ color: $callout-danger-color;
+ }
+ }
+
+ .search-input-container {
+ position: relative;
+ padding: 4px $gl-padding;
+
+ .search-icon {
+ position: absolute;
+ top: 13px;
+ right: 25px;
+ color: $md-area-border;
+ }
+ }
+
+ .section-header {
+ font-weight: 700;
+ margin-top: 8px;
+ }
+
+ .projects-list-search-container {
+ height: 284px;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .projects-list-frequent-container {
+ width: auto;
+ height: auto;
+ padding-bottom: 0;
+ }
+ }
+}
+
+.projects-list-item-container {
+ .project-item-avatar-container
+ .project-item-metadata-container {
+ float: left;
+ }
+
+ .project-title,
+ .project-namespace {
+ max-width: 250px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &:hover {
+ .project-item-avatar-container .avatar {
+ border-color: $md-area-border;
+ }
+ }
+
+ .project-title {
+ font-size: $gl-font-size;
+ font-weight: 400;
+ line-height: 16px;
+ }
+
+ .project-namespace {
+ margin-top: 4px;
+ font-size: 12px;
+ line-height: 12px;
+ color: $gl-text-color-secondary;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .project-item-metadata-container {
+ float: none;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index cc1bb0be776..6c14e8b97e0 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -273,6 +273,7 @@ body[data-page="projects:new"] #select2-drop,
body[data-page="projects:merge_requests:edit"] #select2-drop,
body[data-page="projects:blob:new"] #select2-drop,
body[data-page="profiles:show"] #select2-drop,
+body[data-page="admin:groups:show"] #select2-drop,
body[data-page="projects:issues:show"] #select2-drop,
body[data-page="projects:blob:edit"] #select2-drop {
&.select2-drop {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 6523376ccc3..9f2cb979518 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -617,6 +617,8 @@
}
.issuable-actions {
+ @include new-style-dropdown;
+
padding-top: 10px;
@media (min-width: $screen-sm-min) {
diff --git a/app/models/commit.rb b/app/models/commit.rb
index c943365016f..ba3845df867 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -405,6 +405,6 @@ class Commit
end
def gpg_commit
- @gpg_commit ||= Gitlab::Gpg::Commit.for_commit(self)
+ @gpg_commit ||= Gitlab::Gpg::Commit.new(self)
end
end
diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb
index 3df60ddc950..1633acd4fa9 100644
--- a/app/models/gpg_key.rb
+++ b/app/models/gpg_key.rb
@@ -56,7 +56,7 @@ class GpgKey < ActiveRecord::Base
def verified_user_infos
user_infos.select do |user_info|
- user_info[:email] == user.email
+ user.verified_email?(user_info[:email])
end
end
@@ -64,13 +64,17 @@ class GpgKey < ActiveRecord::Base
user_infos.map do |user_info|
[
user_info[:email],
- user_info[:email] == user.email
+ user.verified_email?(user_info[:email])
]
end.to_h
end
def verified?
- emails_with_verified_status.any? { |_email, verified| verified }
+ emails_with_verified_status.values.any?
+ end
+
+ def verified_and_belongs_to_email?(email)
+ emails_with_verified_status.fetch(email, false)
end
def update_invalid_gpg_signatures
@@ -78,11 +82,14 @@ class GpgKey < ActiveRecord::Base
end
def revoke
- GpgSignature.where(gpg_key: self, valid_signature: true).update_all(
- gpg_key_id: nil,
- valid_signature: false,
- updated_at: Time.zone.now
- )
+ GpgSignature
+ .where(gpg_key: self)
+ .where.not(verification_status: GpgSignature.verification_statuses[:unknown_key])
+ .update_all(
+ gpg_key_id: nil,
+ verification_status: GpgSignature.verification_statuses[:unknown_key],
+ updated_at: Time.zone.now
+ )
destroy
end
diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb
index 50fb35c77ec..454c90d5fc4 100644
--- a/app/models/gpg_signature.rb
+++ b/app/models/gpg_signature.rb
@@ -1,9 +1,21 @@
class GpgSignature < ActiveRecord::Base
include ShaAttribute
+ include IgnorableColumn
+
+ ignore_column :valid_signature
sha_attribute :commit_sha
sha_attribute :gpg_key_primary_keyid
+ enum verification_status: {
+ unverified: 0,
+ verified: 1,
+ same_user_different_email: 2,
+ other_user: 3,
+ unverified_key: 4,
+ unknown_key: 5
+ }
+
belongs_to :project
belongs_to :gpg_key
@@ -20,6 +32,6 @@ class GpgSignature < ActiveRecord::Base
end
def gpg_commit
- Gitlab::Gpg::Commit.new(project, commit_sha)
+ Gitlab::Gpg::Commit.new(commit)
end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 05f2f851162..035f85a0b46 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -166,32 +166,25 @@ class Repository
end
def add_branch(user, branch_name, ref)
- newrev = commit(ref).try(:sha)
-
- return false unless newrev
-
- Gitlab::Git::OperationService.new(user, raw_repository).add_branch(branch_name, newrev)
+ branch = raw_repository.add_branch(branch_name, committer: user, target: ref)
after_create_branch
- find_branch(branch_name)
+
+ branch
+ rescue Gitlab::Git::Repository::InvalidRef
+ false
end
def add_tag(user, tag_name, target, message = nil)
- newrev = commit(target).try(:id)
- options = { message: message, tagger: user_to_committer(user) } if message
-
- return false unless newrev
-
- Gitlab::Git::OperationService.new(user, raw_repository).add_tag(tag_name, newrev, options)
-
- find_tag(tag_name)
+ raw_repository.add_tag(tag_name, committer: user, target: target, message: message)
+ rescue Gitlab::Git::Repository::InvalidRef
+ false
end
def rm_branch(user, branch_name)
before_remove_branch
- branch = find_branch(branch_name)
- Gitlab::Git::OperationService.new(user, raw_repository).rm_branch(branch)
+ raw_repository.rm_branch(branch_name, committer: user)
after_remove_branch
true
@@ -199,9 +192,8 @@ class Repository
def rm_tag(user, tag_name)
before_remove_tag
- tag = find_tag(tag_name)
- Gitlab::Git::OperationService.new(user, raw_repository).rm_tag(tag)
+ raw_repository.rm_tag(tag_name, committer: user)
after_remove_tag
true
diff --git a/app/models/user.rb b/app/models/user.rb
index 9d48c82e861..c5b5f09722f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1041,6 +1041,10 @@ class User < ActiveRecord::Base
ensure_rss_token!
end
+ def verified_email?(email)
+ self.email == email
+ end
+
protected
# override, from Devise::Validatable
diff --git a/app/views/layouts/nav/_new_dashboard.html.haml b/app/views/layouts/nav/_new_dashboard.html.haml
index cfdfcbebc9f..88d164cac32 100644
--- a/app/views/layouts/nav/_new_dashboard.html.haml
+++ b/app/views/layouts/nav/_new_dashboard.html.haml
@@ -1,7 +1,10 @@
%ul.list-unstyled.navbar-sub-nav
- = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "home"}) do
- = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
+ = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown" }) do
+ %a{ href: '#', title: 'Projects', data: { toggle: 'dropdown' } }
Projects
+ = icon("chevron-down", class: "dropdown-chevron")
+ .dropdown-menu.projects-dropdown-menu
+ = render "layouts/nav/projects_dropdown/show"
= nav_link(controller: ['dashboard/groups', 'explore/groups']) do
= link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
@@ -31,3 +34,8 @@
%li.divider
%li
= link_to "Help", help_path, title: 'About GitLab CE'
+
+ -# Shortcut to Dashboard > Projects
+ %li.hidden
+ = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
+ Projects
diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml
new file mode 100644
index 00000000000..a7370180bf6
--- /dev/null
+++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml
@@ -0,0 +1,15 @@
+- project_meta = { id: @project.id, name: @project.name, namespace: @project.name_with_namespace, web_url: @project.web_url, avatar_url: @project.avatar_url } if @project&.persisted?
+.projects-dropdown-container
+ .project-dropdown-sidebar
+ %ul
+ = nav_link(path: 'dashboard/projects#index') do
+ = link_to dashboard_projects_path do
+ = _('Your projects')
+ = nav_link(path: 'projects#starred') do
+ = link_to starred_dashboard_projects_path do
+ = _('Starred projects')
+ = nav_link(path: 'projects#trending') do
+ = link_to explore_root_path do
+ = _('Explore projects')
+ .project-dropdown-content
+ #js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } }
diff --git a/app/views/projects/commit/_invalid_signature_badge.html.haml b/app/views/projects/commit/_invalid_signature_badge.html.haml
deleted file mode 100644
index 3a73aae9d95..00000000000
--- a/app/views/projects/commit/_invalid_signature_badge.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- title = capture do
- .gpg-popover-icon.invalid
- = render 'shared/icons/icon_status_notfound_borderless.svg'
- %div
- This commit was signed with an <strong>unverified</strong> signature.
-
-- locals = { signature: signature, title: title, label: 'Unverified', css_classes: ['invalid'] }
-
-= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_other_user_signature_badge.html.haml b/app/views/projects/commit/_other_user_signature_badge.html.haml
new file mode 100644
index 00000000000..80eca96f7ce
--- /dev/null
+++ b/app/views/projects/commit/_other_user_signature_badge.html.haml
@@ -0,0 +1,6 @@
+- title = capture do
+ This commit was signed with a different user's verified signature.
+
+- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'icon_status_notfound_borderless', show_user: true }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
new file mode 100644
index 00000000000..e737de48e22
--- /dev/null
+++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
@@ -0,0 +1,7 @@
+- title = capture do
+ This commit was signed with a verified signature, but the committer email
+ is <strong>not verified</strong> to belong to the same user.
+
+- locals = { signature: signature, title: title, label: 'Unverified', css_class: ['invalid'], icon: 'icon_status_notfound_borderless', show_user: true }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml
index 60fa52557ef..145bc629380 100644
--- a/app/views/projects/commit/_signature.html.haml
+++ b/app/views/projects/commit/_signature.html.haml
@@ -1,5 +1,2 @@
- if signature
- - if signature.valid_signature?
- = render partial: 'projects/commit/valid_signature_badge', locals: { signature: signature }
- - else
- = render partial: 'projects/commit/invalid_signature_badge', locals: { signature: signature }
+ = render partial: "projects/commit/#{signature.verification_status}_signature_badge", locals: { signature: signature }
diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml
index d06b29db838..edff018ba6d 100644
--- a/app/views/projects/commit/_signature_badge.html.haml
+++ b/app/views/projects/commit/_signature_badge.html.haml
@@ -1,17 +1,27 @@
-- css_classes = commit_signature_badge_classes(css_classes)
+- signature = local_assigns.fetch(:signature)
+- title = local_assigns.fetch(:title)
+- label = local_assigns.fetch(:label)
+- css_class = local_assigns.fetch(:css_class)
+- icon = local_assigns.fetch(:icon)
+- show_user = local_assigns.fetch(:show_user, false)
+
+- css_classes = commit_signature_badge_classes(css_class)
- title = capture do
.gpg-popover-status
- = title
+ .gpg-popover-icon{ class: css_class }
+ = render "shared/icons/#{icon}.svg"
+ %div
+ = title
- content = capture do
- .clearfix
- = content
+ - if show_user
+ .clearfix
+ = render partial: 'projects/commit/signature_badge_user', locals: { signature: signature }
GPG Key ID:
%span.monospace= signature.gpg_key_primary_keyid
-
= link_to('Learn more about signing commits', help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link')
%button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto top', title: title, content: content } }
diff --git a/app/views/projects/commit/_signature_badge_user.html.haml b/app/views/projects/commit/_signature_badge_user.html.haml
new file mode 100644
index 00000000000..b20198e76db
--- /dev/null
+++ b/app/views/projects/commit/_signature_badge_user.html.haml
@@ -0,0 +1,21 @@
+- gpg_key = signature.gpg_key
+- user = gpg_key&.user
+- user_name = signature.gpg_key_user_name
+- user_email = signature.gpg_key_user_email
+
+- if user
+ = link_to user_path(user), class: 'gpg-popover-user-link' do
+ %div
+ = user_avatar_without_link(user: user, size: 32)
+
+ %div
+ %strong= user.name
+ %div= user.to_reference
+- else
+ = mail_to user_email do
+ %div
+ = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32)
+
+ %div
+ %strong= user_name
+ %div= user_email
diff --git a/app/views/projects/commit/_unknown_key_signature_badge.html.haml b/app/views/projects/commit/_unknown_key_signature_badge.html.haml
new file mode 100644
index 00000000000..75c5cf57bcc
--- /dev/null
+++ b/app/views/projects/commit/_unknown_key_signature_badge.html.haml
@@ -0,0 +1 @@
+= render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature }
diff --git a/app/views/projects/commit/_unverified_key_signature_badge.html.haml b/app/views/projects/commit/_unverified_key_signature_badge.html.haml
new file mode 100644
index 00000000000..75c5cf57bcc
--- /dev/null
+++ b/app/views/projects/commit/_unverified_key_signature_badge.html.haml
@@ -0,0 +1 @@
+= render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature }
diff --git a/app/views/projects/commit/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml
new file mode 100644
index 00000000000..1af58027b83
--- /dev/null
+++ b/app/views/projects/commit/_unverified_signature_badge.html.haml
@@ -0,0 +1,6 @@
+- title = capture do
+ This commit was signed with an <strong>unverified</strong> signature.
+
+- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'icon_status_notfound_borderless' }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_valid_signature_badge.html.haml b/app/views/projects/commit/_valid_signature_badge.html.haml
deleted file mode 100644
index db1a41bbf64..00000000000
--- a/app/views/projects/commit/_valid_signature_badge.html.haml
+++ /dev/null
@@ -1,32 +0,0 @@
-- title = capture do
- .gpg-popover-icon.valid
- = render 'shared/icons/icon_status_success_borderless.svg'
- %div
- This commit was signed with a <strong>verified</strong> signature.
-
-- content = capture do
- - gpg_key = signature.gpg_key
- - user = gpg_key&.user
- - user_name = signature.gpg_key_user_name
- - user_email = signature.gpg_key_user_email
-
- - if user
- = link_to user_path(user), class: 'gpg-popover-user-link' do
- %div
- = user_avatar_without_link(user: user, size: 32)
-
- %div
- %strong= gpg_key.user.name
- %div @#{gpg_key.user.username}
- - else
- = mail_to user_email do
- %div
- = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32)
-
- %div
- %strong= user_name
- %div= user_email
-
-- locals = { signature: signature, title: title, content: content, label: 'Verified', css_classes: ['valid'] }
-
-= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml
new file mode 100644
index 00000000000..423beba2120
--- /dev/null
+++ b/app/views/projects/commit/_verified_signature_badge.html.haml
@@ -0,0 +1,7 @@
+- title = capture do
+ This commit was signed with a <strong>verified</strong> signature and the
+ committer email is verified to belong to the same user.
+
+- locals = { signature: signature, title: title, label: 'Verified', css_class: 'valid', icon: 'icon_status_success_borderless', show_user: true }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index b04f5efe1f9..fb07141d2ac 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -31,7 +31,7 @@
%template{ 'v-if' => 'isResolved' }
= render 'shared/icons/icon_status_success_solid.svg'
%template{ 'v-else' => '' }
- = render 'shared/icons/icon_status_success.svg'
+ = render 'shared/icons/icon_resolve_discussion.svg'
- if current_user
- if note.emoji_awardable?
diff --git a/app/views/shared/icons/_icon_resolve_discussion.svg b/app/views/shared/icons/_icon_resolve_discussion.svg
new file mode 100644
index 00000000000..845562e9320
--- /dev/null
+++ b/app/views/shared/icons/_icon_resolve_discussion.svg
@@ -0,0 +1 @@
+<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill-rule="evenodd"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></svg>
diff --git a/app/views/shared/icons/_icon_status_success.svg b/app/views/shared/icons/_icon_status_success.svg
index 845562e9320..eed5006bebe 100755
--- a/app/views/shared/icons/_icon_status_success.svg
+++ b/app/views/shared/icons/_icon_status_success.svg
@@ -1 +1 @@
-<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill-rule="evenodd"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></svg>
+<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></g></svg>
diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb
index f34dff2d656..9b5ff17aafa 100644
--- a/app/workers/create_gpg_signature_worker.rb
+++ b/app/workers/create_gpg_signature_worker.rb
@@ -6,7 +6,11 @@ class CreateGpgSignatureWorker
project = Project.find_by(id: project_id)
return unless project
+ commit = project.commit(commit_sha)
+
+ return unless commit
+
# This calculates and caches the signature in the database
- Gitlab::Gpg::Commit.new(project, commit_sha).signature
+ Gitlab::Gpg::Commit.new(commit).signature
end
end
diff --git a/changelogs/unreleased/35010-projects-nav-dropdown.yml b/changelogs/unreleased/35010-projects-nav-dropdown.yml
new file mode 100644
index 00000000000..c5bed723f55
--- /dev/null
+++ b/changelogs/unreleased/35010-projects-nav-dropdown.yml
@@ -0,0 +1,5 @@
+---
+title: Add dropdown to Projects nav item
+merge_request: 13866
+author:
+type: added
diff --git a/changelogs/unreleased/37406-success-status-icon.yml b/changelogs/unreleased/37406-success-status-icon.yml
new file mode 100644
index 00000000000..faac947f188
--- /dev/null
+++ b/changelogs/unreleased/37406-success-status-icon.yml
@@ -0,0 +1,5 @@
+---
+title: Fix broken svg in jobs dropdown for success status
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/api_branches_head.yml b/changelogs/unreleased/api_branches_head.yml
new file mode 100644
index 00000000000..68d8d3d5168
--- /dev/null
+++ b/changelogs/unreleased/api_branches_head.yml
@@ -0,0 +1,5 @@
+---
+title: Add branch existence check to the APIv4 branches via HEAD request
+merge_request: 13979
+author: Vitaliy @blackst0ne Klachkov
+type: added
diff --git a/changelogs/unreleased/dont-remove-add-diff-btn-on-post.yml b/changelogs/unreleased/dont-remove-add-diff-btn-on-post.yml
new file mode 100644
index 00000000000..a7db18dbd60
--- /dev/null
+++ b/changelogs/unreleased/dont-remove-add-diff-btn-on-post.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed add diff note button not showing after deleting a comment
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/feature-gpg-verification-status.yml b/changelogs/unreleased/feature-gpg-verification-status.yml
new file mode 100644
index 00000000000..7518fafcdb8
--- /dev/null
+++ b/changelogs/unreleased/feature-gpg-verification-status.yml
@@ -0,0 +1,6 @@
+---
+title: 'Update the GPG verification semantics: A GPG signature must additionally match
+ the committer in order to be verified'
+merge_request: 13771
+author: Alexis Reigel
+type: changed
diff --git a/config/webpack.config.js b/config/webpack.config.js
index ad88e48550d..6b0cd023291 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -30,7 +30,7 @@ var config = {
blob: './blob_edit/blob_bundle.js',
boards: './boards/boards_bundle.js',
common: './commons/index.js',
- common_vue: ['vue', './vue_shared/common_vue.js'],
+ common_vue: './vue_shared/vue_resource_interceptor.js',
common_d3: ['d3'],
cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js',
commit_pipelines: './commit/pipelines/pipelines_bundle.js',
diff --git a/db/migrate/20170817123339_add_verification_status_to_gpg_signatures.rb b/db/migrate/20170817123339_add_verification_status_to_gpg_signatures.rb
new file mode 100644
index 00000000000..128cd109f8d
--- /dev/null
+++ b/db/migrate/20170817123339_add_verification_status_to_gpg_signatures.rb
@@ -0,0 +1,20 @@
+class AddVerificationStatusToGpgSignatures < ActiveRecord::Migration
+ DOWNTIME = false
+
+ include Gitlab::Database::MigrationHelpers
+ disable_ddl_transaction!
+
+ def up
+ # First we remove all signatures because we need to re-verify them all
+ # again anyway (because of the updated verification logic).
+ #
+ # This makes adding the column with default values faster
+ truncate(:gpg_signatures)
+
+ add_column_with_default(:gpg_signatures, :verification_status, :smallint, default: 0)
+ end
+
+ def down
+ remove_column(:gpg_signatures, :verification_status)
+ end
+end
diff --git a/db/post_migrate/20170830084744_destroy_gpg_signatures.rb b/db/post_migrate/20170830084744_destroy_gpg_signatures.rb
new file mode 100644
index 00000000000..b04d36f6537
--- /dev/null
+++ b/db/post_migrate/20170830084744_destroy_gpg_signatures.rb
@@ -0,0 +1,10 @@
+class DestroyGpgSignatures < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ truncate(:gpg_signatures)
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20170831195038_remove_valid_signature_from_gpg_signatures.rb b/db/post_migrate/20170831195038_remove_valid_signature_from_gpg_signatures.rb
new file mode 100644
index 00000000000..9b6745e33d9
--- /dev/null
+++ b/db/post_migrate/20170831195038_remove_valid_signature_from_gpg_signatures.rb
@@ -0,0 +1,11 @@
+class RemoveValidSignatureFromGpgSignatures < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ remove_column :gpg_signatures, :valid_signature
+ end
+
+ def down
+ add_column :gpg_signatures, :valid_signature, :boolean
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 216b3cddab0..40b84f2bddd 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20170830125940) do
+ActiveRecord::Schema.define(version: 20170831195038) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -609,11 +609,11 @@ ActiveRecord::Schema.define(version: 20170830125940) do
t.datetime "updated_at", null: false
t.integer "project_id"
t.integer "gpg_key_id"
- t.boolean "valid_signature"
t.binary "commit_sha"
t.binary "gpg_key_primary_keyid"
t.text "gpg_key_user_name"
t.text "gpg_key_user_email"
+ t.integer "verification_status", limit: 2, default: 0, null: false
end
add_index "gpg_signatures", ["commit_sha"], name: "index_gpg_signatures_on_commit_sha", unique: true, using: :btree
diff --git a/doc/README.md b/doc/README.md
index 63ba8ff03e9..b250fa08382 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -160,7 +160,6 @@ have access to GitLab administration tools and settings.
### Integrations
- [Integrations](integration/README.md): How to integrate with systems such as JIRA, Redmine, Twitter.
-- [Koding](administration/integration/koding.md): Set up Koding to use with GitLab.
- [Mattermost](user/project/integrations/mattermost.md): Set up GitLab with Mattermost.
### Monitoring
diff --git a/doc/administration/integration/koding.md b/doc/administration/integration/koding.md
index b95c425842c..67f9f01efb8 100644
--- a/doc/administration/integration/koding.md
+++ b/doc/administration/integration/koding.md
@@ -1,6 +1,10 @@
# Koding & GitLab
-> [Introduced][ce-5909] in GitLab 8.11.
+>**Notes:**
+- **As of GitLab 10.0, the Koding integration is deprecated and will be removed
+ in a future version. The option to configure it is removed from GitLab's admin
+ area.**
+- [Introduced][ce-5909] in GitLab 8.11.
This document will guide you through installing and configuring Koding with
GitLab.
diff --git a/doc/ci/environments.md b/doc/ci/environments.md
index 28b27921f8b..cbf06afa294 100644
--- a/doc/ci/environments.md
+++ b/doc/ci/environments.md
@@ -274,9 +274,7 @@ session - and even a multiplexer like `screen` or `tmux`!
>**Note:**
Container-based deployments often lack basic tools (like an editor), and may
be stopped or restarted at any time. If this happens, you will lose all your
-changes! Treat this as a debugging tool, not a comprehensive online IDE. You
-can use [Koding](../administration/integration/koding.md) for online
-development.
+changes! Treat this as a debugging tool, not a comprehensive online IDE.
---
diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md
index 4ccf1b56771..f5d3b524d6e 100644
--- a/doc/ci/runners/README.md
+++ b/doc/ci/runners/README.md
@@ -107,9 +107,26 @@ To lock/unlock a Runner:
1. Check the **Lock to current projects** option
1. Click **Save changes** for the changes to take effect
+## Assigning a Runner to another project
+
+If you are Master on a project where a specific Runner is assigned to, and the
+Runner is not [locked only to that project](#locking-a-specific-runner-from-being-enabled-for-other-projects),
+you can enable the Runner also on any other project where you have Master permissions.
+
+To enable/disable a Runner in your project:
+
+1. Visit your project's **Settings âž” Pipelines**
+1. Find the Runner you wish to enable/disable
+1. Click **Enable for this project** or **Disable for this project**
+
+> **Note**:
+Consider that if you don't lock your specific Runner to a specific project, any
+user with Master role in you project can assign your runner to another arbitrary
+project without requiring your authorization, so use it with caution.
+
## Protected Runners
->**Notes:**
+>
[Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13194)
in GitLab 10.0.
diff --git a/doc/integration/README.md b/doc/integration/README.md
index d70b9a7f54b..09d96bdd338 100644
--- a/doc/integration/README.md
+++ b/doc/integration/README.md
@@ -13,7 +13,6 @@ Bitbucket.org account
- [External issue tracker](external-issue-tracker.md) Redmine, JIRA, etc.
- [Gmail actions buttons](gmail_action_buttons_for_gitlab.md) Adds GitLab actions to messages
- [JIRA](../user/project/integrations/jira.md) Integrate with the JIRA issue tracker
-- [Koding](../administration/integration/koding.md) Configure Koding to use IDE integration
- [LDAP](ldap.md) Set up sign in via LDAP
- [OAuth2 provider](oauth_provider.md) OAuth2 application creation
- [OmniAuth](omniauth.md) Sign in via Twitter, GitHub, GitLab.com, Google, Bitbucket, Facebook, Shibboleth, SAML, Crowd, Azure and Authentiq ID
diff --git a/doc/user/project/index.md b/doc/user/project/index.md
index 41a96246292..d6b3d59d407 100644
--- a/doc/user/project/index.md
+++ b/doc/user/project/index.md
@@ -67,8 +67,6 @@ website with GitLab Pages
**Other features:**
- [Cycle Analytics](cycle_analytics.md): Review your development lifecycle
-- [Koding integration](koding.md) (not available on GitLab.com): Integrate
-with Koding to have access to a web terminal right from the GitLab UI
- [Syntax highlighting](highlighting.md): An alternative to customize
your code blocks, overriding GitLab's default choice of language
diff --git a/doc/user/project/koding.md b/doc/user/project/koding.md
index 455e2ee47b4..86e06a39e59 100644
--- a/doc/user/project/koding.md
+++ b/doc/user/project/koding.md
@@ -1,6 +1,9 @@
# Koding integration
-> [Introduced][ce-5909] in GitLab 8.11.
+>**Notes:**
+- **As of GitLab 10.0, the Koding integration is deprecated and will be removed
+ in a future version.**
+- [Introduced][ce-5909] in GitLab 8.11.
This document will guide you through using Koding integration on GitLab in
detail. For configuring and installing please follow the
diff --git a/doc/user/project/repository/gpg_signed_commits/img/project_signed_and_unsigned_commits.png b/doc/user/project/repository/gpg_signed_commits/img/project_signed_and_unsigned_commits.png
index 33936a7d6d7..088ecfa6d89 100644
--- a/doc/user/project/repository/gpg_signed_commits/img/project_signed_and_unsigned_commits.png
+++ b/doc/user/project/repository/gpg_signed_commits/img/project_signed_and_unsigned_commits.png
Binary files differ
diff --git a/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_unverified_signature.png b/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_unverified_signature.png
index 22565cf7c7e..4e3392406b1 100644
--- a/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_unverified_signature.png
+++ b/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_unverified_signature.png
Binary files differ
diff --git a/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_verified_signature.png b/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_verified_signature.png
index 1778b2ddf2b..766970dee81 100644
--- a/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_verified_signature.png
+++ b/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_verified_signature.png
Binary files differ
diff --git a/doc/user/project/repository/gpg_signed_commits/index.md b/doc/user/project/repository/gpg_signed_commits/index.md
index ff419d714f9..afe8066d408 100644
--- a/doc/user/project/repository/gpg_signed_commits/index.md
+++ b/doc/user/project/repository/gpg_signed_commits/index.md
@@ -22,11 +22,12 @@ GitLab uses its own keyring to verify the GPG signature. It does not access any
public key server.
In order to have a commit verified on GitLab the corresponding public key needs
-to be uploaded to GitLab. For a signature to be verified two prerequisites need
+to be uploaded to GitLab. For a signature to be verified three conditions need
to be met:
1. The public key needs to be added your GitLab account
1. One of the emails in the GPG key matches your **primary** email
+1. The committer's email matches the verified email from the gpg key
## Generating a GPG key
diff --git a/lib/api/branches.rb b/lib/api/branches.rb
index a989394ad91..642c1140fcc 100644
--- a/lib/api/branches.rb
+++ b/lib/api/branches.rb
@@ -24,17 +24,22 @@ module API
present paginate(branches), with: Entities::RepoBranch, project: user_project
end
- desc 'Get a single branch' do
- success Entities::RepoBranch
- end
- params do
- requires :branch, type: String, desc: 'The name of the branch'
- end
- get ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
- branch = user_project.repository.find_branch(params[:branch])
- not_found!("Branch") unless branch
+ resource ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
+ desc 'Get a single branch' do
+ success Entities::RepoBranch
+ end
+ params do
+ requires :branch, type: String, desc: 'The name of the branch'
+ end
+ head do
+ user_project.repository.branch_exists?(params[:branch]) ? status(204) : status(404)
+ end
+ get do
+ branch = user_project.repository.find_branch(params[:branch])
+ not_found!('Branch') unless branch
- present branch, with: Entities::RepoBranch, project: user_project
+ present branch, with: Entities::RepoBranch, project: user_project
+ end
end
# Note: This API will be deprecated in favor of the protected branches API.
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index c67f7724307..f7577b02d5d 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -605,6 +605,49 @@ module Gitlab
# TODO: implement this method
end
+ def add_branch(branch_name, committer:, target:)
+ target_object = Ref.dereference_object(lookup(target))
+ raise InvalidRef.new("target not found: #{target}") unless target_object
+
+ OperationService.new(committer, self).add_branch(branch_name, target_object.oid)
+ find_branch(branch_name)
+ rescue Rugged::ReferenceError => ex
+ raise InvalidRef, ex
+ end
+
+ def add_tag(tag_name, committer:, target:, message: nil)
+ target_object = Ref.dereference_object(lookup(target))
+ raise InvalidRef.new("target not found: #{target}") unless target_object
+
+ committer = Committer.from_user(committer) if committer.is_a?(User)
+
+ options = nil # Use nil, not the empty hash. Rugged cares about this.
+ if message
+ options = {
+ message: message,
+ tagger: Gitlab::Git.committer_hash(email: committer.email, name: committer.name)
+ }
+ end
+
+ OperationService.new(committer, self).add_tag(tag_name, target_object.oid, options)
+
+ find_tag(tag_name)
+ rescue Rugged::ReferenceError => ex
+ raise InvalidRef, ex
+ end
+
+ def rm_branch(branch_name, committer:)
+ OperationService.new(committer, self).rm_branch(find_branch(branch_name))
+ end
+
+ def rm_tag(tag_name, committer:)
+ OperationService.new(committer, self).rm_tag(find_tag(tag_name))
+ end
+
+ def find_tag(name)
+ tags.find { |tag| tag.name == name }
+ end
+
# Delete the specified branch from the repository
#
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/476
diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb
index 45e9f9d65ae..025f826e65f 100644
--- a/lib/gitlab/gpg.rb
+++ b/lib/gitlab/gpg.rb
@@ -39,7 +39,7 @@ module Gitlab
fingerprints = CurrentKeyChain.fingerprints_from_key(key)
GPGME::Key.find(:public, fingerprints).flat_map do |raw_key|
- raw_key.uids.map { |uid| { name: uid.name, email: uid.email } }
+ raw_key.uids.map { |uid| { name: uid.name, email: uid.email.downcase } }
end
end
end
diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb
index 606c7576f70..86bd9f5b125 100644
--- a/lib/gitlab/gpg/commit.rb
+++ b/lib/gitlab/gpg/commit.rb
@@ -1,17 +1,12 @@
module Gitlab
module Gpg
class Commit
- def self.for_commit(commit)
- new(commit.project, commit.sha)
- end
-
- def initialize(project, sha)
- @project = project
- @sha = sha
+ def initialize(commit)
+ @commit = commit
@signature_text, @signed_text =
begin
- Rugged::Commit.extract_signature(project.repository.rugged, sha)
+ Rugged::Commit.extract_signature(@commit.project.repository.rugged, @commit.sha)
rescue Rugged::OdbError
nil
end
@@ -26,7 +21,7 @@ module Gitlab
return @signature if @signature
- cached_signature = GpgSignature.find_by(commit_sha: @sha)
+ cached_signature = GpgSignature.find_by(commit_sha: @commit.sha)
return @signature = cached_signature if cached_signature.present?
@signature = create_cached_signature!
@@ -73,20 +68,31 @@ module Gitlab
def attributes(gpg_key)
user_infos = user_infos(gpg_key)
+ verification_status = verification_status(gpg_key)
{
- commit_sha: @sha,
- project: @project,
+ commit_sha: @commit.sha,
+ project: @commit.project,
gpg_key: gpg_key,
gpg_key_primary_keyid: gpg_key&.primary_keyid || verified_signature.fingerprint,
gpg_key_user_name: user_infos[:name],
gpg_key_user_email: user_infos[:email],
- valid_signature: gpg_signature_valid_signature_value(gpg_key)
+ verification_status: verification_status
}
end
- def gpg_signature_valid_signature_value(gpg_key)
- !!(gpg_key && gpg_key.verified? && verified_signature.valid?)
+ def verification_status(gpg_key)
+ return :unknown_key unless gpg_key
+ return :unverified_key unless gpg_key.verified?
+ return :unverified unless verified_signature.valid?
+
+ if gpg_key.verified_and_belongs_to_email?(@commit.committer_email)
+ :verified
+ elsif gpg_key.user.all_emails.include?(@commit.committer_email)
+ :same_user_different_email
+ else
+ :other_user
+ end
end
def user_infos(gpg_key)
diff --git a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb
index a525ee7a9ee..e085eab26c9 100644
--- a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb
+++ b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb
@@ -8,7 +8,7 @@ module Gitlab
def run
GpgSignature
.select(:id, :commit_sha, :project_id)
- .where('gpg_key_id IS NULL OR valid_signature = ?', false)
+ .where('gpg_key_id IS NULL OR verification_status <> ?', GpgSignature.verification_statuses[:verified])
.where(gpg_key_primary_keyid: @gpg_key.primary_keyid)
.find_each { |sig| sig.gpg_commit.update_signature!(sig) }
end
diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake
index f7f2fa2f14c..35ba729c156 100644
--- a/lib/tasks/gettext.rake
+++ b/lib/tasks/gettext.rake
@@ -1,5 +1,4 @@
require "gettext_i18n_rails/tasks"
-require 'simple_po_parser'
namespace :gettext do
# Customize list of translatable files
@@ -23,6 +22,8 @@ namespace :gettext do
desc 'Lint all po files in `locale/'
task lint: :environment do
+ require 'simple_po_parser'
+
FastGettext.silence_errors
files = Dir.glob(Rails.root.join('locale/*/gitlab.po'))
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 2b7c6f7ad33..97bc3d80642 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-08-24 09:29+0200\n"
-"PO-Revision-Date: 2017-08-24 09:29+0200\n"
+"POT-Creation-Date: 2017-08-31 17:34+0530\n"
+"PO-Revision-Date: 2017-08-31 17:34+0530\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@@ -427,6 +427,9 @@ msgstr ""
msgid "Every week (Sundays at 4:00am)"
msgstr ""
+msgid "Explore projects"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr ""
@@ -837,6 +840,27 @@ msgstr ""
msgid "ProjectNetworkGraph|Graph"
msgstr ""
+msgid "ProjectsDropdown|Frequently visited"
+msgstr ""
+
+msgid "ProjectsDropdown|Loading projects"
+msgstr ""
+
+msgid "ProjectsDropdown|No projects matched your query"
+msgstr ""
+
+msgid "ProjectsDropdown|Projects you visit often will appear here"
+msgstr ""
+
+msgid "ProjectsDropdown|Search projects"
+msgstr ""
+
+msgid "ProjectsDropdown|Something went wrong on our end."
+msgstr ""
+
+msgid "ProjectsDropdown|This feature requires browser localStorage support"
+msgstr ""
+
msgid "Push events"
msgstr ""
@@ -950,6 +974,9 @@ msgstr ""
msgid "StarProject|Star"
msgstr ""
+msgid "Starred projects"
+msgstr ""
+
msgid "Start a %{new_merge_request} with these changes"
msgstr ""
@@ -1271,6 +1298,9 @@ msgstr ""
msgid "Your name"
msgstr ""
+msgid "Your projects"
+msgstr ""
+
msgid "day"
msgid_plural "days"
msgstr[0] ""
diff --git a/spec/factories/gpg_signature.rb b/spec/factories/gpg_signature.rb
index a5aeffbe12d..c0beecf0bea 100644
--- a/spec/factories/gpg_signature.rb
+++ b/spec/factories/gpg_signature.rb
@@ -6,6 +6,6 @@ FactoryGirl.define do
project
gpg_key
gpg_key_primary_keyid { gpg_key.primary_keyid }
- valid_signature true
+ verification_status :verified
end
end
diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb
index 0c9fcc60d30..479fb713297 100644
--- a/spec/features/commits_spec.rb
+++ b/spec/features/commits_spec.rb
@@ -203,105 +203,4 @@ describe 'Commits' do
end
end
end
-
- describe 'GPG signed commits', :js do
- it 'changes from unverified to verified when the user changes his email to match the gpg key' do
- user = create :user, email: 'unrelated.user@example.org'
- project.team << [user, :master]
-
- Sidekiq::Testing.inline! do
- create :gpg_key, key: GpgHelpers::User1.public_key, user: user
- end
-
- sign_in(user)
-
- visit project_commits_path(project, :'signed-commits')
-
- within '#commits-list' do
- expect(page).to have_content 'Unverified'
- expect(page).not_to have_content 'Verified'
- end
-
- # user changes his email which makes the gpg key verified
- Sidekiq::Testing.inline! do
- user.skip_reconfirmation!
- user.update_attributes!(email: GpgHelpers::User1.emails.first)
- end
-
- visit project_commits_path(project, :'signed-commits')
-
- within '#commits-list' do
- expect(page).to have_content 'Unverified'
- expect(page).to have_content 'Verified'
- end
- end
-
- it 'changes from unverified to verified when the user adds the missing gpg key' do
- user = create :user, email: GpgHelpers::User1.emails.first
- project.team << [user, :master]
-
- sign_in(user)
-
- visit project_commits_path(project, :'signed-commits')
-
- within '#commits-list' do
- expect(page).to have_content 'Unverified'
- expect(page).not_to have_content 'Verified'
- end
-
- # user adds the gpg key which makes the signature valid
- Sidekiq::Testing.inline! do
- create :gpg_key, key: GpgHelpers::User1.public_key, user: user
- end
-
- visit project_commits_path(project, :'signed-commits')
-
- within '#commits-list' do
- expect(page).to have_content 'Unverified'
- expect(page).to have_content 'Verified'
- end
- end
-
- it 'shows popover badges' do
- gpg_user = create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard'
- Sidekiq::Testing.inline! do
- create :gpg_key, key: GpgHelpers::User1.public_key, user: gpg_user
- end
-
- user = create :user
- project.team << [user, :master]
-
- sign_in(user)
- visit project_commits_path(project, :'signed-commits')
-
- # unverified signature
- click_on 'Unverified', match: :first
- within '.popover' do
- expect(page).to have_content 'This commit was signed with an unverified signature.'
- expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}"
- end
-
- # verified and the gpg user has a gitlab profile
- click_on 'Verified', match: :first
- within '.popover' do
- expect(page).to have_content 'This commit was signed with a verified signature.'
- expect(page).to have_content 'Nannie Bernhard'
- expect(page).to have_content '@nannie.bernhard'
- expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}"
- end
-
- # verified and the gpg user's profile doesn't exist anymore
- gpg_user.destroy!
-
- visit project_commits_path(project, :'signed-commits')
-
- click_on 'Verified', match: :first
- within '.popover' do
- expect(page).to have_content 'This commit was signed with a verified signature.'
- expect(page).to have_content 'Nannie Bernhard'
- expect(page).to have_content 'nannie.bernhard@example.com'
- expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}"
- end
- end
- end
end
diff --git a/spec/features/merge_requests/user_posts_diff_notes_spec.rb b/spec/features/merge_requests/user_posts_diff_notes_spec.rb
index 877f305120e..442ce14eb7e 100644
--- a/spec/features/merge_requests/user_posts_diff_notes_spec.rb
+++ b/spec/features/merge_requests/user_posts_diff_notes_spec.rb
@@ -97,6 +97,16 @@ feature 'Merge requests > User posts diff notes', :js do
visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
end
+ context 'after deleteing a note' do
+ it 'allows commenting' do
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
+
+ first('.js-note-delete', visible: false).trigger('click')
+
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
+ end
+ end
+
context 'with a new line' do
it 'allows commenting' do
should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
diff --git a/spec/features/profiles/gpg_keys_spec.rb b/spec/features/profiles/gpg_keys_spec.rb
index 6edc482b47e..623e4f341c5 100644
--- a/spec/features/profiles/gpg_keys_spec.rb
+++ b/spec/features/profiles/gpg_keys_spec.rb
@@ -42,7 +42,7 @@ feature 'Profile > GPG Keys' do
scenario 'User revokes a key via the key index' do
gpg_key = create :gpg_key, user: user, key: GpgHelpers::User2.public_key
- gpg_signature = create :gpg_signature, gpg_key: gpg_key, valid_signature: true
+ gpg_signature = create :gpg_signature, gpg_key: gpg_key, verification_status: :verified
visit profile_gpg_keys_path
@@ -51,7 +51,7 @@ feature 'Profile > GPG Keys' do
expect(page).to have_content('Your GPG keys (0)')
expect(gpg_signature.reload).to have_attributes(
- valid_signature: false,
+ verification_status: 'unknown_key',
gpg_key: nil
)
end
diff --git a/spec/features/signed_commits_spec.rb b/spec/features/signed_commits_spec.rb
new file mode 100644
index 00000000000..8efa5b58141
--- /dev/null
+++ b/spec/features/signed_commits_spec.rb
@@ -0,0 +1,179 @@
+require 'spec_helper'
+
+describe 'GPG signed commits', :js do
+ let(:project) { create(:project, :repository) }
+
+ it 'changes from unverified to verified when the user changes his email to match the gpg key' do
+ user = create :user, email: 'unrelated.user@example.org'
+ project.team << [user, :master]
+
+ Sidekiq::Testing.inline! do
+ create :gpg_key, key: GpgHelpers::User1.public_key, user: user
+ end
+
+ sign_in(user)
+
+ visit project_commits_path(project, :'signed-commits')
+
+ within '#commits-list' do
+ expect(page).to have_content 'Unverified'
+ expect(page).not_to have_content 'Verified'
+ end
+
+ # user changes his email which makes the gpg key verified
+ Sidekiq::Testing.inline! do
+ user.skip_reconfirmation!
+ user.update_attributes!(email: GpgHelpers::User1.emails.first)
+ end
+
+ visit project_commits_path(project, :'signed-commits')
+
+ within '#commits-list' do
+ expect(page).to have_content 'Unverified'
+ expect(page).to have_content 'Verified'
+ end
+ end
+
+ it 'changes from unverified to verified when the user adds the missing gpg key' do
+ user = create :user, email: GpgHelpers::User1.emails.first
+ project.team << [user, :master]
+
+ sign_in(user)
+
+ visit project_commits_path(project, :'signed-commits')
+
+ within '#commits-list' do
+ expect(page).to have_content 'Unverified'
+ expect(page).not_to have_content 'Verified'
+ end
+
+ # user adds the gpg key which makes the signature valid
+ Sidekiq::Testing.inline! do
+ create :gpg_key, key: GpgHelpers::User1.public_key, user: user
+ end
+
+ visit project_commits_path(project, :'signed-commits')
+
+ within '#commits-list' do
+ expect(page).to have_content 'Unverified'
+ expect(page).to have_content 'Verified'
+ end
+ end
+
+ context 'shows popover badges' do
+ let(:user_1) do
+ create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard'
+ end
+
+ let(:user_1_key) do
+ Sidekiq::Testing.inline! do
+ create :gpg_key, key: GpgHelpers::User1.public_key, user: user_1
+ end
+ end
+
+ let(:user_2) do
+ create(:user, email: GpgHelpers::User2.emails.first, username: 'bette.cartwright', name: 'Bette Cartwright').tap do |user|
+ # secondary, unverified email
+ create :email, user: user, email: GpgHelpers::User2.emails.last
+ end
+ end
+
+ let(:user_2_key) do
+ Sidekiq::Testing.inline! do
+ create :gpg_key, key: GpgHelpers::User2.public_key, user: user_2
+ end
+ end
+
+ before do
+ user = create :user
+ project.team << [user, :master]
+
+ sign_in(user)
+ end
+
+ it 'unverified signature' do
+ visit project_commits_path(project, :'signed-commits')
+
+ within(find('.commit', text: 'signed commit by bette cartwright')) do
+ click_on 'Unverified'
+ within '.popover' do
+ expect(page).to have_content 'This commit was signed with an unverified signature.'
+ expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}"
+ end
+ end
+ end
+
+ it 'unverified signature: user email does not match the committer email, but is the same user' do
+ user_2_key
+
+ visit project_commits_path(project, :'signed-commits')
+
+ within(find('.commit', text: 'signed and authored commit by bette cartwright, different email')) do
+ click_on 'Unverified'
+ within '.popover' do
+ expect(page).to have_content 'This commit was signed with a verified signature, but the committer email is not verified to belong to the same user.'
+ expect(page).to have_content 'Bette Cartwright'
+ expect(page).to have_content '@bette.cartwright'
+ expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}"
+ end
+ end
+ end
+
+ it 'unverified signature: user email does not match the committer email' do
+ user_2_key
+
+ visit project_commits_path(project, :'signed-commits')
+
+ within(find('.commit', text: 'signed commit by bette cartwright')) do
+ click_on 'Unverified'
+ within '.popover' do
+ expect(page).to have_content "This commit was signed with a different user's verified signature."
+ expect(page).to have_content 'Bette Cartwright'
+ expect(page).to have_content '@bette.cartwright'
+ expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}"
+ end
+ end
+ end
+
+ it 'verified and the gpg user has a gitlab profile' do
+ user_1_key
+
+ visit project_commits_path(project, :'signed-commits')
+
+ within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do
+ click_on 'Verified'
+ within '.popover' do
+ expect(page).to have_content 'This commit was signed with a verified signature and the committer email is verified to belong to the same user.'
+ expect(page).to have_content 'Nannie Bernhard'
+ expect(page).to have_content '@nannie.bernhard'
+ expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}"
+ end
+ end
+ end
+
+ it "verified and the gpg user's profile doesn't exist anymore" do
+ user_1_key
+
+ visit project_commits_path(project, :'signed-commits')
+
+ # wait for the signature to get generated
+ within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do
+ expect(page).to have_content 'Verified'
+ end
+
+ user_1.destroy!
+
+ refresh
+
+ within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do
+ click_on 'Verified'
+ within '.popover' do
+ expect(page).to have_content 'This commit was signed with a verified signature and the committer email is verified to belong to the same user.'
+ expect(page).to have_content 'Nannie Bernhard'
+ expect(page).to have_content 'nannie.bernhard@example.com'
+ expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}"
+ end
+ end
+ end
+ end
+end
diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js
index 8c68ceff914..2aa4fb1f6c6 100644
--- a/spec/javascripts/api_spec.js
+++ b/spec/javascripts/api_spec.js
@@ -101,12 +101,13 @@ describe('Api', () => {
it('fetches projects with membership when logged in', (done) => {
const query = 'dummy query';
const options = { unused: 'option' };
- const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`;
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`;
window.gon.current_user_id = 1;
const expectedData = Object.assign({
search: query,
per_page: 20,
membership: true,
+ simple: true,
}, options);
spyOn(jQuery, 'ajax').and.callFake((request) => {
expect(request.url).toEqual(expectedUrl);
@@ -124,10 +125,11 @@ describe('Api', () => {
it('fetches projects without membership when not logged in', (done) => {
const query = 'dummy query';
const options = { unused: 'option' };
- const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`;
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`;
const expectedData = Object.assign({
search: query,
per_page: 20,
+ simple: true,
}, options);
spyOn(jQuery, 'ajax').and.callFake((request) => {
expect(request.url).toEqual(expectedUrl);
diff --git a/spec/javascripts/project_title_spec.js b/spec/javascripts/project_title_spec.js
index 3d36bb3e4d4..9ea8414ae40 100644
--- a/spec/javascripts/project_title_spec.js
+++ b/spec/javascripts/project_title_spec.js
@@ -41,12 +41,13 @@ describe('Project Title', () => {
window.gon.current_user_id = 1;
$('.js-projects-dropdown-toggle').click();
expect($menu).toHaveClass('open');
- expect(reqUrl).toBe(`/api/${dummyApiVersion}/projects.json?simple=true`);
+ expect(reqUrl).toBe(`/api/${dummyApiVersion}/projects.json`);
expect(reqData).toEqual({
search: '',
order_by: 'last_activity_at',
per_page: 20,
membership: true,
+ simple: true,
});
$menu.find('.dropdown-menu-close-icon').click();
expect($menu).not.toHaveClass('open');
diff --git a/spec/javascripts/projects_dropdown/components/app_spec.js b/spec/javascripts/projects_dropdown/components/app_spec.js
new file mode 100644
index 00000000000..42f0f6fc1af
--- /dev/null
+++ b/spec/javascripts/projects_dropdown/components/app_spec.js
@@ -0,0 +1,348 @@
+import Vue from 'vue';
+
+import bp from '~/breakpoints';
+import appComponent from '~/projects_dropdown/components/app.vue';
+import eventHub from '~/projects_dropdown/event_hub';
+import ProjectsStore from '~/projects_dropdown/store/projects_store';
+import ProjectsService from '~/projects_dropdown/service/projects_service';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+import { currentSession, mockProject, mockRawProject } from '../mock_data';
+
+const createComponent = () => {
+ gon.api_version = currentSession.apiVersion;
+ const Component = Vue.extend(appComponent);
+ const store = new ProjectsStore();
+ const service = new ProjectsService(currentSession.username);
+
+ return mountComponent(Component, {
+ store,
+ service,
+ currentUserName: currentSession.username,
+ currentProject: currentSession.project,
+ });
+};
+
+const returnServicePromise = (data, failed) => new Promise((resolve, reject) => {
+ if (failed) {
+ reject(data);
+ } else {
+ resolve({
+ json() {
+ return data;
+ },
+ });
+ }
+});
+
+describe('AppComponent', () => {
+ describe('computed', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('frequentProjects', () => {
+ it('should return list of frequently accessed projects from store', () => {
+ expect(vm.frequentProjects).toBeDefined();
+ expect(vm.frequentProjects.length).toBe(0);
+
+ vm.store.setFrequentProjects([mockProject]);
+ expect(vm.frequentProjects).toBeDefined();
+ expect(vm.frequentProjects.length).toBe(1);
+ });
+ });
+
+ describe('searchProjects', () => {
+ it('should return list of frequently accessed projects from store', () => {
+ expect(vm.searchProjects).toBeDefined();
+ expect(vm.searchProjects.length).toBe(0);
+
+ vm.store.setSearchedProjects([mockRawProject]);
+ expect(vm.searchProjects).toBeDefined();
+ expect(vm.searchProjects.length).toBe(1);
+ });
+ });
+ });
+
+ describe('methods', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('toggleFrequentProjectsList', () => {
+ it('should toggle props which control visibility of Frequent Projects list from state passed', () => {
+ vm.toggleFrequentProjectsList(true);
+ expect(vm.isLoadingProjects).toBeFalsy();
+ expect(vm.isSearchListVisible).toBeFalsy();
+ expect(vm.isFrequentsListVisible).toBeTruthy();
+
+ vm.toggleFrequentProjectsList(false);
+ expect(vm.isLoadingProjects).toBeTruthy();
+ expect(vm.isSearchListVisible).toBeTruthy();
+ expect(vm.isFrequentsListVisible).toBeFalsy();
+ });
+ });
+
+ describe('toggleSearchProjectsList', () => {
+ it('should toggle props which control visibility of Searched Projects list from state passed', () => {
+ vm.toggleSearchProjectsList(true);
+ expect(vm.isLoadingProjects).toBeFalsy();
+ expect(vm.isFrequentsListVisible).toBeFalsy();
+ expect(vm.isSearchListVisible).toBeTruthy();
+
+ vm.toggleSearchProjectsList(false);
+ expect(vm.isLoadingProjects).toBeTruthy();
+ expect(vm.isFrequentsListVisible).toBeTruthy();
+ expect(vm.isSearchListVisible).toBeFalsy();
+ });
+ });
+
+ describe('toggleLoader', () => {
+ it('should toggle props which control visibility of list loading animation from state passed', () => {
+ vm.toggleLoader(true);
+ expect(vm.isFrequentsListVisible).toBeFalsy();
+ expect(vm.isSearchListVisible).toBeFalsy();
+ expect(vm.isLoadingProjects).toBeTruthy();
+
+ vm.toggleLoader(false);
+ expect(vm.isFrequentsListVisible).toBeTruthy();
+ expect(vm.isSearchListVisible).toBeTruthy();
+ expect(vm.isLoadingProjects).toBeFalsy();
+ });
+ });
+
+ describe('fetchFrequentProjects', () => {
+ it('should set props for loading animation to `true` while frequent projects list is being loaded', () => {
+ spyOn(vm, 'toggleLoader');
+
+ vm.fetchFrequentProjects();
+ expect(vm.isLocalStorageFailed).toBeFalsy();
+ expect(vm.toggleLoader).toHaveBeenCalledWith(true);
+ });
+
+ it('should set props for loading animation to `false` and props for frequent projects list to `true` once data is loaded', () => {
+ const mockData = [mockProject];
+
+ spyOn(vm.service, 'getFrequentProjects').and.returnValue(mockData);
+ spyOn(vm.store, 'setFrequentProjects');
+ spyOn(vm, 'toggleFrequentProjectsList');
+
+ vm.fetchFrequentProjects();
+ expect(vm.service.getFrequentProjects).toHaveBeenCalled();
+ expect(vm.store.setFrequentProjects).toHaveBeenCalledWith(mockData);
+ expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true);
+ });
+
+ it('should set props for failure message to `true` when method fails to fetch frequent projects list', () => {
+ spyOn(vm.service, 'getFrequentProjects').and.returnValue(null);
+ spyOn(vm.store, 'setFrequentProjects');
+ spyOn(vm, 'toggleFrequentProjectsList');
+
+ expect(vm.isLocalStorageFailed).toBeFalsy();
+
+ vm.fetchFrequentProjects();
+ expect(vm.service.getFrequentProjects).toHaveBeenCalled();
+ expect(vm.store.setFrequentProjects).toHaveBeenCalledWith([]);
+ expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true);
+ expect(vm.isLocalStorageFailed).toBeTruthy();
+ });
+
+ it('should set props for search results list to `true` if search query was already made previously', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('md');
+ spyOn(vm.service, 'getFrequentProjects');
+ spyOn(vm, 'toggleSearchProjectsList');
+
+ vm.searchQuery = 'test';
+ vm.fetchFrequentProjects();
+ expect(vm.service.getFrequentProjects).not.toHaveBeenCalled();
+ expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
+ });
+
+ it('should set props for frequent projects list to `true` if search query was already made but screen size is less than 768px', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('sm');
+ spyOn(vm, 'toggleSearchProjectsList');
+ spyOn(vm.service, 'getFrequentProjects');
+
+ vm.searchQuery = 'test';
+ vm.fetchFrequentProjects();
+ expect(vm.service.getFrequentProjects).toHaveBeenCalled();
+ expect(vm.toggleSearchProjectsList).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('fetchSearchedProjects', () => {
+ const searchQuery = 'test';
+
+ it('should perform search with provided search query', (done) => {
+ const mockData = [mockRawProject];
+ spyOn(vm, 'toggleLoader');
+ spyOn(vm, 'toggleSearchProjectsList');
+ spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise(mockData));
+ spyOn(vm.store, 'setSearchedProjects');
+
+ vm.fetchSearchedProjects(searchQuery);
+ setTimeout(() => {
+ expect(vm.searchQuery).toBe(searchQuery);
+ expect(vm.toggleLoader).toHaveBeenCalledWith(true);
+ expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery);
+ expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
+ expect(vm.store.setSearchedProjects).toHaveBeenCalledWith(mockData);
+ done();
+ }, 0);
+ });
+
+ it('should update props for showing search failure', (done) => {
+ spyOn(vm, 'toggleSearchProjectsList');
+ spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise({}, true));
+
+ vm.fetchSearchedProjects(searchQuery);
+ setTimeout(() => {
+ expect(vm.searchQuery).toBe(searchQuery);
+ expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery);
+ expect(vm.isSearchFailed).toBeTruthy();
+ expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
+ done();
+ }, 0);
+ });
+ });
+
+ describe('logCurrentProjectAccess', () => {
+ it('should log current project access via service', (done) => {
+ spyOn(vm.service, 'logProjectAccess');
+
+ vm.currentProject = mockProject;
+ vm.logCurrentProjectAccess();
+
+ setTimeout(() => {
+ expect(vm.service.logProjectAccess).toHaveBeenCalledWith(mockProject);
+ done();
+ }, 1);
+ });
+ });
+
+ describe('handleSearchClear', () => {
+ it('should show frequent projects list when search input is cleared', () => {
+ spyOn(vm.store, 'clearSearchedProjects');
+ spyOn(vm, 'toggleFrequentProjectsList');
+
+ vm.handleSearchClear();
+
+ expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true);
+ expect(vm.store.clearSearchedProjects).toHaveBeenCalled();
+ expect(vm.searchQuery).toBe('');
+ });
+ });
+
+ describe('handleSearchFailure', () => {
+ it('should show failure message within dropdown', () => {
+ spyOn(vm, 'toggleSearchProjectsList');
+
+ vm.handleSearchFailure();
+ expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
+ expect(vm.isSearchFailed).toBeTruthy();
+ });
+ });
+ });
+
+ describe('created', () => {
+ it('should bind event listeners on eventHub', (done) => {
+ spyOn(eventHub, '$on');
+
+ createComponent().$mount();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('searchProjects', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('searchCleared', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('searchFailed', jasmine.any(Function));
+ done();
+ });
+ });
+ });
+
+ describe('beforeDestroy', () => {
+ it('should unbind event listeners on eventHub', (done) => {
+ const vm = createComponent();
+ spyOn(eventHub, '$off');
+
+ vm.$mount();
+ vm.$destroy();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('searchProjects', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('searchCleared', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('searchFailed', jasmine.any(Function));
+ done();
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render search input', () => {
+ expect(vm.$el.querySelector('.search-input-container')).toBeDefined();
+ });
+
+ it('should render loading animation', (done) => {
+ vm.toggleLoader(true);
+ Vue.nextTick(() => {
+ const loadingEl = vm.$el.querySelector('.loading-animation');
+
+ expect(loadingEl).toBeDefined();
+ expect(loadingEl.classList.contains('prepend-top-20')).toBeTruthy();
+ expect(loadingEl.querySelector('i').getAttribute('aria-label')).toBe('Loading projects');
+ done();
+ });
+ });
+
+ it('should render frequent projects list header', (done) => {
+ vm.toggleFrequentProjectsList(true);
+ Vue.nextTick(() => {
+ const sectionHeaderEl = vm.$el.querySelector('.section-header');
+
+ expect(sectionHeaderEl).toBeDefined();
+ expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited');
+ done();
+ });
+ });
+
+ it('should render frequent projects list', (done) => {
+ vm.toggleFrequentProjectsList(true);
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.projects-list-frequent-container')).toBeDefined();
+ done();
+ });
+ });
+
+ it('should render searched projects list', (done) => {
+ vm.toggleSearchProjectsList(true);
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.section-header')).toBe(null);
+ expect(vm.$el.querySelector('.projects-list-search-container')).toBeDefined();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js
new file mode 100644
index 00000000000..fcd0f6a3630
--- /dev/null
+++ b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js
@@ -0,0 +1,72 @@
+import Vue from 'vue';
+
+import projectsListFrequentComponent from '~/projects_dropdown/components/projects_list_frequent.vue';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+import { mockFrequents } from '../mock_data';
+
+const createComponent = () => {
+ const Component = Vue.extend(projectsListFrequentComponent);
+
+ return mountComponent(Component, {
+ projects: mockFrequents,
+ localStorageFailed: false,
+ });
+};
+
+describe('ProjectsListFrequentComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('isListEmpty', () => {
+ it('should return `true` or `false` representing whether if `projects` is empty of not', () => {
+ vm.projects = [];
+ expect(vm.isListEmpty).toBeTruthy();
+
+ vm.projects = mockFrequents;
+ expect(vm.isListEmpty).toBeFalsy();
+ });
+ });
+
+ describe('listEmptyMessage', () => {
+ it('should return appropriate empty list message based on value of `localStorageFailed` prop', () => {
+ vm.localStorageFailed = true;
+ expect(vm.listEmptyMessage).toBe('This feature requires browser localStorage support');
+
+ vm.localStorageFailed = false;
+ expect(vm.listEmptyMessage).toBe('Projects you visit often will appear here');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component element with list of projects', (done) => {
+ vm.projects = mockFrequents;
+
+ Vue.nextTick(() => {
+ expect(vm.$el.classList.contains('projects-list-frequent-container')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1);
+ expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(5);
+ done();
+ });
+ });
+
+ it('should render component element with empty message', (done) => {
+ vm.projects = [];
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1);
+ expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js
new file mode 100644
index 00000000000..171629fcd6b
--- /dev/null
+++ b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+
+import projectsListItemComponent from '~/projects_dropdown/components/projects_list_item.vue';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+import { mockProject } from '../mock_data';
+
+const createComponent = () => {
+ const Component = Vue.extend(projectsListItemComponent);
+
+ return mountComponent(Component, {
+ projectId: mockProject.id,
+ projectName: mockProject.name,
+ namespace: mockProject.namespace,
+ webUrl: mockProject.webUrl,
+ avatarUrl: mockProject.avatarUrl,
+ });
+};
+
+describe('ProjectsListItemComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('hasAvatar', () => {
+ it('should return `true` or `false` if whether avatar is present or not', () => {
+ vm.avatarUrl = 'path/to/avatar.png';
+ expect(vm.hasAvatar).toBeTruthy();
+
+ vm.avatarUrl = null;
+ expect(vm.hasAvatar).toBeFalsy();
+ });
+ });
+
+ describe('highlightedProjectName', () => {
+ it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => {
+ vm.matcher = 'lab';
+ expect(vm.highlightedProjectName).toContain('<b>Lab</b>');
+ });
+
+ it('should return project name as it is if `matcher` is not available', () => {
+ vm.matcher = null;
+ expect(vm.highlightedProjectName).toBe(mockProject.name);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component element', () => {
+ expect(vm.$el.classList.contains('projects-list-item-container')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('a').length).toBe(1);
+ expect(vm.$el.querySelectorAll('.project-item-avatar-container').length).toBe(1);
+ expect(vm.$el.querySelectorAll('.project-item-metadata-container').length).toBe(1);
+ expect(vm.$el.querySelectorAll('.project-title').length).toBe(1);
+ expect(vm.$el.querySelectorAll('.project-namespace').length).toBe(1);
+ });
+ });
+});
diff --git a/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js
new file mode 100644
index 00000000000..59fc2dedba5
--- /dev/null
+++ b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+
+import projectsListSearchComponent from '~/projects_dropdown/components/projects_list_search.vue';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+import { mockProject } from '../mock_data';
+
+const createComponent = () => {
+ const Component = Vue.extend(projectsListSearchComponent);
+
+ return mountComponent(Component, {
+ projects: [mockProject],
+ matcher: 'lab',
+ searchFailed: false,
+ });
+};
+
+describe('ProjectsListSearchComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('isListEmpty', () => {
+ it('should return `true` or `false` representing whether if `projects` is empty of not', () => {
+ vm.projects = [];
+ expect(vm.isListEmpty).toBeTruthy();
+
+ vm.projects = [mockProject];
+ expect(vm.isListEmpty).toBeFalsy();
+ });
+ });
+
+ describe('listEmptyMessage', () => {
+ it('should return appropriate empty list message based on value of `searchFailed` prop', () => {
+ vm.searchFailed = true;
+ expect(vm.listEmptyMessage).toBe('Something went wrong on our end.');
+
+ vm.searchFailed = false;
+ expect(vm.listEmptyMessage).toBe('No projects matched your query');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component element with list of projects', (done) => {
+ vm.projects = [mockProject];
+
+ Vue.nextTick(() => {
+ expect(vm.$el.classList.contains('projects-list-search-container')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1);
+ expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(1);
+ done();
+ });
+ });
+
+ it('should render component element with empty message', (done) => {
+ vm.projects = [];
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1);
+ expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0);
+ done();
+ });
+ });
+
+ it('should render component element with failure message', (done) => {
+ vm.searchFailed = true;
+ vm.projects = [];
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelectorAll('li.section-empty.section-failure').length).toBe(1);
+ expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/projects_dropdown/components/search_spec.js b/spec/javascripts/projects_dropdown/components/search_spec.js
new file mode 100644
index 00000000000..f2a23e33325
--- /dev/null
+++ b/spec/javascripts/projects_dropdown/components/search_spec.js
@@ -0,0 +1,101 @@
+import Vue from 'vue';
+
+import searchComponent from '~/projects_dropdown/components/search.vue';
+import eventHub from '~/projects_dropdown/event_hub';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = () => {
+ const Component = Vue.extend(searchComponent);
+
+ return mountComponent(Component);
+};
+
+describe('SearchComponent', () => {
+ describe('methods', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('setFocus', () => {
+ it('should set focus to search input', () => {
+ spyOn(vm.$refs.search, 'focus');
+
+ vm.setFocus();
+ expect(vm.$refs.search.focus).toHaveBeenCalled();
+ });
+ });
+
+ describe('emitSearchEvents', () => {
+ it('should emit `searchProjects` event via eventHub when `searchQuery` present', () => {
+ const searchQuery = 'test';
+ spyOn(eventHub, '$emit');
+ vm.searchQuery = searchQuery;
+ vm.emitSearchEvents();
+ expect(eventHub.$emit).toHaveBeenCalledWith('searchProjects', searchQuery);
+ });
+
+ it('should emit `searchCleared` event via eventHub when `searchQuery` is cleared', () => {
+ spyOn(eventHub, '$emit');
+ vm.searchQuery = '';
+ vm.emitSearchEvents();
+ expect(eventHub.$emit).toHaveBeenCalledWith('searchCleared');
+ });
+ });
+ });
+
+ describe('mounted', () => {
+ it('should listen `dropdownOpen` event', (done) => {
+ spyOn(eventHub, '$on');
+ createComponent();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
+ done();
+ });
+ });
+ });
+
+ describe('beforeDestroy', () => {
+ it('should unbind event listeners on eventHub', (done) => {
+ const vm = createComponent();
+ spyOn(eventHub, '$off');
+
+ vm.$mount();
+ vm.$destroy();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
+ done();
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render component element', () => {
+ const inputEl = vm.$el.querySelector('input.form-control');
+
+ expect(vm.$el.classList.contains('search-input-container')).toBeTruthy();
+ expect(vm.$el.classList.contains('hidden-xs')).toBeTruthy();
+ expect(inputEl).not.toBe(null);
+ expect(inputEl.getAttribute('placeholder')).toBe('Search projects');
+ expect(vm.$el.querySelector('.search-icon')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/projects_dropdown/mock_data.js b/spec/javascripts/projects_dropdown/mock_data.js
new file mode 100644
index 00000000000..d6a79fb8ac1
--- /dev/null
+++ b/spec/javascripts/projects_dropdown/mock_data.js
@@ -0,0 +1,96 @@
+export const currentSession = {
+ username: 'root',
+ storageKey: 'root/frequent-projects',
+ apiVersion: 'v4',
+ project: {
+ id: 1,
+ name: 'dummy-project',
+ namespace: 'SamepleGroup / Dummy-Project',
+ webUrl: 'http://127.0.0.1/samplegroup/dummy-project',
+ avatarUrl: null,
+ lastAccessedOn: Date.now(),
+ },
+};
+
+export const mockProject = {
+ id: 1,
+ name: 'GitLab Community Edition',
+ namespace: 'gitlab-org / gitlab-ce',
+ webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce',
+ avatarUrl: null,
+};
+
+export const mockRawProject = {
+ id: 1,
+ name: 'GitLab Community Edition',
+ name_with_namespace: 'gitlab-org / gitlab-ce',
+ web_url: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce',
+ avatar_url: null,
+};
+
+export const mockFrequents = [
+ {
+ id: 1,
+ name: 'GitLab Community Edition',
+ namespace: 'gitlab-org / gitlab-ce',
+ webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce',
+ avatarUrl: null,
+ },
+ {
+ id: 2,
+ name: 'GitLab CI',
+ namespace: 'gitlab-org / gitlab-ci',
+ webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ci',
+ avatarUrl: null,
+ },
+ {
+ id: 3,
+ name: 'Typeahead.Js',
+ namespace: 'twitter / typeahead-js',
+ webUrl: 'http://127.0.0.1:3000/twitter/typeahead-js',
+ avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png',
+ },
+ {
+ id: 4,
+ name: 'Intel',
+ namespace: 'platform / hardware / bsp / intel',
+ webUrl: 'http://127.0.0.1:3000/platform/hardware/bsp/intel',
+ avatarUrl: null,
+ },
+ {
+ id: 5,
+ name: 'v4.4',
+ namespace: 'platform / hardware / bsp / kernel / common / v4.4',
+ webUrl: 'http://localhost:3000/platform/hardware/bsp/kernel/common/v4.4',
+ avatarUrl: null,
+ },
+];
+
+export const unsortedFrequents = [
+ { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
+ { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
+ { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
+ { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
+ { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
+ { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
+ { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
+ { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
+ { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
+];
+
+/**
+ * This const has a specific order which tests authenticity
+ * of `ProjectsService.getTopFrequentProjects` method so
+ * DO NOT change order of items in this const.
+ */
+export const sortedFrequents = [
+ { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
+ { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
+ { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
+ { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
+ { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
+ { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
+ { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
+ { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
+ { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
+];
diff --git a/spec/javascripts/projects_dropdown/service/projects_service_spec.js b/spec/javascripts/projects_dropdown/service/projects_service_spec.js
new file mode 100644
index 00000000000..e9aabfaec1f
--- /dev/null
+++ b/spec/javascripts/projects_dropdown/service/projects_service_spec.js
@@ -0,0 +1,178 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+import bp from '~/breakpoints';
+import ProjectsService from '~/projects_dropdown/service/projects_service';
+import { FREQUENT_PROJECTS } from '~/projects_dropdown/constants';
+import { currentSession, unsortedFrequents, sortedFrequents } from '../mock_data';
+
+Vue.use(VueResource);
+
+FREQUENT_PROJECTS.MAX_COUNT = 3;
+
+describe('ProjectsService', () => {
+ let service;
+
+ beforeEach(() => {
+ gon.api_version = currentSession.apiVersion;
+ service = new ProjectsService(currentSession.username);
+ });
+
+ describe('contructor', () => {
+ it('should initialize default properties of class', () => {
+ expect(service.isLocalStorageAvailable).toBeTruthy();
+ expect(service.currentUserName).toBe(currentSession.username);
+ expect(service.storageKey).toBe(currentSession.storageKey);
+ expect(service.projectsPath).toBeDefined();
+ });
+ });
+
+ describe('getSearchedProjects', () => {
+ it('should return promise from VueResource HTTP GET', () => {
+ spyOn(service.projectsPath, 'get').and.stub();
+
+ const searchQuery = 'lab';
+ const queryParams = {
+ simple: false,
+ per_page: 20,
+ membership: false,
+ order_by: 'last_activity_at',
+ search: searchQuery,
+ };
+
+ service.getSearchedProjects(searchQuery);
+ expect(service.projectsPath.get).toHaveBeenCalledWith(queryParams);
+ });
+ });
+
+ describe('logProjectAccess', () => {
+ let storage;
+
+ beforeEach(() => {
+ storage = {};
+
+ spyOn(window.localStorage, 'setItem').and.callFake((storageKey, value) => {
+ storage[storageKey] = value;
+ });
+
+ spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => {
+ if (storage[storageKey]) {
+ return storage[storageKey];
+ }
+
+ return null;
+ });
+ });
+
+ it('should create a project store if it does not exist and adds a project', () => {
+ service.logProjectAccess(currentSession.project);
+
+ const projects = JSON.parse(storage[currentSession.storageKey]);
+ expect(projects.length).toBe(1);
+ expect(projects[0].frequency).toBe(1);
+ expect(projects[0].lastAccessedOn).toBeDefined();
+ });
+
+ it('should prevent inserting same report multiple times into store', () => {
+ service.logProjectAccess(currentSession.project);
+ service.logProjectAccess(currentSession.project);
+
+ const projects = JSON.parse(storage[currentSession.storageKey]);
+ expect(projects.length).toBe(1);
+ });
+
+ it('should increase frequency of report if it was logged multiple times over the course of an hour', () => {
+ let projects;
+ spyOn(Math, 'abs').and.returnValue(3600001); // this will lead to `diff` > 1;
+ service.logProjectAccess(currentSession.project);
+
+ projects = JSON.parse(storage[currentSession.storageKey]);
+ expect(projects[0].frequency).toBe(1);
+
+ service.logProjectAccess(currentSession.project);
+ projects = JSON.parse(storage[currentSession.storageKey]);
+ expect(projects[0].frequency).toBe(2);
+ expect(projects[0].lastAccessedOn).not.toBe(currentSession.project.lastAccessedOn);
+ });
+
+ it('should always update project metadata', () => {
+ let projects;
+ const oldProject = {
+ ...currentSession.project,
+ };
+
+ const newProject = {
+ ...currentSession.project,
+ name: 'New Name',
+ avatarUrl: 'new/avatar.png',
+ namespace: 'New / Namespace',
+ webUrl: 'http://localhost/new/web/url',
+ };
+
+ service.logProjectAccess(oldProject);
+ projects = JSON.parse(storage[currentSession.storageKey]);
+ expect(projects[0].name).toBe(oldProject.name);
+ expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl);
+ expect(projects[0].namespace).toBe(oldProject.namespace);
+ expect(projects[0].webUrl).toBe(oldProject.webUrl);
+
+ service.logProjectAccess(newProject);
+ projects = JSON.parse(storage[currentSession.storageKey]);
+ expect(projects[0].name).toBe(newProject.name);
+ expect(projects[0].avatarUrl).toBe(newProject.avatarUrl);
+ expect(projects[0].namespace).toBe(newProject.namespace);
+ expect(projects[0].webUrl).toBe(newProject.webUrl);
+ });
+
+ it('should not add more than 20 projects in store', () => {
+ for (let i = 1; i <= 5; i += 1) {
+ const project = Object.assign(currentSession.project, { id: i });
+ service.logProjectAccess(project);
+ }
+
+ const projects = JSON.parse(storage[currentSession.storageKey]);
+ expect(projects.length).toBe(3);
+ });
+ });
+
+ describe('getTopFrequentProjects', () => {
+ let storage = {};
+
+ beforeEach(() => {
+ storage[currentSession.storageKey] = JSON.stringify(unsortedFrequents);
+
+ spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => {
+ if (storage[storageKey]) {
+ return storage[storageKey];
+ }
+
+ return null;
+ });
+ });
+
+ it('should return top 5 frequently accessed projects for desktop screens', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('md');
+ const frequentProjects = service.getTopFrequentProjects();
+
+ expect(frequentProjects.length).toBe(5);
+ frequentProjects.forEach((project, index) => {
+ expect(project.id).toBe(sortedFrequents[index].id);
+ });
+ });
+
+ it('should return top 3 frequently accessed projects for mobile screens', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('sm');
+ const frequentProjects = service.getTopFrequentProjects();
+
+ expect(frequentProjects.length).toBe(3);
+ frequentProjects.forEach((project, index) => {
+ expect(project.id).toBe(sortedFrequents[index].id);
+ });
+ });
+
+ it('should return empty array if there are no projects available in store', () => {
+ storage = {};
+ expect(service.getTopFrequentProjects().length).toBe(0);
+ });
+ });
+});
diff --git a/spec/javascripts/projects_dropdown/store/projects_store_spec.js b/spec/javascripts/projects_dropdown/store/projects_store_spec.js
new file mode 100644
index 00000000000..e57399d37cd
--- /dev/null
+++ b/spec/javascripts/projects_dropdown/store/projects_store_spec.js
@@ -0,0 +1,41 @@
+import ProjectsStore from '~/projects_dropdown/store/projects_store';
+import { mockProject, mockRawProject } from '../mock_data';
+
+describe('ProjectsStore', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new ProjectsStore();
+ });
+
+ describe('setFrequentProjects', () => {
+ it('should set frequent projects list to state', () => {
+ store.setFrequentProjects([mockProject]);
+
+ expect(store.getFrequentProjects().length).toBe(1);
+ expect(store.getFrequentProjects()[0].id).toBe(mockProject.id);
+ });
+ });
+
+ describe('setSearchedProjects', () => {
+ it('should set searched projects list to state', () => {
+ store.setSearchedProjects([mockRawProject]);
+
+ const processedProjects = store.getSearchedProjects();
+ expect(processedProjects.length).toBe(1);
+ expect(processedProjects[0].id).toBe(mockRawProject.id);
+ expect(processedProjects[0].namespace).toBe(mockRawProject.name_with_namespace);
+ expect(processedProjects[0].webUrl).toBe(mockRawProject.web_url);
+ expect(processedProjects[0].avatarUrl).toBe(mockRawProject.avatar_url);
+ });
+ });
+
+ describe('clearSearchedProjects', () => {
+ it('should clear searched projects list from state', () => {
+ store.setSearchedProjects([mockRawProject]);
+ expect(store.getSearchedProjects().length).toBe(1);
+ store.clearSearchedProjects();
+ expect(store.getSearchedProjects().length).toBe(0);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/identicon_spec.js b/spec/javascripts/vue_shared/components/identicon_spec.js
index 4f194e5a64e..647680f00f7 100644
--- a/spec/javascripts/vue_shared/components/identicon_spec.js
+++ b/spec/javascripts/vue_shared/components/identicon_spec.js
@@ -1,25 +1,30 @@
import Vue from 'vue';
import identiconComponent from '~/vue_shared/components/identicon.vue';
-const createComponent = () => {
+const createComponent = (sizeClass) => {
const Component = Vue.extend(identiconComponent);
return new Component({
propsData: {
entityId: 1,
entityName: 'entity-name',
+ sizeClass,
},
}).$mount();
};
describe('IdenticonComponent', () => {
- let vm;
+ describe('computed', () => {
+ let vm;
- beforeEach(() => {
- vm = createComponent();
- });
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
- describe('computed', () => {
describe('identiconStyles', () => {
it('should return styles attribute value with `background-color` property', () => {
vm.entityId = 4;
@@ -48,9 +53,20 @@ describe('IdenticonComponent', () => {
describe('template', () => {
it('should render identicon', () => {
+ const vm = createComponent();
+
expect(vm.$el.nodeName).toBe('DIV');
expect(vm.$el.classList.contains('identicon')).toBeTruthy();
+ expect(vm.$el.classList.contains('s40')).toBeTruthy();
expect(vm.$el.getAttribute('style').indexOf('background-color') > -1).toBeTruthy();
+ vm.$destroy();
+ });
+
+ it('should render identicon with provided sizing class', () => {
+ const vm = createComponent('s32');
+
+ expect(vm.$el.classList.contains('s32')).toBeTruthy();
+ vm.$destroy();
});
});
});
diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb
index e521fcc6dc1..b07462e4978 100644
--- a/spec/lib/gitlab/gpg/commit_spec.rb
+++ b/spec/lib/gitlab/gpg/commit_spec.rb
@@ -2,45 +2,9 @@ require 'rails_helper'
describe Gitlab::Gpg::Commit do
describe '#signature' do
- let!(:project) { create :project, :repository, path: 'sample-project' }
- let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
-
- context 'unsigned commit' do
- it 'returns nil' do
- expect(described_class.new(project, commit_sha).signature).to be_nil
- end
- end
-
- context 'known and verified public key' do
- let!(:gpg_key) do
- create :gpg_key, key: GpgHelpers::User1.public_key, user: create(:user, email: GpgHelpers::User1.emails.first)
- end
-
- before do
- allow(Rugged::Commit).to receive(:extract_signature)
- .with(Rugged::Repository, commit_sha)
- .and_return(
- [
- GpgHelpers::User1.signed_commit_signature,
- GpgHelpers::User1.signed_commit_base_data
- ]
- )
- end
-
- it 'returns a valid signature' do
- expect(described_class.new(project, commit_sha).signature).to have_attributes(
- commit_sha: commit_sha,
- project: project,
- gpg_key: gpg_key,
- gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- gpg_key_user_name: GpgHelpers::User1.names.first,
- gpg_key_user_email: GpgHelpers::User1.emails.first,
- valid_signature: true
- )
- end
-
+ shared_examples 'returns the cached signature on second call' do
it 'returns the cached signature on second call' do
- gpg_commit = described_class.new(project, commit_sha)
+ gpg_commit = described_class.new(commit)
expect(gpg_commit).to receive(:using_keychain).and_call_original
gpg_commit.signature
@@ -51,11 +15,140 @@ describe Gitlab::Gpg::Commit do
end
end
- context 'known but unverified public key' do
- let!(:gpg_key) { create :gpg_key, key: GpgHelpers::User1.public_key }
+ let!(:project) { create :project, :repository, path: 'sample-project' }
+ let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
- before do
- allow(Rugged::Commit).to receive(:extract_signature)
+ context 'unsigned commit' do
+ let!(:commit) { create :commit, project: project, sha: commit_sha }
+
+ it 'returns nil' do
+ expect(described_class.new(commit).signature).to be_nil
+ end
+ end
+
+ context 'known key' do
+ context 'user matches the key uid' do
+ context 'user email matches the email committer' do
+ let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User1.emails.first }
+
+ let!(:user) { create(:user, email: GpgHelpers::User1.emails.first) }
+
+ let!(:gpg_key) do
+ create :gpg_key, key: GpgHelpers::User1.public_key, user: user
+ end
+
+ before do
+ allow(Rugged::Commit).to receive(:extract_signature)
+ .with(Rugged::Repository, commit_sha)
+ .and_return(
+ [
+ GpgHelpers::User1.signed_commit_signature,
+ GpgHelpers::User1.signed_commit_base_data
+ ]
+ )
+ end
+
+ it 'returns a valid signature' do
+ expect(described_class.new(commit).signature).to have_attributes(
+ commit_sha: commit_sha,
+ project: project,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ gpg_key_user_name: GpgHelpers::User1.names.first,
+ gpg_key_user_email: GpgHelpers::User1.emails.first,
+ verification_status: 'verified'
+ )
+ end
+
+ it_behaves_like 'returns the cached signature on second call'
+ end
+
+ context 'user email does not match the committer email, but is the same user' do
+ let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User2.emails.first }
+
+ let(:user) do
+ create(:user, email: GpgHelpers::User1.emails.first).tap do |user|
+ create :email, user: user, email: GpgHelpers::User2.emails.first
+ end
+ end
+
+ let!(:gpg_key) do
+ create :gpg_key, key: GpgHelpers::User1.public_key, user: user
+ end
+
+ before do
+ allow(Rugged::Commit).to receive(:extract_signature)
+ .with(Rugged::Repository, commit_sha)
+ .and_return(
+ [
+ GpgHelpers::User1.signed_commit_signature,
+ GpgHelpers::User1.signed_commit_base_data
+ ]
+ )
+ end
+
+ it 'returns an invalid signature' do
+ expect(described_class.new(commit).signature).to have_attributes(
+ commit_sha: commit_sha,
+ project: project,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ gpg_key_user_name: GpgHelpers::User1.names.first,
+ gpg_key_user_email: GpgHelpers::User1.emails.first,
+ verification_status: 'same_user_different_email'
+ )
+ end
+
+ it_behaves_like 'returns the cached signature on second call'
+ end
+
+ context 'user email does not match the committer email' do
+ let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User2.emails.first }
+
+ let(:user) { create(:user, email: GpgHelpers::User1.emails.first) }
+
+ let!(:gpg_key) do
+ create :gpg_key, key: GpgHelpers::User1.public_key, user: user
+ end
+
+ before do
+ allow(Rugged::Commit).to receive(:extract_signature)
+ .with(Rugged::Repository, commit_sha)
+ .and_return(
+ [
+ GpgHelpers::User1.signed_commit_signature,
+ GpgHelpers::User1.signed_commit_base_data
+ ]
+ )
+ end
+
+ it 'returns an invalid signature' do
+ expect(described_class.new(commit).signature).to have_attributes(
+ commit_sha: commit_sha,
+ project: project,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ gpg_key_user_name: GpgHelpers::User1.names.first,
+ gpg_key_user_email: GpgHelpers::User1.emails.first,
+ verification_status: 'other_user'
+ )
+ end
+
+ it_behaves_like 'returns the cached signature on second call'
+ end
+ end
+
+ context 'user does not match the key uid' do
+ let!(:commit) { create :commit, project: project, sha: commit_sha }
+
+ let(:user) { create(:user, email: GpgHelpers::User2.emails.first) }
+
+ let!(:gpg_key) do
+ create :gpg_key, key: GpgHelpers::User1.public_key, user: user
+ end
+
+ before do
+ allow(Rugged::Commit).to receive(:extract_signature)
.with(Rugged::Repository, commit_sha)
.and_return(
[
@@ -63,33 +156,27 @@ describe Gitlab::Gpg::Commit do
GpgHelpers::User1.signed_commit_base_data
]
)
- end
-
- it 'returns an invalid signature' do
- expect(described_class.new(project, commit_sha).signature).to have_attributes(
- commit_sha: commit_sha,
- project: project,
- gpg_key: gpg_key,
- gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- gpg_key_user_name: GpgHelpers::User1.names.first,
- gpg_key_user_email: GpgHelpers::User1.emails.first,
- valid_signature: false
- )
- end
-
- it 'returns the cached signature on second call' do
- gpg_commit = described_class.new(project, commit_sha)
-
- expect(gpg_commit).to receive(:using_keychain).and_call_original
- gpg_commit.signature
+ end
+
+ it 'returns an invalid signature' do
+ expect(described_class.new(commit).signature).to have_attributes(
+ commit_sha: commit_sha,
+ project: project,
+ gpg_key: gpg_key,
+ gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
+ gpg_key_user_name: GpgHelpers::User1.names.first,
+ gpg_key_user_email: GpgHelpers::User1.emails.first,
+ verification_status: 'unverified_key'
+ )
+ end
- # consecutive call
- expect(gpg_commit).not_to receive(:using_keychain).and_call_original
- gpg_commit.signature
+ it_behaves_like 'returns the cached signature on second call'
end
end
- context 'unknown public key' do
+ context 'unknown key' do
+ let!(:commit) { create :commit, project: project, sha: commit_sha }
+
before do
allow(Rugged::Commit).to receive(:extract_signature)
.with(Rugged::Repository, commit_sha)
@@ -102,27 +189,18 @@ describe Gitlab::Gpg::Commit do
end
it 'returns an invalid signature' do
- expect(described_class.new(project, commit_sha).signature).to have_attributes(
+ expect(described_class.new(commit).signature).to have_attributes(
commit_sha: commit_sha,
project: project,
gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
gpg_key_user_name: nil,
gpg_key_user_email: nil,
- valid_signature: false
+ verification_status: 'unknown_key'
)
end
- it 'returns the cached signature on second call' do
- gpg_commit = described_class.new(project, commit_sha)
-
- expect(gpg_commit).to receive(:using_keychain).and_call_original
- gpg_commit.signature
-
- # consecutive call
- expect(gpg_commit).not_to receive(:using_keychain).and_call_original
- gpg_commit.signature
- end
+ it_behaves_like 'returns the cached signature on second call'
end
end
end
diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb
index 4de4419de27..b9fd4d02156 100644
--- a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb
+++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb
@@ -4,8 +4,29 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
describe '#run' do
let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
let!(:project) { create :project, :repository, path: 'sample-project' }
+ let!(:raw_commit) do
+ raw_commit = double(
+ :raw_commit,
+ signature: [
+ GpgHelpers::User1.signed_commit_signature,
+ GpgHelpers::User1.signed_commit_base_data
+ ],
+ sha: commit_sha,
+ committer_email: GpgHelpers::User1.emails.first
+ )
+
+ allow(raw_commit).to receive :save!
+
+ raw_commit
+ end
+
+ let!(:commit) do
+ create :commit, git_commit: raw_commit, project: project
+ end
before do
+ allow_any_instance_of(Project).to receive(:commit).and_return(commit)
+
allow(Rugged::Commit).to receive(:extract_signature)
.with(Rugged::Repository, commit_sha)
.and_return(
@@ -25,7 +46,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha,
gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- valid_signature: true
+ verification_status: 'verified'
end
it 'assigns the gpg key to the signature when the missing gpg key is added' do
@@ -39,7 +60,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha,
gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- valid_signature: true
+ verification_status: 'verified'
)
end
@@ -54,7 +75,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha,
gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- valid_signature: true
+ verification_status: 'verified'
)
end
end
@@ -68,7 +89,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha,
gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- valid_signature: false
+ verification_status: 'unknown_key'
end
it 'updates the signature to being valid when the missing gpg key is added' do
@@ -82,7 +103,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha,
gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- valid_signature: true
+ verification_status: 'verified'
)
end
@@ -97,7 +118,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha,
gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- valid_signature: false
+ verification_status: 'unknown_key'
)
end
end
@@ -115,7 +136,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha,
gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- valid_signature: false
+ verification_status: 'unknown_key'
end
it 'updates the signature to being valid when the user updates the email address' do
@@ -123,7 +144,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
key: GpgHelpers::User1.public_key,
user: user
- expect(invalid_gpg_signature.reload.valid_signature).to be_falsey
+ expect(invalid_gpg_signature.reload.verification_status).to eq 'unverified_key'
# InvalidGpgSignatureUpdater is called by the after_update hook
user.update_attributes!(email: GpgHelpers::User1.emails.first)
@@ -133,7 +154,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha,
gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- valid_signature: true
+ verification_status: 'verified'
)
end
@@ -147,7 +168,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha,
gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- valid_signature: false
+ verification_status: 'unverified_key'
)
# InvalidGpgSignatureUpdater is called by the after_update hook
@@ -158,7 +179,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha,
gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
- valid_signature: false
+ verification_status: 'unverified_key'
)
end
end
diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb
index 30ad033b204..11a2aea1915 100644
--- a/spec/lib/gitlab/gpg_spec.rb
+++ b/spec/lib/gitlab/gpg_spec.rb
@@ -42,6 +42,21 @@ describe Gitlab::Gpg do
described_class.user_infos_from_key('bogus')
).to eq []
end
+
+ it 'downcases the email' do
+ public_key = double(:key)
+ fingerprints = double(:fingerprints)
+ uid = double(:uid, name: 'Nannie Bernhard', email: 'NANNIE.BERNHARD@EXAMPLE.COM')
+ raw_key = double(:raw_key, uids: [uid])
+ allow(Gitlab::Gpg::CurrentKeyChain).to receive(:fingerprints_from_key).with(public_key).and_return(fingerprints)
+ allow(GPGME::Key).to receive(:find).with(:public, anything).and_return([raw_key])
+
+ user_infos = described_class.user_infos_from_key(public_key)
+ expect(user_infos).to eq([{
+ name: 'Nannie Bernhard',
+ email: 'nannie.bernhard@example.com'
+ }])
+ end
end
describe '.current_home_dir' do
diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb
index e48f20bf53b..9c99c3e5c08 100644
--- a/spec/models/gpg_key_spec.rb
+++ b/spec/models/gpg_key_spec.rb
@@ -99,14 +99,14 @@ describe GpgKey do
end
describe '#verified?' do
- it 'returns true one of the email addresses in the key belongs to the user' do
+ it 'returns true if one of the email addresses in the key belongs to the user' do
user = create :user, email: 'bette.cartwright@example.com'
gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
expect(gpg_key.verified?).to be_truthy
end
- it 'returns false if one of the email addresses in the key does not belong to the user' do
+ it 'returns false if none of the email addresses in the key does not belong to the user' do
user = create :user, email: 'someone.else@example.com'
gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
@@ -114,6 +114,32 @@ describe GpgKey do
end
end
+ describe 'verified_and_belongs_to_email?' do
+ it 'returns false if none of the email addresses in the key does not belong to the user' do
+ user = create :user, email: 'someone.else@example.com'
+ gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
+
+ expect(gpg_key.verified?).to be_falsey
+ expect(gpg_key.verified_and_belongs_to_email?('someone.else@example.com')).to be_falsey
+ end
+
+ it 'returns false if one of the email addresses in the key belongs to the user and does not match the provided email' do
+ user = create :user, email: 'bette.cartwright@example.com'
+ gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
+
+ expect(gpg_key.verified?).to be_truthy
+ expect(gpg_key.verified_and_belongs_to_email?('bette.cartwright@example.net')).to be_falsey
+ end
+
+ it 'returns true if one of the email addresses in the key belongs to the user and matches the provided email' do
+ user = create :user, email: 'bette.cartwright@example.com'
+ gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
+
+ expect(gpg_key.verified?).to be_truthy
+ expect(gpg_key.verified_and_belongs_to_email?('bette.cartwright@example.com')).to be_truthy
+ end
+ end
+
describe 'notification', :mailer do
let(:user) { create(:user) }
@@ -129,15 +155,15 @@ describe GpgKey do
describe '#revoke' do
it 'invalidates all associated gpg signatures and destroys the key' do
gpg_key = create :gpg_key
- gpg_signature = create :gpg_signature, valid_signature: true, gpg_key: gpg_key
+ gpg_signature = create :gpg_signature, verification_status: :verified, gpg_key: gpg_key
unrelated_gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key
- unrelated_gpg_signature = create :gpg_signature, valid_signature: true, gpg_key: unrelated_gpg_key
+ unrelated_gpg_signature = create :gpg_signature, verification_status: :verified, gpg_key: unrelated_gpg_key
gpg_key.revoke
expect(gpg_signature.reload).to have_attributes(
- valid_signature: false,
+ verification_status: 'unknown_key',
gpg_key: nil
)
@@ -145,7 +171,7 @@ describe GpgKey do
# unrelated signature is left untouched
expect(unrelated_gpg_signature.reload).to have_attributes(
- valid_signature: true,
+ verification_status: 'verified',
gpg_key: unrelated_gpg_key
)
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index b70ab5581ac..fd83a58ed9f 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -2102,4 +2102,18 @@ describe User do
end
end
end
+
+ describe '#verified_email?' do
+ it 'returns true when the email is the primary email' do
+ user = build :user, email: 'email@example.com'
+
+ expect(user.verified_email?('email@example.com')).to be true
+ end
+
+ it 'returns false when the email is not the primary email' do
+ user = build :user, email: 'email@example.com'
+
+ expect(user.verified_email?('other_email@example.com')).to be false
+ end
+ end
end
diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb
index b1e011de604..cc794fad3a7 100644
--- a/spec/requests/api/branches_spec.rb
+++ b/spec/requests/api/branches_spec.rb
@@ -75,6 +75,22 @@ describe API::Branches do
let(:route) { "/projects/#{project_id}/repository/branches/#{branch_name}" }
shared_examples_for 'repository branch' do
+ context 'HEAD request' do
+ it 'returns 204 No Content' do
+ head api(route, user)
+
+ expect(response).to have_gitlab_http_status(204)
+ expect(response.body).to be_empty
+ end
+
+ it 'returns 404 Not Found' do
+ head api("/projects/#{project_id}/repository/branches/unknown", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ expect(response.body).to be_empty
+ end
+ end
+
it 'returns the repository branch' do
get api(route, current_user)
diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb
index 1e39f80699c..290ded3ff7e 100644
--- a/spec/support/test_env.rb
+++ b/spec/support/test_env.rb
@@ -5,7 +5,7 @@ module TestEnv
# When developing the seed repository, comment out the branch you will modify.
BRANCH_SHA = {
- 'signed-commits' => '5d4a1cb',
+ 'signed-commits' => '2d1096e',
'not-merged-branch' => 'b83d6e3',
'branch-merged' => '498214d',
'empty-branch' => '7efb185',
diff --git a/spec/workers/create_gpg_signature_worker_spec.rb b/spec/workers/create_gpg_signature_worker_spec.rb
index 54978baca88..aa6c347d738 100644
--- a/spec/workers/create_gpg_signature_worker_spec.rb
+++ b/spec/workers/create_gpg_signature_worker_spec.rb
@@ -7,9 +7,14 @@ describe CreateGpgSignatureWorker do
let(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
it 'calls Gitlab::Gpg::Commit#signature' do
- expect(Gitlab::Gpg::Commit).to receive(:new).with(project, commit_sha).and_call_original
+ commit = instance_double(Commit)
+ gpg_commit = instance_double(Gitlab::Gpg::Commit)
- expect_any_instance_of(Gitlab::Gpg::Commit).to receive(:signature)
+ allow(Project).to receive(:find_by).with(id: project.id).and_return(project)
+ allow(project).to receive(:commit).with(commit_sha).and_return(commit)
+
+ expect(Gitlab::Gpg::Commit).to receive(:new).with(commit).and_return(gpg_commit)
+ expect(gpg_commit).to receive(:signature)
described_class.new.perform(commit_sha, project.id)
end