Commit b0096805 authored by Florie Guibert's avatar Florie Guibert

Roadmap - Introduce progress tracking setting

- Allow user to display epic progress by issue count
- Behind roadmap_settings feature flag
parent 980f065c
<script>
import { GlPopover, GlProgressBar, GlIcon } from '@gitlab/ui';
import { mapState } from 'vuex';
import { __, sprintf } from '~/locale';
import {
EPIC_DETAILS_CELL_WIDTH,
......@@ -7,6 +8,7 @@ import {
PRESET_TYPES,
SMALL_TIMELINE_BAR,
TIMELINE_CELL_MIN_WIDTH,
PROGRESS_TRACKING_OPTIONS,
} from '../constants';
import CommonMixin from '../mixins/common_mixin';
......@@ -55,6 +57,7 @@ export default {
},
},
computed: {
...mapState(['progressTracking']),
timelineBarInnerStyle() {
return {
maxWidth: `${this.clientWidth - EPIC_DETAILS_CELL_WIDTH}px`,
......@@ -75,33 +78,49 @@ export default {
timelineBarTitle() {
return this.isTimelineBarSmall ? '...' : this.epic.title;
},
epicTotalWeight() {
if (this.epic.descendantWeightSum) {
const { openedIssues, closedIssues } = this.epic.descendantWeightSum;
progressTrackingIsCount() {
return this.progressTracking === PROGRESS_TRACKING_OPTIONS.COUNT;
},
epicDescendants() {
return this.progressTrackingIsCount
? this.epic.descendantCounts
: this.epic.descendantWeightSum;
},
epicTotal() {
if (this.epicDescendants) {
const { openedIssues, closedIssues } = this.epicDescendants;
return openedIssues + closedIssues;
}
return undefined;
},
epicWeightPercentage() {
return this.epicTotalWeight
? Math.round(
(this.epic.descendantWeightSum.closedIssues / this.epicTotalWeight) * PERCENTAGE,
)
epicPercentage() {
return this.epicTotal
? Math.round((this.epicDescendants.closedIssues / this.epicTotal) * PERCENTAGE)
: 0;
},
epicWeightPercentageText() {
return sprintf(__(`%{percentage}%% weight completed`), {
percentage: this.epicWeightPercentage,
epicPercentageText() {
return sprintf(__(`%{percentage}%% %{trackingOption}`), {
percentage: this.epicPercentage,
trackingOption: this.trackingOptionText,
});
},
popoverWeightText() {
if (this.epic.descendantWeightSum) {
return sprintf(__('%{completedWeight} of %{totalWeight} weight completed'), {
completedWeight: this.epic.descendantWeightSum.closedIssues,
totalWeight: this.epicTotalWeight,
popoverText() {
if (this.epicDescendants) {
return sprintf(__('%{completed} of %{total} %{trackingOption}'), {
completed: this.epicDescendants.closedIssues,
total: this.epicTotal,
trackingOption: this.trackingOptionText,
});
}
return __('- of - weight completed');
return sprintf(__('- of - %{trackingOption}'), {
trackingOption: this.trackingOptionText,
});
},
trackingOptionText() {
return this.progressTrackingIsCount ? __('issues closed') : __('weight completed');
},
progressIcon() {
return this.progressTrackingIsCount ? 'issue-closed' : 'weight';
},
},
methods: {
......@@ -125,19 +144,19 @@ export default {
<div v-if="!isTimelineBarSmall" class="gl-display-flex gl-align-items-center">
<gl-progress-bar
class="epic-bar-progress gl-flex-grow-1 gl-mr-2"
:value="epicWeightPercentage"
:value="epicPercentage"
aria-hidden="true"
/>
<div class="gl-font-sm gl-display-flex gl-align-items-center gl-white-space-nowrap">
<gl-icon class="gl-mr-1" :size="12" name="weight" />
<p class="gl-m-0" :aria-label="epicWeightPercentageText">{{ epicWeightPercentage }}%</p>
<gl-icon class="gl-mr-1" :size="12" :name="progressIcon" />
<p class="gl-m-0" :aria-label="epicPercentageText">{{ epicPercentage }}%</p>
</div>
</div>
</div>
</a>
<gl-popover :target="generateKey(epic)" :title="epic.title" placement="left">
<p class="gl-text-gray-500 gl-m-0">{{ timeframeString(epic) }}</p>
<p class="gl-m-0">{{ popoverWeightText }}</p>
<p class="gl-m-0">{{ popoverText }}</p>
</gl-popover>
</div>
</template>
......@@ -71,7 +71,7 @@ export default {
};
},
computed: {
...mapState(['presetType', 'epicsState', 'sortedBy', 'filterParams']),
...mapState(['presetType', 'epicsState', 'sortedBy', 'filterParams', 'progressTracking']),
selectedEpicStateTitle() {
if (this.epicsState === EPICS_STATES.ALL) {
return __('All epics');
......
<script>
import { GlFormGroup, GlFormRadioGroup } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import { PROGRESS_TRACKING_OPTIONS } from '../constants';
export default {
components: {
GlFormGroup,
GlFormRadioGroup,
},
computed: {
...mapState(['progressTracking']),
availableOptions() {
const weight = { text: __('Use issue weight'), value: PROGRESS_TRACKING_OPTIONS.WEIGHT };
const count = { text: __('Use issue count'), value: PROGRESS_TRACKING_OPTIONS.COUNT };
return [weight, count];
},
},
methods: {
...mapActions(['setProgressTracking']),
handleProgressTrackingChange(option) {
if (option !== this.progressTracking) {
this.setProgressTracking(option);
}
},
},
i18n: {
header: __('Progress tracking'),
},
};
</script>
<template>
<div>
<gl-form-group
class="gl-mb-0"
:label="$options.i18n.header"
data-testid="roadmap-progress-tracking"
>
<gl-form-radio-group
:checked="progressTracking"
stacked
:options="availableOptions"
@change="handleProgressTrackingChange"
/>
</gl-form-group>
</div>
</template>
......@@ -2,12 +2,14 @@
import { GlDrawer } from '@gitlab/ui';
import RoadmapDaterange from './roadmap_daterange.vue';
import RoadmapEpicsState from './roadmap_epics_state.vue';
import RoadmapProgressTracking from './roadmap_progress_tracking.vue';
export default {
components: {
GlDrawer,
RoadmapDaterange,
RoadmapEpicsState,
RoadmapProgressTracking,
},
props: {
isOpen: {
......@@ -47,6 +49,7 @@ export default {
<template #default>
<roadmap-daterange :timeframe-range-type="timeframeRangeType" />
<roadmap-epics-state />
<roadmap-progress-tracking />
</template>
</gl-drawer>
</template>
......@@ -61,3 +61,8 @@ export const EPIC_LEVEL_MARGIN = {
};
export const ROADMAP_PAGE_SIZE = 50;
export const PROGRESS_TRACKING_OPTIONS = {
WEIGHT: 'WEIGHT',
COUNT: 'COUNT',
};
......@@ -51,6 +51,7 @@ export default {
'not[author_username]': notAuthorUsername,
'not[my_reaction_emoji]': notMyReactionEmoji,
'not[label_name][]': notLabelName,
progress: this.progressTracking,
};
},
},
......
......@@ -17,6 +17,8 @@ fragment BaseEpic on Epic {
descendantCounts {
openedEpics
closedEpics
closedIssues
openedIssues
}
group {
id
......
......@@ -74,7 +74,15 @@ export default () => {
});
const filterParams = {
...convertObjectPropsToCamelCase(rawFilterParams, {
dropKeys: ['scope', 'utf8', 'state', 'sort', 'timeframe_range_type', 'layout'], // These keys are unsupported/unnecessary
dropKeys: [
'scope',
'utf8',
'state',
'sort',
'timeframe_range_type',
'layout',
'progress',
], // These keys are unsupported/unnecessary
}),
// We shall put parsed value of `confidential` only
// when it is defined.
......@@ -103,6 +111,7 @@ export default () => {
timeframeRangeType,
presetType,
timeframe,
progressTracking: rawFilterParams.progress,
};
},
created() {
......@@ -121,6 +130,7 @@ export default () => {
isChildEpics: this.isChildEpics,
hasFiltersApplied: this.hasFiltersApplied,
allowSubEpics: this.allowSubEpics,
progressTracking: this.progressTracking,
});
},
methods: {
......
......@@ -326,3 +326,6 @@ export const setFilterParams = ({ commit }, filterParams) =>
commit(types.SET_FILTER_PARAMS, filterParams);
export const setSortedBy = ({ commit }, sortedBy) => commit(types.SET_SORTED_BY, sortedBy);
export const setProgressTracking = ({ commit }, progressTracking) =>
commit(types.SET_PROGRESS_TRACKING, progressTracking);
......@@ -32,3 +32,4 @@ export const SET_EPICS_STATE = 'SET_EPICS_STATE';
export const SET_DATERANGE = 'SET_DATERANGE';
export const SET_FILTER_PARAMS = 'SET_FILTER_PARAMS';
export const SET_SORTED_BY = 'SET_SORTED_BY';
export const SET_PROGRESS_TRACKING = 'SET_PROGRESS_TRACKING';
......@@ -140,4 +140,8 @@ export default {
state.sortedBy = sortedBy;
resetEpics(state);
},
[types.SET_PROGRESS_TRACKING](state, progressTracking) {
state.progressTracking = progressTracking;
},
};
......@@ -2,6 +2,7 @@ export default () => ({
// API Calls
basePath: '',
epicsState: '',
progressTracking: '',
filterParams: null,
// Data
......
import { GlPopover, GlProgressBar } from '@gitlab/ui';
import { GlIcon, GlPopover, GlProgressBar } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
import EpicItemTimeline from 'ee/roadmap/components/epic_item_timeline.vue';
import { DATE_RANGES, PRESET_TYPES } from 'ee/roadmap/constants';
import { DATE_RANGES, PRESET_TYPES, PROGRESS_TRACKING_OPTIONS } from 'ee/roadmap/constants';
import createStore from 'ee/roadmap/store';
import { getTimeframeForRangeType } from 'ee/roadmap/utils/roadmap_utils';
import { mockTimeframeInitialDate, mockFormattedEpic } from 'ee_jest/roadmap/mock_data';
Vue.use(Vuex);
const mockTimeframeMonths = getTimeframeForRangeType({
timeframeRangeType: DATE_RANGES.CURRENT_YEAR,
presetType: PRESET_TYPES.MONTHS,
......@@ -17,8 +22,16 @@ const createComponent = ({
timeframe = mockTimeframeMonths,
timeframeItem = mockTimeframeMonths[0],
timeframeString = '',
progressTracking = PROGRESS_TRACKING_OPTIONS.WEIGHT,
} = {}) => {
const store = createStore();
store.dispatch('setInitialData', {
progressTracking,
});
return shallowMount(EpicItemTimeline, {
store,
propsData: {
epic,
startDate: epic.originalStartDate,
......@@ -51,7 +64,7 @@ describe('EpicItemTimelineComponent', () => {
});
it('shows the progress bar with correct value', () => {
expect(wrapper.find(GlProgressBar).attributes('value')).toBe('60');
expect(wrapper.findComponent(GlProgressBar).attributes('value')).toBe('60');
});
it('shows the percentage', () => {
......@@ -61,6 +74,19 @@ describe('EpicItemTimelineComponent', () => {
it('contains a link to the epic', () => {
expect(getEpicBar(wrapper).attributes('href')).toBe(mockFormattedEpic.webUrl);
});
it.each`
progressTracking | icon
${'WEIGHT'} | ${'weight'}
${'COUNT'} | ${'issue-closed'}
`(
'displays icon $icon when progressTracking equals $progressTracking',
({ progressTracking, icon }) => {
wrapper = createComponent({ progressTracking });
expect(wrapper.findComponent(GlIcon).props('name')).toBe(icon);
},
);
});
describe('popover', () => {
......@@ -70,21 +96,37 @@ describe('EpicItemTimelineComponent', () => {
expect(wrapper.find(GlPopover).text()).toContain('Jun 26, 2017 – Mar 10, 2018');
});
it('shows the weight completed', () => {
wrapper = createComponent();
expect(wrapper.find(GlPopover).text()).toContain('3 of 5 weight completed');
});
it.each`
progressTracking | option | text
${'WEIGHT'} | ${'weight'} | ${'3 of 5 weight completed'}
${'COUNT'} | ${'issues'} | ${'3 of 5 issues closed'}
`(
'shows $option completed when progressTracking equals $progressTracking',
({ progressTracking, text }) => {
wrapper = createComponent({ progressTracking });
it('shows the weight completed with no numbers when there is no weights information', () => {
expect(wrapper.findComponent(GlPopover).text()).toContain(text);
},
);
it.each`
progressTracking | option | text
${'WEIGHT'} | ${'weight'} | ${'- of - weight completed'}
${'COUNT'} | ${'issues'} | ${'- of - issues closed'}
`(
'shows $option completed with no numbers when there is no $option information and progressTracking equals $progressTracking',
({ progressTracking, text }) => {
wrapper = createComponent({
progressTracking,
epic: {
...mockFormattedEpic,
descendantWeightSum: undefined,
descendantCounts: undefined,
},
});
expect(wrapper.find(GlPopover).text()).toContain('- of - weight completed');
});
expect(wrapper.findComponent(GlPopover).text()).toContain(text);
},
);
});
});
......@@ -3,7 +3,12 @@ import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import RoadmapFilters from 'ee/roadmap/components/roadmap_filters.vue';
import { PRESET_TYPES, EPICS_STATES, DATE_RANGES } from 'ee/roadmap/constants';
import {
PRESET_TYPES,
EPICS_STATES,
DATE_RANGES,
PROGRESS_TRACKING_OPTIONS,
} from 'ee/roadmap/constants';
import createStore from 'ee/roadmap/store';
import { getTimeframeForRangeType } from 'ee/roadmap/utils/roadmap_utils';
import {
......@@ -55,6 +60,7 @@ const createComponent = ({
sortedBy,
filterParams,
timeframe,
progressTracking: PROGRESS_TRACKING_OPTIONS.WEIGHT,
});
return shallowMountExtended(RoadmapFilters, {
......@@ -116,7 +122,7 @@ describe('RoadmapFilters', () => {
await nextTick();
expect(global.window.location.href).toBe(
`${TEST_HOST}/?state=${EPICS_STATES.CLOSED}&sort=end_date_asc&layout=MONTHS&author_username=root&label_name%5B%5D=Bug&milestone_title=4.0&confidential=true`,
`${TEST_HOST}/?state=${EPICS_STATES.CLOSED}&sort=end_date_asc&layout=MONTHS&author_username=root&label_name%5B%5D=Bug&milestone_title=4.0&confidential=true&progress=WEIGHT`,
);
});
});
......@@ -148,10 +154,10 @@ describe('RoadmapFilters', () => {
});
it('renders epics state toggling dropdown', () => {
const epicsStateDropdown = wrapper.find(GlDropdown);
const epicsStateDropdown = wrapper.findComponent(GlDropdown);
expect(epicsStateDropdown.exists()).toBe(true);
expect(epicsStateDropdown.findAll(GlDropdownItem)).toHaveLength(3);
expect(epicsStateDropdown.findAllComponents(GlDropdownItem)).toHaveLength(3);
});
it('does not render settings button', () => {
......
import { GlFormGroup, GlFormRadioGroup } from '@gitlab/ui';
import { __ } from '~/locale';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createStore from 'ee/roadmap/store';
import RoadmapProgressTracking from 'ee/roadmap/components/roadmap_progress_tracking.vue';
import { PROGRESS_TRACKING_OPTIONS } from 'ee/roadmap/constants';
describe('RoadmapProgressTracking', () => {
let wrapper;
const availableOptions = [
{ text: __('Use issue weight'), value: PROGRESS_TRACKING_OPTIONS.WEIGHT },
{ text: __('Use issue count'), value: PROGRESS_TRACKING_OPTIONS.COUNT },
];
const createComponent = () => {
const store = createStore();
store.dispatch('setInitialData', {
progressTracking: PROGRESS_TRACKING_OPTIONS.WEIGHT,
});
wrapper = shallowMountExtended(RoadmapProgressTracking, {
store,
});
};
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
const findFormRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders form group', () => {
expect(findFormGroup().exists()).toBe(true);
expect(findFormGroup().attributes('label')).toBe('Progress tracking');
});
it('renders radio form group', () => {
expect(findFormRadioGroup().exists()).toBe(true);
expect(findFormRadioGroup().props('options')).toEqual(availableOptions);
});
});
});
......@@ -3,6 +3,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RoadmapSettings from 'ee/roadmap/components/roadmap_settings.vue';
import RoadmapDaterange from 'ee/roadmap/components/roadmap_daterange.vue';
import RoadmapEpicsState from 'ee/roadmap/components/roadmap_epics_state.vue';
import RoadmapProgressTracking from 'ee/roadmap/components/roadmap_progress_tracking.vue';
describe('RoadmapSettings', () => {
let wrapper;
......@@ -16,6 +17,7 @@ describe('RoadmapSettings', () => {
const findSettingsDrawer = () => wrapper.findComponent(GlDrawer);
const findDaterange = () => wrapper.findComponent(RoadmapDaterange);
const findEpicsSate = () => wrapper.findComponent(RoadmapEpicsState);
const findProgressTracking = () => wrapper.findComponent(RoadmapProgressTracking);
beforeEach(() => {
createComponent();
......@@ -38,5 +40,9 @@ describe('RoadmapSettings', () => {
it('renders roadmap epics state component', () => {
expect(findEpicsSate().exists()).toBe(true);
});
it('renders roadmap progress tracking component', () => {
expect(findProgressTracking().exists()).toBe(true);
});
});
});
import Vue from 'vue';
import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { DATE_RANGES, PRESET_TYPES } from 'ee/roadmap/constants';
import { DATE_RANGES, PRESET_TYPES, PROGRESS_TRACKING_OPTIONS } from 'ee/roadmap/constants';
import createStore from 'ee/roadmap/store';
import { getTimeframeForRangeType } from 'ee/roadmap/utils/roadmap_utils';
import { mockTimeframeInitialDate, mockEpic } from 'ee_jest/roadmap/mock_data';
Vue.use(Vuex);
const mockTimeframeMonths = getTimeframeForRangeType({
timeframeRangeType: DATE_RANGES.CURRENT_YEAR,
presetType: PRESET_TYPES.MONTHS,
......@@ -20,7 +25,14 @@ describe('MonthsPresetMixin', () => {
timeframeItem = mockTimeframeMonths[0],
epic = mockEpic,
} = {}) => {
const store = createStore();
store.dispatch('setInitialData', {
progressTracking: PROGRESS_TRACKING_OPTIONS.WEIGHT,
});
return shallowMount(EpicItemTimelineComponent, {
store,
propsData: {
presetType,
timeframe,
......
import Vue from 'vue';
import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { DATE_RANGES, PRESET_TYPES } from 'ee/roadmap/constants';
import { DATE_RANGES, PRESET_TYPES, PROGRESS_TRACKING_OPTIONS } from 'ee/roadmap/constants';
import createStore from 'ee/roadmap/store';
import { getTimeframeForRangeType } from 'ee/roadmap/utils/roadmap_utils';
import { mockTimeframeInitialDate, mockEpic } from 'ee_jest/roadmap/mock_data';
Vue.use(Vuex);
const mockTimeframeQuarters = getTimeframeForRangeType({
timeframeRangeType: DATE_RANGES.THREE_YEARS,
presetType: PRESET_TYPES.QUARTERS,
......@@ -20,7 +25,14 @@ describe('QuartersPresetMixin', () => {
timeframeItem = mockTimeframeQuarters[0],
epic = mockEpic,
} = {}) => {
const store = createStore();
store.dispatch('setInitialData', {
progressTracking: PROGRESS_TRACKING_OPTIONS.WEIGHT,
});
return shallowMount(EpicItemTimelineComponent, {
store,
propsData: {
presetType,
timeframe,
......
import Vue from 'vue';
import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { DATE_RANGES, PRESET_TYPES } from 'ee/roadmap/constants';
import { DATE_RANGES, PRESET_TYPES, PROGRESS_TRACKING_OPTIONS } from 'ee/roadmap/constants';
import createStore from 'ee/roadmap/store';
import { getTimeframeForRangeType } from 'ee/roadmap/utils/roadmap_utils';
import { mockTimeframeInitialDate, mockEpic } from 'ee_jest/roadmap/mock_data';
Vue.use(Vuex);
const mockTimeframeWeeks = getTimeframeForRangeType({
timeframeRangeType: DATE_RANGES.CURRENT_QUARTER,
presetType: PRESET_TYPES.WEEKS,
......@@ -20,7 +25,14 @@ describe('WeeksPresetMixin', () => {
timeframeItem = mockTimeframeWeeks[0],
epic = mockEpic,
} = {}) => {
const store = createStore();
store.dispatch('setInitialData', {
progressTracking: PROGRESS_TRACKING_OPTIONS.WEIGHT,
});
return shallowMount(EpicItemTimelineComponent, {
store,
propsData: {
presetType,
timeframe,
......
......@@ -222,6 +222,8 @@ export const mockRawEpic = {
descendantCounts: {
openedEpics: 3,
closedEpics: 2,
closedIssues: 3,
openedIssues: 2,
__typename: 'EpicDescendantCount',
},
group: mockGroup1,
......
......@@ -657,6 +657,17 @@ describe('Roadmap Vuex Actions', () => {
payload: { timeframeRangeType: 'CURRENT_YEAR', presetType: 'MONTHS' },
},
],
);
});
});
describe('setProgressTracking', () => {
it('should set progressTracking in store state', () => {
return testAction(
actions.setProgressTracking,
'COUNT',
state,
[{ type: types.SET_PROGRESS_TRACKING, payload: 'COUNT' }],
[],
);
});
......
......@@ -342,4 +342,17 @@ describe('Roadmap Store Mutations', () => {
});
});
});
describe('SET_PROGRESS_TRACKING', () => {
it('Should set `progressTracking` to the state', () => {
const progressTracking = 'COUNT';
setEpicMockData(state);
mutations[types.SET_PROGRESS_TRACKING](state, progressTracking);
expect(state).toMatchObject({
progressTracking,
});
});
});
});
......@@ -510,6 +510,9 @@ msgstr[1] ""
msgid "%{completedWeight} of %{totalWeight} weight completed"
msgstr ""
msgid "%{completed} of %{total} %{trackingOption}"
msgstr ""
msgid "%{cores} cores"
msgstr ""
......@@ -858,7 +861,7 @@ msgstr ""
msgid "%{openedIssues} open, %{closedIssues} closed"
msgstr ""
msgid "%{percentage}%% weight completed"
msgid "%{percentage}%% %{trackingOption}"
msgstr ""
msgid "%{percent}%% complete"
......@@ -1287,7 +1290,7 @@ msgid_plural "- Users"
msgstr[0] ""
msgstr[1] ""
msgid "- of - weight completed"
msgid "- of - %{trackingOption}"
msgstr ""
msgid "- show less"
......@@ -27709,6 +27712,9 @@ msgstr ""
msgid "Progress"
msgstr ""
msgid "Progress tracking"
msgstr ""
msgid "Project"
msgstr ""
......@@ -38998,6 +39004,12 @@ msgstr ""
msgid "Use hashed storage paths for newly created and renamed repositories. Always enabled since 13.0."
msgstr ""
msgid "Use issue count"
msgstr ""
msgid "Use issue weight"
msgstr ""
msgid "Use one line per URI"
msgstr ""
......@@ -42833,6 +42845,9 @@ msgstr ""
msgid "issues at risk"
msgstr ""
msgid "issues closed"
msgstr ""
msgid "issues need attention"
msgstr ""
......@@ -43787,6 +43802,9 @@ msgstr ""
msgid "was scheduled to merge after pipeline succeeds by"
msgstr ""
msgid "weight completed"
msgstr ""
msgid "wiki page"
msgstr ""
......
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