Commit 337a73e1 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'master' into breadcrumbs-improvements

parents 9477f4a8 d316a8c0
......@@ -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) {
......
......@@ -2,3 +2,4 @@ import 'underscore';
import './polyfills';
import './jquery';
import './bootstrap';
import './vue';
import Vue from 'vue';
import './vue_resource_interceptor';
if (process.env.NODE_ENV !== 'production') {
Vue.config.productionTip = false;
......
......@@ -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 = [];
......
......@@ -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 () {
......
......@@ -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';
......@@ -252,7 +253,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();
......
......@@ -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();
......
<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>
<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>
<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>
<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>
<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>
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';
import Vue from 'vue';
export default new Vue();
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,
},
});
},
});
});
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);
}
}
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 = [];
}
}
......@@ -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>
......
......@@ -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; }
......
......@@ -830,3 +830,152 @@
@include new-style-dropdown('.breadcrumbs-list .dropdown ');
@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;
}
}
}
......@@ -272,6 +272,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 {
......
......@@ -617,6 +617,8 @@
}
.issuable-actions {
@include new-style-dropdown;
padding-top: 10px;
@media (min-width: $screen-sm-min) {
......
......@@ -14,6 +14,7 @@
# search: string
# label_name: string
# sort: string
# my_reaction_emoji: string
#
class IssuesFinder < IssuableFinder
CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER
......
......@@ -16,6 +16,7 @@
# label_name: string
# sort: string
# non_archived: boolean
# my_reaction_emoji: string
#
class MergeRequestsFinder < IssuableFinder
def klass
......
......@@ -77,12 +77,6 @@ module ProjectsHelper
project_link = breadcrumb_list_item project_link
end
if current_user && !show_new_nav?
project_link << button_tag(type: 'button', class: 'dropdown-toggle-caret js-projects-dropdown-toggle', aria: { label: 'Toggle switch project dropdown' }, data: { target: '.js-dropdown-menu-projects', toggle: 'dropdown', order_by: 'last_activity_at' }) do
icon("chevron-down")
end
end
"#{namespace_link} #{('/' unless show_new_nav?)} #{project_link}".html_safe
end
......
......@@ -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
......@@ -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,9 +82,12 @@ class GpgKey < ActiveRecord::Base
end
def revoke
GpgSignature.where(gpg_key: self, valid_signature: true).update_all(
GpgSignature
.where(gpg_key: self)
.where.not(verification_status: GpgSignature.verification_statuses[:unknown_key])
.update_all(
gpg_key_id: nil,
valid_signature: false,
verification_status: GpgSignature.verification_statuses[:unknown_key],
updated_at: Time.zone.now
)
......
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
......@@ -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
......
......@@ -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
......
%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
- 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 } }
- 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.
This commit was signed with a different user's verified signature.
- locals = { signature: signature, title: title, label: 'Unverified', css_classes: ['invalid'] }
- 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
- 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
- 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 }
- 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
.gpg-popover-icon{ class: css_class }
= render "shared/icons/#{icon}.svg"
%div
= title
- content = capture do
- if show_user
.clearfix
= content
= 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 } }
......
- 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
= render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature }
= render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature }
- 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
- 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
- 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
......@@ -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?
......
<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"><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>
......@@ -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
---
title: Add dropdown to Projects nav item
merge_request: 13866
author:
type: added
---
title: Fix broken svg in jobs dropdown for success status
merge_request:
author:
type: fixed
---
title: Add branch existence check to the APIv4 branches via HEAD request
merge_request: 13979
author: Vitaliy @blackst0ne Klachkov
type: added
---
title: Fixed add diff note button not showing after deleting a comment
merge_request:
author:
type: fixed
---
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
---
title: Add my_reaction_emoji param to /issues and /merge_requests API
merge_request: 14016
author: Hiroyuki Sato
type: added
......@@ -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',
......
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
class DestroyGpgSignatures < ActiveRecord::Migration
DOWNTIME = false
def up
truncate(:gpg_signatures)
end
def down
end
end
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
......@@ -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
......
......@@ -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
......
# 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.
......
......@@ -30,16 +30,18 @@ GET /issues?milestone=1.0.0&state=opened
GET /issues?iids[]=42&iids[]=43
GET /issues?author_id=5
GET /issues?assignee_id=5
GET /issues?my_reaction_emoji=star
```
| Attribute | Type | Required | Description |
|-------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------------------|
| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `state` | string | no | Return all issues or just those that are `opened` or `closed` |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels |
| `milestone` | string | no | The milestone title |
| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `author_id` | integer | no | Return issues created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me`. _([Introduced][ce-13004] in GitLab 9.5)_ |
| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `my_reaction_emoji` | string | no | Return issues reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` |
| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` |
......@@ -131,10 +133,11 @@ GET /groups/:id/issues?iids[]=42&iids[]=43
GET /groups/:id/issues?search=issue+title+or+description
GET /groups/:id/issues?author_id=5
GET /groups/:id/issues?assignee_id=5
GET /groups/:id/issues?my_reaction_emoji=star
```
| Attribute | Type | Required | Description |
|-------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------------------|
| ------------------- | ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `state` | string | no | Return all issues or just those that are `opened` or `closed` |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels |
......@@ -143,6 +146,7 @@ GET /groups/:id/issues?assignee_id=5
| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `my_reaction_emoji` | string | no | Return issues reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` |
| `search` | string | no | Search group issues against their `title` and `description` |
......@@ -234,10 +238,11 @@ GET /projects/:id/issues?iids[]=42&iids[]=43
GET /projects/:id/issues?search=issue+title+or+description
GET /projects/:id/issues?author_id=5
GET /projects/:id/issues?assignee_id=5
GET /projects/:id/issues?my_reaction_emoji=star
```
| Attribute | Type | Required | Description |
|-------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------------------|
| ------------------- | ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `iids[]` | Array[integer] | no | Return only the milestone having the given `iid` |
| `state` | string | no | Return all issues or just those that are `opened` or `closed` |
......@@ -246,6 +251,7 @@ GET /projects/:id/issues?assignee_id=5
| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `my_reaction_emoji` | string | no | Return issues reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` |
| `search` | string | no | Search project issues against their `title` and `description` |
......@@ -1093,3 +1099,4 @@ Example response:
```
[ce-13004]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13004
[ce-14016]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14016
......@@ -22,15 +22,16 @@ GET /merge_requests?state=all
GET /merge_requests?milestone=release
GET /merge_requests?labels=bug,reproduced
GET /merge_requests?author_id=5
GET /merge_requests?my_reaction_emoji=star
GET /merge_requests?scope=assigned-to-me
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged`|
| `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| ------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------- |
| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged` |
| `order_by` | string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` |
| `milestone` | string | no | Return merge requests for a specific milestone |
| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request |
......@@ -40,6 +41,7 @@ Parameters:
| `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` |
| `author_id` | integer | no | Returns merge requests created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me` |
| `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` |
| `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
```json
[
......@@ -116,16 +118,17 @@ GET /projects/:id/merge_requests?state=all
GET /projects/:id/merge_requests?iids[]=42&iids[]=43
GET /projects/:id/merge_requests?milestone=release
GET /projects/:id/merge_requests?labels=bug,reproduced
GET /projects/:id/merge_requests?my_reaction_emoji=star
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| ------------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `id` | integer | yes | The ID of a project |
| `iids[]` | Array[integer] | no | Return the request having the given `iid` |
| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged`|
| `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged` |
| `order_by` | string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` |
| `milestone` | string | no | Return merge requests for a specific milestone |
| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request |
......@@ -135,6 +138,7 @@ Parameters:
| `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13060] in GitLab 9.5)_ |
| `author_id` | integer | no | Returns merge requests created by the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ |
| `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ |
| `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
```json
[
......@@ -1315,3 +1319,4 @@ Example response:
```
[ce-13060]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13060
[ce-14016]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14016
......@@ -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.
---
......
......@@ -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.
......
......@@ -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
......
......@@ -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
......
# 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
......
......@@ -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
......
......@@ -24,18 +24,23 @@ module API
present paginate(branches), with: Entities::RepoBranch, project: user_project
end
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
get ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
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
not_found!('Branch') unless branch
present branch, with: Entities::RepoBranch, project: user_project
end
end
# Note: This API will be deprecated in favor of the protected branches API.
# Note: The internal data model moved from `developers_can_{merge,push}` to `allowed_to_{merge,push}`
......
......@@ -36,6 +36,7 @@ module API
optional :assignee_id, type: Integer, desc: 'Return issues which are assigned to the user with the given ID'
optional :scope, type: String, values: %w[created-by-me assigned-to-me all],
desc: 'Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`'
optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji'
use :pagination
end
......
......@@ -40,6 +40,7 @@ module API
optional :assignee_id, type: Integer, desc: 'Return merge requests which are assigned to the user with the given ID'
optional :scope, type: String, values: %w[created-by-me assigned-to-me all],
desc: 'Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`'
optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji'
use :pagination
end
end
......
......@@ -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
......
......@@ -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
......
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)
......
......@@ -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
......
......@@ -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] ""
......
......@@ -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
......@@ -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
......@@ -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"]'))
......
......@@ -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
......
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
......@@ -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);
......
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();
});
});
});
});
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();
});
});
});
});
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);
});
});
});
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();
});
});
});
});
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();
});
});
});
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 },
];
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);
});
});
});
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);
});
});
});
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', () => {
describe('computed', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
describe('computed', () => {
afterEach(() => {
vm.$destroy();
});
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();
});
});
});
......@@ -2,18 +2,39 @@ require 'rails_helper'
describe Gitlab::Gpg::Commit do
describe '#signature' do
shared_examples 'returns the cached signature on second call' do
it 'returns the cached signature on second call' do
gpg_commit = described_class.new(commit)
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
end
let!(:project) { create :project, :repository, path: 'sample-project' }
let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
context 'unsigned commit' do
let!(:commit) { create :commit, project: project, sha: commit_sha }
it 'returns nil' do
expect(described_class.new(project, commit_sha).signature).to be_nil
expect(described_class.new(commit).signature).to be_nil
end
end
context 'known and verified public key' do
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: create(:user, email: GpgHelpers::User1.emails.first)
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
before do
......@@ -28,31 +49,32 @@ describe Gitlab::Gpg::Commit do
end
it 'returns a valid 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: 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
verification_status: 'verified'
)
end
it 'returns the cached signature on second call' do
gpg_commit = described_class.new(project, commit_sha)
it_behaves_like 'returns the cached signature on second call'
end
expect(gpg_commit).to receive(:using_keychain).and_call_original
gpg_commit.signature
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 }
# consecutive call
expect(gpg_commit).not_to receive(:using_keychain).and_call_original
gpg_commit.signature
let(:user) do
create(:user, email: GpgHelpers::User1.emails.first).tap do |user|
create :email, user: user, email: GpgHelpers::User2.emails.first
end
end
context 'known but unverified public key' do
let!(:gpg_key) { create :gpg_key, key: GpgHelpers::User1.public_key }
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
before do
allow(Rugged::Commit).to receive(:extract_signature)
......@@ -66,30 +88,65 @@ 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: 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
verification_status: 'same_user_different_email'
)
end
it 'returns the cached signature on second call' do
gpg_commit = described_class.new(project, commit_sha)
it_behaves_like 'returns the cached signature on second call'
end
expect(gpg_commit).to receive(:using_keychain).and_call_original
gpg_commit.signature
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 }
# consecutive call
expect(gpg_commit).not_to receive(:using_keychain).and_call_original
gpg_commit.signature
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
context 'unknown public key' do
before do
allow(Rugged::Commit).to receive(:extract_signature)
.with(Rugged::Repository, commit_sha)
......@@ -102,27 +159,48 @@ 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: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
gpg_key_user_name: nil,
gpg_key_user_email: nil,
valid_signature: false
gpg_key_user_name: GpgHelpers::User1.names.first,
gpg_key_user_email: GpgHelpers::User1.emails.first,
verification_status: 'unverified_key'
)
end
it 'returns the cached signature on second call' do
gpg_commit = described_class.new(project, commit_sha)
it_behaves_like 'returns the cached signature on second call'
end
end
expect(gpg_commit).to receive(:using_keychain).and_call_original
gpg_commit.signature
context 'unknown key' do
let!(:commit) { create :commit, project: project, sha: commit_sha }
# consecutive call
expect(gpg_commit).not_to receive(:using_keychain).and_call_original
gpg_commit.signature
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: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
gpg_key_user_name: nil,
gpg_key_user_email: nil,
verification_status: 'unknown_key'
)
end
it_behaves_like 'returns the cached signature on second call'
end
end
end
......@@ -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
......
......@@ -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
......
......@@ -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
)
......
......@@ -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
......@@ -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)
......
......@@ -138,6 +138,16 @@ describe API::Issues, :mailer do
expect(first_issue['id']).to eq(issue2.id)
end
it 'returns issues reacted by the authenticated user by the given emoji' do
issue2 = create(:issue, project: project, author: user, assignees: [user])
award_emoji = create(:award_emoji, awardable: issue2, user: user2, name: 'star')
get api('/issues', user2), my_reaction_emoji: award_emoji.name, scope: 'all'
expect_paginated_array_response(size: 1)
expect(first_issue['id']).to eq(issue2.id)
end
it 'returns issues matching given search string for title' do
get api("/issues", user), search: issue.title
......
......@@ -117,6 +117,18 @@ describe API::MergeRequests do
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(merge_request3.id)
end
it 'returns merge requests reacted by the authenticated user by the given emoji' do
merge_request3 = create(:merge_request, :simple, author: user, assignee: user, source_project: project2, target_project: project2, source_branch: 'other-branch')
award_emoji = create(:award_emoji, awardable: merge_request3, user: user2, name: 'star')
get api('/merge_requests', user2), my_reaction_emoji: award_emoji.name, scope: 'all'
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(merge_request3.id)
end
end
end
......
......@@ -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',
......
......@@ -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
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment