Commit f693a63b authored by Illya Klymov's avatar Illya Klymov Committed by Natalia Tepluhina

Implement detailed github status reporting

* expose github stats data to user

Changelog: added
parent cbc35aaa
<script> <script>
import { GlIcon } from '@gitlab/ui'; import { GlAccordion, GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui';
import STATUS_MAP from '../constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __, s__ } from '~/locale';
import { STATUSES } from '../constants';
const STATISTIC_ITEMS = {
diff_note: __('Diff notes'),
issue: __('Issues'),
label: __('Labels'),
milestone: __('Milestones'),
note: __('Notes'),
pull_request: s__('GithubImporter|Pull requests'),
pull_request_merged_by: s__('GithubImporter|PR mergers'),
pull_request_review: s__('GithubImporter|PR reviews'),
release: __('Releases'),
};
// support both camel case and snake case versions
Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS));
const SCHEDULED_STATUS = {
icon: 'status-scheduled',
text: __('Pending'),
variant: 'muted',
};
const STATUS_MAP = {
[STATUSES.NONE]: {
icon: 'status-waiting',
text: __('Not started'),
variant: 'muted',
},
[STATUSES.SCHEDULING]: SCHEDULED_STATUS,
[STATUSES.SCHEDULED]: SCHEDULED_STATUS,
[STATUSES.CREATED]: SCHEDULED_STATUS,
[STATUSES.STARTED]: {
icon: 'status-running',
text: __('Importing...'),
variant: 'info',
},
[STATUSES.FAILED]: {
icon: 'status-failed',
text: __('Failed'),
variant: 'danger',
},
[STATUSES.CANCELLED]: {
icon: 'status-stopped',
text: __('Cancelled'),
variant: 'neutral',
},
};
function isIncompleteImport(stats) {
return Object.keys(stats.fetched).some((key) => stats.fetched[key] !== stats.imported[key]);
}
export default { export default {
name: 'ImportStatus', name: 'ImportStatus',
components: { components: {
GlAccordion,
GlAccordionItem,
GlBadge,
GlIcon, GlIcon,
}, },
props: { props: {
...@@ -12,19 +68,88 @@ export default { ...@@ -12,19 +68,88 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
stats: {
type: Object,
required: false,
default: () => ({ fetched: {}, imported: {} }),
},
}, },
computed: { computed: {
knownStats() {
const knownStatisticKeys = Object.keys(STATISTIC_ITEMS);
return Object.keys(this.stats.fetched).filter((key) => knownStatisticKeys.includes(key));
},
hasStats() {
return this.stats && this.knownStats.length > 0;
},
mappedStatus() { mappedStatus() {
if (this.status === STATUSES.FINISHED) {
const isIncomplete = this.stats && isIncompleteImport(this.stats);
return {
icon: 'status-success',
...(isIncomplete
? {
text: __('Partial import'),
variant: 'warning',
}
: {
text: __('Complete'),
variant: 'success',
}),
};
}
return STATUS_MAP[this.status]; return STATUS_MAP[this.status];
}, },
}, },
methods: {
getStatisticIconProps(key) {
const fetched = this.stats.fetched[key];
const imported = this.stats.imported[key];
if (fetched === imported) {
return { name: 'status-success', class: 'gl-text-green-400' };
} else if (imported === 0) {
return { name: 'status-scheduled', class: 'gl-text-gray-400' };
}
return { name: 'status-running', class: 'gl-text-blue-400' };
},
},
STATISTIC_ITEMS,
}; };
</script> </script>
<template> <template>
<div> <div>
<gl-icon :name="mappedStatus.icon" :class="mappedStatus.iconClass" :size="12" class="gl-mr-2" /> <div class="gl-display-inline-block gl-w-13">
<span>{{ mappedStatus.text }}</span> <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" class="gl-mr-2">
{{ mappedStatus.text }}
</gl-badge>
</div>
<gl-accordion v-if="hasStats" :header-level="3">
<gl-accordion-item :title="__('Details')">
<ul class="gl-p-0 gl-list-style-none gl-font-sm">
<li v-for="key in knownStats" :key="key">
<div class="gl-display-flex gl-w-20 gl-align-items-center">
<gl-icon
:size="12"
class="gl-mr-3 gl-flex-shrink-0"
v-bind="getStatisticIconProps(key)"
/>
<span class="">{{ $options.STATISTIC_ITEMS[key] }}</span>
<span class="gl-ml-auto">
{{ stats.imported[key] || 0 }}/{{ stats.fetched[key] }}
</span>
</div>
</li>
</ul>
</gl-accordion-item>
</gl-accordion>
</div> </div>
</template> </template>
import { __ } from '~/locale';
// The `scheduling` status is only present on the client-side, // The `scheduling` status is only present on the client-side,
// it is used as the status when we are requesting to start an import. // it is used as the status when we are requesting to start an import.
...@@ -13,42 +11,3 @@ export const STATUSES = { ...@@ -13,42 +11,3 @@ export const STATUSES = {
SCHEDULING: 'scheduling', SCHEDULING: 'scheduling',
CANCELLED: 'cancelled', CANCELLED: 'cancelled',
}; };
const SCHEDULED_STATUS = {
icon: 'status-scheduled',
text: __('Pending'),
iconClass: 'gl-text-orange-400',
};
const STATUS_MAP = {
[STATUSES.NONE]: {
icon: 'status-waiting',
text: __('Not started'),
iconClass: 'gl-text-gray-400',
},
[STATUSES.SCHEDULING]: SCHEDULED_STATUS,
[STATUSES.SCHEDULED]: SCHEDULED_STATUS,
[STATUSES.CREATED]: SCHEDULED_STATUS,
[STATUSES.STARTED]: {
icon: 'status-running',
text: __('Importing...'),
iconClass: 'gl-text-blue-400',
},
[STATUSES.FINISHED]: {
icon: 'status-success',
text: __('Complete'),
iconClass: 'gl-text-green-400',
},
[STATUSES.FAILED]: {
icon: 'status-failed',
text: __('Failed'),
iconClass: 'gl-text-red-600',
},
[STATUSES.CANCELLED]: {
icon: 'status-stopped',
text: __('Cancelled'),
iconClass: 'gl-text-red-600',
},
};
export default STATUS_MAP;
...@@ -69,6 +69,10 @@ export default { ...@@ -69,6 +69,10 @@ export default {
return getImportStatus(this.repo); return getImportStatus(this.repo);
}, },
stats() {
return this.repo.importedProject?.stats;
},
importTarget() { importTarget() {
return this.getImportTarget(this.repo.importSource.id); return this.getImportTarget(this.repo.importSource.id);
}, },
...@@ -101,11 +105,11 @@ export default { ...@@ -101,11 +105,11 @@ export default {
<template> <template>
<tr <tr
class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11" class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11 gl-vertical-align-top"
data-qa-selector="project_import_row" data-qa-selector="project_import_row"
:data-qa-source-project="repo.importSource.fullName" :data-qa-source-project="repo.importSource.fullName"
> >
<td class="gl-p-4"> <td class="gl-p-4 gl-vertical-align-top">
<gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink" <gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink"
>{{ repo.importSource.fullName }} >{{ repo.importSource.fullName }}
<gl-icon v-if="repo.importSource.providerLink" name="external-link" /> <gl-icon v-if="repo.importSource.providerLink" name="external-link" />
...@@ -156,10 +160,10 @@ export default { ...@@ -156,10 +160,10 @@ export default {
</template> </template>
<template v-else-if="repo.importedProject">{{ displayFullPath }}</template> <template v-else-if="repo.importedProject">{{ displayFullPath }}</template>
</td> </td>
<td class="gl-p-4" data-qa-selector="import_status_indicator"> <td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator">
<import-status :status="importStatus" /> <import-status :status="importStatus" :stats="stats" />
</td> </td>
<td data-testid="actions"> <td data-testid="actions" class="gl-vertical-align-top gl-pt-4">
<gl-button <gl-button
v-if="isFinished" v-if="isFinished"
class="btn btn-default" class="btn btn-default"
......
...@@ -113,7 +113,11 @@ export default { ...@@ -113,7 +113,11 @@ export default {
updatedProjects.forEach((updatedProject) => { updatedProjects.forEach((updatedProject) => {
const repo = state.repositories.find((p) => p.importedProject?.id === updatedProject.id); const repo = state.repositories.find((p) => p.importedProject?.id === updatedProject.id);
if (repo?.importedProject) { if (repo?.importedProject) {
repo.importedProject.importStatus = updatedProject.importStatus; repo.importedProject = {
...repo.importedProject,
stats: updatedProject.stats,
importStatus: updatedProject.importStatus,
};
} }
}); });
}, },
......
@import 'mixins_and_variables_and_functions'; @import 'mixins_and_variables_and_functions';
.import-jobs-from-col {
width: 37%;
}
.import-jobs-to-col { .import-jobs-to-col {
width: 39%; width: 37%;
} }
.import-jobs-status-col { .import-jobs-status-col {
width: 15%; width: 25%;
} }
.import-jobs-cta-col { .import-jobs-cta-col {
width: 1%; width: 1%;
} }
.import-entities-target-select { .import-entities-target-select {
&.disabled { &.disabled {
.import-entities-target-select-separator { .import-entities-target-select-separator {
......
...@@ -13016,6 +13016,9 @@ msgstr "" ...@@ -13016,6 +13016,9 @@ msgstr ""
msgid "Diff limits" msgid "Diff limits"
msgstr "" msgstr ""
msgid "Diff notes"
msgstr ""
msgid "Difference between start date and now" msgid "Difference between start date and now"
msgstr "" msgstr ""
...@@ -17085,6 +17088,15 @@ msgstr "" ...@@ -17085,6 +17088,15 @@ msgstr ""
msgid "Gitea Import" msgid "Gitea Import"
msgstr "" msgstr ""
msgid "GithubImporter|PR mergers"
msgstr ""
msgid "GithubImporter|PR reviews"
msgstr ""
msgid "GithubImporter|Pull requests"
msgstr ""
msgid "GithubIntegration|Create a %{token_link_start}personal access token%{token_link_end} with %{status_html} access granted and paste it here." msgid "GithubIntegration|Create a %{token_link_start}personal access token%{token_link_end} with %{status_html} access granted and paste it here."
msgstr "" msgstr ""
...@@ -25642,6 +25654,9 @@ msgstr "" ...@@ -25642,6 +25654,9 @@ msgstr ""
msgid "NoteForm|Note" msgid "NoteForm|Note"
msgstr "" msgstr ""
msgid "Notes"
msgstr ""
msgid "Notes rate limit" msgid "Notes rate limit"
msgstr "" msgstr ""
...@@ -27010,6 +27025,9 @@ msgstr "" ...@@ -27010,6 +27025,9 @@ msgstr ""
msgid "Part of merge request changes" msgid "Part of merge request changes"
msgstr "" msgstr ""
msgid "Partial import"
msgstr ""
msgid "Participants" msgid "Participants"
msgstr "" msgstr ""
......
import { GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ImportStatus from '~/import_entities/components/import_status.vue';
import { STATUSES } from '~/import_entities/constants';
describe('Import entities status component', () => {
let wrapper;
const createComponent = (propsData) => {
wrapper = shallowMount(ImportStatus, {
propsData,
});
};
afterEach(() => {
wrapper.destroy();
});
describe('success status', () => {
const getStatusText = () => wrapper.findComponent(GlBadge).text();
it('displays finished status as complete when no stats are provided', () => {
createComponent({
status: STATUSES.FINISHED,
});
expect(getStatusText()).toBe('Complete');
});
it('displays finished status as complete when all stats items were processed', () => {
const statItems = { label: 100, note: 200 };
createComponent({
status: STATUSES.FINISHED,
stats: {
fetched: { ...statItems },
imported: { ...statItems },
},
});
expect(getStatusText()).toBe('Complete');
});
it('displays finished status as partial when all stats items were processed', () => {
const statItems = { label: 100, note: 200 };
createComponent({
status: STATUSES.FINISHED,
stats: {
fetched: { ...statItems },
imported: { ...statItems, label: 50 },
},
});
expect(getStatusText()).toBe('Partial import');
});
});
describe('details drawer', () => {
const findDetailsDrawer = () => wrapper.findComponent(GlAccordionItem);
it('renders details drawer to be present when stats are provided', () => {
createComponent({
status: 'created',
stats: { fetched: { label: 1 }, imported: { label: 0 } },
});
expect(findDetailsDrawer().exists()).toBe(true);
});
it('does not render details drawer when no stats are provided', () => {
createComponent({
status: 'created',
});
expect(findDetailsDrawer().exists()).toBe(false);
});
it('does not render details drawer when stats are empty', () => {
createComponent({
status: 'created',
stats: { fetched: {}, imported: {} },
});
expect(findDetailsDrawer().exists()).toBe(false);
});
it('does not render details drawer when no known stats are provided', () => {
createComponent({
status: 'created',
stats: {
fetched: {
UNKNOWN_STAT: 100,
},
imported: {
UNKNOWN_STAT: 0,
},
},
});
expect(findDetailsDrawer().exists()).toBe(false);
});
});
describe('stats display', () => {
const getStatusIcon = () =>
wrapper.findComponent(GlAccordionItem).findComponent(GlIcon).props().name;
const createComponentWithStats = ({ fetched, imported }) => {
createComponent({
status: 'created',
stats: {
fetched: { label: fetched },
imported: { label: imported },
},
});
};
it('displays scheduled status when imported is 0', () => {
createComponentWithStats({
fetched: 100,
imported: 0,
});
expect(getStatusIcon()).toBe('status-scheduled');
});
it('displays running status when imported is not equal to fetched', () => {
createComponentWithStats({
fetched: 100,
imported: 10,
});
expect(getStatusIcon()).toBe('status-running');
});
it('displays success status when imported is equal to fetched', () => {
createComponentWithStats({
fetched: 100,
imported: 100,
});
expect(getStatusIcon()).toBe('status-success');
});
});
});
...@@ -98,6 +98,8 @@ describe('ProviderRepoTableRow', () => { ...@@ -98,6 +98,8 @@ describe('ProviderRepoTableRow', () => {
}); });
describe('when rendering imported project', () => { describe('when rendering imported project', () => {
const FAKE_STATS = {};
const repo = { const repo = {
importSource: { importSource: {
id: 'remote-1', id: 'remote-1',
...@@ -109,6 +111,7 @@ describe('ProviderRepoTableRow', () => { ...@@ -109,6 +111,7 @@ describe('ProviderRepoTableRow', () => {
fullPath: 'fullPath', fullPath: 'fullPath',
importSource: 'importSource', importSource: 'importSource',
importStatus: STATUSES.FINISHED, importStatus: STATUSES.FINISHED,
stats: FAKE_STATS,
}, },
}; };
...@@ -134,6 +137,10 @@ describe('ProviderRepoTableRow', () => { ...@@ -134,6 +137,10 @@ describe('ProviderRepoTableRow', () => {
it('does not render import button', () => { it('does not render import button', () => {
expect(findImportButton().exists()).toBe(false); expect(findImportButton().exists()).toBe(false);
}); });
it('passes stats to import status component', () => {
expect(wrapper.find(ImportStatus).props().stats).toBe(FAKE_STATS);
});
}); });
describe('when rendering incompatible project', () => { describe('when rendering incompatible project', () => {
......
...@@ -232,6 +232,35 @@ describe('import_projects store mutations', () => { ...@@ -232,6 +232,35 @@ describe('import_projects store mutations', () => {
updatedProjects[0].importStatus, updatedProjects[0].importStatus,
); );
}); });
it('updates import stats of project', () => {
const repoId = 1;
state = {
repositories: [
{ importedProject: { id: repoId, stats: {} }, importStatus: STATUSES.STARTED },
],
};
const newStats = {
fetched: {
label: 10,
},
imported: {
label: 1,
},
};
const updatedProjects = [
{
id: repoId,
importStatus: STATUSES.FINISHED,
stats: newStats,
},
];
mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects);
expect(state.repositories[0].importedProject.stats).toStrictEqual(newStats);
});
}); });
describe(`${types.REQUEST_NAMESPACES}`, () => { describe(`${types.REQUEST_NAMESPACES}`, () => {
......
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