Commit 7aa0be3c authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge branch 'import-go-to-project-cta-nibble-backend' into 'master'

BE Improve the GitHub and Gitea import feature table interface

See merge request gitlab-org/gitlab-ce!24606
parents 50193341 a4bb4fbf
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import { __, sprintf } from '~/locale';
import ImportedProjectTableRow from './imported_project_table_row.vue';
import ProviderRepoTableRow from './provider_repo_table_row.vue';
import eventHub from '../event_hub';
export default {
name: 'ImportProjectsTable',
components: {
ImportedProjectTableRow,
ProviderRepoTableRow,
LoadingButton,
GlLoadingIcon,
},
props: {
providerTitle: {
type: String,
required: true,
},
},
computed: {
...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos']),
...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']),
emptyStateText() {
return sprintf(__('No %{providerTitle} repositories available to import'), {
providerTitle: this.providerTitle,
});
},
fromHeaderText() {
return sprintf(__('From %{providerTitle}'), { providerTitle: this.providerTitle });
},
},
mounted() {
return this.fetchRepos();
},
beforeDestroy() {
this.stopJobsPolling();
this.clearJobsEtagPoll();
},
methods: {
...mapActions(['fetchRepos', 'fetchJobs', 'stopJobsPolling', 'clearJobsEtagPoll']),
importAll() {
eventHub.$emit('importAll');
},
},
};
</script>
<template>
<div>
<div class="d-flex justify-content-between align-items-end flex-wrap mb-3">
<p class="light text-nowrap mt-2 my-sm-0">
{{ s__('ImportProjects|Select the projects you want to import') }}
</p>
<loading-button
container-class="btn btn-success js-import-all"
:loading="isImportingAnyRepo"
:label="__('Import all repositories')"
:disabled="!hasProviderRepos"
type="button"
@click="importAll"
/>
</div>
<gl-loading-icon
v-if="isLoadingRepos"
class="js-loading-button-icon import-projects-loading-icon"
:size="4"
/>
<div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive">
<table class="table import-table">
<thead>
<th class="import-jobs-from-col">{{ fromHeaderText }}</th>
<th class="import-jobs-to-col">{{ __('To GitLab') }}</th>
<th class="import-jobs-status-col">{{ __('Status') }}</th>
<th class="import-jobs-cta-col"></th>
</thead>
<tbody>
<imported-project-table-row
v-for="project in importedProjects"
:key="project.id"
:project="project"
/>
<provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" />
</tbody>
</table>
</div>
<div v-else class="text-center">
<strong>{{ emptyStateText }}</strong>
</div>
</div>
</template>
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import STATUS_MAP from '../constants';
export default {
name: 'ImportStatus',
components: {
CiIcon,
GlLoadingIcon,
},
props: {
status: {
type: String,
required: true,
},
},
computed: {
mappedStatus() {
return STATUS_MAP[this.status];
},
ciIconStatus() {
const { icon } = this.mappedStatus;
return {
icon: `status_${icon}`,
group: icon,
};
},
},
};
</script>
<template>
<div>
<gl-loading-icon
v-if="mappedStatus.loadingIcon"
:inline="true"
:class="mappedStatus.textClass"
class="align-middle mr-2"
/>
<ci-icon v-else css-classes="align-middle mr-2" :status="ciIconStatus" />
<span :class="mappedStatus.textClass">{{ mappedStatus.text }}</span>
</div>
</template>
<script>
import ImportStatus from './import_status.vue';
import { STATUSES } from '../constants';
export default {
name: 'ImportedProjectTableRow',
components: {
ImportStatus,
},
props: {
project: {
type: Object,
required: true,
},
},
computed: {
displayFullPath() {
return this.project.fullPath.replace(/^\//, '');
},
isFinished() {
return this.project.importStatus === STATUSES.FINISHED;
},
},
};
</script>
<template>
<tr class="js-imported-project import-row">
<td>
<a
:href="project.providerLink"
rel="noreferrer noopener"
target="_blank"
class="js-provider-link"
>
{{ project.importSource }}
</a>
</td>
<td class="js-full-path">{{ displayFullPath }}</td>
<td><import-status :status="project.importStatus" /></td>
<td>
<a
v-if="isFinished"
class="btn btn-default js-go-to-project"
:href="project.fullPath"
rel="noreferrer noopener"
target="_blank"
>
{{ __('Go to project') }}
</a>
</td>
</tr>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import Select2Select from '~/vue_shared/components/select2_select.vue';
import { __ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '../event_hub';
import { STATUSES } from '../constants';
import ImportStatus from './import_status.vue';
export default {
name: 'ProviderRepoTableRow',
components: {
Select2Select,
LoadingButton,
ImportStatus,
},
props: {
repo: {
type: Object,
required: true,
},
},
data() {
return {
targetNamespace: this.$store.state.defaultTargetNamespace,
newName: this.repo.sanitizedName,
};
},
computed: {
...mapState(['namespaces', 'reposBeingImported', 'ciCdOnly']),
...mapGetters(['namespaceSelectOptions']),
importButtonText() {
return this.ciCdOnly ? __('Connect') : __('Import');
},
select2Options() {
return {
data: this.namespaceSelectOptions,
containerCssClass:
'import-namespace-select js-namespace-select qa-project-namespace-select',
};
},
isLoadingImport() {
return this.reposBeingImported.includes(this.repo.id);
},
status() {
return this.isLoadingImport ? STATUSES.SCHEDULING : STATUSES.NONE;
},
},
created() {
eventHub.$on('importAll', () => this.importRepo());
},
methods: {
...mapActions(['fetchImport']),
importRepo() {
return this.fetchImport({
newName: this.newName,
targetNamespace: this.targetNamespace,
repo: this.repo,
});
},
},
};
</script>
<template>
<tr class="qa-project-import-row js-provider-repo import-row">
<td>
<a
:href="repo.providerLink"
rel="noreferrer noopener"
target="_blank"
class="js-provider-link"
>
{{ repo.fullName }}
</a>
</td>
<td class="d-flex flex-wrap flex-lg-nowrap">
<select2-select v-model="targetNamespace" :options="select2Options" />
<span class="px-2 import-slash-divider d-flex justify-content-center align-items-center"
>/</span
>
<input
v-model="newName"
type="text"
class="form-control import-project-name-input js-new-name qa-project-path-field"
/>
</td>
<td><import-status :status="status" /></td>
<td>
<button
v-if="!isLoadingImport"
type="button"
class="qa-import-button js-import-button btn btn-default"
@click="importRepo"
>
{{ importButtonText }}
</button>
</td>
</tr>
</template>
import { __ } from '../locale';
// The `scheduling` status is only present on the client-side,
// it is used as the status when we are requesting to start an import.
export const STATUSES = {
FINISHED: 'finished',
FAILED: 'failed',
SCHEDULED: 'scheduled',
STARTED: 'started',
NONE: 'none',
SCHEDULING: 'scheduling',
};
const STATUS_MAP = {
[STATUSES.FINISHED]: {
icon: 'success',
text: __('Done'),
textClass: 'text-success',
},
[STATUSES.FAILED]: {
icon: 'failed',
text: __('Failed'),
textClass: 'text-danger',
},
[STATUSES.SCHEDULED]: {
icon: 'pending',
text: __('Scheduled'),
textClass: 'text-warning',
},
[STATUSES.STARTED]: {
icon: 'running',
text: __('Running…'),
textClass: 'text-info',
},
[STATUSES.NONE]: {
icon: 'created',
text: __('Not started'),
textClass: 'text-muted',
},
[STATUSES.SCHEDULING]: {
loadingIcon: true,
text: __('Scheduling'),
textClass: 'text-warning',
},
};
export default STATUS_MAP;
import Vue from 'vue';
export default new Vue();
import Vue from 'vue';
import { mapActions } from 'vuex';
import Translate from '../vue_shared/translate';
import ImportProjectsTable from './components/import_projects_table.vue';
import { parseBoolean } from '../lib/utils/common_utils';
import store from './store';
Vue.use(Translate);
export default function mountImportProjectsTable(mountElement) {
if (!mountElement) return undefined;
const {
reposPath,
provider,
providerTitle,
canSelectNamespace,
jobsPath,
importPath,
ciCdOnly,
} = mountElement.dataset;
return new Vue({
el: mountElement,
store,
created() {
this.setInitialData({
reposPath,
provider,
jobsPath,
importPath,
defaultTargetNamespace: gon.current_username,
ciCdOnly: parseBoolean(ciCdOnly),
canSelectNamespace: parseBoolean(canSelectNamespace),
});
},
methods: {
...mapActions(['setInitialData']),
},
render(createElement) {
return createElement(ImportProjectsTable, { props: { providerTitle } });
},
});
}
import Visibility from 'visibilityjs';
import * as types from './mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
let eTagPoll;
export const clearJobsEtagPoll = () => {
eTagPoll = null;
};
export const stopJobsPolling = () => {
if (eTagPoll) eTagPoll.stop();
};
export const restartJobsPolling = () => {
if (eTagPoll) eTagPoll.restart();
};
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos);
export const receiveReposSuccess = ({ commit }, repos) =>
commit(types.RECEIVE_REPOS_SUCCESS, repos);
export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR);
export const fetchRepos = ({ state, dispatch }) => {
dispatch('requestRepos');
return axios
.get(state.reposPath)
.then(({ data }) =>
dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
)
.then(() => dispatch('fetchJobs'))
.catch(() => {
createFlash(
sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), {
provider: state.provider,
}),
);
dispatch('receiveReposError');
});
};
export const requestImport = ({ commit, state }, repoId) => {
if (!state.reposBeingImported.includes(repoId)) commit(types.REQUEST_IMPORT, repoId);
};
export const receiveImportSuccess = ({ commit }, { importedProject, repoId }) =>
commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject, repoId });
export const receiveImportError = ({ commit }, repoId) =>
commit(types.RECEIVE_IMPORT_ERROR, repoId);
export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, repo }) => {
dispatch('requestImport', repo.id);
return axios
.post(state.importPath, {
ci_cd_only: state.ciCdOnly,
new_name: newName,
repo_id: repo.id,
target_namespace: targetNamespace,
})
.then(({ data }) =>
dispatch('receiveImportSuccess', {
importedProject: convertObjectPropsToCamelCase(data, { deep: true }),
repoId: repo.id,
}),
)
.catch(() => {
createFlash(s__('ImportProjects|Importing the project failed'));
dispatch('receiveImportError', { repoId: repo.id });
});
};
export const receiveJobsSuccess = ({ commit }, updatedProjects) =>
commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects);
export const fetchJobs = ({ state, dispatch }) => {
if (eTagPoll) return;
eTagPoll = new Poll({
resource: {
fetchJobs: () => axios.get(state.jobsPath),
},
method: 'fetchJobs',
successCallback: ({ data }) =>
dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
errorCallback: () => createFlash(s__('ImportProjects|Updating the imported projects failed')),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
dispatch('restartJobsPolling');
} else {
dispatch('stopJobsPolling');
}
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const namespaceSelectOptions = state => {
const serializedNamespaces = state.namespaces.map(({ fullPath }) => ({
id: fullPath,
text: fullPath,
}));
return [
{ text: 'Groups', children: serializedNamespaces },
{
text: 'Users',
children: [{ id: state.defaultTargetNamespace, text: state.defaultTargetNamespace }],
},
];
};
export const isImportingAnyRepo = state => state.reposBeingImported.length > 0;
export const hasProviderRepos = state => state.providerRepos.length > 0;
export const hasImportedProjects = state => state.importedProjects.length > 0;
import Vue from 'vue';
import Vuex from 'vuex';
import state from './state';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default new Vuex.Store({
state: state(),
actions,
mutations,
getters,
});
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const REQUEST_REPOS = 'REQUEST_REPOS';
export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS';
export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR';
export const REQUEST_IMPORT = 'REQUEST_IMPORT';
export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS';
export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR';
export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
import Vue from 'vue';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_DATA](state, data) {
Object.assign(state, data);
},
[types.REQUEST_REPOS](state) {
state.isLoadingRepos = true;
},
[types.RECEIVE_REPOS_SUCCESS](state, { importedProjects, providerRepos, namespaces }) {
state.isLoadingRepos = false;
state.importedProjects = importedProjects;
state.providerRepos = providerRepos;
state.namespaces = namespaces;
},
[types.RECEIVE_REPOS_ERROR](state) {
state.isLoadingRepos = false;
},
[types.REQUEST_IMPORT](state, repoId) {
state.reposBeingImported.push(repoId);
},
[types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId }) {
const existingRepoIndex = state.reposBeingImported.indexOf(repoId);
if (state.reposBeingImported.includes(repoId))
state.reposBeingImported.splice(existingRepoIndex, 1);
const providerRepoIndex = state.providerRepos.findIndex(
providerRepo => providerRepo.id === repoId,
);
state.providerRepos.splice(providerRepoIndex, 1);
state.importedProjects.unshift(importedProject);
},
[types.RECEIVE_IMPORT_ERROR](state, repoId) {
const repoIndex = state.reposBeingImported.indexOf(repoId);
if (state.reposBeingImported.includes(repoId)) state.reposBeingImported.splice(repoIndex, 1);
},
[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects) {
updatedProjects.forEach(updatedProject => {
const existingProject = state.importedProjects.find(
importedProject => importedProject.id === updatedProject.id,
);
Vue.set(existingProject, 'importStatus', updatedProject.importStatus);
});
},
};
export default () => ({
reposPath: '',
importPath: '',
jobsPath: '',
currentProjectId: '',
provider: '',
currentUsername: '',
importedProjects: [],
providerRepos: [],
namespaces: [],
reposBeingImported: [],
isLoadingRepos: false,
canSelectNamespace: false,
ciCdOnly: false,
});
import mountImportProjectsTable from '~/import_projects';
document.addEventListener('DOMContentLoaded', () => {
const mountElement = document.getElementById('import-projects-mount-element');
mountImportProjectsTable(mountElement);
});
import mountImportProjectsTable from '~/import_projects';
document.addEventListener('DOMContentLoaded', () => {
const mountElement = document.getElementById('import-projects-mount-element');
mountImportProjectsTable(mountElement);
});
......@@ -46,6 +46,11 @@ export default {
required: false,
default: false,
},
cssClasses: {
type: String,
required: false,
default: '',
},
},
computed: {
cssClass() {
......@@ -59,5 +64,5 @@ export default {
};
</script>
<template>
<span :class="cssClass"> <icon :name="icon" :size="size" /> </span>
<span :class="cssClass"> <icon :name="icon" :size="size" :css-classes="cssClasses" /> </span>
</template>
<script>
import $ from 'jquery';
export default {
name: 'Select2Select',
props: {
options: {
type: Object,
required: false,
default: () => ({}),
},
value: {
type: String,
required: false,
default: '',
},
},
mounted() {
$(this.$refs.dropdownInput)
.val(this.value)
.select2(this.options)
.on('change', event => this.$emit('input', event.target.value));
},
beforeDestroy() {
$(this.$refs.dropdownInput).select2('destroy');
},
};
</script>
<template>
<input ref="dropdownInput" type="hidden" />
</template>
.import-jobs-from-col,
.import-jobs-to-col {
width: 40%;
width: 39%;
}
.import-jobs-status-col {
width: 20%;
width: 15%;
}
.btn-import {
.loading-icon {
display: none;
.import-jobs-cta-col {
width: 1%;
}
.import-project-name-input {
border-radius: 0 $border-radius-default $border-radius-default 0;
position: relative;
left: -1px;
max-width: 300px;
}
.import-namespace-select {
width: auto !important;
> .select2-choice {
border-radius: $border-radius-default 0 0 $border-radius-default;
position: relative;
left: 1px;
}
}
&.is-loading {
.loading-icon {
display: inline-block;
}
.import-slash-divider {
background-color: $gray-lightest;
border: 1px solid $border-color;
}
.import-row {
height: 55px;
}
.import-table {
.import-jobs-from-col,
.import-jobs-to-col,
.import-jobs-status-col,
.import-jobs-cta-col {
border-bottom-width: 1px;
padding-left: $gl-padding;
}
}
.import-projects-loading-icon {
margin-top: $gl-padding-32;
}
# frozen_string_literal: true
class Import::GiteaController < Import::GithubController
extend ::Gitlab::Utils::Override
def new
if session[access_token_key].present? && session[host_key].present?
if session[access_token_key].present? && provider_url.present?
redirect_to status_import_url
end
end
......@@ -12,8 +14,8 @@ class Import::GiteaController < Import::GithubController
super
end
# Must be defined or it will 404
def status
@gitea_host_url = session[host_key]
super
end
......@@ -23,25 +25,33 @@ class Import::GiteaController < Import::GithubController
:"#{provider}_host_url"
end
# Overridden methods
override :provider
def provider
:gitea
end
override :provider_url
def provider_url
session[host_key]
end
# Gitea is not yet an OAuth provider
# See https://github.com/go-gitea/gitea/issues/27
override :logged_in_with_provider?
def logged_in_with_provider?
false
end
override :provider_auth
def provider_auth
if session[access_token_key].blank? || session[host_key].blank?
if session[access_token_key].blank? || provider_url.blank?
redirect_to new_import_gitea_url,
alert: 'You need to specify both an Access Token and a Host URL.'
end
end
override :client_options
def client_options
{ host: session[host_key], api_version: 'v1' }
{ host: provider_url, api_version: 'v1' }
end
end
# frozen_string_literal: true
class Import::GithubController < Import::BaseController
include ImportHelper
before_action :verify_import_enabled
before_action :provider_auth, only: [:status, :jobs, :create]
before_action :provider_auth, only: [:status, :realtime_changes, :create]
before_action :expire_etag_cache, only: [:status, :create]
rescue_from Octokit::Unauthorized, with: :provider_unauthorized
......@@ -24,30 +27,37 @@ class Import::GithubController < Import::BaseController
redirect_to status_import_url
end
# rubocop: disable CodeReuse/ActiveRecord
def status
@repos = client.repos
@already_added_projects = find_already_added_projects(provider)
already_added_projects_names = @already_added_projects.pluck(:import_source)
@repos.reject! { |repo| already_added_projects_names.include? repo.full_name }
end
# rubocop: enable CodeReuse/ActiveRecord
def jobs
render json: find_jobs(provider)
# Request repos to display error page if provider token is invalid
# Improving in https://gitlab.com/gitlab-org/gitlab-ce/issues/55585
client_repos
respond_to do |format|
format.json do
render json: { imported_projects: serialized_imported_projects,
provider_repos: serialized_provider_repos,
namespaces: serialized_namespaces }
end
format.html
end
end
def create
result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider)
if result[:status] == :success
render json: ProjectSerializer.new.represent(result[:project])
render json: serialized_imported_projects(result[:project])
else
render json: { errors: result[:message] }, status: result[:http_status]
end
end
def realtime_changes
Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: find_jobs(provider)
end
private
def import_params
......@@ -58,10 +68,45 @@ class Import::GithubController < Import::BaseController
[:repo_id, :new_name, :target_namespace]
end
def serialized_imported_projects(projects = already_added_projects)
ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url)
end
def serialized_provider_repos
repos = client_repos.reject { |repo| already_added_project_names.include? repo.full_name }
ProviderRepoSerializer.new(current_user: current_user).represent(repos, provider: provider, provider_url: provider_url)
end
def serialized_namespaces
NamespaceSerializer.new.represent(namespaces)
end
def already_added_projects
@already_added_projects ||= find_already_added_projects(provider)
end
def already_added_project_names
@already_added_projects_names ||= already_added_projects.pluck(:import_source) # rubocop:disable CodeReuse/ActiveRecord
end
def namespaces
current_user.manageable_groups_with_routes
end
def expire_etag_cache
Gitlab::EtagCaching::Store.new.tap do |store|
store.touch(realtime_changes_path)
end
end
def client
@client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options)
end
def client_repos
@client_repos ||= client.repos
end
def verify_import_enabled
render_404 unless import_enabled?
end
......@@ -74,6 +119,10 @@ class Import::GithubController < Import::BaseController
__send__("#{provider}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend
end
def realtime_changes_path
public_send("realtime_changes_import_#{provider}_path", format: :json) # rubocop:disable GitlabSecurity/PublicSend
end
def new_import_url
public_send("new_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
......@@ -105,6 +154,14 @@ class Import::GithubController < Import::BaseController
:github
end
def provider_url
strong_memoize(:provider_url) do
provider = Gitlab::Auth::OAuth::Provider.config_for('github')
provider&.dig('url').presence || 'https://github.com'
end
end
# rubocop: disable CodeReuse/ActiveRecord
def logged_in_with_provider?
current_user.identities.exists?(provider: provider)
......
......@@ -18,10 +18,8 @@ module ImportHelper
"#{namespace}/#{name}"
end
def provider_project_link(provider, full_path)
url = __send__("#{provider}_project_url", full_path) # rubocop:disable GitlabSecurity/PublicSend
link_to full_path, url, target: '_blank', rel: 'noopener noreferrer'
def provider_project_link_url(provider_url, full_path)
Gitlab::Utils.append_path(provider_url, full_path)
end
def import_will_timeout_message(_ci_cd_only)
......@@ -46,10 +44,6 @@ module ImportHelper
_('Please wait while we import the repository for you. Refresh at will.')
end
def import_github_title
_('Import repositories from GitHub')
end
def import_github_authorize_message
_('To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:')
end
......@@ -73,30 +67,4 @@ module ImportHelper
_('Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link }
end
end
def import_githubish_choose_repository_message
_('Choose which repositories you want to import.')
end
def import_all_githubish_repositories_button_label
_('Import all repositories')
end
private
def github_project_url(full_path)
Gitlab::Utils.append_path(github_root_url, full_path)
end
def github_root_url
strong_memoize(:github_url) do
provider = Gitlab::Auth::OAuth::Provider.config_for('github')
provider&.dig('url').presence || 'https://github.com'
end
end
def gitea_project_url(full_path)
Gitlab::Utils.append_path(@gitea_host_url, full_path)
end
end
......@@ -5,11 +5,8 @@ module NamespacesHelper
params.dig(:project, :namespace_id) || params[:namespace_id]
end
# rubocop: disable CodeReuse/ActiveRecord
def namespaces_options(selected = :current_user, display_path: false, groups: nil, extra_group: nil, groups_only: false)
groups ||= current_user.manageable_groups
.eager_load(:route)
.order('routes.path')
groups ||= current_user.manageable_groups_with_routes
users = [current_user.namespace]
selected_id = selected
......@@ -43,7 +40,6 @@ module NamespacesHelper
grouped_options_for_select(options, selected_id)
end
# rubocop: enable CodeReuse/ActiveRecord
def namespace_icon(namespace, size = 40)
if namespace.is_a?(Group)
......
......@@ -1168,6 +1168,10 @@ class User < ApplicationRecord
Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants
end
def manageable_groups_with_routes
manageable_groups.eager_load(:route).order('routes.path')
end
def namespaces
namespace_ids = groups.pluck(:id)
namespace_ids.push(namespace.id)
......
# frozen_string_literal: true
class NamespaceBasicEntity < Grape::Entity
expose :id
expose :full_path
end
# frozen_string_literal: true
class NamespaceSerializer < BaseSerializer
entity NamespaceBasicEntity
end
# frozen_string_literal: true
class ProjectImportEntity < ProjectEntity
include ImportHelper
expose :import_source
expose :import_status
expose :human_import_status_name
expose :provider_link do |project, options|
provider_project_link_url(options[:provider_url], project[:import_source])
end
end
# frozen_string_literal: true
class ProjectSerializer < BaseSerializer
entity ProjectEntity
def represent(project, opts = {})
entity =
case opts[:serializer]
when :import
ProjectImportEntity
else
ProjectEntity
end
super(project, opts, entity)
end
end
# frozen_string_literal: true
class ProviderRepoEntity < Grape::Entity
include ImportHelper
expose :id
expose :full_name
expose :owner_name do |provider_repo, options|
owner_name(provider_repo, options[:provider])
end
expose :sanitized_name do |provider_repo|
sanitize_project_name(provider_repo[:name])
end
expose :provider_link do |provider_repo, options|
provider_project_link_url(options[:provider_url], provider_repo[:full_name])
end
private
def owner_name(provider_repo, provider)
provider_repo.dig(:owner, :login) if provider == :github
end
end
# frozen_string_literal: true
class ProviderRepoSerializer < BaseSerializer
entity ProviderRepoEntity
end
- provider = local_assigns.fetch(:provider)
- provider_title = Gitlab::ImportSources.title(provider)
%p.light
= import_githubish_choose_repository_message
%hr
%p
= button_tag class: "btn btn-import btn-success js-import-all" do
= import_all_githubish_repositories_button_label
= icon("spinner spin", class: "loading-icon")
.table-responsive
%table.table.import-jobs
%colgroup.import-jobs-from-col
%colgroup.import-jobs-to-col
%colgroup.import-jobs-status-col
%thead
%tr
%th= _('From %{provider_title}') % { provider_title: provider_title }
%th= _('To GitLab')
%th= _('Status')
%tbody
- @already_added_projects.each do |project|
%tr{ id: "project_#{project.id}", class: project_status_css_class(project.import_status) }
%td
= provider_project_link(provider, project.import_source)
%td
= link_to project.full_path, [project.namespace.becomes(Namespace), project]
%td.job-status
= render 'import/project_status', project: project
- @repos.each do |repo|
%tr{ id: "repo_#{repo.id}", data: { qa: { repo_path: repo.full_name } } }
%td
= provider_project_link(provider, repo.full_name)
%td.import-target
%fieldset.row
.input-group
.project-path.input-group-prepend
- if current_user.can_select_namespace?
- selected = params[:namespace_id] || :current_user
- opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {}
= select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace qa-project-namespace-select', tabindex: 1 }
- else
= text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
%span.input-group-prepend
.input-group-text /
= text_field_tag :path, sanitize_project_name(repo.name), class: "input-mini form-control", tabindex: 2, autofocus: true, required: true
%td.import-actions.job-status
= button_tag class: "btn btn-import js-add-to-import" do
= has_ci_cd_only_params? ? _('Connect') : _('Import')
= icon("spinner spin", class: "loading-icon")
.js-importer-status{ data: { jobs_import_path: url_for([:jobs, :import, provider]),
import_path: url_for([:import, provider]),
ci_cd_only: has_ci_cd_only_params?.to_s } }
#import-projects-mount-element{ data: { provider: provider, provider_title: provider_title,
can_select_namespace: current_user.can_select_namespace?.to_s,
ci_cd_only: has_ci_cd_only_params?.to_s,
repos_path: url_for([:status, :import, provider, format: :json]),
jobs_path: url_for([:realtime_changes, :import, provider, format: :json]),
import_path: url_for([:import, provider, format: :json]) } }
......@@ -4,7 +4,7 @@
- header_title _("Projects"), root_path
%h3.page-title
= icon 'github', text: import_github_title
= icon 'github', text: _('Import repositories from GitHub')
- if github_import_configured?
%p
......
......@@ -2,7 +2,7 @@
- page_title title
- breadcrumb_title title
- header_title _("Projects"), root_path
%h3.page-title
= icon 'github', text: import_github_title
%h3.page-title.mb-0
= icon 'github', class: 'fa-2x', text: _('Import repositories from GitHub')
= render 'import/githubish_status', provider: 'github'
......@@ -7,7 +7,7 @@
%p
= button_tag class: "btn btn-import btn-success js-import-all" do
= import_all_githubish_repositories_button_label
= _('Import all repositories')
= icon("spinner spin", class: "loading-icon")
.table-responsive
......
---
title: Improve GitHub and Gitea project import table UI
merge_request: 24606
author:
type: other
......@@ -12,13 +12,13 @@ namespace :import do
post :personal_access_token
get :status
get :callback
get :jobs
get :realtime_changes
end
resource :gitea, only: [:create, :new], controller: :gitea do
post :personal_access_token
get :status
get :jobs
get :realtime_changes
end
resource :gitlab, only: [:create], controller: :gitlab do
......
......@@ -52,6 +52,14 @@ module Gitlab
Gitlab::EtagCaching::Router::Route.new(
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/environments\.json\z),
'environments'
),
Gitlab::EtagCaching::Router::Route.new(
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/import/github/realtime_changes\.json\z),
'realtime_changes_import_github'
),
Gitlab::EtagCaching::Router::Route.new(
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/import/gitea/realtime_changes\.json\z),
'realtime_changes_import_gitea'
)
].freeze
......
......@@ -1389,9 +1389,6 @@ msgstr ""
msgid "Choose the top-level group for your repository imports."
msgstr ""
msgid "Choose which repositories you want to import."
msgstr ""
msgid "CiStatusLabel|canceled"
msgstr ""
......@@ -3459,7 +3456,7 @@ msgstr ""
msgid "Found errors in your .gitlab-ci.yml:"
msgstr ""
msgid "From %{provider_title}"
msgid "From %{providerTitle}"
msgstr ""
msgid "From Bitbucket"
......@@ -3582,6 +3579,9 @@ msgstr ""
msgid "Go to %{link_to_google_takeout}."
msgstr ""
msgid "Go to project"
msgstr ""
msgid "Google Code import"
msgstr ""
......@@ -3953,6 +3953,18 @@ msgstr ""
msgid "Import timed out. Import took longer than %{import_jobs_expiration} seconds"
msgstr ""
msgid "ImportProjects|Importing the project failed"
msgstr ""
msgid "ImportProjects|Requesting your %{provider} repositories failed"
msgstr ""
msgid "ImportProjects|Select the projects you want to import"
msgstr ""
msgid "ImportProjects|Updating the imported projects failed"
msgstr ""
msgid "In order to enable instance-level analytics, please ask an admin to enable %{usage_ping_link_start}usage ping%{usage_ping_link_end}."
msgstr ""
......@@ -4862,6 +4874,9 @@ msgstr ""
msgid "No"
msgstr ""
msgid "No %{providerTitle} repositories available to import"
msgstr ""
msgid "No activities found"
msgstr ""
......@@ -4970,6 +4985,9 @@ msgstr ""
msgid "Not now"
msgstr ""
msgid "Not started"
msgstr ""
msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
msgstr ""
......@@ -6320,6 +6338,9 @@ msgstr ""
msgid "Running"
msgstr ""
msgid "Running…"
msgstr ""
msgid "SSH Keys"
msgstr ""
......@@ -6362,6 +6383,9 @@ msgstr ""
msgid "Schedules"
msgstr ""
msgid "Scheduling"
msgstr ""
msgid "Scheduling Pipelines"
msgstr ""
......
......@@ -10,12 +10,11 @@ module QA
element :list_repos_button, "submit_tag _('List your GitHub repositories')" # rubocop:disable QA/ElementWithPattern
end
view 'app/views/import/_githubish_status.html.haml' do
element :project_import_row, 'data: { qa: { repo_path: repo.full_name } }' # rubocop:disable QA/ElementWithPattern
view 'app/assets/javascripts/import_projects/components/provider_repo_table_row.vue' do
element :project_import_row
element :project_namespace_select
element :project_namespace_field, 'select_tag :namespace_id' # rubocop:disable QA/ElementWithPattern
element :project_path_field, 'text_field_tag :path, sanitize_project_name(repo.name)' # rubocop:disable QA/ElementWithPattern
element :import_button, "_('Import')" # rubocop:disable QA/ElementWithPattern
element :project_path_field
element :import_button
end
def add_personal_access_token(personal_access_token)
......
......@@ -40,4 +40,12 @@ describe Import::GiteaController do
end
end
end
describe "GET realtime_changes" do
it_behaves_like 'a GitHub-ish import controller: GET realtime_changes' do
before do
assign_host_url
end
end
end
end
......@@ -60,4 +60,8 @@ describe Import::GithubController do
describe "POST create" do
it_behaves_like 'a GitHub-ish import controller: POST create'
end
describe "GET realtime_changes" do
it_behaves_like 'a GitHub-ish import controller: GET realtime_changes'
end
end
......@@ -39,59 +39,12 @@ describe ImportHelper do
end
end
describe '#provider_project_link' do
context 'when provider is "github"' do
let(:github_server_url) { nil }
let(:provider) { OpenStruct.new(name: 'github', url: github_server_url) }
describe '#provider_project_link_url' do
let(:full_path) { '/repo/path' }
let(:host_url) { 'http://provider.com/' }
before do
stub_omniauth_setting(providers: [provider])
end
context 'when provider does not specify a custom URL' do
it 'uses default GitHub URL' do
expect(helper.provider_project_link('github', 'octocat/Hello-World'))
.to include('href="https://github.com/octocat/Hello-World"')
end
end
context 'when provider specify a custom URL' do
let(:github_server_url) { 'https://github.company.com' }
it 'uses custom URL' do
expect(helper.provider_project_link('github', 'octocat/Hello-World'))
.to include('href="https://github.company.com/octocat/Hello-World"')
end
end
context "when custom URL contains a '/' char at the end" do
let(:github_server_url) { 'https://github.company.com/' }
it "doesn't render double slash" do
expect(helper.provider_project_link('github', 'octocat/Hello-World'))
.to include('href="https://github.company.com/octocat/Hello-World"')
end
end
context 'when provider is missing' do
it 'uses the default URL' do
allow(Gitlab.config.omniauth).to receive(:providers).and_return([])
expect(helper.provider_project_link('github', 'octocat/Hello-World'))
.to include('href="https://github.com/octocat/Hello-World"')
end
end
end
context 'when provider is "gitea"' do
before do
assign(:gitea_host_url, 'https://try.gitea.io/')
end
it 'uses given host' do
expect(helper.provider_project_link('gitea', 'octocat/Hello-World'))
.to include('href="https://try.gitea.io/octocat/Hello-World"')
end
it 'appends repo full path to provider host url' do
expect(helper.provider_project_link_url(host_url, full_path)).to match('http://provider.com/repo/path')
end
end
end
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import store from '~/import_projects/store';
import importProjectsTable from '~/import_projects/components/import_projects_table.vue';
import STATUS_MAP from '~/import_projects/constants';
import setTimeoutPromise from '../../helpers/set_timeout_promise_helper';
describe('ImportProjectsTable', () => {
let vm;
let mock;
const reposPath = '/repos-path';
const jobsPath = '/jobs-path';
const providerTitle = 'THE PROVIDER';
const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' };
const importedProject = {
id: 1,
fullPath: 'fullPath',
importStatus: 'started',
providerLink: 'providerLink',
importSource: 'importSource',
};
function createComponent() {
const ImportProjectsTable = Vue.extend(importProjectsTable);
const component = new ImportProjectsTable({
store,
propsData: {
providerTitle,
},
}).$mount();
component.$store.dispatch('stopJobsPolling');
return component;
}
beforeEach(() => {
store.dispatch('setInitialData', { reposPath });
mock = new MockAdapter(axios);
});
afterEach(() => {
vm.$destroy();
mock.restore();
});
it('renders a loading icon whilst repos are loading', done => {
mock.restore(); // Stop the mock adapter from responding to the request, keeping the spinner up
vm = createComponent();
setTimeoutPromise()
.then(() => {
expect(vm.$el.querySelector('.js-loading-button-icon')).not.toBeNull();
})
.then(() => done())
.catch(() => done.fail());
});
it('renders a table with imported projects and provider repos', done => {
const response = {
importedProjects: [importedProject],
providerRepos: [providerRepo],
namespaces: [{ path: 'path' }],
};
mock.onGet(reposPath).reply(200, response);
vm = createComponent();
setTimeoutPromise()
.then(() => {
expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull();
expect(vm.$el.querySelector('.table')).not.toBeNull();
expect(vm.$el.querySelector('.import-jobs-from-col').innerText).toMatch(
`From ${providerTitle}`,
);
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull();
})
.then(() => done())
.catch(() => done.fail());
});
it('renders an empty state if there are no imported projects or provider repos', done => {
const response = {
importedProjects: [],
providerRepos: [],
namespaces: [],
};
mock.onGet(reposPath).reply(200, response);
vm = createComponent();
setTimeoutPromise()
.then(() => {
expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull();
expect(vm.$el.querySelector('.table')).toBeNull();
expect(vm.$el.innerText).toMatch(`No ${providerTitle} repositories available to import`);
})
.then(() => done())
.catch(() => done.fail());
});
it('imports provider repos if bulk import button is clicked', done => {
const importPath = '/import-path';
const response = {
importedProjects: [],
providerRepos: [providerRepo],
namespaces: [{ path: 'path' }],
};
mock.onGet(reposPath).replyOnce(200, response);
mock.onPost(importPath).replyOnce(200, importedProject);
store.dispatch('setInitialData', { importPath });
vm = createComponent();
setTimeoutPromise()
.then(() => {
expect(vm.$el.querySelector('.js-imported-project')).toBeNull();
expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull();
vm.$el.querySelector('.js-import-all').click();
})
.then(() => setTimeoutPromise())
.then(() => {
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
expect(vm.$el.querySelector('.js-provider-repo')).toBeNull();
})
.then(() => done())
.catch(() => done.fail());
});
it('polls to update the status of imported projects', done => {
const importPath = '/import-path';
const response = {
importedProjects: [importedProject],
providerRepos: [],
namespaces: [{ path: 'path' }],
};
const updatedProjects = [
{
id: importedProject.id,
importStatus: 'finished',
},
];
mock.onGet(reposPath).replyOnce(200, response);
store.dispatch('setInitialData', { importPath, jobsPath });
vm = createComponent();
setTimeoutPromise()
.then(() => {
const statusObject = STATUS_MAP[importedProject.importStatus];
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch(
statusObject.text,
);
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
mock.onGet(jobsPath).replyOnce(200, updatedProjects);
return vm.$store.dispatch('restartJobsPolling');
})
.then(() => setTimeoutPromise())
.then(() => {
const statusObject = STATUS_MAP[updatedProjects[0].importStatus];
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch(
statusObject.text,
);
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
})
.then(() => done())
.catch(() => done.fail());
});
});
import Vue from 'vue';
import store from '~/import_projects/store';
import importedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue';
import STATUS_MAP from '~/import_projects/constants';
describe('ImportedProjectTableRow', () => {
let vm;
const project = {
id: 1,
fullPath: 'fullPath',
importStatus: 'finished',
providerLink: 'providerLink',
importSource: 'importSource',
};
function createComponent() {
const ImportedProjectTableRow = Vue.extend(importedProjectTableRow);
return new ImportedProjectTableRow({
store,
propsData: {
project: {
...project,
},
},
}).$mount();
}
afterEach(() => {
vm.$destroy();
});
it('renders an imported project table row', () => {
vm = createComponent();
const providerLink = vm.$el.querySelector('.js-provider-link');
const statusObject = STATUS_MAP[project.importStatus];
expect(vm.$el.classList.contains('js-imported-project')).toBe(true);
expect(providerLink.href).toMatch(project.providerLink);
expect(providerLink.textContent).toMatch(project.importSource);
expect(vm.$el.querySelector('.js-full-path').textContent).toMatch(project.fullPath);
expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch(
statusObject.text,
);
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
expect(vm.$el.querySelector('.js-go-to-project').href).toMatch(project.fullPath);
});
});
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import store from '~/import_projects/store';
import providerRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue';
import STATUS_MAP, { STATUSES } from '~/import_projects/constants';
import setTimeoutPromise from '../../helpers/set_timeout_promise_helper';
describe('ProviderRepoTableRow', () => {
let vm;
const repo = {
id: 10,
sanitizedName: 'sanitizedName',
fullName: 'fullName',
providerLink: 'providerLink',
};
function createComponent() {
const ProviderRepoTableRow = Vue.extend(providerRepoTableRow);
return new ProviderRepoTableRow({
store,
propsData: {
repo: {
...repo,
},
},
}).$mount();
}
afterEach(() => {
vm.$destroy();
});
it('renders a provider repo table row', () => {
vm = createComponent();
const providerLink = vm.$el.querySelector('.js-provider-link');
const statusObject = STATUS_MAP[STATUSES.NONE];
expect(vm.$el.classList.contains('js-provider-repo')).toBe(true);
expect(providerLink.href).toMatch(repo.providerLink);
expect(providerLink.textContent).toMatch(repo.fullName);
expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch(
statusObject.text,
);
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
expect(vm.$el.querySelector('.js-import-button')).not.toBeNull();
});
it('imports repo when clicking import button', done => {
const importPath = '/import-path';
const defaultTargetNamespace = 'user';
const ciCdOnly = true;
const mock = new MockAdapter(axios);
store.dispatch('setInitialData', { importPath, defaultTargetNamespace, ciCdOnly });
mock.onPost(importPath).replyOnce(200);
spyOn(store, 'dispatch').and.returnValue(new Promise(() => {}));
vm = createComponent();
vm.$el.querySelector('.js-import-button').click();
setTimeoutPromise()
.then(() => {
expect(store.dispatch).toHaveBeenCalledWith('fetchImport', {
repo,
newName: repo.sanitizedName,
targetNamespace: defaultTargetNamespace,
});
})
.then(() => mock.restore())
.then(done)
.catch(done.fail);
});
});
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
SET_INITIAL_DATA,
REQUEST_REPOS,
RECEIVE_REPOS_SUCCESS,
RECEIVE_REPOS_ERROR,
REQUEST_IMPORT,
RECEIVE_IMPORT_SUCCESS,
RECEIVE_IMPORT_ERROR,
RECEIVE_JOBS_SUCCESS,
} from '~/import_projects/store/mutation_types';
import {
setInitialData,
requestRepos,
receiveReposSuccess,
receiveReposError,
fetchRepos,
requestImport,
receiveImportSuccess,
receiveImportError,
fetchImport,
receiveJobsSuccess,
fetchJobs,
clearJobsEtagPoll,
stopJobsPolling,
} from '~/import_projects/store/actions';
import state from '~/import_projects/store/state';
import testAction from 'spec/helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants';
describe('import_projects store actions', () => {
let localState;
const repoId = 1;
const repos = [{ id: 1 }, { id: 2 }];
const importPayload = { newName: 'newName', targetNamespace: 'targetNamespace', repo: { id: 1 } };
beforeEach(() => {
localState = state();
});
describe('setInitialData', () => {
it(`commits ${SET_INITIAL_DATA} mutation`, done => {
const initialData = {
reposPath: 'reposPath',
provider: 'provider',
jobsPath: 'jobsPath',
importPath: 'impapp/assets/javascripts/vue_shared/components/select2_select.vueortPath',
defaultTargetNamespace: 'defaultTargetNamespace',
ciCdOnly: 'ciCdOnly',
canSelectNamespace: 'canSelectNamespace',
};
testAction(
setInitialData,
initialData,
localState,
[{ type: SET_INITIAL_DATA, payload: initialData }],
[],
done,
);
});
});
describe('requestRepos', () => {
it(`requestRepos commits ${REQUEST_REPOS} mutation`, done => {
testAction(
requestRepos,
null,
localState,
[{ type: REQUEST_REPOS, payload: null }],
[],
done,
);
});
});
describe('receiveReposSuccess', () => {
it(`commits ${RECEIVE_REPOS_SUCCESS} mutation`, done => {
testAction(
receiveReposSuccess,
repos,
localState,
[{ type: RECEIVE_REPOS_SUCCESS, payload: repos }],
[],
done,
);
});
});
describe('receiveReposError', () => {
it(`commits ${RECEIVE_REPOS_ERROR} mutation`, done => {
testAction(receiveReposError, repos, localState, [{ type: RECEIVE_REPOS_ERROR }], [], done);
});
});
describe('fetchRepos', () => {
let mock;
beforeEach(() => {
localState.reposPath = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});
afterEach(() => mock.restore());
it('dispatches requestRepos and receiveReposSuccess actions on a successful request', done => {
const payload = { imported_projects: [{}], provider_repos: [{}], namespaces: [{}] };
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, payload);
testAction(
fetchRepos,
null,
localState,
[],
[
{ type: 'requestRepos' },
{
type: 'receiveReposSuccess',
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
},
{
type: 'fetchJobs',
},
],
done,
);
});
it('dispatches requestRepos and receiveReposSuccess actions on an unsuccessful request', done => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
testAction(
fetchRepos,
null,
localState,
[],
[{ type: 'requestRepos' }, { type: 'receiveReposError' }],
done,
);
});
});
describe('requestImport', () => {
it(`commits ${REQUEST_IMPORT} mutation`, done => {
testAction(
requestImport,
repoId,
localState,
[{ type: REQUEST_IMPORT, payload: repoId }],
[],
done,
);
});
});
describe('receiveImportSuccess', () => {
it(`commits ${RECEIVE_IMPORT_SUCCESS} mutation`, done => {
const payload = { importedProject: { name: 'imported/project' }, repoId: 2 };
testAction(
receiveImportSuccess,
payload,
localState,
[{ type: RECEIVE_IMPORT_SUCCESS, payload }],
[],
done,
);
});
});
describe('receiveImportError', () => {
it(`commits ${RECEIVE_IMPORT_ERROR} mutation`, done => {
testAction(
receiveImportError,
repoId,
localState,
[{ type: RECEIVE_IMPORT_ERROR, payload: repoId }],
[],
done,
);
});
});
describe('fetchImport', () => {
let mock;
beforeEach(() => {
localState.importPath = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});
afterEach(() => mock.restore());
it('dispatches requestImport and receiveImportSuccess actions on a successful request', done => {
const importedProject = { name: 'imported/project' };
const importRepoId = importPayload.repo.id;
mock.onPost(`${TEST_HOST}/endpoint.json`).reply(200, importedProject);
testAction(
fetchImport,
importPayload,
localState,
[],
[
{ type: 'requestImport', payload: importRepoId },
{
type: 'receiveImportSuccess',
payload: {
importedProject: convertObjectPropsToCamelCase(importedProject, { deep: true }),
repoId: importRepoId,
},
},
],
done,
);
});
it('dispatches requestImport and receiveImportSuccess actions on an unsuccessful request', done => {
mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500);
testAction(
fetchImport,
importPayload,
localState,
[],
[
{ type: 'requestImport', payload: importPayload.repo.id },
{ type: 'receiveImportError', payload: { repoId: importPayload.repo.id } },
],
done,
);
});
});
describe('receiveJobsSuccess', () => {
it(`commits ${RECEIVE_JOBS_SUCCESS} mutation`, done => {
testAction(
receiveJobsSuccess,
repos,
localState,
[{ type: RECEIVE_JOBS_SUCCESS, payload: repos }],
[],
done,
);
});
});
describe('fetchJobs', () => {
let mock;
beforeEach(() => {
localState.jobsPath = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});
afterEach(() => {
stopJobsPolling();
clearJobsEtagPoll();
});
afterEach(() => mock.restore());
it('dispatches requestJobs and receiveJobsSuccess actions on a successful request', done => {
const updatedProjects = [{ name: 'imported/project' }, { name: 'provider/repo' }];
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, updatedProjects);
testAction(
fetchJobs,
null,
localState,
[],
[
{
type: 'receiveJobsSuccess',
payload: convertObjectPropsToCamelCase(updatedProjects, { deep: true }),
},
],
done,
);
});
});
});
import {
namespaceSelectOptions,
isImportingAnyRepo,
hasProviderRepos,
hasImportedProjects,
} from '~/import_projects/store/getters';
import state from '~/import_projects/store/state';
describe('import_projects store getters', () => {
let localState;
beforeEach(() => {
localState = state();
});
describe('namespaceSelectOptions', () => {
const namespaces = [{ fullPath: 'namespace-0' }, { fullPath: 'namespace-1' }];
const defaultTargetNamespace = 'current-user';
it('returns an options array with a "Users" and "Groups" optgroups', () => {
localState.namespaces = namespaces;
localState.defaultTargetNamespace = defaultTargetNamespace;
const optionsArray = namespaceSelectOptions(localState);
const groupsGroup = optionsArray[0];
const usersGroup = optionsArray[1];
expect(groupsGroup.text).toBe('Groups');
expect(usersGroup.text).toBe('Users');
groupsGroup.children.forEach((child, index) => {
expect(child.id).toBe(namespaces[index].fullPath);
expect(child.text).toBe(namespaces[index].fullPath);
});
expect(usersGroup.children.length).toBe(1);
expect(usersGroup.children[0].id).toBe(defaultTargetNamespace);
expect(usersGroup.children[0].text).toBe(defaultTargetNamespace);
});
});
describe('isImportingAnyRepo', () => {
it('returns true if there are any reposBeingImported', () => {
localState.reposBeingImported = new Array(1);
expect(isImportingAnyRepo(localState)).toBe(true);
});
it('returns false if there are no reposBeingImported', () => {
localState.reposBeingImported = [];
expect(isImportingAnyRepo(localState)).toBe(false);
});
});
describe('hasProviderRepos', () => {
it('returns true if there are any providerRepos', () => {
localState.providerRepos = new Array(1);
expect(hasProviderRepos(localState)).toBe(true);
});
it('returns false if there are no providerRepos', () => {
localState.providerRepos = [];
expect(hasProviderRepos(localState)).toBe(false);
});
});
describe('hasImportedProjects', () => {
it('returns true if there are any importedProjects', () => {
localState.importedProjects = new Array(1);
expect(hasImportedProjects(localState)).toBe(true);
});
it('returns false if there are no importedProjects', () => {
localState.importedProjects = [];
expect(hasImportedProjects(localState)).toBe(false);
});
});
});
import * as types from '~/import_projects/store/mutation_types';
import mutations from '~/import_projects/store/mutations';
describe('import_projects store mutations', () => {
describe(types.RECEIVE_IMPORT_SUCCESS, () => {
it('removes repoId from reposBeingImported and providerRepos, adds to importedProjects', () => {
const repoId = 1;
const state = {
reposBeingImported: [repoId],
providerRepos: [{ id: repoId }],
importedProjects: [],
};
const importedProject = { id: repoId };
mutations[types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId });
expect(state.reposBeingImported.includes(repoId)).toBe(false);
expect(state.providerRepos.some(repo => repo.id === repoId)).toBe(false);
expect(state.importedProjects.some(repo => repo.id === repoId)).toBe(true);
});
});
describe(types.RECEIVE_JOBS_SUCCESS, () => {
it('updates importStatus of existing importedProjects', () => {
const repoId = 1;
const state = { importedProjects: [{ id: repoId, importStatus: 'started' }] };
const updatedProjects = [{ id: repoId, importStatus: 'finished' }];
mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects);
expect(state.importedProjects[0].importStatus).toBe(updatedProjects[0].importStatus);
});
});
});
......@@ -925,6 +925,21 @@ describe User do
expect(user.manageable_groups).to contain_exactly(group, subgroup)
end
end
describe '#manageable_groups_with_routes' do
it 'eager loads routes from manageable groups' do
control_count =
ActiveRecord::QueryRecorder.new(skip_cached: false) do
user.manageable_groups_with_routes.map(&:route)
end.count
create(:group, parent: subgroup)
expect do
user.manageable_groups_with_routes.map(&:route)
end.not_to exceed_all_query_limit(control_count)
end
end
end
end
......
......@@ -23,6 +23,11 @@ require 'spec_helper'
# end
shared_examples 'importer routing' do
let(:except_actions) { [] }
let(:is_realtime) { false }
before do
except_actions.push(is_realtime ? :jobs : :realtime_changes)
end
it 'to #create' do
expect(post("/import/#{provider}")).to route_to("import/#{provider}#create") unless except_actions.include?(:create)
......@@ -43,17 +48,22 @@ shared_examples 'importer routing' do
it 'to #jobs' do
expect(get("/import/#{provider}/jobs")).to route_to("import/#{provider}#jobs") unless except_actions.include?(:jobs)
end
it 'to #realtime_changes' do
expect(get("/import/#{provider}/realtime_changes")).to route_to("import/#{provider}#realtime_changes") unless except_actions.include?(:realtime_changes)
end
end
# personal_access_token_import_github POST /import/github/personal_access_token(.:format) import/github#personal_access_token
# status_import_github GET /import/github/status(.:format) import/github#status
# callback_import_github GET /import/github/callback(.:format) import/github#callback
# jobs_import_github GET /import/github/jobs(.:format) import/github#jobs
# realtime_changes_import_github GET /import/github/realtime_changes(.:format) import/github#jobs
# import_github POST /import/github(.:format) import/github#create
# new_import_github GET /import/github/new(.:format) import/github#new
describe Import::GithubController, 'routing' do
it_behaves_like 'importer routing' do
let(:provider) { 'github' }
let(:is_realtime) { true }
end
it 'to #personal_access_token' do
......@@ -63,13 +73,14 @@ end
# personal_access_token_import_gitea POST /import/gitea/personal_access_token(.:format) import/gitea#personal_access_token
# status_import_gitea GET /import/gitea/status(.:format) import/gitea#status
# jobs_import_gitea GET /import/gitea/jobs(.:format) import/gitea#jobs
# realtime_changes_import_gitea GET /import/gitea/realtime_changes(.:format) import/gitea#jobs
# import_gitea POST /import/gitea(.:format) import/gitea#create
# new_import_gitea GET /import/gitea/new(.:format) import/gitea#new
describe Import::GiteaController, 'routing' do
it_behaves_like 'importer routing' do
let(:except_actions) { [:callback] }
let(:provider) { 'gitea' }
let(:is_realtime) { true }
end
it 'to #personal_access_token' do
......
# frozen_string_literal: true
require 'spec_helper'
describe NamespaceBasicEntity do
set(:group) { create(:group) }
let(:entity) do
described_class.represent(group)
end
describe '#as_json' do
subject { entity.as_json }
it 'includes required fields' do
expect(subject).to include :id, :full_path
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe NamespaceSerializer do
it 'represents NamespaceBasicEntity entities' do
expect(described_class.entity_class).to eq(NamespaceBasicEntity)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ProjectImportEntity do
include ImportHelper
set(:project) { create(:project, import_status: :started, import_source: 'namespace/project') }
let(:provider_url) { 'https://provider.com' }
let(:entity) { described_class.represent(project, provider_url: provider_url) }
describe '#as_json' do
subject { entity.as_json }
it 'includes required fields' do
expect(subject[:import_source]).to eq(project.import_source)
expect(subject[:import_status]).to eq(project.import_status)
expect(subject[:human_import_status_name]).to eq(project.human_import_status_name)
expect(subject[:provider_link]).to eq(provider_project_link_url(provider_url, project[:import_source]))
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ProjectSerializer do
set(:project) { create(:project) }
let(:provider_url) { 'http://provider.com' }
context 'when serializer option is :import' do
subject do
described_class.new.represent(project, serializer: :import, provider_url: provider_url)
end
before do
allow(ProjectImportEntity).to receive(:represent)
end
it 'represents with ProjectImportEntity' do
subject
expect(ProjectImportEntity)
.to have_received(:represent)
.with(project, serializer: :import, provider_url: provider_url, request: an_instance_of(EntityRequest))
end
end
context 'when serializer option is omitted' do
subject do
described_class.new.represent(project)
end
before do
allow(ProjectEntity).to receive(:represent)
end
it 'represents with ProjectEntity' do
subject
expect(ProjectEntity)
.to have_received(:represent)
.with(project, request: an_instance_of(EntityRequest))
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ProviderRepoEntity do
include ImportHelper
let(:provider_repo) { { id: 1, full_name: 'full/name', name: 'name', owner: { login: 'owner' } } }
let(:provider) { :github }
let(:provider_url) { 'https://github.com' }
let(:entity) { described_class.represent(provider_repo, provider: provider, provider_url: provider_url) }
describe '#as_json' do
subject { entity.as_json }
it 'includes requried fields' do
expect(subject[:id]).to eq(provider_repo[:id])
expect(subject[:full_name]).to eq(provider_repo[:full_name])
expect(subject[:owner_name]).to eq(provider_repo[:owner][:login])
expect(subject[:sanitized_name]).to eq(sanitize_project_name(provider_repo[:name]))
expect(subject[:provider_link]).to eq(provider_project_link_url(provider_url, provider_repo[:full_name]))
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ProviderRepoSerializer do
it 'represents ProviderRepoEntity entities' do
expect(described_class.entity_class).to eq(ProviderRepoEntity)
end
end
......@@ -58,36 +58,54 @@ end
shared_examples 'a GitHub-ish import controller: GET status' do
let(:new_import_url) { public_send("new_import_#{provider}_url") }
let(:user) { create(:user) }
let(:repo) { OpenStruct.new(login: 'vim', full_name: 'asd/vim') }
let(:repo) { OpenStruct.new(login: 'vim', full_name: 'asd/vim', name: 'vim', owner: { login: 'owner' }) }
let(:org) { OpenStruct.new(login: 'company') }
let(:org_repo) { OpenStruct.new(login: 'company', full_name: 'company/repo') }
let(:extra_assign_expectations) { {} }
let(:org_repo) { OpenStruct.new(login: 'company', full_name: 'company/repo', name: 'repo', owner: { login: 'owner' }) }
before do
assign_session_token(provider)
end
it "assigns variables" do
project = create(:project, import_type: provider, namespace: user.namespace)
it "returns variables for json request" do
project = create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'example/repo')
group = create(:group)
group.add_owner(user)
stub_client(repos: [repo, org_repo], orgs: [org], org_repos: [org_repo])
get :status
get :status, format: :json
expect(assigns(:already_added_projects)).to eq([project])
expect(assigns(:repos)).to eq([repo, org_repo])
extra_assign_expectations.each do |key, value|
expect(assigns(key)).to eq(value)
end
expect(response).to have_gitlab_http_status(200)
expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
expect(json_response.dig("provider_repos", 0, "id")).to eq(repo.id)
expect(json_response.dig("provider_repos", 1, "id")).to eq(org_repo.id)
expect(json_response.dig("namespaces", 0, "id")).to eq(group.id)
end
it "does not show already added project" do
project = create(:project, import_type: provider, namespace: user.namespace, import_source: 'asd/vim')
project = create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'asd/vim')
stub_client(repos: [repo], orgs: [])
get :status, format: :json
expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
expect(json_response.dig("provider_repos")).to eq([])
end
it "touches the etag cache store" do
expect(stub_client(repos: [], orgs: [])).to receive(:repos)
expect_next_instance_of(Gitlab::EtagCaching::Store) do |store|
expect(store).to receive(:touch) { "realtime_changes_import_#{provider}_path" }
end
get :status, format: :json
end
it "requests provider repos list" do
expect(stub_client(repos: [], orgs: [])).to receive(:repos)
get :status
expect(assigns(:already_added_projects)).to eq([project])
expect(assigns(:repos)).to eq([])
expect(response).to have_gitlab_http_status(200)
end
it "handles an invalid access token" do
......@@ -100,13 +118,32 @@ shared_examples 'a GitHub-ish import controller: GET status' do
expect(controller).to redirect_to(new_import_url)
expect(flash[:alert]).to eq("Access denied to your #{Gitlab::ImportSources.title(provider.to_s)} account.")
end
it "does not produce N+1 database queries" do
stub_client(repos: [repo], orgs: [])
group_a = create(:group)
group_a.add_owner(user)
create(:project, :import_started, import_type: provider, namespace: user.namespace)
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
get :status, format: :json
end.count
stub_client(repos: [repo, org_repo], orgs: [])
group_b = create(:group)
group_b.add_owner(user)
create(:project, :import_started, import_type: provider, namespace: user.namespace)
expect { get :status, format: :json }
.not_to exceed_all_query_limit(control_count)
end
end
shared_examples 'a GitHub-ish import controller: POST create' do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:provider_username) { user.username }
let(:provider_user) { OpenStruct.new(login: provider_username) }
let(:project) { create(:project, import_type: provider, import_status: :finished, import_source: "#{provider_username}/vim") }
let(:provider_repo) do
OpenStruct.new(
name: 'vim',
......@@ -145,6 +182,17 @@ shared_examples 'a GitHub-ish import controller: POST create' do
expect(json_response['errors']).to eq('Name is invalid, Path is old')
end
it "touches the etag cache store" do
allow(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
.and_return(double(execute: project))
expect_next_instance_of(Gitlab::EtagCaching::Store) do |store|
expect(store).to receive(:touch) { "realtime_changes_import_#{provider}_path" }
end
post :create, format: :json
end
context "when the repository owner is the provider user" do
context "when the provider user and GitLab user's usernames match" do
it "takes the current user's namespace" do
......@@ -351,7 +399,7 @@ shared_examples 'a GitHub-ish import controller: POST create' do
it 'does not create a new namespace under the user namespace' do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider)
.and_return(double(execute: build_stubbed(:project)))
.and_return(double(execute: project))
expect { post :create, params: { target_namespace: "#{user.namespace_path}/test_group", new_name: test_name }, format: :js }
.not_to change { Namespace.count }
......@@ -365,7 +413,7 @@ shared_examples 'a GitHub-ish import controller: POST create' do
it 'does not take the selected namespace and name' do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider)
.and_return(double(execute: build_stubbed(:project)))
.and_return(double(execute: project))
post :create, params: { target_namespace: 'foo/foobar/bar', new_name: test_name }, format: :js
end
......@@ -373,7 +421,7 @@ shared_examples 'a GitHub-ish import controller: POST create' do
it 'does not create the namespaces' do
allow(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
.and_return(double(execute: build_stubbed(:project)))
.and_return(double(execute: project))
expect { post :create, params: { target_namespace: 'foo/foobar/bar', new_name: test_name }, format: :js }
.not_to change { Namespace.count }
......@@ -390,7 +438,7 @@ shared_examples 'a GitHub-ish import controller: POST create' do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, group, user, access_params, type: provider)
.and_return(double(execute: build_stubbed(:project)))
.and_return(double(execute: project))
post :create, params: { target_namespace: 'foo', new_name: test_name }, format: :js
end
......@@ -407,3 +455,20 @@ shared_examples 'a GitHub-ish import controller: POST create' do
end
end
end
shared_examples 'a GitHub-ish import controller: GET realtime_changes' do
let(:user) { create(:user) }
before do
assign_session_token(provider)
end
it 'sets a Poll-Interval header' do
project = create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'example/repo')
get :realtime_changes
expect(json_response).to eq([{ "id" => project.id, "import_status" => project.import_status }])
expect(Integer(response.headers['Poll-Interval'])).to be > -1
end
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