diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index f658c634e50..b6b02e5ce0e 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-56c571a6bf20894de96f4739cfe17ede1befc8a2
+c50b0080e9996e5db5eb4d75dfc3811618812798
diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
new file mode 100644
index 00000000000..396c1703c1e
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
@@ -0,0 +1,703 @@
+import { isNumber } from 'lodash';
+import { __, n__ } from '../../../locale';
+import { getDayName, parseSeconds } from './date_format_utility';
+
+const DAYS_IN_WEEK = 7;
+export const SECONDS_IN_DAY = 86400;
+export const MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000;
+
+/**
+ * This method allows you to create new Date instance from existing
+ * date instance without keeping the reference.
+ *
+ * @param {Date} date
+ */
+export const newDate = (date) => (date instanceof Date ? new Date(date.getTime()) : new Date());
+
+/**
+ * Returns number of days in a month for provided date.
+ * courtesy: https://stacko(verflow.com/a/1185804/414749
+ *
+ * @param {Date} date
+ */
+export const totalDaysInMonth = (date) => {
+ if (!date) {
+ return 0;
+ }
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
+};
+
+/**
+ * Returns number of days in a quarter from provided
+ * months array.
+ *
+ * @param {Array} quarter
+ */
+export const totalDaysInQuarter = (quarter) =>
+ quarter.reduce((acc, month) => acc + totalDaysInMonth(month), 0);
+
+/**
+ * Returns list of Dates referring to Sundays of the month
+ * based on provided date
+ *
+ * @param {Date} date
+ */
+export const getSundays = (date) => {
+ if (!date) {
+ return [];
+ }
+
+ const daysToSunday = [
+ __('Saturday'),
+ __('Friday'),
+ __('Thursday'),
+ __('Wednesday'),
+ __('Tuesday'),
+ __('Monday'),
+ __('Sunday'),
+ ];
+
+ const month = date.getMonth();
+ const year = date.getFullYear();
+ const sundays = [];
+ const dateOfMonth = new Date(year, month, 1);
+
+ while (dateOfMonth.getMonth() === month) {
+ const dayName = getDayName(dateOfMonth);
+ if (dayName === __('Sunday')) {
+ sundays.push(new Date(dateOfMonth.getTime()));
+ }
+
+ const daysUntilNextSunday = daysToSunday.indexOf(dayName) + 1;
+ dateOfMonth.setDate(dateOfMonth.getDate() + daysUntilNextSunday);
+ }
+
+ return sundays;
+};
+
+/**
+ * Returns list of Dates representing a timeframe of months from startDate and length
+ * This method also supports going back in time when `length` is negative number
+ *
+ * @param {Date} initialStartDate
+ * @param {Number} length
+ */
+export const getTimeframeWindowFrom = (initialStartDate, length) => {
+ if (!(initialStartDate instanceof Date) || !length) {
+ return [];
+ }
+
+ const startDate = newDate(initialStartDate);
+ const moveMonthBy = length > 0 ? 1 : -1;
+
+ startDate.setDate(1);
+ startDate.setHours(0, 0, 0, 0);
+
+ // Iterate and set date for the size of length
+ // and push date reference to timeframe list
+ const timeframe = new Array(Math.abs(length)).fill().map(() => {
+ const currentMonth = startDate.getTime();
+ startDate.setMonth(startDate.getMonth() + moveMonthBy);
+ return new Date(currentMonth);
+ });
+
+ // Change date of last timeframe item to last date of the month
+ // when length is positive
+ if (length > 0) {
+ timeframe[timeframe.length - 1].setDate(totalDaysInMonth(timeframe[timeframe.length - 1]));
+ }
+
+ return timeframe;
+};
+
+/**
+ * Returns count of day within current quarter from provided date
+ * and array of months for the quarter
+ *
+ * Eg;
+ * If date is 15 Feb 2018
+ * and quarter is [Jan, Feb, Mar]
+ *
+ * Then 15th Feb is 46th day of the quarter
+ * Where 31 (days in Jan) + 15 (date of Feb).
+ *
+ * @param {Date} date
+ * @param {Array} quarter
+ */
+export const dayInQuarter = (date, quarter) => {
+ const dateValues = {
+ date: date.getDate(),
+ month: date.getMonth(),
+ };
+
+ return quarter.reduce((acc, month) => {
+ if (dateValues.month > month.getMonth()) {
+ return acc + totalDaysInMonth(month);
+ } else if (dateValues.month === month.getMonth()) {
+ return acc + dateValues.date;
+ }
+ return acc + 0;
+ }, 0);
+};
+
+export const millisecondsPerDay = 1000 * 60 * 60 * 24;
+
+export const getDayDifference = (a, b) => {
+ const date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
+ const date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
+
+ return Math.floor((date2 - date1) / millisecondsPerDay);
+};
+
+/**
+ * Calculates the milliseconds between now and a given date string.
+ * The result cannot become negative.
+ *
+ * @param endDate date string that the time difference is calculated for
+ * @return {Number} number of milliseconds remaining until the given date
+ */
+export const calculateRemainingMilliseconds = (endDate) => {
+ const remainingMilliseconds = new Date(endDate).getTime() - Date.now();
+ return Math.max(remainingMilliseconds, 0);
+};
+
+/**
+ * Subtracts a given number of days from a given date and returns the new date.
+ *
+ * @param {Date} date the date that we will substract days from
+ * @param {Number} daysInPast number of days that are subtracted from a given date
+ * @returns {Date} Date in past as Date object
+ */
+export const getDateInPast = (date, daysInPast) =>
+ new Date(newDate(date).setDate(date.getDate() - daysInPast));
+
+/**
+ * Adds a given number of days to a given date and returns the new date.
+ *
+ * @param {Date} date the date that we will add days to
+ * @param {Number} daysInFuture number of days that are added to a given date
+ * @returns {Date} Date in future as Date object
+ */
+export const getDateInFuture = (date, daysInFuture) =>
+ new Date(newDate(date).setDate(date.getDate() + daysInFuture));
+
+/**
+ * Checks if a given date-instance was created with a valid date
+ *
+ * @param {Date} date
+ * @returns boolean
+ */
+export const isValidDate = (date) => date instanceof Date && !Number.isNaN(date.getTime());
+
+/*
+ * Appending T00:00:00 makes JS assume local time and prevents it from shifting the date
+ * to match the user's time zone. We want to display the date in server time for now, to
+ * be consistent with the "edit issue -> due date" UI.
+ */
+
+export const newDateAsLocaleTime = (date) => {
+ const suffix = 'T00:00:00';
+ return new Date(`${date}${suffix}`);
+};
+
+export const beginOfDayTime = 'T00:00:00Z';
+export const endOfDayTime = 'T23:59:59Z';
+
+/**
+ * @param {Date} d1
+ * @param {Date} d2
+ * @param {Function} formatter
+ * @return {Any[]} an array of formatted dates between 2 given dates (including start&end date)
+ */
+export const getDatesInRange = (d1, d2, formatter = (x) => x) => {
+ if (!(d1 instanceof Date) || !(d2 instanceof Date)) {
+ return [];
+ }
+ let startDate = d1.getTime();
+ const endDate = d2.getTime();
+ const oneDay = 24 * 3600 * 1000;
+ const range = [d1];
+
+ while (startDate < endDate) {
+ startDate += oneDay;
+ range.push(new Date(startDate));
+ }
+
+ return range.map(formatter);
+};
+
+/**
+ * Converts the supplied number of seconds to milliseconds.
+ *
+ * @param {Number} seconds
+ * @return {Number} number of milliseconds
+ */
+export const secondsToMilliseconds = (seconds) => seconds * 1000;
+
+/**
+ * Converts the supplied number of seconds to days.
+ *
+ * @param {Number} seconds
+ * @return {Number} number of days
+ */
+export const secondsToDays = (seconds) => Math.round(seconds / 86400);
+
+/**
+ * Converts a numeric utc offset in seconds to +/- hours
+ * ie -32400 => -9 hours
+ * ie -12600 => -3.5 hours
+ *
+ * @param {Number} offset UTC offset in seconds as a integer
+ *
+ * @return {String} the + or - offset in hours
+ */
+export const secondsToHours = (offset) => {
+ const parsed = parseInt(offset, 10);
+ if (Number.isNaN(parsed) || parsed === 0) {
+ return `0`;
+ }
+ const num = offset / 3600;
+ return parseInt(num, 10) !== num ? num.toFixed(1) : num;
+};
+
+/**
+ * Returns the date `n` days after the date provided
+ *
+ * @param {Date} date the initial date
+ * @param {Number} numberOfDays number of days after
+ * @param {Object} [options={}] Additional options for this calculation
+ * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
+ * This will cause Daylight Saving Time to be ignored. Defaults to `false`
+ * if not provided, which causes the calculation to be performed in the
+ * user's timezone.
+ *
+ * @return {Date} A `Date` object `n` days after the provided `Date`
+ */
+export const nDaysAfter = (date, numberOfDays, { utc = false } = {}) => {
+ const clone = newDate(date);
+
+ const cloneValue = utc
+ ? clone.setUTCDate(date.getUTCDate() + numberOfDays)
+ : clone.setDate(date.getDate() + numberOfDays);
+
+ return new Date(cloneValue);
+};
+
+/**
+ * Returns the date `n` days before the date provided
+ *
+ * @param {Date} date the initial date
+ * @param {Number} numberOfDays number of days before
+ * @param {Object} [options={}] Additional options for this calculation
+ * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
+ * This will cause Daylight Saving Time to be ignored. Defaults to `false`
+ * if not provided, which causes the calculation to be performed in the
+ * user's timezone.
+ * @return {Date} A `Date` object `n` days before the provided `Date`
+ */
+export const nDaysBefore = (date, numberOfDays, options) =>
+ nDaysAfter(date, -numberOfDays, options);
+
+/**
+ * Returns the date `n` months after the date provided
+ *
+ * @param {Date} date the initial date
+ * @param {Number} numberOfMonths number of months after
+ * @param {Object} [options={}] Additional options for this calculation
+ * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
+ * This will cause Daylight Saving Time to be ignored. Defaults to `false`
+ * if not provided, which causes the calculation to be performed in the
+ * user's timezone.
+ *
+ * @return {Date} A `Date` object `n` months after the provided `Date`
+ */
+export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => {
+ const clone = newDate(date);
+
+ const cloneValue = utc
+ ? clone.setUTCMonth(date.getUTCMonth() + numberOfMonths)
+ : clone.setMonth(date.getMonth() + numberOfMonths);
+
+ return new Date(cloneValue);
+};
+
+/**
+ * Returns the date `n` months before the date provided
+ *
+ * @param {Date} date the initial date
+ * @param {Number} numberOfMonths number of months before
+ * @param {Object} [options={}] Additional options for this calculation
+ * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
+ * This will cause Daylight Saving Time to be ignored. Defaults to `false`
+ * if not provided, which causes the calculation to be performed in the
+ * user's timezone.
+ *
+ * @return {Date} A `Date` object `n` months before the provided `Date`
+ */
+export const nMonthsBefore = (date, numberOfMonths, options) =>
+ nMonthsAfter(date, -numberOfMonths, options);
+
+/**
+ * Returns the date `n` weeks after the date provided
+ *
+ * @param {Date} date the initial date
+ * @param {Number} numberOfWeeks number of weeks after
+ * @param {Object} [options={}] Additional options for this calculation
+ * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
+ * This will cause Daylight Saving Time to be ignored. Defaults to `false`
+ * if not provided, which causes the calculation to be performed in the
+ * user's timezone.
+ *
+ * @return {Date} A `Date` object `n` weeks after the provided `Date`
+ */
+export const nWeeksAfter = (date, numberOfWeeks, options) =>
+ nDaysAfter(date, DAYS_IN_WEEK * numberOfWeeks, options);
+
+/**
+ * Returns the date `n` weeks before the date provided
+ *
+ * @param {Date} date the initial date
+ * @param {Number} numberOfWeeks number of weeks before
+ * @param {Object} [options={}] Additional options for this calculation
+ * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
+ * This will cause Daylight Saving Time to be ignored. Defaults to `false`
+ * if not provided, which causes the calculation to be performed in the
+ * user's timezone.
+ *
+ * @return {Date} A `Date` object `n` weeks before the provided `Date`
+ */
+export const nWeeksBefore = (date, numberOfWeeks, options) =>
+ nWeeksAfter(date, -numberOfWeeks, options);
+
+/**
+ * Returns the date `n` years after the date provided.
+ *
+ * @param {Date} date the initial date
+ * @param {Number} numberOfYears number of years after
+ * @return {Date} A `Date` object `n` years after the provided `Date`
+ */
+export const nYearsAfter = (date, numberOfYears) => {
+ const clone = newDate(date);
+ clone.setFullYear(clone.getFullYear() + numberOfYears);
+ return clone;
+};
+
+/**
+ * Returns the date after the date provided
+ *
+ * @param {Date} date the initial date
+ * @param {Object} [options={}] Additional options for this calculation
+ * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
+ * This will cause Daylight Saving Time to be ignored. Defaults to `false`
+ * if not provided, which causes the calculation to be performed in the
+ * user's timezone.
+ *
+ * @return {Date} the date following the date provided
+ */
+export const dayAfter = (date, options) => nDaysAfter(date, 1, options);
+
+/**
+ * A utility function which computes the difference in seconds
+ * between 2 dates.
+ *
+ * @param {Date} startDate the start date
+ * @param {Date} endDate the end date
+ *
+ * @return {Int} the difference in seconds
+ */
+export const differenceInSeconds = (startDate, endDate) => {
+ return (endDate.getTime() - startDate.getTime()) / 1000;
+};
+
+/**
+ * A utility function which computes the difference in months
+ * between 2 dates.
+ *
+ * @param {Date} startDate the start date
+ * @param {Date} endDate the end date
+ *
+ * @return {Int} the difference in months
+ */
+export const differenceInMonths = (startDate, endDate) => {
+ const yearDiff = endDate.getYear() - startDate.getYear();
+ const monthDiff = endDate.getMonth() - startDate.getMonth();
+ return monthDiff + 12 * yearDiff;
+};
+
+/**
+ * A utility function which computes the difference in milliseconds
+ * between 2 dates.
+ *
+ * @param {Date|Int} startDate the start date. Can be either a date object or a unix timestamp.
+ * @param {Date|Int} endDate the end date. Can be either a date object or a unix timestamp. Defaults to now.
+ *
+ * @return {Int} the difference in milliseconds
+ */
+export const differenceInMilliseconds = (startDate, endDate = Date.now()) => {
+ const startDateInMS = startDate instanceof Date ? startDate.getTime() : startDate;
+ const endDateInMS = endDate instanceof Date ? endDate.getTime() : endDate;
+ return endDateInMS - startDateInMS;
+};
+
+/**
+ * A utility which returns a new date at the first day of the month for any given date.
+ *
+ * @param {Date} date
+ *
+ * @return {Date} the date at the first day of the month
+ */
+export const dateAtFirstDayOfMonth = (date) => new Date(newDate(date).setDate(1));
+
+/**
+ * A utility function which checks if two dates match.
+ *
+ * @param {Date|Int} date1 Can be either a date object or a unix timestamp.
+ * @param {Date|Int} date2 Can be either a date object or a unix timestamp.
+ *
+ * @return {Boolean} true if the dates match
+ */
+export const datesMatch = (date1, date2) => differenceInMilliseconds(date1, date2) === 0;
+
+/**
+ * A utility function which checks if two date ranges overlap.
+ *
+ * @param {Object} givenPeriodLeft - the first period to compare.
+ * @param {Object} givenPeriodRight - the second period to compare.
+ * @returns {Object} { overlap: number of days the overlap is present, overlapStartDate: the start date of the overlap in time format, overlapEndDate: the end date of the overlap in time format }
+ * @throws {Error} Uncaught Error: Invalid period
+ *
+ * @example
+ * getOverlappingDaysInPeriods(
+ * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 13) },
+ * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 14) }
+ * ) => { daysOverlap: 2, overlapStartDate: 1610323200000, overlapEndDate: 1610496000000 }
+ *
+ */
+export const getOverlappingDaysInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => {
+ const leftStartTime = new Date(givenPeriodLeft.start).getTime();
+ const leftEndTime = new Date(givenPeriodLeft.end).getTime();
+ const rightStartTime = new Date(givenPeriodRight.start).getTime();
+ const rightEndTime = new Date(givenPeriodRight.end).getTime();
+
+ if (!(leftStartTime <= leftEndTime && rightStartTime <= rightEndTime)) {
+ throw new Error(__('Invalid period'));
+ }
+
+ const isOverlapping = leftStartTime < rightEndTime && rightStartTime < leftEndTime;
+
+ if (!isOverlapping) {
+ return { daysOverlap: 0 };
+ }
+
+ const overlapStartDate = Math.max(leftStartTime, rightStartTime);
+ const overlapEndDate = rightEndTime > leftEndTime ? leftEndTime : rightEndTime;
+ const differenceInMs = overlapEndDate - overlapStartDate;
+
+ return {
+ daysOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_DAY),
+ overlapStartDate,
+ overlapEndDate,
+ };
+};
+
+/**
+ * Mimics the behaviour of the rails distance_of_time_in_words function
+ * https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words
+ * 0 < -> 29 secs => less than a minute
+ * 30 secs < -> 1 min, 29 secs => 1 minute
+ * 1 min, 30 secs < -> 44 mins, 29 secs => [2..44] minutes
+ * 44 mins, 30 secs < -> 89 mins, 29 secs => about 1 hour
+ * 89 mins, 30 secs < -> 23 hrs, 59 mins, 29 secs => about[2..24]hours
+ * 23 hrs, 59 mins, 30 secs < -> 41 hrs, 59 mins, 29 secs => 1 day
+ * 41 hrs, 59 mins, 30 secs => x days
+ *
+ * @param {Number} seconds
+ * @return {String} approximated time
+ */
+export const approximateDuration = (seconds = 0) => {
+ if (!isNumber(seconds) || seconds < 0) {
+ return '';
+ }
+
+ const ONE_MINUTE_LIMIT = 90; // 1 minute 30s
+ const MINUTES_LIMIT = 2670; // 44 minutes 30s
+ const ONE_HOUR_LIMIT = 5370; // 89 minutes 30s
+ const HOURS_LIMIT = 86370; // 23 hours 59 minutes 30s
+ const ONE_DAY_LIMIT = 151170; // 41 hours 59 minutes 30s
+
+ const { days = 0, hours = 0, minutes = 0 } = parseSeconds(seconds, {
+ daysPerWeek: 7,
+ hoursPerDay: 24,
+ limitToDays: true,
+ });
+
+ if (seconds < 30) {
+ return __('less than a minute');
+ } else if (seconds < MINUTES_LIMIT) {
+ return n__('1 minute', '%d minutes', seconds < ONE_MINUTE_LIMIT ? 1 : minutes);
+ } else if (seconds < HOURS_LIMIT) {
+ return n__('about 1 hour', 'about %d hours', seconds < ONE_HOUR_LIMIT ? 1 : hours);
+ }
+ return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days);
+};
+
+/**
+ * A utility function which helps creating a date object
+ * for a specific date. Accepts the year, month and day
+ * returning a date object for the given params.
+ *
+ * @param {Int} year the full year as a number i.e. 2020
+ * @param {Int} month the month index i.e. January => 0
+ * @param {Int} day the day as a number i.e. 23
+ *
+ * @return {Date} the date object from the params
+ */
+export const dateFromParams = (year, month, day) => {
+ return new Date(year, month, day);
+};
+
+/**
+ * A utility function which computes a formatted 24 hour
+ * time string from a positive int in the range 0 - 24.
+ *
+ * @param {Int} time a positive Int between 0 and 24
+ *
+ * @returns {String} formatted 24 hour time String
+ */
+export const format24HourTimeStringFromInt = (time) => {
+ if (!Number.isInteger(time) || time < 0 || time > 24) {
+ return '';
+ }
+
+ const formatted24HourString = time > 9 ? `${time}:00` : `0${time}:00`;
+ return formatted24HourString;
+};
+
+/**
+ * A utility function that checks that the date is today
+ *
+ * @param {Date} date
+ *
+ * @return {Boolean} true if provided date is today
+ */
+export const isToday = (date) => {
+ const today = new Date();
+ return (
+ date.getDate() === today.getDate() &&
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() === today.getFullYear()
+ );
+};
+
+/**
+ * Checks whether the date is in the past.
+ *
+ * @param {Date} date
+ * @return {Boolean} Returns true if the date falls before today, otherwise false.
+ */
+export const isInPast = (date) => !isToday(date) && differenceInMilliseconds(date, Date.now()) > 0;
+
+/**
+ * Checks whether the date is in the future.
+ * .
+ * @param {Date} date
+ * @return {Boolean} Returns true if the date falls after today, otherwise false.
+ */
+export const isInFuture = (date) =>
+ !isToday(date) && differenceInMilliseconds(Date.now(), date) > 0;
+
+/**
+ * Checks whether dateA falls before dateB.
+ *
+ * @param {Date} dateA
+ * @param {Date} dateB
+ * @return {Boolean} Returns true if dateA falls before dateB, otherwise false
+ */
+export const fallsBefore = (dateA, dateB) => differenceInMilliseconds(dateA, dateB) > 0;
+
+/**
+ * Removes the time component of the date.
+ *
+ * @param {Date} date
+ * @return {Date} Returns a clone of the date with the time set to midnight
+ */
+export const removeTime = (date) => {
+ const clone = newDate(date);
+ clone.setHours(0, 0, 0, 0);
+ return clone;
+};
+
+/**
+ * Returns the start of the provided day
+ *
+ * @param {Object} [options={}] Additional options for this calculation
+ * @param {boolean} [options.utc=false] Performs the calculation using UTC time.
+ * If `true`, the time returned will be midnight UTC. If `false` (the default)
+ * the time returned will be midnight in the user's local time.
+ *
+ * @returns {Date} A new `Date` object that represents the start of the day
+ * of the provided date
+ */
+export const getStartOfDay = (date, { utc = false } = {}) => {
+ const clone = newDate(date);
+
+ const cloneValue = utc ? clone.setUTCHours(0, 0, 0, 0) : clone.setHours(0, 0, 0, 0);
+
+ return new Date(cloneValue);
+};
+
+/**
+ * Returns the start of the current week against the provide date
+ *
+ * @param {Date} date The current date instance to calculate against
+ * @param {Object} [options={}] Additional options for this calculation
+ * @param {boolean} [options.utc=false] Performs the calculation using UTC time.
+ * If `true`, the time returned will be midnight UTC. If `false` (the default)
+ * the time returned will be midnight in the user's local time.
+ *
+ * @returns {Date} A new `Date` object that represents the start of the current week
+ * of the provided date
+ */
+export const getStartOfWeek = (date, { utc = false } = {}) => {
+ const cloneValue = utc
+ ? new Date(date.setUTCHours(0, 0, 0, 0))
+ : new Date(date.setHours(0, 0, 0, 0));
+
+ const diff = cloneValue.getDate() - cloneValue.getDay() + (cloneValue.getDay() === 0 ? -6 : 1);
+
+ return new Date(date.setDate(diff));
+};
+
+/**
+ * Calculates the time remaining from today in words in the format
+ * `n days/weeks/months/years remaining`.
+ *
+ * @param {Date} date A date in future
+ * @return {String} The time remaining in the format `n days/weeks/months/years remaining`
+ */
+export const getTimeRemainingInWords = (date) => {
+ const today = removeTime(new Date());
+ const dateInFuture = removeTime(date);
+
+ const oneWeekFromNow = nWeeksAfter(today, 1);
+ const oneMonthFromNow = nMonthsAfter(today, 1);
+ const oneYearFromNow = nYearsAfter(today, 1);
+
+ if (fallsBefore(dateInFuture, oneWeekFromNow)) {
+ const days = getDayDifference(today, dateInFuture);
+ return n__('1 day remaining', '%d days remaining', days);
+ }
+
+ if (fallsBefore(dateInFuture, oneMonthFromNow)) {
+ const weeks = Math.floor(getDayDifference(today, dateInFuture) / 7);
+ return n__('1 week remaining', '%d weeks remaining', weeks);
+ }
+
+ if (fallsBefore(dateInFuture, oneYearFromNow)) {
+ const months = differenceInMonths(today, dateInFuture);
+ return n__('1 month remaining', '%d months remaining', months);
+ }
+
+ const years = dateInFuture.getFullYear() - today.getFullYear();
+ return n__('1 year remaining', '%d years remaining', years);
+};
diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
new file mode 100644
index 00000000000..246f290a90a
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -0,0 +1,260 @@
+import dateFormat from 'dateformat';
+import { isString, mapValues, reduce } from 'lodash';
+import { s__, n__, __ } from '../../../locale';
+
+/**
+ * Returns i18n month names array.
+ * If `abbreviated` is provided, returns abbreviated
+ * name.
+ *
+ * @param {Boolean} abbreviated
+ */
+export const getMonthNames = (abbreviated) => {
+ if (abbreviated) {
+ return [
+ s__('Jan'),
+ s__('Feb'),
+ s__('Mar'),
+ s__('Apr'),
+ s__('May'),
+ s__('Jun'),
+ s__('Jul'),
+ s__('Aug'),
+ s__('Sep'),
+ s__('Oct'),
+ s__('Nov'),
+ s__('Dec'),
+ ];
+ }
+ return [
+ s__('January'),
+ s__('February'),
+ s__('March'),
+ s__('April'),
+ s__('May'),
+ s__('June'),
+ s__('July'),
+ s__('August'),
+ s__('September'),
+ s__('October'),
+ s__('November'),
+ s__('December'),
+ ];
+};
+
+/**
+ * Returns month name based on provided date.
+ *
+ * @param {Date} date
+ * @param {Boolean} abbreviated
+ */
+export const monthInWords = (date, abbreviated = false) => {
+ if (!date) {
+ return '';
+ }
+
+ return getMonthNames(abbreviated)[date.getMonth()];
+};
+
+export const dateInWords = (date, abbreviated = false, hideYear = false) => {
+ if (!date) return date;
+
+ const month = date.getMonth();
+ const year = date.getFullYear();
+
+ const monthName = getMonthNames(abbreviated)[month];
+
+ if (hideYear) {
+ return `${monthName} ${date.getDate()}`;
+ }
+
+ return `${monthName} ${date.getDate()}, ${year}`;
+};
+
+/**
+ * Similar to `timeIntervalInWords`, but rounds the return value
+ * to 1/10th of the largest time unit. For example:
+ *
+ * 30 => 30 seconds
+ * 90 => 1.5 minutes
+ * 7200 => 2 hours
+ * 86400 => 1 day
+ * ... etc.
+ *
+ * The largest supported unit is "days".
+ *
+ * @param {Number} intervalInSeconds The time interval in seconds
+ * @returns {String} A humanized description of the time interval
+ */
+export const humanizeTimeInterval = (intervalInSeconds) => {
+ if (intervalInSeconds < 60 /* = 1 minute */) {
+ const seconds = Math.round(intervalInSeconds * 10) / 10;
+ return n__('%d second', '%d seconds', seconds);
+ } else if (intervalInSeconds < 3600 /* = 1 hour */) {
+ const minutes = Math.round(intervalInSeconds / 6) / 10;
+ return n__('%d minute', '%d minutes', minutes);
+ } else if (intervalInSeconds < 86400 /* = 1 day */) {
+ const hours = Math.round(intervalInSeconds / 360) / 10;
+ return n__('%d hour', '%d hours', hours);
+ }
+
+ const days = Math.round(intervalInSeconds / 8640) / 10;
+ return n__('%d day', '%d days', days);
+};
+
+/**
+ * Returns i18n weekday names array.
+ */
+export const getWeekdayNames = () => [
+ __('Sunday'),
+ __('Monday'),
+ __('Tuesday'),
+ __('Wednesday'),
+ __('Thursday'),
+ __('Friday'),
+ __('Saturday'),
+];
+
+/**
+ * Given a date object returns the day of the week in English
+ * @param {date} date
+ * @returns {String}
+ */
+export const getDayName = (date) => getWeekdayNames()[date.getDay()];
+
+/**
+ * Returns the i18n month name from a given date
+ * @example
+ * formatDateAsMonth(new Date('2020-06-28')) -> 'Jun'
+ * @param {String} datetime where month is extracted from
+ * @param {Object} options
+ * @param {Boolean} options.abbreviated whether to use the abbreviated month string, or not
+ * @return {String} the i18n month name
+ */
+export function formatDateAsMonth(datetime, options = {}) {
+ const { abbreviated = true } = options;
+ const month = new Date(datetime).getMonth();
+ return getMonthNames(abbreviated)[month];
+}
+
+/**
+ * @example
+ * dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am UTC"
+ * @param {date} datetime
+ * @param {String} format
+ * @param {Boolean} UTC convert local time to UTC
+ * @returns {String}
+ */
+export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z', utc = false) => {
+ if (isString(datetime) && datetime.match(/\d+-\d+\d+ /)) {
+ throw new Error(__('Invalid date'));
+ }
+ return dateFormat(datetime, format, utc);
+};
+
+/**
+ * Formats milliseconds as timestamp (e.g. 01:02:03).
+ * This takes durations longer than a day into account (e.g. two days would be 48:00:00).
+ *
+ * @param milliseconds
+ * @returns {string}
+ */
+export const formatTime = (milliseconds) => {
+ const remainingSeconds = Math.floor(milliseconds / 1000) % 60;
+ const remainingMinutes = Math.floor(milliseconds / 1000 / 60) % 60;
+ const remainingHours = Math.floor(milliseconds / 1000 / 60 / 60);
+ let formattedTime = '';
+ if (remainingHours < 10) formattedTime += '0';
+ formattedTime += `${remainingHours}:`;
+ if (remainingMinutes < 10) formattedTime += '0';
+ formattedTime += `${remainingMinutes}:`;
+ if (remainingSeconds < 10) formattedTime += '0';
+ formattedTime += remainingSeconds;
+ return formattedTime;
+};
+
+/**
+ * Port of ruby helper time_interval_in_words.
+ *
+ * @param {Number} seconds
+ * @return {String}
+ */
+export const timeIntervalInWords = (intervalInSeconds) => {
+ const secondsInteger = parseInt(intervalInSeconds, 10);
+ const minutes = Math.floor(secondsInteger / 60);
+ const seconds = secondsInteger - minutes * 60;
+ const secondsText = n__('%d second', '%d seconds', seconds);
+ return minutes >= 1
+ ? [n__('%d minute', '%d minutes', minutes), secondsText].join(' ')
+ : secondsText;
+};
+
+/**
+ * Accepts a timeObject (see parseSeconds) and returns a condensed string representation of it
+ * (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included.
+ * If the 'fullNameFormat' param is passed it returns a non condensed string eg '1 week 3 days'
+ */
+export const stringifyTime = (timeObject, fullNameFormat = false) => {
+ const reducedTime = reduce(
+ timeObject,
+ (memo, unitValue, unitName) => {
+ const isNonZero = Boolean(unitValue);
+
+ if (fullNameFormat && isNonZero) {
+ // Remove traling 's' if unit value is singular
+ const formattedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, '');
+ return `${memo} ${unitValue} ${formattedUnitName}`;
+ }
+
+ return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
+ },
+ '',
+ ).trim();
+ return reducedTime.length ? reducedTime : '0m';
+};
+
+/**
+ * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
+ * Seconds can be negative or positive, zero or non-zero. Can be configured for any day
+ * or week length.
+ */
+export const parseSeconds = (
+ seconds,
+ { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false, limitToDays = false } = {},
+) => {
+ const DAYS_PER_WEEK = daysPerWeek;
+ const HOURS_PER_DAY = hoursPerDay;
+ const SECONDS_PER_MINUTE = 60;
+ const MINUTES_PER_HOUR = 60;
+ const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR;
+ const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
+
+ const timePeriodConstraints = {
+ weeks: MINUTES_PER_WEEK,
+ days: MINUTES_PER_DAY,
+ hours: MINUTES_PER_HOUR,
+ minutes: 1,
+ };
+
+ if (limitToDays || limitToHours) {
+ timePeriodConstraints.weeks = 0;
+ }
+
+ if (limitToHours) {
+ timePeriodConstraints.days = 0;
+ }
+
+ let unorderedMinutes = Math.abs(seconds / SECONDS_PER_MINUTE);
+
+ return mapValues(timePeriodConstraints, (minutesPerPeriod) => {
+ if (minutesPerPeriod === 0) {
+ return 0;
+ }
+
+ const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
+
+ unorderedMinutes -= periodCount * minutesPerPeriod;
+
+ return periodCount;
+ });
+};
diff --git a/app/assets/javascripts/lib/utils/datetime/pikaday_utility.js b/app/assets/javascripts/lib/utils/datetime/pikaday_utility.js
new file mode 100644
index 00000000000..63542ddbb6a
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datetime/pikaday_utility.js
@@ -0,0 +1,28 @@
+export const pad = (val, len = 2) => `0${val}`.slice(-len);
+
+/**
+ * Formats dates in Pickaday
+ * @param {String} dateString Date in yyyy-mm-dd format
+ * @return {Date} UTC format
+ */
+export const parsePikadayDate = (dateString) => {
+ const parts = dateString.split('-');
+ const year = parseInt(parts[0], 10);
+ const month = parseInt(parts[1] - 1, 10);
+ const day = parseInt(parts[2], 10);
+
+ return new Date(year, month, day);
+};
+
+/**
+ * Used `onSelect` method in pickaday
+ * @param {Date} date UTC format
+ * @return {String} Date formatted in yyyy-mm-dd
+ */
+export const pikadayToString = (date) => {
+ const day = pad(date.getDate());
+ const month = pad(date.getMonth() + 1);
+ const year = date.getFullYear();
+
+ return `${year}-${month}-${day}`;
+};
diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
new file mode 100644
index 00000000000..512b1f079a1
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
@@ -0,0 +1,124 @@
+import $ from 'jquery';
+import * as timeago from 'timeago.js';
+import { languageCode, s__ } from '../../../locale';
+import { formatDate } from './date_format_utility';
+
+window.timeago = timeago;
+
+/**
+ * Timeago uses underscores instead of dashes to separate language from country code.
+ *
+ * see https://github.com/hustcc/timeago.js/tree/v3.0.0/locales
+ */
+const timeagoLanguageCode = languageCode().replace(/-/g, '_');
+
+/**
+ * Registers timeago locales
+ */
+const memoizedLocaleRemaining = () => {
+ const cache = [];
+
+ const timeAgoLocaleRemaining = [
+ () => [s__('Timeago|just now'), s__('Timeago|right now')],
+ () => [s__('Timeago|just now'), s__('Timeago|%s seconds remaining')],
+ () => [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')],
+ () => [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')],
+ () => [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')],
+ () => [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')],
+ () => [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')],
+ () => [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')],
+ () => [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')],
+ () => [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')],
+ () => [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')],
+ () => [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')],
+ () => [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')],
+ () => [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')],
+ ];
+
+ return (number, index) => {
+ if (cache[index]) {
+ return cache[index];
+ }
+ cache[index] = timeAgoLocaleRemaining[index] && timeAgoLocaleRemaining[index]();
+ return cache[index];
+ };
+};
+
+const memoizedLocale = () => {
+ const cache = [];
+
+ const timeAgoLocale = [
+ () => [s__('Timeago|just now'), s__('Timeago|right now')],
+ () => [s__('Timeago|just now'), s__('Timeago|in %s seconds')],
+ () => [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')],
+ () => [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')],
+ () => [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')],
+ () => [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')],
+ () => [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')],
+ () => [s__('Timeago|%s days ago'), s__('Timeago|in %s days')],
+ () => [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')],
+ () => [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')],
+ () => [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')],
+ () => [s__('Timeago|%s months ago'), s__('Timeago|in %s months')],
+ () => [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')],
+ () => [s__('Timeago|%s years ago'), s__('Timeago|in %s years')],
+ ];
+
+ return (number, index) => {
+ if (cache[index]) {
+ return cache[index];
+ }
+ cache[index] = timeAgoLocale[index] && timeAgoLocale[index]();
+ return cache[index];
+ };
+};
+
+timeago.register(timeagoLanguageCode, memoizedLocale());
+timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining());
+
+export const getTimeago = () => timeago;
+
+/**
+ * For the given elements, sets a tooltip with a formatted date.
+ * @param {JQuery} $timeagoEls
+ * @param {Boolean} setTimeago
+ */
+export const localTimeAgo = ($timeagoEls, setTimeago = true) => {
+ $timeagoEls.each((i, el) => {
+ $(el).text(timeago.format($(el).attr('datetime'), timeagoLanguageCode));
+ });
+
+ if (!setTimeago) {
+ return;
+ }
+
+ function addTimeAgoTooltip() {
+ $timeagoEls.each((i, el) => {
+ // Recreate with custom template
+ el.setAttribute('title', formatDate(el.dateTime));
+ });
+ }
+
+ requestIdleCallback(addTimeAgoTooltip);
+};
+
+/**
+ * Returns remaining or passed time over the given time.
+ * @param {*} time
+ * @param {*} expiredLabel
+ */
+export const timeFor = (time, expiredLabel) => {
+ if (!time) {
+ return '';
+ }
+ if (new Date(time) < new Date()) {
+ return expiredLabel || s__('Timeago|Past due');
+ }
+ return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim();
+};
+
+window.gl = window.gl || {};
+window.gl.utils = {
+ ...(window.gl.utils || {}),
+ localTimeAgo,
+};
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 8716608a41c..c1081239544 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -1,1092 +1,4 @@
-import dateFormat from 'dateformat';
-import $ from 'jquery';
-import { isString, mapValues, isNumber, reduce } from 'lodash';
-import * as timeago from 'timeago.js';
-import { languageCode, s__, __, n__ } from '../../locale';
-
-export const SECONDS_IN_DAY = 86400;
-
-const DAYS_IN_WEEK = 7;
-
-window.timeago = timeago;
-
-/**
- * This method allows you to create new Date instance from existing
- * date instance without keeping the reference.
- *
- * @param {Date} date
- */
-export const newDate = (date) => (date instanceof Date ? new Date(date.getTime()) : new Date());
-
-/**
- * Returns i18n month names array.
- * If `abbreviated` is provided, returns abbreviated
- * name.
- *
- * @param {Boolean} abbreviated
- */
-export const getMonthNames = (abbreviated) => {
- if (abbreviated) {
- return [
- s__('Jan'),
- s__('Feb'),
- s__('Mar'),
- s__('Apr'),
- s__('May'),
- s__('Jun'),
- s__('Jul'),
- s__('Aug'),
- s__('Sep'),
- s__('Oct'),
- s__('Nov'),
- s__('Dec'),
- ];
- }
- return [
- s__('January'),
- s__('February'),
- s__('March'),
- s__('April'),
- s__('May'),
- s__('June'),
- s__('July'),
- s__('August'),
- s__('September'),
- s__('October'),
- s__('November'),
- s__('December'),
- ];
-};
-
-export const pad = (val, len = 2) => `0${val}`.slice(-len);
-
-/**
- * Returns i18n weekday names array.
- */
-export const getWeekdayNames = () => [
- __('Sunday'),
- __('Monday'),
- __('Tuesday'),
- __('Wednesday'),
- __('Thursday'),
- __('Friday'),
- __('Saturday'),
-];
-
-/**
- * Given a date object returns the day of the week in English
- * @param {date} date
- * @returns {String}
- */
-export const getDayName = (date) =>
- [
- __('Sunday'),
- __('Monday'),
- __('Tuesday'),
- __('Wednesday'),
- __('Thursday'),
- __('Friday'),
- __('Saturday'),
- ][date.getDay()];
-
-/**
- * Returns the i18n month name from a given date
- * @example
- * formatDateAsMonth(new Date('2020-06-28')) -> 'Jun'
- * @param {String} datetime where month is extracted from
- * @param {Object} options
- * @param {Boolean} options.abbreviated whether to use the abbreviated month string, or not
- * @return {String} the i18n month name
- */
-export function formatDateAsMonth(datetime, options = {}) {
- const { abbreviated = true } = options;
- const month = new Date(datetime).getMonth();
- return getMonthNames(abbreviated)[month];
-}
-
-/**
- * @example
- * dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am UTC"
- * @param {date} datetime
- * @param {String} format
- * @param {Boolean} UTC convert local time to UTC
- * @returns {String}
- */
-export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z', utc = false) => {
- if (isString(datetime) && datetime.match(/\d+-\d+\d+ /)) {
- throw new Error(__('Invalid date'));
- }
- return dateFormat(datetime, format, utc);
-};
-
-/**
- * Timeago uses underscores instead of dashes to separate language from country code.
- *
- * see https://github.com/hustcc/timeago.js/tree/v3.0.0/locales
- */
-const timeagoLanguageCode = languageCode().replace(/-/g, '_');
-
-/**
- * Registers timeago locales
- */
-const memoizedLocaleRemaining = () => {
- const cache = [];
-
- const timeAgoLocaleRemaining = [
- () => [s__('Timeago|just now'), s__('Timeago|right now')],
- () => [s__('Timeago|just now'), s__('Timeago|%s seconds remaining')],
- () => [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')],
- () => [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')],
- () => [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')],
- () => [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')],
- () => [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')],
- () => [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')],
- () => [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')],
- () => [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')],
- () => [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')],
- () => [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')],
- () => [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')],
- () => [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')],
- ];
-
- return (number, index) => {
- if (cache[index]) {
- return cache[index];
- }
- cache[index] = timeAgoLocaleRemaining[index] && timeAgoLocaleRemaining[index]();
- return cache[index];
- };
-};
-
-const memoizedLocale = () => {
- const cache = [];
-
- const timeAgoLocale = [
- () => [s__('Timeago|just now'), s__('Timeago|right now')],
- () => [s__('Timeago|just now'), s__('Timeago|in %s seconds')],
- () => [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')],
- () => [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')],
- () => [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')],
- () => [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')],
- () => [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')],
- () => [s__('Timeago|%s days ago'), s__('Timeago|in %s days')],
- () => [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')],
- () => [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')],
- () => [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')],
- () => [s__('Timeago|%s months ago'), s__('Timeago|in %s months')],
- () => [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')],
- () => [s__('Timeago|%s years ago'), s__('Timeago|in %s years')],
- ];
-
- return (number, index) => {
- if (cache[index]) {
- return cache[index];
- }
- cache[index] = timeAgoLocale[index] && timeAgoLocale[index]();
- return cache[index];
- };
-};
-
-timeago.register(timeagoLanguageCode, memoizedLocale());
-timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining());
-
-export const getTimeago = () => timeago;
-
-/**
- * For the given elements, sets a tooltip with a formatted date.
- * @param {JQuery} $timeagoEls
- * @param {Boolean} setTimeago
- */
-export const localTimeAgo = ($timeagoEls, setTimeago = true) => {
- $timeagoEls.each((i, el) => {
- $(el).text(timeago.format($(el).attr('datetime'), timeagoLanguageCode));
- });
-
- if (!setTimeago) {
- return;
- }
-
- function addTimeAgoTooltip() {
- $timeagoEls.each((i, el) => {
- // Recreate with custom template
- el.setAttribute('title', formatDate(el.dateTime));
- });
- }
-
- requestIdleCallback(addTimeAgoTooltip);
-};
-
-/**
- * Returns remaining or passed time over the given time.
- * @param {*} time
- * @param {*} expiredLabel
- */
-export const timeFor = (time, expiredLabel) => {
- if (!time) {
- return '';
- }
- if (new Date(time) < new Date()) {
- return expiredLabel || s__('Timeago|Past due');
- }
- return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim();
-};
-
-export const millisecondsPerDay = 1000 * 60 * 60 * 24;
-
-export const getDayDifference = (a, b) => {
- const date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
- const date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
-
- return Math.floor((date2 - date1) / millisecondsPerDay);
-};
-
-/**
- * Port of ruby helper time_interval_in_words.
- *
- * @param {Number} seconds
- * @return {String}
- */
-export const timeIntervalInWords = (intervalInSeconds) => {
- const secondsInteger = parseInt(intervalInSeconds, 10);
- const minutes = Math.floor(secondsInteger / 60);
- const seconds = secondsInteger - minutes * 60;
- const secondsText = n__('%d second', '%d seconds', seconds);
- return minutes >= 1
- ? [n__('%d minute', '%d minutes', minutes), secondsText].join(' ')
- : secondsText;
-};
-
-/**
- * Similar to `timeIntervalInWords`, but rounds the return value
- * to 1/10th of the largest time unit. For example:
- *
- * 30 => 30 seconds
- * 90 => 1.5 minutes
- * 7200 => 2 hours
- * 86400 => 1 day
- * ... etc.
- *
- * The largest supported unit is "days".
- *
- * @param {Number} intervalInSeconds The time interval in seconds
- * @returns {String} A humanized description of the time interval
- */
-export const humanizeTimeInterval = (intervalInSeconds) => {
- if (intervalInSeconds < 60 /* = 1 minute */) {
- const seconds = Math.round(intervalInSeconds * 10) / 10;
- return n__('%d second', '%d seconds', seconds);
- } else if (intervalInSeconds < 3600 /* = 1 hour */) {
- const minutes = Math.round(intervalInSeconds / 6) / 10;
- return n__('%d minute', '%d minutes', minutes);
- } else if (intervalInSeconds < 86400 /* = 1 day */) {
- const hours = Math.round(intervalInSeconds / 360) / 10;
- return n__('%d hour', '%d hours', hours);
- }
-
- const days = Math.round(intervalInSeconds / 8640) / 10;
- return n__('%d day', '%d days', days);
-};
-
-export const dateInWords = (date, abbreviated = false, hideYear = false) => {
- if (!date) return date;
-
- const month = date.getMonth();
- const year = date.getFullYear();
-
- const monthNames = [
- s__('January'),
- s__('February'),
- s__('March'),
- s__('April'),
- s__('May'),
- s__('June'),
- s__('July'),
- s__('August'),
- s__('September'),
- s__('October'),
- s__('November'),
- s__('December'),
- ];
- const monthNamesAbbr = [
- s__('Jan'),
- s__('Feb'),
- s__('Mar'),
- s__('Apr'),
- s__('May'),
- s__('Jun'),
- s__('Jul'),
- s__('Aug'),
- s__('Sep'),
- s__('Oct'),
- s__('Nov'),
- s__('Dec'),
- ];
-
- const monthName = abbreviated ? monthNamesAbbr[month] : monthNames[month];
-
- if (hideYear) {
- return `${monthName} ${date.getDate()}`;
- }
-
- return `${monthName} ${date.getDate()}, ${year}`;
-};
-
-/**
- * Returns month name based on provided date.
- *
- * @param {Date} date
- * @param {Boolean} abbreviated
- */
-export const monthInWords = (date, abbreviated = false) => {
- if (!date) {
- return '';
- }
-
- return getMonthNames(abbreviated)[date.getMonth()];
-};
-
-/**
- * Returns number of days in a month for provided date.
- * courtesy: https://stacko(verflow.com/a/1185804/414749
- *
- * @param {Date} date
- */
-export const totalDaysInMonth = (date) => {
- if (!date) {
- return 0;
- }
- return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
-};
-
-/**
- * Returns number of days in a quarter from provided
- * months array.
- *
- * @param {Array} quarter
- */
-export const totalDaysInQuarter = (quarter) =>
- quarter.reduce((acc, month) => acc + totalDaysInMonth(month), 0);
-
-/**
- * Returns list of Dates referring to Sundays of the month
- * based on provided date
- *
- * @param {Date} date
- */
-export const getSundays = (date) => {
- if (!date) {
- return [];
- }
-
- const daysToSunday = [
- __('Saturday'),
- __('Friday'),
- __('Thursday'),
- __('Wednesday'),
- __('Tuesday'),
- __('Monday'),
- __('Sunday'),
- ];
-
- const month = date.getMonth();
- const year = date.getFullYear();
- const sundays = [];
- const dateOfMonth = new Date(year, month, 1);
-
- while (dateOfMonth.getMonth() === month) {
- const dayName = getDayName(dateOfMonth);
- if (dayName === __('Sunday')) {
- sundays.push(new Date(dateOfMonth.getTime()));
- }
-
- const daysUntilNextSunday = daysToSunday.indexOf(dayName) + 1;
- dateOfMonth.setDate(dateOfMonth.getDate() + daysUntilNextSunday);
- }
-
- return sundays;
-};
-
-/**
- * Returns list of Dates representing a timeframe of months from startDate and length
- * This method also supports going back in time when `length` is negative number
- *
- * @param {Date} initialStartDate
- * @param {Number} length
- */
-export const getTimeframeWindowFrom = (initialStartDate, length) => {
- if (!(initialStartDate instanceof Date) || !length) {
- return [];
- }
-
- const startDate = newDate(initialStartDate);
- const moveMonthBy = length > 0 ? 1 : -1;
-
- startDate.setDate(1);
- startDate.setHours(0, 0, 0, 0);
-
- // Iterate and set date for the size of length
- // and push date reference to timeframe list
- const timeframe = new Array(Math.abs(length)).fill().map(() => {
- const currentMonth = startDate.getTime();
- startDate.setMonth(startDate.getMonth() + moveMonthBy);
- return new Date(currentMonth);
- });
-
- // Change date of last timeframe item to last date of the month
- // when length is positive
- if (length > 0) {
- timeframe[timeframe.length - 1].setDate(totalDaysInMonth(timeframe[timeframe.length - 1]));
- }
-
- return timeframe;
-};
-
-/**
- * Returns count of day within current quarter from provided date
- * and array of months for the quarter
- *
- * Eg;
- * If date is 15 Feb 2018
- * and quarter is [Jan, Feb, Mar]
- *
- * Then 15th Feb is 46th day of the quarter
- * Where 31 (days in Jan) + 15 (date of Feb).
- *
- * @param {Date} date
- * @param {Array} quarter
- */
-export const dayInQuarter = (date, quarter) => {
- const dateValues = {
- date: date.getDate(),
- month: date.getMonth(),
- };
-
- return quarter.reduce((acc, month) => {
- if (dateValues.month > month.getMonth()) {
- return acc + totalDaysInMonth(month);
- } else if (dateValues.month === month.getMonth()) {
- return acc + dateValues.date;
- }
- return acc + 0;
- }, 0);
-};
-
-window.gl = window.gl || {};
-window.gl.utils = {
- ...(window.gl.utils || {}),
- localTimeAgo,
-};
-
-/**
- * Formats milliseconds as timestamp (e.g. 01:02:03).
- * This takes durations longer than a day into account (e.g. two days would be 48:00:00).
- *
- * @param milliseconds
- * @returns {string}
- */
-export const formatTime = (milliseconds) => {
- const remainingSeconds = Math.floor(milliseconds / 1000) % 60;
- const remainingMinutes = Math.floor(milliseconds / 1000 / 60) % 60;
- const remainingHours = Math.floor(milliseconds / 1000 / 60 / 60);
- let formattedTime = '';
- if (remainingHours < 10) formattedTime += '0';
- formattedTime += `${remainingHours}:`;
- if (remainingMinutes < 10) formattedTime += '0';
- formattedTime += `${remainingMinutes}:`;
- if (remainingSeconds < 10) formattedTime += '0';
- formattedTime += remainingSeconds;
- return formattedTime;
-};
-
-/**
- * Formats dates in Pickaday
- * @param {String} dateString Date in yyyy-mm-dd format
- * @return {Date} UTC format
- */
-export const parsePikadayDate = (dateString) => {
- const parts = dateString.split('-');
- const year = parseInt(parts[0], 10);
- const month = parseInt(parts[1] - 1, 10);
- const day = parseInt(parts[2], 10);
-
- return new Date(year, month, day);
-};
-
-/**
- * Used `onSelect` method in pickaday
- * @param {Date} date UTC format
- * @return {String} Date formatted in yyyy-mm-dd
- */
-export const pikadayToString = (date) => {
- const day = pad(date.getDate());
- const month = pad(date.getMonth() + 1);
- const year = date.getFullYear();
-
- return `${year}-${month}-${day}`;
-};
-
-/**
- * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
- * Seconds can be negative or positive, zero or non-zero. Can be configured for any day
- * or week length.
- */
-export const parseSeconds = (
- seconds,
- { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false, limitToDays = false } = {},
-) => {
- const DAYS_PER_WEEK = daysPerWeek;
- const HOURS_PER_DAY = hoursPerDay;
- const SECONDS_PER_MINUTE = 60;
- const MINUTES_PER_HOUR = 60;
- const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR;
- const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
-
- const timePeriodConstraints = {
- weeks: MINUTES_PER_WEEK,
- days: MINUTES_PER_DAY,
- hours: MINUTES_PER_HOUR,
- minutes: 1,
- };
-
- if (limitToDays || limitToHours) {
- timePeriodConstraints.weeks = 0;
- }
-
- if (limitToHours) {
- timePeriodConstraints.days = 0;
- }
-
- let unorderedMinutes = Math.abs(seconds / SECONDS_PER_MINUTE);
-
- return mapValues(timePeriodConstraints, (minutesPerPeriod) => {
- if (minutesPerPeriod === 0) {
- return 0;
- }
-
- const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
-
- unorderedMinutes -= periodCount * minutesPerPeriod;
-
- return periodCount;
- });
-};
-
-/**
- * Accepts a timeObject (see parseSeconds) and returns a condensed string representation of it
- * (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included.
- * If the 'fullNameFormat' param is passed it returns a non condensed string eg '1 week 3 days'
- */
-export const stringifyTime = (timeObject, fullNameFormat = false) => {
- const reducedTime = reduce(
- timeObject,
- (memo, unitValue, unitName) => {
- const isNonZero = Boolean(unitValue);
-
- if (fullNameFormat && isNonZero) {
- // Remove traling 's' if unit value is singular
- const formattedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, '');
- return `${memo} ${unitValue} ${formattedUnitName}`;
- }
-
- return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
- },
- '',
- ).trim();
- return reducedTime.length ? reducedTime : '0m';
-};
-
-/**
- * Calculates the milliseconds between now and a given date string.
- * The result cannot become negative.
- *
- * @param endDate date string that the time difference is calculated for
- * @return {Number} number of milliseconds remaining until the given date
- */
-export const calculateRemainingMilliseconds = (endDate) => {
- const remainingMilliseconds = new Date(endDate).getTime() - Date.now();
- return Math.max(remainingMilliseconds, 0);
-};
-
-/**
- * Subtracts a given number of days from a given date and returns the new date.
- *
- * @param {Date} date the date that we will substract days from
- * @param {Number} daysInPast number of days that are subtracted from a given date
- * @returns {Date} Date in past as Date object
- */
-export const getDateInPast = (date, daysInPast) =>
- new Date(newDate(date).setDate(date.getDate() - daysInPast));
-
-/**
- * Adds a given number of days to a given date and returns the new date.
- *
- * @param {Date} date the date that we will add days to
- * @param {Number} daysInFuture number of days that are added to a given date
- * @returns {Date} Date in future as Date object
- */
-export const getDateInFuture = (date, daysInFuture) =>
- new Date(newDate(date).setDate(date.getDate() + daysInFuture));
-
-/**
- * Checks if a given date-instance was created with a valid date
- *
- * @param {Date} date
- * @returns boolean
- */
-export const isValidDate = (date) => date instanceof Date && !Number.isNaN(date.getTime());
-
-/*
- * Appending T00:00:00 makes JS assume local time and prevents it from shifting the date
- * to match the user's time zone. We want to display the date in server time for now, to
- * be consistent with the "edit issue -> due date" UI.
- */
-
-export const newDateAsLocaleTime = (date) => {
- const suffix = 'T00:00:00';
- return new Date(`${date}${suffix}`);
-};
-
-export const beginOfDayTime = 'T00:00:00Z';
-export const endOfDayTime = 'T23:59:59Z';
-
-/**
- * @param {Date} d1
- * @param {Date} d2
- * @param {Function} formatter
- * @return {Any[]} an array of formatted dates between 2 given dates (including start&end date)
- */
-export const getDatesInRange = (d1, d2, formatter = (x) => x) => {
- if (!(d1 instanceof Date) || !(d2 instanceof Date)) {
- return [];
- }
- let startDate = d1.getTime();
- const endDate = d2.getTime();
- const oneDay = 24 * 3600 * 1000;
- const range = [d1];
-
- while (startDate < endDate) {
- startDate += oneDay;
- range.push(new Date(startDate));
- }
-
- return range.map(formatter);
-};
-
-/**
- * Converts the supplied number of seconds to milliseconds.
- *
- * @param {Number} seconds
- * @return {Number} number of milliseconds
- */
-export const secondsToMilliseconds = (seconds) => seconds * 1000;
-
-/**
- * Converts the supplied number of seconds to days.
- *
- * @param {Number} seconds
- * @return {Number} number of days
- */
-export const secondsToDays = (seconds) => Math.round(seconds / 86400);
-
-/**
- * Converts a numeric utc offset in seconds to +/- hours
- * ie -32400 => -9 hours
- * ie -12600 => -3.5 hours
- *
- * @param {Number} offset UTC offset in seconds as a integer
- *
- * @return {String} the + or - offset in hours
- */
-export const secondsToHours = (offset) => {
- const parsed = parseInt(offset, 10);
- if (Number.isNaN(parsed) || parsed === 0) {
- return `0`;
- }
- const num = offset / 3600;
- return parseInt(num, 10) !== num ? num.toFixed(1) : num;
-};
-
-/**
- * Returns the date `n` days after the date provided
- *
- * @param {Date} date the initial date
- * @param {Number} numberOfDays number of days after
- * @param {Object} [options={}] Additional options for this calculation
- * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
- * This will cause Daylight Saving Time to be ignored. Defaults to `false`
- * if not provided, which causes the calculation to be performed in the
- * user's timezone.
- *
- * @return {Date} A `Date` object `n` days after the provided `Date`
- */
-export const nDaysAfter = (date, numberOfDays, { utc = false } = {}) => {
- const clone = newDate(date);
-
- const cloneValue = utc
- ? clone.setUTCDate(date.getUTCDate() + numberOfDays)
- : clone.setDate(date.getDate() + numberOfDays);
-
- return new Date(cloneValue);
-};
-
-/**
- * Returns the date `n` days before the date provided
- *
- * @param {Date} date the initial date
- * @param {Number} numberOfDays number of days before
- * @param {Object} [options={}] Additional options for this calculation
- * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
- * This will cause Daylight Saving Time to be ignored. Defaults to `false`
- * if not provided, which causes the calculation to be performed in the
- * user's timezone.
- * @return {Date} A `Date` object `n` days before the provided `Date`
- */
-export const nDaysBefore = (date, numberOfDays, options) =>
- nDaysAfter(date, -numberOfDays, options);
-
-/**
- * Returns the date `n` weeks after the date provided
- *
- * @param {Date} date the initial date
- * @param {Number} numberOfWeeks number of weeks after
- * @param {Object} [options={}] Additional options for this calculation
- * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
- * This will cause Daylight Saving Time to be ignored. Defaults to `false`
- * if not provided, which causes the calculation to be performed in the
- * user's timezone.
- *
- * @return {Date} A `Date` object `n` weeks after the provided `Date`
- */
-export const nWeeksAfter = (date, numberOfWeeks, options) =>
- nDaysAfter(date, DAYS_IN_WEEK * numberOfWeeks, options);
-
-/**
- * Returns the date `n` weeks before the date provided
- *
- * @param {Date} date the initial date
- * @param {Number} numberOfWeeks number of weeks before
- * @param {Object} [options={}] Additional options for this calculation
- * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
- * This will cause Daylight Saving Time to be ignored. Defaults to `false`
- * if not provided, which causes the calculation to be performed in the
- * user's timezone.
- *
- * @return {Date} A `Date` object `n` weeks before the provided `Date`
- */
-export const nWeeksBefore = (date, numberOfWeeks, options) =>
- nWeeksAfter(date, -numberOfWeeks, options);
-
-/**
- * Returns the date `n` months after the date provided
- *
- * @param {Date} date the initial date
- * @param {Number} numberOfMonths number of months after
- * @param {Object} [options={}] Additional options for this calculation
- * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
- * This will cause Daylight Saving Time to be ignored. Defaults to `false`
- * if not provided, which causes the calculation to be performed in the
- * user's timezone.
- *
- * @return {Date} A `Date` object `n` months after the provided `Date`
- */
-export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => {
- const clone = newDate(date);
-
- const cloneValue = utc
- ? clone.setUTCMonth(date.getUTCMonth() + numberOfMonths)
- : clone.setMonth(date.getMonth() + numberOfMonths);
-
- return new Date(cloneValue);
-};
-
-/**
- * Returns the date `n` years after the date provided.
- *
- * @param {Date} date the initial date
- * @param {Number} numberOfYears number of years after
- * @return {Date} A `Date` object `n` years after the provided `Date`
- */
-export const nYearsAfter = (date, numberOfYears) => {
- const clone = newDate(date);
- clone.setFullYear(clone.getFullYear() + numberOfYears);
- return clone;
-};
-
-/**
- * Returns the date `n` months before the date provided
- *
- * @param {Date} date the initial date
- * @param {Number} numberOfMonths number of months before
- * @param {Object} [options={}] Additional options for this calculation
- * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
- * This will cause Daylight Saving Time to be ignored. Defaults to `false`
- * if not provided, which causes the calculation to be performed in the
- * user's timezone.
- *
- * @return {Date} A `Date` object `n` months before the provided `Date`
- */
-export const nMonthsBefore = (date, numberOfMonths, options) =>
- nMonthsAfter(date, -numberOfMonths, options);
-
-/**
- * Returns the date after the date provided
- *
- * @param {Date} date the initial date
- * @param {Object} [options={}] Additional options for this calculation
- * @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
- * This will cause Daylight Saving Time to be ignored. Defaults to `false`
- * if not provided, which causes the calculation to be performed in the
- * user's timezone.
- *
- * @return {Date} the date following the date provided
- */
-export const dayAfter = (date, options) => nDaysAfter(date, 1, options);
-
-/**
- * Mimics the behaviour of the rails distance_of_time_in_words function
- * https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words
- * 0 < -> 29 secs => less than a minute
- * 30 secs < -> 1 min, 29 secs => 1 minute
- * 1 min, 30 secs < -> 44 mins, 29 secs => [2..44] minutes
- * 44 mins, 30 secs < -> 89 mins, 29 secs => about 1 hour
- * 89 mins, 30 secs < -> 23 hrs, 59 mins, 29 secs => about[2..24]hours
- * 23 hrs, 59 mins, 30 secs < -> 41 hrs, 59 mins, 29 secs => 1 day
- * 41 hrs, 59 mins, 30 secs => x days
- *
- * @param {Number} seconds
- * @return {String} approximated time
- */
-export const approximateDuration = (seconds = 0) => {
- if (!isNumber(seconds) || seconds < 0) {
- return '';
- }
-
- const ONE_MINUTE_LIMIT = 90; // 1 minute 30s
- const MINUTES_LIMIT = 2670; // 44 minutes 30s
- const ONE_HOUR_LIMIT = 5370; // 89 minutes 30s
- const HOURS_LIMIT = 86370; // 23 hours 59 minutes 30s
- const ONE_DAY_LIMIT = 151170; // 41 hours 59 minutes 30s
-
- const { days = 0, hours = 0, minutes = 0 } = parseSeconds(seconds, {
- daysPerWeek: 7,
- hoursPerDay: 24,
- limitToDays: true,
- });
-
- if (seconds < 30) {
- return __('less than a minute');
- } else if (seconds < MINUTES_LIMIT) {
- return n__('1 minute', '%d minutes', seconds < ONE_MINUTE_LIMIT ? 1 : minutes);
- } else if (seconds < HOURS_LIMIT) {
- return n__('about 1 hour', 'about %d hours', seconds < ONE_HOUR_LIMIT ? 1 : hours);
- }
- return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days);
-};
-
-/**
- * A utility function which computes the difference in seconds
- * between 2 dates.
- *
- * @param {Date} startDate the start date
- * @param {Date} endDate the end date
- *
- * @return {Int} the difference in seconds
- */
-export const differenceInSeconds = (startDate, endDate) => {
- return (endDate.getTime() - startDate.getTime()) / 1000;
-};
-
-/**
- * A utility function which computes the difference in months
- * between 2 dates.
- *
- * @param {Date} startDate the start date
- * @param {Date} endDate the end date
- *
- * @return {Int} the difference in months
- */
-export const differenceInMonths = (startDate, endDate) => {
- const yearDiff = endDate.getYear() - startDate.getYear();
- const monthDiff = endDate.getMonth() - startDate.getMonth();
- return monthDiff + 12 * yearDiff;
-};
-
-/**
- * A utility function which computes the difference in milliseconds
- * between 2 dates.
- *
- * @param {Date|Int} startDate the start date. Can be either a date object or a unix timestamp.
- * @param {Date|Int} endDate the end date. Can be either a date object or a unix timestamp. Defaults to now.
- *
- * @return {Int} the difference in milliseconds
- */
-export const differenceInMilliseconds = (startDate, endDate = Date.now()) => {
- const startDateInMS = startDate instanceof Date ? startDate.getTime() : startDate;
- const endDateInMS = endDate instanceof Date ? endDate.getTime() : endDate;
- return endDateInMS - startDateInMS;
-};
-
-/**
- * A utility which returns a new date at the first day of the month for any given date.
- *
- * @param {Date} date
- *
- * @return {Date} the date at the first day of the month
- */
-export const dateAtFirstDayOfMonth = (date) => new Date(newDate(date).setDate(1));
-
-/**
- * A utility function which checks if two dates match.
- *
- * @param {Date|Int} date1 Can be either a date object or a unix timestamp.
- * @param {Date|Int} date2 Can be either a date object or a unix timestamp.
- *
- * @return {Boolean} true if the dates match
- */
-export const datesMatch = (date1, date2) => differenceInMilliseconds(date1, date2) === 0;
-
-/**
- * A utility function which computes a formatted 24 hour
- * time string from a positive int in the range 0 - 24.
- *
- * @param {Int} time a positive Int between 0 and 24
- *
- * @returns {String} formatted 24 hour time String
- */
-export const format24HourTimeStringFromInt = (time) => {
- if (!Number.isInteger(time) || time < 0 || time > 24) {
- return '';
- }
-
- const formatted24HourString = time > 9 ? `${time}:00` : `0${time}:00`;
- return formatted24HourString;
-};
-
-/**
- * A utility function that checks that the date is today
- *
- * @param {Date} date
- *
- * @return {Boolean} true if provided date is today
- */
-export const isToday = (date) => {
- const today = new Date();
- return (
- date.getDate() === today.getDate() &&
- date.getMonth() === today.getMonth() &&
- date.getFullYear() === today.getFullYear()
- );
-};
-
-/**
- * Checks whether the date is in the past.
- *
- * @param {Date} date
- * @return {Boolean} Returns true if the date falls before today, otherwise false.
- */
-export const isInPast = (date) => !isToday(date) && differenceInMilliseconds(date, Date.now()) > 0;
-
-/**
- * Checks whether the date is in the future.
- * .
- * @param {Date} date
- * @return {Boolean} Returns true if the date falls after today, otherwise false.
- */
-export const isInFuture = (date) =>
- !isToday(date) && differenceInMilliseconds(Date.now(), date) > 0;
-
-/**
- * Checks whether dateA falls before dateB.
- *
- * @param {Date} dateA
- * @param {Date} dateB
- * @return {Boolean} Returns true if dateA falls before dateB, otherwise false
- */
-export const fallsBefore = (dateA, dateB) => differenceInMilliseconds(dateA, dateB) > 0;
-
-/**
- * Removes the time component of the date.
- *
- * @param {Date} date
- * @return {Date} Returns a clone of the date with the time set to midnight
- */
-export const removeTime = (date) => {
- const clone = newDate(date);
- clone.setHours(0, 0, 0, 0);
- return clone;
-};
-
-/**
- * Calculates the time remaining from today in words in the format
- * `n days/weeks/months/years remaining`.
- *
- * @param {Date} date A date in future
- * @return {String} The time remaining in the format `n days/weeks/months/years remaining`
- */
-export const getTimeRemainingInWords = (date) => {
- const today = removeTime(new Date());
- const dateInFuture = removeTime(date);
-
- const oneWeekFromNow = nWeeksAfter(today, 1);
- const oneMonthFromNow = nMonthsAfter(today, 1);
- const oneYearFromNow = nYearsAfter(today, 1);
-
- if (fallsBefore(dateInFuture, oneWeekFromNow)) {
- const days = getDayDifference(today, dateInFuture);
- return n__('1 day remaining', '%d days remaining', days);
- }
-
- if (fallsBefore(dateInFuture, oneMonthFromNow)) {
- const weeks = Math.floor(getDayDifference(today, dateInFuture) / 7);
- return n__('1 week remaining', '%d weeks remaining', weeks);
- }
-
- if (fallsBefore(dateInFuture, oneYearFromNow)) {
- const months = differenceInMonths(today, dateInFuture);
- return n__('1 month remaining', '%d months remaining', months);
- }
-
- const years = dateInFuture.getFullYear() - today.getFullYear();
- return n__('1 year remaining', '%d years remaining', years);
-};
-
-/**
- * Returns the start of the provided day
- *
- * @param {Object} [options={}] Additional options for this calculation
- * @param {boolean} [options.utc=false] Performs the calculation using UTC time.
- * If `true`, the time returned will be midnight UTC. If `false` (the default)
- * the time returned will be midnight in the user's local time.
- *
- * @returns {Date} A new `Date` object that represents the start of the day
- * of the provided date
- */
-export const getStartOfDay = (date, { utc = false } = {}) => {
- const clone = newDate(date);
-
- const cloneValue = utc ? clone.setUTCHours(0, 0, 0, 0) : clone.setHours(0, 0, 0, 0);
-
- return new Date(cloneValue);
-};
-
-/**
- * Returns the start of the current week against the provide date
- *
- * @param {Date} date The current date instance to calculate against
- * @param {Object} [options={}] Additional options for this calculation
- * @param {boolean} [options.utc=false] Performs the calculation using UTC time.
- * If `true`, the time returned will be midnight UTC. If `false` (the default)
- * the time returned will be midnight in the user's local time.
- *
- * @returns {Date} A new `Date` object that represents the start of the current week
- * of the provided date
- */
-export const getStartOfWeek = (date, { utc = false } = {}) => {
- const cloneValue = utc
- ? new Date(date.setUTCHours(0, 0, 0, 0))
- : new Date(date.setHours(0, 0, 0, 0));
-
- const diff = cloneValue.getDate() - cloneValue.getDay() + (cloneValue.getDay() === 0 ? -6 : 1);
-
- return new Date(date.setDate(diff));
-};
+export * from './datetime/timeago_utility';
+export * from './datetime/date_format_utility';
+export * from './datetime/date_calculation_utility';
+export * from './datetime/pikaday_utility';
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index ee27af72b71..2309f7a420f 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -20,7 +20,7 @@ import { removeFlashClickListener } from './flash';
import initTodoToggle from './header';
import initLayoutNav from './layout_nav';
import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils';
-import { localTimeAgo } from './lib/utils/datetime_utility';
+import { localTimeAgo } from './lib/utils/datetime/timeago_utility';
import { getLocationHash, visitUrl } from './lib/utils/url_utility';
// everything else
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 6179586e56c..6cc0095f5a5 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -20,12 +20,16 @@ const apolloProvider = new VueApollo({
const viewBlobEl = document.querySelector('#js-view-blob-app');
if (viewBlobEl) {
- const { blobPath, projectPath } = viewBlobEl.dataset;
+ const { blobPath, projectPath, targetBranch, originalBranch } = viewBlobEl.dataset;
// eslint-disable-next-line no-new
new Vue({
el: viewBlobEl,
apolloProvider,
+ provide: {
+ targetBranch,
+ originalBranch,
+ },
render(createElement) {
return createElement(BlobContentViewer, {
props: {
diff --git a/app/assets/javascripts/releases/components/app_index_apollo_client.vue b/app/assets/javascripts/releases/components/app_index_apollo_client.vue
index 29525ce1abc..ea0aa409577 100644
--- a/app/assets/javascripts/releases/components/app_index_apollo_client.vue
+++ b/app/assets/javascripts/releases/components/app_index_apollo_client.vue
@@ -33,7 +33,34 @@ export default {
},
},
apollo: {
- graphqlResponse: {
+ /**
+ * The same query as `fullGraphqlResponse`, except that it limits its
+ * results to a single item. This causes this request to complete much more
+ * quickly than `fullGraphqlResponse`, which allows the page to show
+ * meaningful content to the user much earlier.
+ */
+ singleGraphqlResponse: {
+ query: allReleasesQuery,
+ // This trick only works when paginating _forward_.
+ // When paginating backwards, limiting the query to a single item loads
+ // the _last_ item in the page, which is not useful for our purposes.
+ skip() {
+ return !this.includeSingleQuery;
+ },
+ variables() {
+ return {
+ ...this.queryVariables,
+ first: 1,
+ };
+ },
+ update(data) {
+ return { data };
+ },
+ error() {
+ this.singleRequestError = true;
+ },
+ },
+ fullGraphqlResponse: {
query: allReleasesQuery,
variables() {
return this.queryVariables;
@@ -42,7 +69,7 @@ export default {
return { data };
},
error(error) {
- this.hasError = true;
+ this.fullRequestError = true;
createFlash({
message: this.$options.i18n.errorMessage,
@@ -54,7 +81,8 @@ export default {
},
data() {
return {
- hasError: false,
+ singleRequestError: false,
+ fullRequestError: false,
cursors: {
before: getParameterByName('before'),
after: getParameterByName('after'),
@@ -83,41 +111,65 @@ export default {
sort: this.sort,
};
},
- isLoading() {
- return this.$apollo.queries.graphqlResponse.loading;
+ /**
+ * @returns {Boolean} Whether or not to request/include
+ * the results of the single-item query
+ */
+ includeSingleQuery() {
+ return Boolean(!this.cursors.before || this.cursors.after);
+ },
+ isSingleRequestLoading() {
+ return this.$apollo.queries.singleGraphqlResponse.loading;
+ },
+ isFullRequestLoading() {
+ return this.$apollo.queries.fullGraphqlResponse.loading;
+ },
+ /**
+ * @returns {Boolean} `true` if the `singleGraphqlResponse`
+ * query has finished loading without errors
+ */
+ isSingleRequestLoaded() {
+ return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project);
+ },
+ /**
+ * @returns {Boolean} `true` if the `fullGraphqlResponse`
+ * query has finished loading without errors
+ */
+ isFullRequestLoaded() {
+ return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project);
},
releases() {
- if (!this.graphqlResponse || this.hasError) {
- return [];
+ if (this.isFullRequestLoaded) {
+ return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data;
}
- return convertAllReleasesGraphQLResponse(this.graphqlResponse).data;
+ if (this.isSingleRequestLoaded && this.includeSingleQuery) {
+ return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data;
+ }
+
+ return [];
},
pageInfo() {
- if (!this.graphqlResponse || this.hasError) {
+ if (!this.isFullRequestLoaded) {
return {
hasPreviousPage: false,
hasNextPage: false,
};
}
- return this.graphqlResponse.data.project.releases.pageInfo;
+ return this.fullGraphqlResponse.data.project.releases.pageInfo;
},
shouldRenderEmptyState() {
- return !this.releases.length && !this.hasError && !this.isLoading;
- },
- shouldRenderSuccessState() {
- return this.releases.length && !this.isLoading && !this.hasError;
+ return this.isFullRequestLoaded && this.releases.length === 0;
},
shouldRenderLoadingIndicator() {
- return this.isLoading && !this.hasError;
+ return (
+ (this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) ||
+ (this.isFullRequestLoading && !this.fullRequestError)
+ );
},
shouldRenderPagination() {
- return (
- !this.isLoading &&
- !this.hasError &&
- (this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage)
- );
+ return this.isFullRequestLoaded && !this.shouldRenderEmptyState;
},
},
created() {
@@ -130,7 +182,7 @@ export default {
},
methods: {
getReleaseKey(release, index) {
- return [release.tagNamerstrs, release.name, index].join('|');
+ return [release.tagName, release.name, index].join('|');
},
updateQueryParamsFromUrl() {
this.cursors.before = getParameterByName('before');
@@ -191,19 +243,17 @@ export default {
>
+
+
+
+
-
-
-
-
-
-
{
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ // This page attempts to decrease the perceived loading time
+ // by sending two requests: one request for the first item only (which
+ // completes relatively quickly), and one for all the items (which is slower).
+ // By default, Apollo Client batches these requests together, which defeats
+ // the purpose of making separate requests. So we explicitly
+ // disable batching on this page.
+ batchMax: 1,
+ assumeImmutableResults: true,
+ },
+ ),
});
return new Vue({
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 4a2f516e5cb..7fbf331d585 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -8,11 +8,13 @@ import createFlash from '~/flash';
import { __ } from '~/locale';
import blobInfoQuery from '../queries/blob_info.query.graphql';
import BlobHeaderEdit from './blob_header_edit.vue';
+import BlobReplace from './blob_replace.vue';
export default {
components: {
BlobHeader,
BlobHeaderEdit,
+ BlobReplace,
BlobContent,
GlLoadingIcon,
},
@@ -87,6 +89,9 @@ export default {
};
},
computed: {
+ isLoggedIn() {
+ return Boolean(gon.current_user_id);
+ },
isLoading() {
return this.$apollo.queries.project.loading;
},
@@ -130,6 +135,13 @@ export default {
:edit-path="blobInfo.editBlobPath"
:web-ide-path="blobInfo.ideEditPath"
/>
+
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { sprintf, __ } from '~/locale';
+import getRefMixin from '../mixins/get_ref';
+import UploadBlobModal from './upload_blob_modal.vue';
+
+export default {
+ i18n: {
+ replace: __('Replace'),
+ replacePrimaryBtnText: __('Replace file'),
+ },
+ components: {
+ GlButton,
+ UploadBlobModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ mixins: [getRefMixin],
+ inject: {
+ targetBranch: {
+ default: '',
+ },
+ originalBranch: {
+ default: '',
+ },
+ },
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ replacePath: {
+ type: String,
+ required: true,
+ },
+ canPushCode: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ replaceModalId() {
+ return uniqueId('replace-modal');
+ },
+ title() {
+ return sprintf(__('Replace %{name}'), { name: this.name });
+ },
+ },
+};
+
+
+
+
+
+ {{ $options.i18n.replace }}
+
+
+
+
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index aa087d4c631..7f065dbdf6d 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -43,7 +43,6 @@ export default {
GlAlert,
},
i18n: {
- MODAL_TITLE,
COMMIT_LABEL,
TARGET_BRANCH_LABEL,
TOGGLE_CREATE_MR_LABEL,
@@ -51,6 +50,16 @@ export default {
NEW_BRANCH_IN_FORK,
},
props: {
+ modalTitle: {
+ type: String,
+ default: MODAL_TITLE,
+ required: false,
+ },
+ primaryBtnText: {
+ type: String,
+ default: PRIMARY_OPTIONS_TEXT,
+ required: false,
+ },
modalId: {
type: String,
required: true,
@@ -75,6 +84,11 @@ export default {
type: String,
required: true,
},
+ replacePath: {
+ type: String,
+ default: null,
+ required: false,
+ },
},
data() {
return {
@@ -90,7 +104,7 @@ export default {
computed: {
primaryOptions() {
return {
- text: PRIMARY_OPTIONS_TEXT,
+ text: this.primaryBtnText,
attributes: [
{
variant: 'confirm',
@@ -136,6 +150,45 @@ export default {
this.file = null;
this.filePreviewURL = null;
},
+ submitForm() {
+ return this.replacePath ? this.replaceFile() : this.uploadFile();
+ },
+ submitRequest(method, url) {
+ return axios({
+ method,
+ url,
+ data: this.formData(),
+ headers: {
+ ...ContentTypeMultipartFormData,
+ },
+ })
+ .then((response) => {
+ if (!this.replacePath) {
+ trackFileUploadEvent('click_upload_modal_form_submit');
+ }
+ visitUrl(response.data.filePath);
+ })
+ .catch(() => {
+ this.loading = false;
+ createFlash(ERROR_MESSAGE);
+ });
+ },
+ formData() {
+ const formData = new FormData();
+ formData.append('branch_name', this.target);
+ formData.append('create_merge_request', this.createNewMr);
+ formData.append('commit_message', this.commit);
+ formData.append('file', this.file);
+
+ return formData;
+ },
+ replaceFile() {
+ this.loading = true;
+
+ // The PUT path can be geneated from $route (similar to "uploadFile") once router is connected
+ // Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/332736
+ return this.submitRequest('put', this.replacePath);
+ },
uploadFile() {
this.loading = true;
@@ -146,26 +199,7 @@ export default {
} = this;
const uploadPath = joinPaths(this.path, path);
- const formData = new FormData();
- formData.append('branch_name', this.target);
- formData.append('create_merge_request', this.createNewMr);
- formData.append('commit_message', this.commit);
- formData.append('file', this.file);
-
- return axios
- .post(uploadPath, formData, {
- headers: {
- ...ContentTypeMultipartFormData,
- },
- })
- .then((response) => {
- trackFileUploadEvent('click_upload_modal_form_submit');
- visitUrl(response.data.filePath);
- })
- .catch(() => {
- this.loading = false;
- createFlash(ERROR_MESSAGE);
- });
+ return this.submitRequest('post', uploadPath);
},
},
validFileMimetypes: [],
@@ -175,10 +209,10 @@ export default {
{ version? && maven? }
validates :version, format: { with: Gitlab::Regex.pypi_version_regex }, if: :pypi?
validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :golang?
- validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :helm?
+ validates :version, format: { with: Gitlab::Regex.helm_version_regex }, if: :helm?
validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { composer_tag_version? || npm? || terraform_module? }
validates :version,
@@ -269,6 +269,10 @@ class Packages::Package < ApplicationRecord
tags.pluck(:name)
end
+ def infrastructure_package?
+ terraform_module?
+ end
+
def debian_incoming?
debian? && version.nil?
end
diff --git a/app/models/project.rb b/app/models/project.rb
index a28389c359f..a709ec2a490 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -2031,7 +2031,6 @@ class Project < ApplicationRecord
.append(key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level))
.append(key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: repository_languages.map(&:name).join(',').downcase)
.append(key: 'CI_DEFAULT_BRANCH', value: default_branch)
- .append(key: 'CI_PROJECT_CONFIG_PATH', value: ci_config_path_or_default)
.append(key: 'CI_CONFIG_PATH', value: ci_config_path_or_default)
end
diff --git a/app/services/notification_recipients/builder/base.rb b/app/services/notification_recipients/builder/base.rb
index e8f783136cc..4f1bb0dc877 100644
--- a/app/services/notification_recipients/builder/base.rb
+++ b/app/services/notification_recipients/builder/base.rb
@@ -100,25 +100,6 @@ module NotificationRecipients
# Get project/group users with CUSTOM notification level
# rubocop: disable CodeReuse/ActiveRecord
def add_custom_notifications
- return new_add_custom_notifications if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml)
-
- user_ids = []
-
- # Users with a notification setting on group or project
- user_ids += user_ids_notifiable_on(project, :custom)
- user_ids += user_ids_notifiable_on(group, :custom)
-
- # Users with global level custom
- user_ids_with_project_level_global = user_ids_notifiable_on(project, :global)
- user_ids_with_group_level_global = user_ids_notifiable_on(group, :global)
-
- global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global)
- user_ids += user_ids_with_global_level_custom(global_users_ids, custom_action)
-
- add_recipients(user_scope.where(id: user_ids), :custom, nil)
- end
-
- def new_add_custom_notifications
notification_by_sources = related_notification_settings_sources(:custom)
return if notification_by_sources.blank?
@@ -172,22 +153,6 @@ module NotificationRecipients
# Get project users with WATCH notification level
# rubocop: disable CodeReuse/ActiveRecord
def project_watchers
- return new_project_watchers if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml)
-
- project_members_ids = user_ids_notifiable_on(project)
-
- user_ids_with_project_global = user_ids_notifiable_on(project, :global)
- user_ids_with_group_global = user_ids_notifiable_on(project.group, :global)
-
- user_ids = user_ids_with_global_level_watch((user_ids_with_project_global + user_ids_with_group_global).uniq)
-
- user_ids_with_project_setting = select_project_members_ids(user_ids_with_project_global, user_ids)
- user_ids_with_group_setting = select_group_members_ids(project.group, project_members_ids, user_ids_with_group_global, user_ids)
-
- user_scope.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq)
- end
-
- def new_project_watchers
notification_by_sources = related_notification_settings_sources(:watch)
return if notification_by_sources.blank?
@@ -200,16 +165,6 @@ module NotificationRecipients
# rubocop: disable CodeReuse/ActiveRecord
def group_watchers
- return new_group_watchers if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml)
-
- user_ids_with_group_global = user_ids_notifiable_on(group, :global)
- user_ids = user_ids_with_global_level_watch(user_ids_with_group_global)
- user_ids_with_group_setting = select_group_members_ids(group, [], user_ids_with_group_global, user_ids)
-
- user_scope.where(id: user_ids_with_group_setting)
- end
-
- def new_group_watchers
return [] unless group
user_ids = group
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index e50b964a253..9fa65f27651 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -1,4 +1,6 @@
= render "projects/blob/breadcrumb", blob: blob
+- project = @project.present(current_user: current_user)
+- ref = local_assigns[:ref] || @ref
.info-well.d-none.d-sm-block
.well-segment
@@ -12,7 +14,12 @@
- if @code_navigation_path
#js-code-navigation{ data: { code_navigation_path: @code_navigation_path, blob_path: blob.path, definition_path_prefix: project_blob_path(@project, @ref) } }
- if Feature.enabled?(:refactor_blob_viewer, @project, default_enabled: :yaml)
- #js-view-blob-app{ data: { blob_path: blob.path, project_path: @project.full_path } }
+ -# Data info will be removed once we migrate this to use GraphQL
+ -# Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/330406
+ #js-view-blob-app{ data: { blob_path: blob.path,
+ project_path: @project.full_path,
+ target_branch: project.empty_repo? ? ref : @ref,
+ original_branch: @ref } }
.gl-spinner-container
= loading_icon(size: 'md')
- else
diff --git a/config/feature_flags/development/notification_setting_recipient_refactor.yml b/config/feature_flags/development/notification_setting_recipient_refactor.yml
deleted file mode 100644
index 8e070034170..00000000000
--- a/config/feature_flags/development/notification_setting_recipient_refactor.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-name: notification_setting_recipient_refactor
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57688
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/327303
-milestone: '13.11'
-type: development
-group: group::code review
-default_enabled: true
diff --git a/doc/administration/geo/index.md b/doc/administration/geo/index.md
index 422e4705d86..295a448c432 100644
--- a/doc/administration/geo/index.md
+++ b/doc/administration/geo/index.md
@@ -122,7 +122,7 @@ The following are required to run Geo:
The following operating systems are known to ship with a current version of OpenSSH:
- [CentOS](https://www.centos.org) 7.4+
- [Ubuntu](https://ubuntu.com) 16.04+
-- PostgreSQL 11+ with [Streaming Replication](https://wiki.postgresql.org/wiki/Streaming_Replication)
+- PostgreSQL 12+ with [Streaming Replication](https://wiki.postgresql.org/wiki/Streaming_Replication)
- Git 2.9+
- Git-lfs 2.4.2+ on the user side when using LFS
- All sites must run the same GitLab version.
diff --git a/doc/administration/geo/replication/multiple_servers.md b/doc/administration/geo/replication/multiple_servers.md
index eee78246e13..ea2488b65fb 100644
--- a/doc/administration/geo/replication/multiple_servers.md
+++ b/doc/administration/geo/replication/multiple_servers.md
@@ -219,7 +219,7 @@ the **primary** database. Use the following as a guide.
prometheus['enable'] = false
redis['enable'] = false
redis_exporter['enable'] = false
- repmgr['enable'] = false
+ patroni['enable'] = false
sidekiq['enable'] = false
sidekiq_cluster['enable'] = false
puma['enable'] = false
@@ -290,7 +290,7 @@ Configure the tracking database.
prometheus['enable'] = false
redis['enable'] = false
redis_exporter['enable'] = false
- repmgr['enable'] = false
+ patroni['enable'] = false
sidekiq['enable'] = false
sidekiq_cluster['enable'] = false
puma['enable'] = false
@@ -437,7 +437,7 @@ application servers above, with some changes to run only the `sidekiq` service:
prometheus['enable'] = false
redis['enable'] = false
redis_exporter['enable'] = false
- repmgr['enable'] = false
+ patroni['enable'] = false
puma['enable'] = false
##
diff --git a/doc/administration/geo/replication/security_review.md b/doc/administration/geo/replication/security_review.md
index f84d7a2171d..ae41599311b 100644
--- a/doc/administration/geo/replication/security_review.md
+++ b/doc/administration/geo/replication/security_review.md
@@ -184,7 +184,7 @@ from [owasp.org](https://owasp.org/).
### What databases and application servers support the application?
-- PostgreSQL >= 11, Redis, Sidekiq, Puma.
+- PostgreSQL >= 12, Redis, Sidekiq, Puma.
### How will database connection strings, encryption keys, and other sensitive components be stored, accessed, and protected from unauthorized detection?
diff --git a/doc/administration/geo/replication/troubleshooting.md b/doc/administration/geo/replication/troubleshooting.md
index 278e21386c1..1fd923dbaf1 100644
--- a/doc/administration/geo/replication/troubleshooting.md
+++ b/doc/administration/geo/replication/troubleshooting.md
@@ -583,64 +583,6 @@ to start again from scratch, there are a few steps that can help you:
gitlab-ctl start
```
-## Fixing errors during a PostgreSQL upgrade or downgrade
-
-### Message: `ERROR: psql: FATAL: role "gitlab-consul" does not exist`
-
-When
-[upgrading PostgreSQL on a Geo instance](https://docs.gitlab.com/omnibus/settings/database.html#upgrading-a-geo-instance), you might encounter the
-following error:
-
-```plaintext
-$ sudo gitlab-ctl pg-upgrade --target-version=11
-Checking for an omnibus managed postgresql: OK
-Checking if postgresql['version'] is set: OK
-Checking if we already upgraded: NOT OK
-Checking for a newer version of PostgreSQL to install
-Upgrading PostgreSQL to 11.7
-Checking if PostgreSQL bin files are symlinked to the expected location: OK
-Waiting 30 seconds to ensure tasks complete before PostgreSQL upgrade.
-See https://docs.gitlab.com/omnibus/settings/database.html#upgrade-packaged-postgresql-server for details
-If you do not want to upgrade the PostgreSQL server at this time, enter Ctrl-C and see the documentation for details
-
-Please hit Ctrl-C now if you want to cancel the operation.
-..............................Detected an HA cluster.
-Error running command: /opt/gitlab/embedded/bin/psql -qt -d gitlab_repmgr -h /var/opt/gitlab/postgresql -p 5432 -c "SELECT name FROM repmgr_gitlab_cluster.repl_nodes WHERE type='master' AND active != 'f'" -U gitlab-consul
-ERROR: psql: FATAL: role "gitlab-consul" does not exist
-Traceback (most recent call last):
- 10: from /opt/gitlab/embedded/bin/omnibus-ctl:23:in `'
- 9: from /opt/gitlab/embedded/bin/omnibus-ctl:23:in `load'
- 8: from /opt/gitlab/embedded/lib/ruby/gems/2.6.0/gems/omnibus-ctl-0.6.0/bin/omnibus-ctl:31:in `'
- 7: from /opt/gitlab/embedded/lib/ruby/gems/2.6.0/gems/omnibus-ctl-0.6.0/lib/omnibus-ctl.rb:746:in `run'
- 6: from /opt/gitlab/embedded/lib/ruby/gems/2.6.0/gems/omnibus-ctl-0.6.0/lib/omnibus-ctl.rb:204:in `block in add_command_under_category'
- 5: from /opt/gitlab/embedded/service/omnibus-ctl/pg-upgrade.rb:171:in `block in load_file'
- 4: from /opt/gitlab/embedded/service/omnibus-ctl-ee/lib/repmgr.rb:248:in `is_master?'
- 3: from /opt/gitlab/embedded/service/omnibus-ctl-ee/lib/repmgr.rb:100:in `execute_psql'
- 2: from /opt/gitlab/embedded/service/omnibus-ctl-ee/lib/repmgr.rb:113:in `cmd'
- 1: from /opt/gitlab/embedded/lib/ruby/gems/2.6.0/gems/mixlib-shellout-3.0.9/lib/mixlib/shellout.rb:287:in `error!'
-/opt/gitlab/embedded/lib/ruby/gems/2.6.0/gems/mixlib-shellout-3.0.9/lib/mixlib/shellout.rb:300:in `invalid!': Expected process to exit with [0], but received '2' (Mixlib::ShellOut::ShellCommandFailed)
----- Begin output of /opt/gitlab/embedded/bin/psql -qt -d gitlab_repmgr -h /var/opt/gitlab/postgresql -p 5432 -c "SELECT name FROM repmgr_gitlab_cluster.repl_nodes WHERE type='master' AND active != 'f'" -U gitlab-consul ----
-STDOUT:
-STDERR: psql: FATAL: role "gitlab-consul" does not exist
----- End output of /opt/gitlab/embedded/bin/psql -qt -d gitlab_repmgr -h /var/opt/gitlab/postgresql -p 5432 -c "SELECT name FROM repmgr_gitlab_cluster.repl_nodes WHERE type='master' AND active != 'f'" -U gitlab-consul ----
-Ran /opt/gitlab/embedded/bin/psql -qt -d gitlab_repmgr -h /var/opt/gitlab/postgresql -p 5432 -c "SELECT name FROM repmgr_gitlab_cluster.repl_nodes WHERE type='master' AND active != 'f'" -U gitlab-consul returned 2
-```
-
-If you are upgrading the PostgreSQL read-replica of a Geo secondary node, and
-you are not using `consul` or `repmgr`, you may need to disable `consul` and/or
-`repmgr` services in `gitlab.rb`:
-
-```ruby
-consul['enable'] = false
-repmgr['enable'] = false
-```
-
-Then reconfigure GitLab:
-
-```shell
-sudo gitlab-ctl reconfigure
-```
-
## Fixing errors during a failover or when promoting a secondary to a primary node
The following are possible errors that might be encountered during failover or
diff --git a/doc/administration/geo/setup/database.md b/doc/administration/geo/setup/database.md
index c1a9e695cef..9b0bf80599d 100644
--- a/doc/administration/geo/setup/database.md
+++ b/doc/administration/geo/setup/database.md
@@ -50,8 +50,8 @@ recover. See below for more details.
The following guide assumes that:
-- You are using Omnibus and therefore you are using PostgreSQL 11 or later
- which includes the [`pg_basebackup` tool](https://www.postgresql.org/docs/11/app-pgbasebackup.html).
+- You are using Omnibus and therefore you are using PostgreSQL 12 or later
+ which includes the [`pg_basebackup` tool](https://www.postgresql.org/docs/12/app-pgbasebackup.html).
- You have a **primary** node already set up (the GitLab server you are
replicating from), running Omnibus' PostgreSQL (or equivalent version), and
you have a new **secondary** server set up with the same versions of the OS,
@@ -187,7 +187,7 @@ There is an [issue where support is being discussed](https://gitlab.com/gitlab-o
`postgresql['md5_auth_cidr_addresses']` and `postgresql['listen_address']`.
The `listen_address` option opens PostgreSQL up to network connections with the interface
- corresponding to the given address. See [the PostgreSQL documentation](https://www.postgresql.org/docs/11/runtime-config-connection.html)
+ corresponding to the given address. See [the PostgreSQL documentation](https://www.postgresql.org/docs/12/runtime-config-connection.html)
for more details.
NOTE:
@@ -245,7 +245,7 @@ There is an [issue where support is being discussed](https://gitlab.com/gitlab-o
```
You may also want to edit the `wal_keep_segments` and `max_wal_senders` to match your
- database replication requirements. Consult the [PostgreSQL - Replication documentation](https://www.postgresql.org/docs/11/runtime-config-replication.html)
+ database replication requirements. Consult the [PostgreSQL - Replication documentation](https://www.postgresql.org/docs/12/runtime-config-replication.html)
for more information.
1. Save the file and reconfigure GitLab for the database listen changes and
@@ -468,7 +468,7 @@ data before running `pg_basebackup`.
(e.g., you know the network path is secure, or you are using a site-to-site
VPN). This is **not** safe over the public Internet!
- You can read more details about each `sslmode` in the
- [PostgreSQL documentation](https://www.postgresql.org/docs/11/libpq-ssl.html#LIBPQ-SSL-PROTECTION);
+ [PostgreSQL documentation](https://www.postgresql.org/docs/12/libpq-ssl.html#LIBPQ-SSL-PROTECTION);
the instructions above are carefully written to ensure protection against
both passive eavesdroppers and active "man-in-the-middle" attackers.
- Change the `--slot-name` to the name of the replication slot
diff --git a/doc/administration/geo/setup/external_database.md b/doc/administration/geo/setup/external_database.md
index b4fb1ac6967..9e187424afa 100644
--- a/doc/administration/geo/setup/external_database.md
+++ b/doc/administration/geo/setup/external_database.md
@@ -57,7 +57,7 @@ developed and tested. We aim to be compatible with most external
To set up an external database, you can either:
-- Set up [streaming replication](https://www.postgresql.org/docs/11/warm-standby.html#STREAMING-REPLICATION-SLOTS) yourself (for example AWS RDS, bare metal not managed by Omnibus, etc.).
+- Set up [streaming replication](https://www.postgresql.org/docs/12/warm-standby.html#STREAMING-REPLICATION-SLOTS) yourself (for example AWS RDS, bare metal not managed by Omnibus, etc.).
- Perform the Omnibus configuration manually as follows.
#### Leverage your cloud provider's tools to replicate the primary database
diff --git a/doc/administration/troubleshooting/kubernetes_cheat_sheet.md b/doc/administration/troubleshooting/kubernetes_cheat_sheet.md
index 94a28036226..b43825092b7 100644
--- a/doc/administration/troubleshooting/kubernetes_cheat_sheet.md
+++ b/doc/administration/troubleshooting/kubernetes_cheat_sheet.md
@@ -147,7 +147,7 @@ and they will assist you with any issues you are having.
You can also use `gitlab-rake`, instead of `/usr/local/bin/gitlab-rake`.
-- Troubleshooting **Operations > Kubernetes** integration:
+- Troubleshooting **Infrastructure > Kubernetes** integration:
- Check the output of `kubectl get events -w --all-namespaces`.
- Check the logs of pods within `gitlab-managed-apps` namespace.
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 2c0e3e7ddeb..fceb3cc9de4 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -2154,6 +2154,28 @@ Input type: `EscalationPolicyDestroyInput`
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `escalationPolicy` | [`EscalationPolicyType`](#escalationpolicytype) | The escalation policy. |
+### `Mutation.escalationPolicyUpdate`
+
+Input type: `EscalationPolicyUpdateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `description` | [`String`](#string) | The description of the escalation policy. |
+| `id` | [`IncidentManagementEscalationPolicyID!`](#incidentmanagementescalationpolicyid) | The ID of the on-call schedule to create the on-call rotation in. |
+| `name` | [`String`](#string) | The name of the escalation policy. |
+| `rules` | [`[EscalationRuleInput!]`](#escalationruleinput) | The steps of the escalation policy. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| `escalationPolicy` | [`EscalationPolicyType`](#escalationpolicytype) | The escalation policy. |
+
### `Mutation.exportRequirements`
Input type: `ExportRequirementsInput`
diff --git a/doc/ci/environments/index.md b/doc/ci/environments/index.md
index a60c0d03e8f..d7796895964 100644
--- a/doc/ci/environments/index.md
+++ b/doc/ci/environments/index.md
@@ -31,7 +31,7 @@ Prerequisites:
To view a list of environments and deployments:
-1. Go to the project's **Operations > Environments** page.
+1. Go to the project's **Deployments > Environments** page.
The environments are displayed.

@@ -57,7 +57,7 @@ You can create an environment and deployment in the UI or in your `.gitlab-ci.ym
In the UI:
-1. Go to the project's **Operations > Environments** page.
+1. Go to the project's **Deployments > Environments** page.
1. Select **New environment**.
1. Enter a name and external URL.
1. Select **Save**.
@@ -326,7 +326,7 @@ If there is a problem with a deployment, you can retry it or roll it back.
To retry or rollback a deployment:
-1. Go to the project's **Operations > Environments**.
+1. Go to the project's **Deployments > Environments**.
1. Select the environment.
1. To the right of the deployment name:
- To retry a deployment, select **Re-deploy to environment**.
@@ -465,7 +465,7 @@ GitLab automatically triggers the `stop_review_app` job to stop the environment.
You can view a deployment's expiration date in the GitLab UI.
-1. Go to the project's **Operations > Environments** page.
+1. Go to the project's **Deployments > Environments** page.
1. Select the name of the deployment.
In the top left, next to the environment name, the expiration date is displayed.
@@ -474,7 +474,7 @@ In the top left, next to the environment name, the expiration date is displayed.
You can manually override a deployment's expiration date.
-1. Go to the project's **Operations > Environments** page.
+1. Go to the project's **Deployments > Environments** page.
1. Select the deployment name.
1. On the top right, select the thumbtack (**{thumbtack}**).
@@ -491,7 +491,7 @@ You can delete [stopped environments](#stopping-an-environment) in the GitLab UI
To delete a stopped environment in the GitLab UI:
-1. Go to the project's **Operations > Environments** page.
+1. Go to the project's **Deployments > Environments** page.
1. Select the **Stopped** tab.
1. Next to the environment you want to delete, select **Delete environment**.
1. On the confirmation dialog box, select **Delete environment**.
diff --git a/doc/ci/review_apps/index.md b/doc/ci/review_apps/index.md
index 122f9caebe7..a64efd50f6f 100644
--- a/doc/ci/review_apps/index.md
+++ b/doc/ci/review_apps/index.md
@@ -77,7 +77,7 @@ the **Enable Review Apps** button and GitLab prompts you with a template code bl
you can copy and paste into `.gitlab-ci.yml` as a starting point. To do so:
1. Go to the project your want to create a Review App job for.
-1. From the left nav, go to **Operations** > **Environments**.
+1. From the left nav, go to **Deployments > Environments**.
1. Click on the **Enable Review Apps** button. It is available to you
if you have Developer or higher [permissions](../../user/permissions.md) to that project.
1. Copy the provided code snippet and paste it into your
diff --git a/doc/ci/variables/predefined_variables.md b/doc/ci/variables/predefined_variables.md
index fe168461a68..bd280cc4825 100644
--- a/doc/ci/variables/predefined_variables.md
+++ b/doc/ci/variables/predefined_variables.md
@@ -77,7 +77,7 @@ There are also [Kubernetes-specific deployment variables](../../user/project/clu
| `CI_PIPELINE_TRIGGERED` | all | all | `true` if the job was [triggered](../triggers/README.md). |
| `CI_PIPELINE_URL` | 11.1 | 0.5 | The URL for the pipeline details. |
| `CI_PIPELINE_CREATED_AT` | 13.10 | all | The UTC datetime when the pipeline was created, in [ISO 8601](https://tools.ietf.org/html/rfc3339#appendix-A) format. |
-| `CI_PROJECT_CONFIG_PATH` | 13.8 | all | (Deprecated) The CI configuration path for the project. [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/321334) in GitLab 13.10. [Removal planned](https://gitlab.com/gitlab-org/gitlab/-/issues/322807) for GitLab 14.0. |
+| `CI_PROJECT_CONFIG_PATH` | 13.8 to 13.12 | all | [Removed](https://gitlab.com/gitlab-org/gitlab/-/issues/322807) in GitLab 14.0. Use `CI_CONFIG_PATH`. |
| `CI_PROJECT_DIR` | all | all | The full path the repository is cloned to, and where the job runs from. If the GitLab Runner `builds_dir` parameter is set, this variable is set relative to the value of `builds_dir`. For more information, see the [Advanced GitLab Runner configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section). |
| `CI_PROJECT_ID` | all | all | The ID of the current project. This ID is unique across all projects on the GitLab instance. |
| `CI_PROJECT_NAME` | 8.10 | 0.5 | The name of the directory for the project. For example if the project URL is `gitlab.example.com/group-name/project-1`, `CI_PROJECT_NAME` is `project-1`. |
diff --git a/doc/development/snowplow/index.md b/doc/development/snowplow/index.md
index da056a87b60..2ca678e6b0c 100644
--- a/doc/development/snowplow/index.md
+++ b/doc/development/snowplow/index.md
@@ -24,7 +24,7 @@ More useful links:
Snowplow is an enterprise-grade marketing and Product Intelligence platform which helps track the way users engage with our website and application.
-[Snowplow](https://github.com/snowplow/snowplow) consists of the following loosely-coupled sub-systems:
+[Snowplow](https://snowplowanalytics.com) consists of the following loosely-coupled sub-systems:
- **Trackers** fire Snowplow events. Snowplow has 12 trackers, covering web, mobile, desktop, server, and IoT.
- **Collectors** receive Snowplow events from trackers. We have three different event collectors, synchronizing events either to Amazon S3, Apache Kafka, or Amazon Kinesis.
@@ -35,15 +35,11 @@ Snowplow is an enterprise-grade marketing and Product Intelligence platform whic

-## Snowplow schema
+### Useful links
-We have many definitions of Snowplow's schema. We have an active issue to [standardize this schema](https://gitlab.com/gitlab-org/gitlab/-/issues/207930) including the following definitions:
-
-- Frontend and backend taxonomy as listed below
-- [Structured event taxonomy](#structured-event-taxonomy)
-- [Self describing events](https://github.com/snowplow/snowplow/wiki/Custom-events#self-describing-events)
-- [Iglu schema](https://gitlab.com/gitlab-org/iglu/)
-- [Snowplow authored events](https://github.com/snowplow/snowplow/wiki/Snowplow-authored-events)
+- [Understanding the structure of Snowplow data](https://docs.snowplowanalytics.com/docs/understanding-your-pipeline/canonical-event/)
+- [Our Iglu schema registry](https://gitlab.com/gitlab-org/iglu)
+- [List of events used in our codebase (Event Dictionary)](dictionary.md)
## Enable Snowplow tracking
@@ -57,7 +53,7 @@ Snowplow tracking is enabled on GitLab.com, and we use it for most of our tracki
To enable Snowplow tracking on a self-managed instance:
-1. Go to the Admin Area (**{admin}**) and select **Settings > General**.
+1. Go to the Admin Area (**{admin}**) and select **Settings > General**.
Alternatively, go to `admin/application_settings/general` in your browser.
1. Expand **Snowplow**.
@@ -162,7 +158,7 @@ Snowplow JS adds many [web-specific parameters](https://docs.snowplowanalytics.c
## Implementing Snowplow JS (Frontend) tracking
-GitLab provides `Tracking`, an interface that wraps the [Snowplow JavaScript Tracker](https://github.com/snowplow/snowplow/wiki/javascript-tracker) for tracking custom events. The simplest way to use it is to add `data-` attributes to clickable elements and dropdowns. There is also a Vue mixin (exposing a `track` method), and the static method `Tracking.event`. Each of these requires at minimum a `category` and an `action`. You can provide additional [Structured event taxonomy](#structured-event-taxonomy) properties along with an `extra` object that accepts key-value pairs.
+GitLab provides `Tracking`, an interface that wraps the [Snowplow JavaScript Tracker](https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-trackers) for tracking custom events. The simplest way to use it is to add `data-` attributes to clickable elements and dropdowns. There is also a Vue mixin (exposing a `track` method), and the static method `Tracking.event`. Each of these requires at minimum a `category` and an `action`. You can provide additional [Structured event taxonomy](#structured-event-taxonomy) properties along with an `extra` object that accepts key-value pairs.
| field | type | default value | description |
|:-----------|:-------|:---------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
@@ -406,7 +402,7 @@ describe('MyTracking', () => {
## Implementing Snowplow Ruby (Backend) tracking
-GitLab provides `Gitlab::Tracking`, an interface that wraps the [Snowplow Ruby Tracker](https://github.com/snowplow/snowplow/wiki/ruby-tracker) for tracking custom events.
+GitLab provides `Gitlab::Tracking`, an interface that wraps the [Snowplow Ruby Tracker](https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/ruby-tracker) for tracking custom events.
Custom event tracking and instrumentation can be added by directly calling the `GitLab::Tracking.event` class method, which accepts the following arguments:
diff --git a/doc/operations/feature_flags.md b/doc/operations/feature_flags.md
index 3d87f03832c..4045e46de04 100644
--- a/doc/operations/feature_flags.md
+++ b/doc/operations/feature_flags.md
@@ -38,7 +38,7 @@ with GitLab, so it's up to developers to use a compatible client library and
To create and enable a feature flag:
-1. Navigate to your project's **Operations > Feature Flags**.
+1. Navigate to your project's **Deployments > Feature Flags**.
1. Click the **New feature flag** button.
1. Enter a name that starts with a letter and contains only lowercase letters, digits, underscores (`_`),
or dashes (`-`), and does not end with a dash (`-`) or underscore (`_`).
@@ -90,7 +90,7 @@ and the supported strategies are:
- [User List](#user-list)
Strategies can be added to feature flags when [creating a feature flag](#create-a-feature-flag),
-or by editing an existing feature flag after creation by navigating to **Operations > Feature Flags**
+or by editing an existing feature flag after creation by navigating to **Deployments > Feature Flags**
and clicking **{pencil}** (edit).
### All users
@@ -189,7 +189,7 @@ For example:
To create a user list:
-1. In your project, navigate to **Operations > Feature Flags**.
+1. In your project, navigate to **Deployments > Feature Flags**.
1. Select **View user lists**
1. Select **New user list**.
1. Enter a name for the list.
@@ -204,7 +204,7 @@ When viewing a list, you can rename it by clicking the **Edit** button.
To add users to a user list:
-1. In your project, navigate to **Operations > Feature Flags**.
+1. In your project, navigate to **Deployments > Feature Flags**.
1. Click on the **{pencil}** (edit) button next to the list you want to add users to.
1. Click on **Add Users**.
1. Enter the user IDs as a comma-separated list of values. For example,
@@ -217,7 +217,7 @@ To add users to a user list:
To remove users from a user list:
-1. In your project, navigate to **Operations > Feature Flags**.
+1. In your project, navigate to **Deployments > Feature Flags**.
1. Click on the **{pencil}** (edit) button next to the list you want to change.
1. Click on the **{remove}** (remove) button next to the ID you want to remove.
@@ -255,7 +255,7 @@ See [this video tutorial](https://www.youtube.com/watch?v=CAJY2IGep7Y) for help
In [GitLab 13.0 and earlier](https://gitlab.com/gitlab-org/gitlab/-/issues/8621),
to disable a feature flag for a specific environment:
-1. Navigate to your project's **Operations > Feature Flags**.
+1. Navigate to your project's **Deployments > Feature Flags**.
1. For the feature flag you want to disable, click the Pencil icon.
1. To disable the flag:
@@ -269,7 +269,7 @@ to disable a feature flag for a specific environment:
To disable a feature flag for all environments:
-1. Navigate to your project's **Operations > Feature Flags**.
+1. Navigate to your project's **Deployments > Feature Flags**.
1. For the feature flag you want to disable, slide the Status toggle to **Disabled**.
The feature flag is displayed on the **Disabled** tab.
@@ -283,7 +283,7 @@ Then prepare your application with a client library.
To get the access credentials that your application needs to communicate with GitLab:
-1. Navigate to your project's **Operations > Feature Flags**.
+1. Navigate to your project's **Deployments > Feature Flags**.
1. Click the **Configure** button to view the following:
- **API URL**: URL where the client (application) connects to get a list of feature flags.
- **Instance ID**: Unique token that authorizes the retrieval of the feature flags.
diff --git a/doc/operations/product_analytics.md b/doc/operations/product_analytics.md
index db6dc13607b..c89500ab92c 100644
--- a/doc/operations/product_analytics.md
+++ b/doc/operations/product_analytics.md
@@ -52,7 +52,7 @@ user interface:
1. Sign in to GitLab as a user with Reporter or greater
[permissions](../user/permissions.md).
-1. Navigate to **Operations > Product Analytics**
+1. Navigate to **Monitor > Product Analytics**.
The user interface contains:
diff --git a/doc/subscriptions/self_managed/index.md b/doc/subscriptions/self_managed/index.md
index 443ea5a240a..c4484ea609f 100644
--- a/doc/subscriptions/self_managed/index.md
+++ b/doc/subscriptions/self_managed/index.md
@@ -209,9 +209,14 @@ Seat Link daily sends a count of all users in connected GitLab self-managed inst
Seat Link provides **only** the following information to GitLab:
- Date
+- Timestamp
- License key
- Historical maximum user count
- Billable users count
+- GitLab version
+- Hostname
+- Instance ID
+- MD5 hash of license
For offline or closed network customers, the existing [true-up model](#users-over-license) is used. Prorated charges are not possible without user count data.
@@ -220,6 +225,8 @@ For offline or closed network customers, the existing [true-up model](#users-ove
{
+ gitlab_version: '13.12.0',
+ timestamp: '2020-01-29T18:25:57+00:00',
date: '2020-01-29',
license_key: 'ZXlKa1lYUmhJam9pWm5WNmVsTjVZekZ2YTJoV2NucDBh
RXRxTTA5amQxcG1VMVZqDQpXR3RwZEc5SGIyMVhibmxuZDJ0NWFrNXJTVzVH
@@ -255,8 +262,9 @@ TjJ4eVlVUkdkWEJtDQpkSHByYWpreVJrcG9UVlo0Y0hKSU9URndiV2RzVFdO
VlhHNXRhVmszTkV0SVEzcEpNMWRyZEVoRU4ydHINCmRIRnFRVTlCVUVVM1pV
SlRORE4xUjFaYVJGb3JlWGM5UFZ4dUlpd2lhWFlpt2lKV00yRnNVbk5RTjJk
Sg0KU1hNMGExaE9SVGR2V2pKQlBUMWNiaUo5DQo=',
- max_historical_user_count: 10,
- billable_users_count: 6
+ hostname: 'gitlab.example.com',
+ instance_id: 'c1ac02cb-cb3f-4120-b7fe-961bbfa3abb7',
+ license_md5: '7cd897fffb3517dddf01b79a0889b515'
}
diff --git a/doc/user/project/canary_deployments.md b/doc/user/project/canary_deployments.md
index c67c465b680..c3900d33cb8 100644
--- a/doc/user/project/canary_deployments.md
+++ b/doc/user/project/canary_deployments.md
@@ -116,7 +116,7 @@ or by sending requests to the [GraphQL API](../../api/graphql/getting_started.md
To use your [Deploy Board](../../user/project/deploy_boards.md):
-1. Navigate to **Operations > Environments** for your project.
+1. Navigate to **Deployments > Environments** for your project.
1. Set the new weight with the dropdown on the right side.
1. Confirm your selection.
diff --git a/doc/user/project/deploy_boards.md b/doc/user/project/deploy_boards.md
index 804c013d317..89c82d4dc6f 100644
--- a/doc/user/project/deploy_boards.md
+++ b/doc/user/project/deploy_boards.md
@@ -117,7 +117,7 @@ To display the Deploy Boards for a specific [environment](../../ci/environments/

Once all of the above are set up and the pipeline has run at least once,
-navigate to the environments page under **Operations > Environments**.
+navigate to the environments page under **Deployments > Environments**.
Deploy Boards are visible by default. You can explicitly click
the triangle next to their respective environment name in order to hide them.
diff --git a/doc/user/project/releases/index.md b/doc/user/project/releases/index.md
index 85b96fd9794..71cbff9e545 100644
--- a/doc/user/project/releases/index.md
+++ b/doc/user/project/releases/index.md
@@ -33,7 +33,7 @@ and attach [release assets](#release-assets), like runbooks or packages.
To view a list of releases:
-- On the left sidebar, select **Project information > Releases**, or
+- On the left sidebar, select **Deployments > Releases**, or
- On the project's overview page, if at least one release exists, click the number of releases.
@@ -64,7 +64,7 @@ Read more about [Release permissions](../../../user/permissions.md#project-membe
To create a new release through the GitLab UI:
-1. On the left sidebar, select **Project information > Releases** and select **New release**.
+1. On the left sidebar, select **Deployments > Releases** and select **New release**.
1. Open the [**Tag name**](#tag-name) dropdown. Select an existing tag or type
in a new tag name. Selecting an existing tag that is already associated with
a release will result in a validation error.
@@ -104,7 +104,7 @@ Read more about [Release permissions](../../../user/permissions.md#project-membe
To edit the details of a release:
-1. On the left sidebar, select **Project information > Releases**.
+1. On the left sidebar, select **Deployments > Releases**.
1. In the top-right corner of the release you want to modify, click **Edit this release** (the pencil icon).
1. On the **Edit Release** page, change the release's details.
1. Click **Save changes**.
@@ -150,12 +150,12 @@ the [Releases API](../../../api/releases/index.md#create-a-release).
In the user interface, to associate milestones to a release:
-1. On the left sidebar, select **Project information > Releases**.
+1. On the left sidebar, select **Deployments > Releases**.
1. In the top-right corner of the release you want to modify, click **Edit this release** (the pencil icon).
1. From the **Milestones** list, select each milestone you want to associate. You can select multiple milestones.
1. Click **Save changes**.
-On the **Project information > Releases** page, the **Milestone** is listed in the top
+On the **Deployments > Releases** page, the **Milestone** is listed in the top
section, along with statistics about the issues in the milestones.

diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md
index bed326ab993..cd9d36bf161 100644
--- a/doc/user/project/settings/index.md
+++ b/doc/user/project/settings/index.md
@@ -134,7 +134,7 @@ audit trail:
include: # Execute individual project's configuration
project: '$CI_PROJECT_PATH'
- file: '$CI_PROJECT_CONFIG_PATH'
+ file: '$CI_CONFIG_PATH'
```
##### Ensure compliance jobs are always run
diff --git a/doc/user/shortcuts.md b/doc/user/shortcuts.md
index 29e0f797260..6abbb128f49 100644
--- a/doc/user/shortcuts.md
+++ b/doc/user/shortcuts.md
@@ -79,9 +79,9 @@ relatively quickly to work, and they take you to another page in the project.
| g + b | Go to the project issue boards list (**Issues > Boards**). |
| g + m | Go to the project merge requests list (**Merge Requests**). |
| g + j | Go to the CI/CD jobs list (**CI/CD > Jobs**). |
-| g + l | Go to the project metrics (**Operations > Metrics**). |
-| g + e | Go to the project environments (**Operations > Environments**). |
-| g + k | Go to the project Kubernetes cluster integration page (**Operations > Kubernetes**). Note that you must have at least [`maintainer` permissions](permissions.md) to access this page. |
+| g + l | Go to the project metrics (**Monitor > Metrics**). |
+| g + e | Go to the project environments (**Deployments > Environments**). |
+| g + k | Go to the project Kubernetes cluster integration page (**Infrastructure > Kubernetes**). Note that you must have at least [`maintainer` permissions](permissions.md) to access this page. |
| g + s | Go to the project snippets list (**Snippets**). |
| g + w | Go to the project wiki (**Wiki**), if enabled. |
diff --git a/lib/api/entities/package.rb b/lib/api/entities/package.rb
index 2f60a0bf6bd..1efd457aa5f 100644
--- a/lib/api/entities/package.rb
+++ b/lib/api/entities/package.rb
@@ -25,8 +25,12 @@ module API
expose :status
expose :_links do
- expose :web_path do |package|
- ::Gitlab::Routing.url_helpers.project_package_path(package.project, package)
+ expose :web_path do |package, opts|
+ if package.infrastructure_package?
+ ::Gitlab::Routing.url_helpers.namespace_project_infrastructure_registry_path(opts[:namespace], package.project, package)
+ else
+ ::Gitlab::Routing.url_helpers.project_package_path(package.project, package)
+ end
end
expose :delete_api_path, if: can_destroy(:package, &:project) do |package|
diff --git a/lib/api/group_packages.rb b/lib/api/group_packages.rb
index ab4e91ff925..d9010dfd329 100644
--- a/lib/api/group_packages.rb
+++ b/lib/api/group_packages.rb
@@ -43,7 +43,7 @@ module API
declared(params).slice(:exclude_subgroups, :order_by, :sort, :package_type, :package_name, :include_versionless, :status)
).execute
- present paginate(packages), with: ::API::Entities::Package, user: current_user, group: true
+ present paginate(packages), with: ::API::Entities::Package, user: current_user, group: true, namespace: user_group.root_ancestor
end
end
end
diff --git a/lib/api/project_packages.rb b/lib/api/project_packages.rb
index babc7b9dd58..276cbe50e42 100644
--- a/lib/api/project_packages.rb
+++ b/lib/api/project_packages.rb
@@ -41,7 +41,7 @@ module API
declared_params.slice(:order_by, :sort, :package_type, :package_name, :include_versionless, :status)
).execute
- present paginate(packages), with: ::API::Entities::Package, user: current_user
+ present paginate(packages), with: ::API::Entities::Package, user: current_user, namespace: user_project.root_ancestor
end
desc 'Get a single project package' do
@@ -55,7 +55,7 @@ module API
package = ::Packages::PackageFinder
.new(user_project, params[:package_id]).execute
- present package, with: ::API::Entities::Package, user: current_user
+ present package, with: ::API::Entities::Package, user: current_user, namespace: user_project.root_ancestor
end
desc 'Remove a package' do
diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb
index ccb4f6e1097..a31f574fad2 100644
--- a/lib/gitlab/regex.rb
+++ b/lib/gitlab/regex.rb
@@ -138,7 +138,8 @@ module Gitlab
end
def helm_version_regex
- @helm_version_regex ||= %r{#{prefixed_semver_regex}}.freeze
+ # identical to semver_regex, with optional preceding 'v'
+ @helm_version_regex ||= Regexp.new("\\Av?#{::Gitlab::Regex.unbounded_semver_regex.source}\\z", ::Gitlab::Regex.unbounded_semver_regex.options)
end
def unbounded_semver_regex
diff --git a/lib/tasks/gitlab/graphql.rake b/lib/tasks/gitlab/graphql.rake
index 27bba6aa307..b405cbd3f68 100644
--- a/lib/tasks/gitlab/graphql.rake
+++ b/lib/tasks/gitlab/graphql.rake
@@ -3,11 +3,12 @@
return if Rails.env.production?
require 'graphql/rake_task'
+require_relative '../../../tooling/graphql/docs/renderer'
namespace :gitlab do
OUTPUT_DIR = Rails.root.join("doc/api/graphql/reference")
TEMP_SCHEMA_DIR = Rails.root.join('tmp/tests/graphql')
- TEMPLATES_DIR = 'lib/gitlab/graphql/docs/templates/'
+ TEMPLATES_DIR = 'tooling/graphql/docs/templates/'
# Make all feature flags enabled so that all feature flag
# controlled fields are considered visible and are output.
@@ -110,7 +111,7 @@ namespace :gitlab do
desc 'GitLab | GraphQL | Generate GraphQL docs'
task compile_docs: [:environment, :enable_feature_flags] do
- renderer = Gitlab::Graphql::Docs::Renderer.new(GitlabSchema, render_options)
+ renderer = Tooling::Graphql::Docs::Renderer.new(GitlabSchema, render_options)
renderer.write
@@ -119,7 +120,7 @@ namespace :gitlab do
desc 'GitLab | GraphQL | Check if GraphQL docs are up to date'
task check_docs: [:environment, :enable_feature_flags] do
- renderer = Gitlab::Graphql::Docs::Renderer.new(GitlabSchema, render_options)
+ renderer = Tooling::Graphql::Docs::Renderer.new(GitlabSchema, render_options)
doc = File.read(Rails.root.join(OUTPUT_DIR, 'index.md'))
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 360f5bb729a..db9ce5233b7 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1474,9 +1474,6 @@ msgstr ""
msgid "A rebase is already in progress."
msgstr ""
-msgid "A rule must be provided to create an escalation policy"
-msgstr ""
-
msgid "A secure token that identifies an external storage request."
msgstr ""
@@ -3228,6 +3225,9 @@ msgstr ""
msgid "All epics"
msgstr ""
+msgid "All escalations rules must have a schedule in the same project as the policy"
+msgstr ""
+
msgid "All groups and projects"
msgstr ""
@@ -13039,7 +13039,7 @@ msgstr ""
msgid "Escalation policies"
msgstr ""
-msgid "Escalation policies are not supported for this project"
+msgid "Escalation policies must have at least one rule"
msgstr ""
msgid "EscalationPolicies|+ Add an additional rule"
@@ -17854,6 +17854,9 @@ msgstr ""
msgid "Invalid login or password"
msgstr ""
+msgid "Invalid period"
+msgstr ""
+
msgid "Invalid pin code"
msgstr ""
@@ -27444,9 +27447,15 @@ msgstr ""
msgid "Replace"
msgstr ""
+msgid "Replace %{name}"
+msgstr ""
+
msgid "Replace all label(s)"
msgstr ""
+msgid "Replace file"
+msgstr ""
+
msgid "Replaced all labels with %{label_references} %{label_text}."
msgstr ""
@@ -37485,21 +37494,18 @@ msgstr ""
msgid "You have imported from this project %{numberOfPreviousImports} times before. Each new import will create duplicate issues."
msgstr ""
+msgid "You have insufficient permissions to configure escalation policies for this project"
+msgstr ""
+
msgid "You have insufficient permissions to create a Todo for this alert"
msgstr ""
msgid "You have insufficient permissions to create an HTTP integration for this project"
msgstr ""
-msgid "You have insufficient permissions to create an escalation policy for this project"
-msgstr ""
-
msgid "You have insufficient permissions to create an on-call schedule for this project"
msgstr ""
-msgid "You have insufficient permissions to remove an escalation policy from this project"
-msgstr ""
-
msgid "You have insufficient permissions to remove an on-call rotation from this project"
msgstr ""
diff --git a/qa/qa/specs/features/browser_ui/1_manage/group/bulk_import_group_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/group/bulk_import_group_spec.rb
index 10321303873..d4c4ec5611a 100644
--- a/qa/qa/specs/features/browser_ui/1_manage/group/bulk_import_group_spec.rb
+++ b/qa/qa/specs/features/browser_ui/1_manage/group/bulk_import_group_spec.rb
@@ -100,10 +100,11 @@ module QA
expect(import_page).to have_imported_group(source_group.path, wait: 180)
expect { imported_group.reload! }.to eventually_eq(source_group).within(duration: 10)
- expect { imported_subgroup.reload! }.to eventually_eq(subgroup).within(duration: 30)
-
expect { imported_group.labels }.to eventually_include(*source_group.labels).within(duration: 10)
- expect { imported_subgroup.labels }.to eventually_include(*subgroup.labels).within(duration: 30)
+
+ # Do not validate subgroups until https://gitlab.com/gitlab-org/gitlab/-/issues/332818 is resolved
+ # expect { imported_subgroup.reload! }.to eventually_eq(subgroup).within(duration: 30)
+ # expect { imported_subgroup.labels }.to eventually_include(*subgroup.labels).within(duration: 30)
end
end
end
diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb
index 7ec155fcb10..1882ac49fd6 100644
--- a/spec/frontend/fixtures/releases.rb
+++ b/spec/frontend/fixtures/releases.rb
@@ -100,6 +100,17 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
link_type: :image)
end
+ let_it_be(:another_release) do
+ create(:release,
+ project: project,
+ tag: 'v1.2',
+ name: 'The second release',
+ author: admin,
+ description: 'An okay release :shrug:',
+ created_at: Time.zone.parse('2019-01-03'),
+ released_at: Time.zone.parse('2019-01-10'))
+ end
+
after(:all) do
remove_repository(project)
end
diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap
index 14f9e69f3f9..e0a1343c39c 100644
--- a/spec/frontend/releases/__snapshots__/util_spec.js.snap
+++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap
@@ -3,6 +3,58 @@
exports[`releases/util.js convertAllReleasesGraphQLResponse matches snapshot 1`] = `
Object {
"data": Array [
+ Object {
+ "_links": Object {
+ "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.2&scope=all&state=closed",
+ "closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=closed",
+ "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.2/edit",
+ "mergedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=merged",
+ "openedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.2&scope=all&state=opened",
+ "openedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=opened",
+ "self": "http://localhost/releases-namespace/releases-project/-/releases/v1.2",
+ "selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.2",
+ },
+ "assets": Object {
+ "count": 4,
+ "links": Array [],
+ "sources": Array [
+ Object {
+ "format": "zip",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.zip",
+ },
+ Object {
+ "format": "tar.gz",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.gz",
+ },
+ Object {
+ "format": "tar.bz2",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.bz2",
+ },
+ Object {
+ "format": "tar",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar",
+ },
+ ],
+ },
+ "author": Object {
+ "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon",
+ "username": "administrator",
+ "webUrl": "http://localhost/administrator",
+ },
+ "commit": Object {
+ "shortId": "b83d6e39",
+ "title": "Merge branch 'branch-merged' into 'master'",
+ },
+ "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0",
+ "descriptionHtml": "An okay release 🤷
",
+ "evidences": Array [],
+ "milestones": Array [],
+ "name": "The second release",
+ "releasedAt": "2019-01-10T00:00:00Z",
+ "tagName": "v1.2",
+ "tagPath": "/releases-namespace/releases-project/-/tags/v1.2",
+ "upcomingRelease": true,
+ },
Object {
"_links": Object {
"closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=closed",
@@ -124,7 +176,7 @@ Object {
"endCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMSJ9",
"hasNextPage": false,
"hasPreviousPage": false,
- "startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMSJ9",
+ "startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTktMDEtMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMiJ9",
},
}
`;
diff --git a/spec/frontend/releases/components/app_index_apollo_client_spec.js b/spec/frontend/releases/components/app_index_apollo_client_spec.js
index eae3a5b96a4..002d8939058 100644
--- a/spec/frontend/releases/components/app_index_apollo_client_spec.js
+++ b/spec/frontend/releases/components/app_index_apollo_client_spec.js
@@ -37,12 +37,22 @@ describe('app_index_apollo_client.vue', () => {
const after = 'afterCursor';
let wrapper;
- let allReleasesQueryResponse;
- let allReleasesQueryMock;
+ let allReleases;
+ let singleRelease;
+ let noReleases;
+ let queryMock;
- const createComponent = (queryResponse = Promise.resolve(allReleasesQueryResponse)) => {
+ const createComponent = ({
+ singleResponse = Promise.resolve(singleRelease),
+ fullResponse = Promise.resolve(allReleases),
+ } = {}) => {
const apolloProvider = createMockApollo([
- [allReleasesQuery, allReleasesQueryMock.mockReturnValueOnce(queryResponse)],
+ [
+ allReleasesQuery,
+ queryMock.mockImplementation((vars) => {
+ return vars.first === 1 ? singleResponse : fullResponse;
+ }),
+ ],
]);
wrapper = shallowMountExtended(ReleasesIndexApolloClientApp, {
@@ -56,8 +66,19 @@ describe('app_index_apollo_client.vue', () => {
beforeEach(() => {
mockQueryParams = {};
- allReleasesQueryResponse = cloneDeep(originalAllReleasesQueryResponse);
- allReleasesQueryMock = jest.fn();
+
+ allReleases = cloneDeep(originalAllReleasesQueryResponse);
+
+ singleRelease = cloneDeep(originalAllReleasesQueryResponse);
+ singleRelease.data.project.releases.nodes.splice(
+ 1,
+ singleRelease.data.project.releases.nodes.length,
+ );
+
+ noReleases = cloneDeep(originalAllReleasesQueryResponse);
+ noReleases.data.project.releases.nodes = [];
+
+ queryMock = jest.fn();
});
afterEach(() => {
@@ -73,148 +94,88 @@ describe('app_index_apollo_client.vue', () => {
const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient);
const findSort = () => wrapper.findComponent(ReleasesSortApolloClient);
- // Expectations
- const expectLoadingIndicator = () => {
- it('renders a loading indicator', () => {
- expect(findLoadingIndicator().exists()).toBe(true);
- });
- };
-
- const expectNoLoadingIndicator = () => {
- it('does not render a loading indicator', () => {
- expect(findLoadingIndicator().exists()).toBe(false);
- });
- };
-
- const expectEmptyState = () => {
- it('renders the empty state', () => {
- expect(findEmptyState().exists()).toBe(true);
- });
- };
-
- const expectNoEmptyState = () => {
- it('does not render the empty state', () => {
- expect(findEmptyState().exists()).toBe(false);
- });
- };
-
- const expectFlashMessage = (message = ReleasesIndexApolloClientApp.i18n.errorMessage) => {
- it(`shows a flash message that reads "${message}"`, () => {
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith({
- message,
- captureError: true,
- error: expect.any(Error),
- });
- });
- };
-
- const expectNewReleaseButton = () => {
- it('renders the "New Release" button', () => {
- expect(findNewReleaseButton().exists()).toBe(true);
- });
- };
-
- const expectNoFlashMessage = () => {
- it(`does not show a flash message`, () => {
- expect(createFlash).not.toHaveBeenCalled();
- });
- };
-
- const expectReleases = (count) => {
- it(`renders ${count} release(s)`, () => {
- expect(findAllReleaseBlocks()).toHaveLength(count);
- });
- };
-
- const expectPagination = () => {
- it('renders the pagination buttons', () => {
- expect(findPagination().exists()).toBe(true);
- });
- };
-
- const expectNoPagination = () => {
- it('does not render the pagination buttons', () => {
- expect(findPagination().exists()).toBe(false);
- });
- };
-
- const expectSort = () => {
- it('renders the sort controls', () => {
- expect(findSort().exists()).toBe(true);
- });
- };
-
// Tests
- describe('when the component is loading data', () => {
- beforeEach(() => {
- createComponent(new Promise(() => {}));
- });
+ describe('component states', () => {
+ // These need to be defined as functions, since `singleRelease` and
+ // `allReleases` are generated in a `beforeEach`, and therefore
+ // aren't available at test definition time.
+ const getInProgressResponse = () => new Promise(() => {});
+ const getErrorResponse = () => Promise.reject(new Error('Oops!'));
+ const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease);
+ const getFullRequestLoadedResponse = () => Promise.resolve(allReleases);
+ const getLoadedEmptyResponse = () => Promise.resolve(noReleases);
- expectLoadingIndicator();
- expectNoEmptyState();
- expectNoFlashMessage();
- expectNewReleaseButton();
- expectReleases(0);
- expectNoPagination();
- expectSort();
- });
+ const toDescription = (bool) => (bool ? 'does' : 'does not');
- describe('when the data has successfully loaded, but there are no releases', () => {
- beforeEach(() => {
- allReleasesQueryResponse.data.project.releases.nodes = [];
- createComponent(Promise.resolve(allReleasesQueryResponse));
- });
+ describe.each`
+ description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination
+ ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
+ ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false}
+ ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
+ ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
+ ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
+ ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false}
+ ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false}
+ ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false}
+ ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
+ ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
+ ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
+ ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
+ `(
+ '$description',
+ ({
+ singleResponseFn,
+ fullResponseFn,
+ loadingIndicator,
+ emptyState,
+ flashMessage,
+ releaseCount,
+ pagination,
+ }) => {
+ beforeEach(() => {
+ createComponent({
+ singleResponse: singleResponseFn(),
+ fullResponse: fullResponseFn(),
+ });
+ });
- expectNoLoadingIndicator();
- expectEmptyState();
- expectNoFlashMessage();
- expectNewReleaseButton();
- expectReleases(0);
- expectNoPagination();
- expectSort();
- });
+ it(`${toDescription(loadingIndicator)} render a loading indicator`, () => {
+ expect(findLoadingIndicator().exists()).toBe(loadingIndicator);
+ });
- describe('when an error occurs while loading data', () => {
- beforeEach(() => {
- createComponent(Promise.reject(new Error('Oops!')));
- });
+ it(`${toDescription(emptyState)} render an empty state`, () => {
+ expect(findEmptyState().exists()).toBe(emptyState);
+ });
- expectNoLoadingIndicator();
- expectNoEmptyState();
- expectFlashMessage();
- expectNewReleaseButton();
- expectReleases(0);
- expectNoPagination();
- expectSort();
- });
+ it(`${toDescription(flashMessage)} show a flash message`, () => {
+ if (flashMessage) {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: ReleasesIndexApolloClientApp.i18n.errorMessage,
+ captureError: true,
+ error: expect.any(Error),
+ });
+ } else {
+ expect(createFlash).not.toHaveBeenCalled();
+ }
+ });
- describe('when the data has successfully loaded with a single page of results', () => {
- beforeEach(() => {
- createComponent();
- });
+ it(`renders ${releaseCount} release(s)`, () => {
+ expect(findAllReleaseBlocks()).toHaveLength(releaseCount);
+ });
- expectNoLoadingIndicator();
- expectNoEmptyState();
- expectNoFlashMessage();
- expectNewReleaseButton();
- expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
- expectNoPagination();
- });
+ it(`${toDescription(pagination)} render the pagination controls`, () => {
+ expect(findPagination().exists()).toBe(pagination);
+ });
- describe('when the data has successfully loaded with multiple pages of results', () => {
- beforeEach(() => {
- allReleasesQueryResponse.data.project.releases.pageInfo.hasNextPage = true;
- createComponent(Promise.resolve(allReleasesQueryResponse));
- });
+ it('does render the "New release" button', () => {
+ expect(findNewReleaseButton().exists()).toBe(true);
+ });
- expectNoLoadingIndicator();
- expectNoEmptyState();
- expectNoFlashMessage();
- expectNewReleaseButton();
- expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
- expectPagination();
- expectSort();
+ it('does render the sort controls', () => {
+ expect(findSort().exists()).toBe(true);
+ });
+ },
+ );
});
describe('URL parameters', () => {
@@ -224,7 +185,15 @@ describe('app_index_apollo_client.vue', () => {
});
it('makes a request with the correct GraphQL query parameters', () => {
- expect(allReleasesQueryMock).toHaveBeenCalledWith({
+ expect(queryMock).toHaveBeenCalledTimes(2);
+
+ expect(queryMock).toHaveBeenCalledWith({
+ first: 1,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+
+ expect(queryMock).toHaveBeenCalledWith({
first: PAGE_SIZE,
fullPath: projectPath,
sort: DEFAULT_SORT,
@@ -239,7 +208,9 @@ describe('app_index_apollo_client.vue', () => {
});
it('makes a request with the correct GraphQL query parameters', () => {
- expect(allReleasesQueryMock).toHaveBeenCalledWith({
+ expect(queryMock).toHaveBeenCalledTimes(1);
+
+ expect(queryMock).toHaveBeenCalledWith({
before,
last: PAGE_SIZE,
fullPath: projectPath,
@@ -255,7 +226,16 @@ describe('app_index_apollo_client.vue', () => {
});
it('makes a request with the correct GraphQL query parameters', () => {
- expect(allReleasesQueryMock).toHaveBeenCalledWith({
+ expect(queryMock).toHaveBeenCalledTimes(2);
+
+ expect(queryMock).toHaveBeenCalledWith({
+ after,
+ first: 1,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+
+ expect(queryMock).toHaveBeenCalledWith({
after,
first: PAGE_SIZE,
fullPath: projectPath,
@@ -271,7 +251,16 @@ describe('app_index_apollo_client.vue', () => {
});
it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => {
- expect(allReleasesQueryMock).toHaveBeenCalledWith({
+ expect(queryMock).toHaveBeenCalledTimes(2);
+
+ expect(queryMock).toHaveBeenCalledWith({
+ after,
+ first: 1,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+
+ expect(queryMock).toHaveBeenCalledWith({
after,
first: PAGE_SIZE,
fullPath: projectPath,
@@ -292,27 +281,23 @@ describe('app_index_apollo_client.vue', () => {
});
describe('pagination', () => {
- beforeEach(async () => {
+ beforeEach(() => {
mockQueryParams = { before };
-
- allReleasesQueryResponse.data.project.releases.pageInfo.hasNextPage = true;
- createComponent(Promise.resolve(allReleasesQueryResponse));
-
- await wrapper.vm.$nextTick();
+ createComponent();
});
it('requeries the GraphQL endpoint when a pagination button is clicked', async () => {
- expect(allReleasesQueryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
+ expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
mockQueryParams = { after };
-
findPagination().vm.$emit('next', after);
await wrapper.vm.$nextTick();
- expect(allReleasesQueryMock.mock.calls).toEqual([
+ expect(queryMock.mock.calls).toEqual([
[expect.objectContaining({ before })],
[expect.objectContaining({ after })],
+ [expect.objectContaining({ after })],
]);
});
});
@@ -323,7 +308,8 @@ describe('app_index_apollo_client.vue', () => {
});
it(`sorts by ${DEFAULT_SORT} by default`, () => {
- expect(allReleasesQueryMock.mock.calls).toEqual([
+ expect(queryMock.mock.calls).toEqual([
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
[expect.objectContaining({ sort: DEFAULT_SORT })],
]);
});
@@ -333,8 +319,10 @@ describe('app_index_apollo_client.vue', () => {
await wrapper.vm.$nextTick();
- expect(allReleasesQueryMock.mock.calls).toEqual([
+ expect(queryMock.mock.calls).toEqual([
[expect.objectContaining({ sort: DEFAULT_SORT })],
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ [expect.objectContaining({ sort: CREATED_ASC })],
[expect.objectContaining({ sort: CREATED_ASC })],
]);
@@ -347,7 +335,8 @@ describe('app_index_apollo_client.vue', () => {
await wrapper.vm.$nextTick();
- expect(allReleasesQueryMock.mock.calls).toEqual([
+ expect(queryMock.mock.calls).toEqual([
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
[expect.objectContaining({ sort: DEFAULT_SORT })],
]);
@@ -381,11 +370,13 @@ describe('app_index_apollo_client.vue', () => {
});
it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => {
- const firstRequestVariables = allReleasesQueryMock.mock.calls[0][0];
- const secondRequestVariables = allReleasesQueryMock.mock.calls[1][0];
+ const firstRequestVariables = queryMock.mock.calls[0][0];
+ // Might be request #2 or #3, depending on the pagination direction
+ const mostRecentRequestVariables =
+ queryMock.mock.calls[queryMock.mock.calls.length - 1][0];
expect(firstRequestVariables[paramName]).toBe(paramInitialValue);
- expect(secondRequestVariables[paramName]).toBeUndefined();
+ expect(mostRecentRequestVariables[paramName]).toBeUndefined();
});
it(`updates the URL to not include the "${paramName}" URL query parameter`, () => {
diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js
index ea554db6909..495039b4ccb 100644
--- a/spec/frontend/repository/components/blob_content_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_content_viewer_spec.js
@@ -5,6 +5,7 @@ import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import BlobHeaderEdit from '~/repository/components/blob_header_edit.vue';
+import BlobReplace from '~/repository/components/blob_replace.vue';
let wrapper;
const simpleMockData = {
@@ -75,10 +76,11 @@ const factory = createFactory(shallowMount);
const fullFactory = createFactory(mount);
describe('Blob content viewer component', () => {
- const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
- const findBlobHeader = () => wrapper.find(BlobHeader);
- const findBlobHeaderEdit = () => wrapper.find(BlobHeaderEdit);
- const findBlobContent = () => wrapper.find(BlobContent);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findBlobHeader = () => wrapper.findComponent(BlobHeader);
+ const findBlobHeaderEdit = () => wrapper.findComponent(BlobHeaderEdit);
+ const findBlobContent = () => wrapper.findComponent(BlobContent);
+ const findBlobReplace = () => wrapper.findComponent(BlobReplace);
afterEach(() => {
wrapper.destroy();
@@ -169,6 +171,7 @@ describe('Blob content viewer component', () => {
mockData: { blobInfo: simpleMockData },
stubs: {
BlobContent: true,
+ BlobReplace: true,
},
});
@@ -185,6 +188,7 @@ describe('Blob content viewer component', () => {
mockData: { blobInfo: richMockData },
stubs: {
BlobContent: true,
+ BlobReplace: true,
},
});
@@ -195,5 +199,44 @@ describe('Blob content viewer component', () => {
webIdePath: ideEditPath,
});
});
+
+ describe('BlobReplace', () => {
+ const { name, path } = simpleMockData;
+
+ it('renders component', async () => {
+ window.gon.current_user_id = 1;
+
+ fullFactory({
+ mockData: { blobInfo: simpleMockData },
+ stubs: {
+ BlobContent: true,
+ BlobReplace: true,
+ },
+ });
+
+ await nextTick();
+
+ expect(findBlobReplace().props()).toMatchObject({
+ name,
+ path,
+ });
+ });
+
+ it('does not render if not logged in', async () => {
+ window.gon.current_user_id = null;
+
+ fullFactory({
+ mockData: { blobInfo: simpleMockData },
+ stubs: {
+ BlobContent: true,
+ BlobReplace: true,
+ },
+ });
+
+ await nextTick();
+
+ expect(findBlobReplace().exists()).toBe(false);
+ });
+ });
});
});
diff --git a/spec/frontend/repository/components/blob_replace_spec.js b/spec/frontend/repository/components/blob_replace_spec.js
new file mode 100644
index 00000000000..4a6f147da22
--- /dev/null
+++ b/spec/frontend/repository/components/blob_replace_spec.js
@@ -0,0 +1,67 @@
+import { shallowMount } from '@vue/test-utils';
+import BlobReplace from '~/repository/components/blob_replace.vue';
+import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
+
+const DEFAULT_PROPS = {
+ name: 'some name',
+ path: 'some/path',
+ canPushCode: true,
+ replacePath: 'some/replace/path',
+};
+
+const DEFAULT_INJECT = {
+ targetBranch: 'master',
+ originalBranch: 'master',
+};
+
+describe('BlobReplace component', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(BlobReplace, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ ...props,
+ },
+ provide: {
+ ...DEFAULT_INJECT,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal);
+
+ it('renders component', () => {
+ createComponent();
+
+ const { name, path } = DEFAULT_PROPS;
+
+ expect(wrapper.props()).toMatchObject({
+ name,
+ path,
+ });
+ });
+
+ it('renders UploadBlobModal', () => {
+ createComponent();
+
+ const { targetBranch, originalBranch } = DEFAULT_INJECT;
+ const { name, path, canPushCode, replacePath } = DEFAULT_PROPS;
+ const title = `Replace ${name}`;
+
+ expect(findUploadBlobModal().props()).toMatchObject({
+ modalTitle: title,
+ commitMessage: title,
+ targetBranch,
+ originalBranch,
+ canPushCode,
+ path,
+ replacePath,
+ primaryBtnText: 'Replace file',
+ });
+ });
+});
diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js
index ec85d5666fb..d93b1d7e5f1 100644
--- a/spec/frontend/repository/components/upload_blob_modal_spec.js
+++ b/spec/frontend/repository/components/upload_blob_modal_spec.js
@@ -200,4 +200,84 @@ describe('UploadBlobModal', () => {
});
},
);
+
+ describe('blob file submission type', () => {
+ const submitForm = async () => {
+ wrapper.vm.uploadFile = jest.fn();
+ wrapper.vm.replaceFile = jest.fn();
+ wrapper.vm.submitForm();
+ await wrapper.vm.$nextTick();
+ };
+
+ const submitRequest = async () => {
+ mock = new MockAdapter(axios);
+ findModal().vm.$emit('primary', mockEvent);
+ await waitForPromises();
+ };
+
+ describe('upload blob file', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays the default "Upload New File" modal title ', () => {
+ expect(findModal().props('title')).toBe('Upload New File');
+ });
+
+ it('display the defaul primary button text', () => {
+ expect(findModal().props('actionPrimary').text).toBe('Upload file');
+ });
+
+ it('calls the default uploadFile when the form submit', async () => {
+ await submitForm();
+
+ expect(wrapper.vm.uploadFile).toHaveBeenCalled();
+ expect(wrapper.vm.replaceFile).not.toHaveBeenCalled();
+ });
+
+ it('makes a POST request', async () => {
+ await submitRequest();
+
+ expect(mock.history.put).toHaveLength(0);
+ expect(mock.history.post).toHaveLength(1);
+ });
+ });
+
+ describe('replace blob file', () => {
+ const modalTitle = 'Replace foo.js';
+ const replacePath = 'replace-path';
+ const primaryBtnText = 'Replace file';
+
+ beforeEach(() => {
+ createComponent({
+ modalTitle,
+ replacePath,
+ primaryBtnText,
+ });
+ });
+
+ it('displays the passed modal title', () => {
+ expect(findModal().props('title')).toBe(modalTitle);
+ });
+
+ it('display the passed primary button text', () => {
+ expect(findModal().props('actionPrimary').text).toBe(primaryBtnText);
+ });
+
+ it('calls the replaceFile when the form submit', async () => {
+ await submitForm();
+
+ expect(wrapper.vm.replaceFile).toHaveBeenCalled();
+ expect(wrapper.vm.uploadFile).not.toHaveBeenCalled();
+ });
+
+ it('makes a PUT request', async () => {
+ await submitRequest();
+
+ expect(mock.history.put).toHaveLength(1);
+ expect(mock.history.post).toHaveLength(0);
+ expect(mock.history.put[0].url).toBe(replacePath);
+ });
+ });
+ });
});
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index 28447d5c2a9..c1c97e87a4c 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -675,9 +675,20 @@ RSpec.describe Gitlab::Regex do
describe '.helm_version_regex' do
subject { described_class.helm_version_regex }
+ it { is_expected.to match('1.2.3') }
+ it { is_expected.to match('1.2.3-beta') }
+ it { is_expected.to match('1.2.3-alpha.3') }
+
it { is_expected.to match('v1.2.3') }
it { is_expected.to match('v1.2.3-beta') }
it { is_expected.to match('v1.2.3-alpha.3') }
+
+ it { is_expected.not_to match('1') }
+ it { is_expected.not_to match('1.2') }
+ it { is_expected.not_to match('1./2.3') }
+ it { is_expected.not_to match('../../../../../1.2.3') }
+ it { is_expected.not_to match('%2e%2e%2f1.2.3') }
+
it { is_expected.not_to match('v1') }
it { is_expected.not_to match('v1.2') }
it { is_expected.not_to match('v1./2.3') }
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 5bd8fee339d..62dec522161 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -2621,7 +2621,6 @@ RSpec.describe Ci::Build do
{ key: 'CI_PROJECT_VISIBILITY', value: 'private', public: true, masked: false },
{ key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: project.repository_languages.map(&:name).join(',').downcase, public: true, masked: false },
{ key: 'CI_DEFAULT_BRANCH', value: project.default_branch, public: true, masked: false },
- { key: 'CI_PROJECT_CONFIG_PATH', value: project.ci_config_path_or_default, public: true, masked: false },
{ key: 'CI_CONFIG_PATH', value: project.ci_config_path_or_default, public: true, masked: false },
{ key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host, public: true, masked: false },
{ key: 'CI_PAGES_URL', value: project.pages_url, public: true, masked: false },
diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb
index 6e9d02b157b..1e44327c089 100644
--- a/spec/models/packages/package_spec.rb
+++ b/spec/models/packages/package_spec.rb
@@ -404,7 +404,8 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.not_to allow_value(nil).for(:version) }
it { is_expected.not_to allow_value('').for(:version) }
it { is_expected.to allow_value('v1.2.3').for(:version) }
- it { is_expected.not_to allow_value('1.2.3').for(:version) }
+ it { is_expected.to allow_value('1.2.3').for(:version) }
+ it { is_expected.not_to allow_value('v1.2').for(:version) }
end
it_behaves_like 'validating version to be SemVer compliant for', :npm_package
@@ -897,6 +898,26 @@ RSpec.describe Packages::Package, type: :model do
end
end
+ describe '#infrastructure_package?' do
+ let(:package) { create(:package) }
+
+ subject { package.infrastructure_package? }
+
+ it { is_expected.to eq(false) }
+
+ context 'with generic package' do
+ let(:package) { create(:generic_package) }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'with terraform module package' do
+ let(:package) { create(:terraform_module_package) }
+
+ it { is_expected.to eq(true) }
+ end
+ end
+
describe 'plan_limits' do
Packages::Package.package_types.keys.without('composer').each do |pt|
plan_limit_name = if pt == 'generic'
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index e012fcac810..eb2ced81db1 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -4666,7 +4666,6 @@ RSpec.describe Project, factory_default: :keep do
specify do
expect(subject).to include
[
- { key: 'CI_PROJECT_CONFIG_PATH', value: Ci::Pipeline::DEFAULT_CONFIG_PATH, public: true, masked: false },
{ key: 'CI_CONFIG_PATH', value: Ci::Pipeline::DEFAULT_CONFIG_PATH, public: true, masked: false }
]
end
@@ -4679,7 +4678,6 @@ RSpec.describe Project, factory_default: :keep do
it do
expect(subject).to include
[
- { key: 'CI_PROJECT_CONFIG_PATH', value: 'random.yml', public: true, masked: false },
{ key: 'CI_CONFIG_PATH', value: 'random.yml', public: true, masked: false }
]
end
diff --git a/spec/requests/api/project_packages_spec.rb b/spec/requests/api/project_packages_spec.rb
index fb1aa65c08d..5886f293f41 100644
--- a/spec/requests/api/project_packages_spec.rb
+++ b/spec/requests/api/project_packages_spec.rb
@@ -40,10 +40,36 @@ RSpec.describe API::ProjectPackages do
context 'with terraform module package' do
let_it_be(:terraform_module_package) { create(:terraform_module_package, project: project) }
- it 'filters out terraform module packages when no package_type filter is set' do
- subject
+ context 'when no package_type filter is set' do
+ let(:params) { {} }
- expect(json_response).not_to include(a_hash_including('package_type' => 'terraform_module'))
+ it 'filters out terraform module packages' do
+ subject
+
+ expect(json_response).not_to include(a_hash_including('package_type' => 'terraform_module'))
+ end
+
+ it 'returns packages with the package registry web_path' do
+ subject
+
+ expect(json_response).to include(a_hash_including('_links' => a_hash_including('web_path' => include('packages'))))
+ end
+ end
+
+ context 'when package_type filter is set to terraform_module' do
+ let(:params) { { package_type: :terraform_module } }
+
+ it 'returns the terraform module package' do
+ subject
+
+ expect(json_response).to include(a_hash_including('package_type' => 'terraform_module'))
+ end
+
+ it 'returns the terraform module package with the infrastructure registry web_path' do
+ subject
+
+ expect(json_response).to include(a_hash_including('_links' => a_hash_including('web_path' => include('infrastructure_registry'))))
+ end
end
end
diff --git a/spec/services/notification_recipients/builder/default_spec.rb b/spec/services/notification_recipients/builder/default_spec.rb
index 994138ea828..c142cc11384 100644
--- a/spec/services/notification_recipients/builder/default_spec.rb
+++ b/spec/services/notification_recipients/builder/default_spec.rb
@@ -160,21 +160,7 @@ RSpec.describe NotificationRecipients::Builder::Default do
end
end
- before do
- stub_feature_flags(notification_setting_recipient_refactor: enabled)
- end
-
- context 'with notification_setting_recipient_refactor enabled' do
- let(:enabled) { true }
-
- it_behaves_like 'custom notification recipients'
- end
-
- context 'with notification_setting_recipient_refactor disabled' do
- let(:enabled) { false }
-
- it_behaves_like 'custom notification recipients'
- end
+ it_behaves_like 'custom notification recipients'
end
end
end
diff --git a/spec/lib/gitlab/graphql/docs/renderer_spec.rb b/spec/tooling/graphql/docs/renderer_spec.rb
similarity index 99%
rename from spec/lib/gitlab/graphql/docs/renderer_spec.rb
rename to spec/tooling/graphql/docs/renderer_spec.rb
index 2958c76ac70..50ebb754ca4 100644
--- a/spec/lib/gitlab/graphql/docs/renderer_spec.rb
+++ b/spec/tooling/graphql/docs/renderer_spec.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-require 'spec_helper'
+require_relative '../../../../tooling/graphql/docs/renderer'
-RSpec.describe Gitlab::Graphql::Docs::Renderer do
+RSpec.describe Tooling::Graphql::Docs::Renderer do
describe '#contents' do
shared_examples 'renders correctly as GraphQL documentation' do
it 'contains the expected section' do
@@ -12,7 +12,7 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end
end
- let(:template) { Rails.root.join('lib/gitlab/graphql/docs/templates/default.md.haml') }
+ let(:template) { Rails.root.join('tooling/graphql/docs/templates/default.md.haml') }
let(:field_description) { 'List of objects.' }
let(:type) { ::GraphQL::INT_TYPE }
diff --git a/lib/gitlab/graphql/docs/helper.rb b/tooling/graphql/docs/helper.rb
similarity index 99%
rename from lib/gitlab/graphql/docs/helper.rb
rename to tooling/graphql/docs/helper.rb
index 6fb5e926540..4a41930df46 100644
--- a/lib/gitlab/graphql/docs/helper.rb
+++ b/tooling/graphql/docs/helper.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-return if Rails.env.production?
+require 'gitlab/utils/strong_memoize'
-module Gitlab
+module Tooling
module Graphql
module Docs
# We assume a few things about the schema. We use the graphql-ruby gem, which enforces:
diff --git a/lib/gitlab/graphql/docs/renderer.rb b/tooling/graphql/docs/renderer.rb
similarity index 93%
rename from lib/gitlab/graphql/docs/renderer.rb
rename to tooling/graphql/docs/renderer.rb
index ae0898e6198..0c2e8cb3b86 100644
--- a/lib/gitlab/graphql/docs/renderer.rb
+++ b/tooling/graphql/docs/renderer.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-return if Rails.env.production?
+require_relative 'helper'
-module Gitlab
+module Tooling
module Graphql
module Docs
# Gitlab renderer for graphql-docs.
@@ -14,7 +14,7 @@ module Gitlab
# output_dir: The folder where the markdown files will be saved
# template: The path of the haml template to be parsed
class Renderer
- include Gitlab::Graphql::Docs::Helper
+ include Tooling::Graphql::Docs::Helper
attr_reader :schema
diff --git a/lib/gitlab/graphql/docs/templates/default.md.haml b/tooling/graphql/docs/templates/default.md.haml
similarity index 100%
rename from lib/gitlab/graphql/docs/templates/default.md.haml
rename to tooling/graphql/docs/templates/default.md.haml