diff --git a/app/assets/javascripts/lib/gfm/constants.js b/app/assets/javascripts/lib/gfm/constants.js new file mode 100644 index 00000000000..eaabeb2a767 --- /dev/null +++ b/app/assets/javascripts/lib/gfm/constants.js @@ -0,0 +1,10 @@ +export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN = '[['; +export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN = 'TOC'; +export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN = ']]'; +export const TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN = '[TOC]'; + +export const MDAST_TEXT_NODE = 'text'; +export const MDAST_EMPHASIS_NODE = 'emphasis'; +export const MDAST_PARAGRAPH_NODE = 'paragraph'; + +export const GLFM_TABLE_OF_CONTENTS_NODE = 'tableOfContents'; diff --git a/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js b/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js new file mode 100644 index 00000000000..4d2484a657a --- /dev/null +++ b/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js @@ -0,0 +1,85 @@ +import { first, last } from 'lodash'; +import { u } from 'unist-builder'; +import { visitParents, SKIP, CONTINUE } from 'unist-util-visit-parents'; +import { + TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN, + TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN, + TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN, + TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN, + MDAST_TEXT_NODE, + MDAST_EMPHASIS_NODE, + MDAST_PARAGRAPH_NODE, + GLFM_TABLE_OF_CONTENTS_NODE, +} from '../constants'; + +const isTOCTextNode = ({ type, value }) => + type === MDAST_TEXT_NODE && value === TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN; + +const isTOCEmphasisNode = ({ type, children }) => + type === MDAST_EMPHASIS_NODE && children.length === 1 && isTOCTextNode(first(children)); + +const isTOCDoubleSquareBracketOpenTokenTextNode = ({ type, value }) => + type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN; + +const isTOCDoubleSquareBracketCloseTokenTextNode = ({ type, value }) => + type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN; + +/* + * Detects table of contents declaration with syntax [[_TOC_]] + */ +const isTableOfContentsDoubleSquareBracketSyntax = ({ children }) => { + if (children.length !== 3) { + return false; + } + + const [firstChild, middleChild, lastChild] = children; + + return ( + isTOCDoubleSquareBracketOpenTokenTextNode(firstChild) && + isTOCEmphasisNode(middleChild) && + isTOCDoubleSquareBracketCloseTokenTextNode(lastChild) + ); +}; + +/* + * Detects table of contents declaration with syntax [TOC] + */ +const isTableOfContentsSingleSquareBracketSyntax = ({ children }) => { + if (children.length !== 1) { + return false; + } + + const [firstChild] = children; + const { type, value } = firstChild; + + return type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN; +}; + +const isTableOfContentsNode = (node) => + node.type === MDAST_PARAGRAPH_NODE && + (isTableOfContentsDoubleSquareBracketSyntax(node) || + isTableOfContentsSingleSquareBracketSyntax(node)); + +export default () => { + return (tree) => { + visitParents(tree, (node, ancestors) => { + const parent = last(ancestors); + + if (!parent) { + return CONTINUE; + } + + if (isTableOfContentsNode(node)) { + const index = parent.children.indexOf(node); + + parent.children[index] = u(GLFM_TABLE_OF_CONTENTS_NODE, { + position: node.position, + }); + } + + return SKIP; + }); + + return tree; + }; +}; diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js index eaf653e9924..fad73f93c1a 100644 --- a/app/assets/javascripts/lib/gfm/index.js +++ b/app/assets/javascripts/lib/gfm/index.js @@ -6,6 +6,8 @@ import remarkFrontmatter from 'remark-frontmatter'; import remarkGfm from 'remark-gfm'; import remarkRehype, { all } from 'remark-rehype'; import rehypeRaw from 'rehype-raw'; +import glfmTableOfContents from './glfm_extensions/table_of_contents'; +import * as glfmMdastToHastHandlers from './mdast_to_hast_handlers/glfm_mdast_to_hast_handlers'; const skipFrontmatterHandler = (language) => (h, node) => h(node.position, 'frontmatter', { language }, [{ type: 'text', value: node.value }]); @@ -65,19 +67,22 @@ const skipRenderingHandlers = { all(h, node), ); }, + tableOfContents: (h, node) => h(node.position, 'tableOfContents'), toml: skipFrontmatterHandler('toml'), yaml: skipFrontmatterHandler('yaml'), json: skipFrontmatterHandler('json'), }; -const createParser = ({ skipRendering = [] }) => { +const createParser = ({ skipRendering }) => { return unified() .use(remarkParse) .use(remarkGfm) .use(remarkFrontmatter, ['yaml', 'toml', { type: 'json', marker: ';' }]) + .use(glfmTableOfContents) .use(remarkRehype, { allowDangerousHtml: true, handlers: { + ...glfmMdastToHastHandlers, ...pick(skipRenderingHandlers, skipRendering), }, }) @@ -99,13 +104,13 @@ const compilerFactory = (renderer) => * tree in any desired representation * * @param {String} params.markdown Markdown to parse - * @param {(tree: MDast -> any)} params.renderer A function that accepts mdast + * @param {Function} params.renderer A function that accepts mdast * AST tree and returns an object of any type that represents the result of * rendering the tree. See the references below to for more information * about MDast. * * MDastTree documentation https://github.com/syntax-tree/mdast - * @returns {Promise} Returns a promise with the result of rendering + * @returns {Promise} Returns a promise with the result of rendering * the MDast tree */ export const render = async ({ markdown, renderer, skipRendering = [] }) => { diff --git a/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js b/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js new file mode 100644 index 00000000000..91b09e69405 --- /dev/null +++ b/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js @@ -0,0 +1 @@ +export const tableOfContents = (h, node) => h(node.position, 'nav'); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue index 5581863591c..c9fc2dde0bd 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue @@ -7,6 +7,7 @@ import Poll from '~/lib/utils/poll'; import StatusIcon from '../extensions/status_icon.vue'; import ActionButtons from '../action_buttons.vue'; import { EXTENSION_ICONS } from '../../constants'; +import ContentSection from './widget_content_section.vue'; const FETCH_TYPE_COLLAPSED = 'collapsed'; const FETCH_TYPE_EXPANDED = 'expanded'; @@ -17,6 +18,7 @@ export default { StatusIcon, GlButton, GlLoadingIcon, + ContentSection, }, directives: { GlTooltip: GlTooltipDirective, @@ -92,15 +94,16 @@ export default { isCollapsed: true, isLoading: false, isLoadingExpandedContent: false, - error: null, + summaryError: null, + contentError: null, }; }, computed: { collapseButtonLabel() { return sprintf(this.isCollapsed ? __('Show details') : __('Hide details')); }, - statusIcon() { - return this.error ? EXTENSION_ICONS.failed : this.statusIconName; + summaryStatusIcon() { + return this.summaryError ? this.$options.failedStatusIcon : this.statusIconName; }, }, watch: { @@ -114,7 +117,7 @@ export default { try { await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED); } catch { - this.error = this.errorText; + this.summaryError = this.errorText; } this.isLoading = false; @@ -130,12 +133,12 @@ export default { }, async fetchExpandedContent() { this.isLoadingExpandedContent = true; - this.error = null; + this.contentError = null; try { await this.fetch(this.fetchExpandedData, FETCH_TYPE_EXPANDED); } catch { - this.error = this.errorText; + this.contentError = this.errorText; // Reset these values so that we allow refetching this.isExpandedForTheFirstTime = true; @@ -178,20 +181,26 @@ export default { }); }, }, + failedStatusIcon: EXTENSION_ICONS.failed, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue new file mode 100644 index 00000000000..42fd02f978b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue @@ -0,0 +1,35 @@ + + diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 5977f51cab1..ecf29c41100 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -142,6 +142,16 @@ module TodosHelper todos_filter_params.values.none? end + def no_todos_messages + [ + s_('Todos|Good job! Looks like you don\'t have anything left on your To-Do List'), + s_('Todos|Isn\'t an empty To-Do List beautiful?'), + s_('Todos|Give yourself a pat on the back!'), + s_('Todos|Nothing left to do. High five!'), + s_('Todos|Henceforth, you shall be known as "To-Do Destroyer"') + ] + end + def todos_filter_path(options = {}) without = options.delete(:without) diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 6bfe18fd3b2..deb1ac9e360 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -93,7 +93,7 @@ .text-content.gl-text-center - if todos_filter_empty? %h4 - = Gitlab.config.gitlab.no_todos_messages.sample + = no_todos_messages.sample %p = (s_("Todos|Are you looking for things to do? Take a look at %{strongStart}%{openIssuesLinkStart}open issues%{openIssuesLinkEnd}%{strongEnd}, contribute to %{strongStart}%{mergeRequestLinkStart}a merge request%{mergeRequestLinkEnd}%{mergeRequestLinkEnd}%{strongEnd}, or mention someone in a comment to automatically assign them a new to-do item.") % { strongStart: '', strongEnd: '', openIssuesLinkStart: "", openIssuesLinkEnd: '', mergeRequestLinkStart: "", mergeRequestLinkEnd: '' }).html_safe - else diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index b6f97c3ba30..83cae631a88 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -214,7 +214,6 @@ Settings.gitlab['import_sources'] ||= Gitlab::ImportSources.values Settings.gitlab['trusted_proxies'] ||= [] Settings.gitlab['content_security_policy'] ||= {} Settings.gitlab['allowed_hosts'] ||= [] -Settings.gitlab['no_todos_messages'] ||= YAML.load_file(Rails.root.join('config', 'no_todos_messages.yml')) Settings.gitlab['impersonation_enabled'] ||= true if Settings.gitlab['impersonation_enabled'].nil? Settings.gitlab['usage_ping_enabled'] = true if Settings.gitlab['usage_ping_enabled'].nil? Settings.gitlab['max_request_duration_seconds'] ||= 57 diff --git a/config/metrics/counts_all/20220825232556_count_user_auth.yml b/config/metrics/counts_all/20220825232556_count_user_auth.yml new file mode 100644 index 00000000000..623e0dbb7a4 --- /dev/null +++ b/config/metrics/counts_all/20220825232556_count_user_auth.yml @@ -0,0 +1,23 @@ +--- +key_path: usage_activity_by_stage.manage.count_user_auth +description: Number of unique user logins +product_section: dev +product_stage: manage +product_group: authentication_and_authorization +product_category: system_access +value_type: number +status: active +milestone: "15.4" +introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/96321" +time_frame: all +data_source: database +instrumentation_class: CountUserAuthMetric +data_category: optional +performance_indicator_type: [] +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/no_todos_messages.yml b/config/no_todos_messages.yml deleted file mode 100644 index d2076f235fd..00000000000 --- a/config/no_todos_messages.yml +++ /dev/null @@ -1,11 +0,0 @@ -# When the todo list on the user's dashboard becomes empty, a random message -# from the list below will be shown. -# -# If you come up with a fun one, please feel free to contribute it to GitLab! -# https://about.gitlab.com/contributing/ ---- -- Good job! Looks like you don't have anything left on your To-Do List -- Isn't an empty To-Do List beautiful? -- Give yourself a pat on the back! -- Nothing left to do. High five! -- Henceforth, you shall be known as "To-Do Destroyer" diff --git a/doc/user/application_security/dast/index.md b/doc/user/application_security/dast/index.md index 760ddb96430..b8558fcedf2 100644 --- a/doc/user/application_security/dast/index.md +++ b/doc/user/application_security/dast/index.md @@ -1157,16 +1157,13 @@ A site profile contains: - **Target URL**: The URL that DAST runs against. - **Excluded URLs**: A comma-separated list of URLs to exclude from the scan. - **Request headers**: A comma-separated list of HTTP request headers, including names and values. These headers are added to every request made by DAST. -- **Authentication (for website)**: +- **Authentication**: - **Authenticated URL**: The URL of the page containing the sign-in HTML form on the target website. The username and password are submitted with the login form to create an authenticated scan. - **Username**: The username used to authenticate to the website. - **Password**: The password used to authenticate to the website. - **Username form field**: The name of username field at the sign-in HTML form. - **Password form field**: The name of password field at the sign-in HTML form. - **Submit form field**: The `id` or `name` of the element that when clicked submits the sign-in HTML form. -- **Authentication (for API scan)**: - - **Username**: The username used to authenticate to the API. - - **Password**: The password used to authenticate to the API. When an API site type is selected, a [host override](#host-override) is used to ensure the API being scanned is on the same host as the target. This is done to reduce the risk of running an active scan against the wrong API. diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 9e64d951547..1dc0e1f0d22 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -212,7 +212,17 @@ module API recheck_mergeability_of(merge_requests: merge_requests) unless options[:skip_merge_status_recheck] - present_cached merge_requests, expires_in: 8.hours, cache_context: -> (mr) { "#{current_user&.cache_key}:#{mr.merge_status}" }, **options + present_cached merge_requests, + expires_in: 8.hours, + cache_context: -> (mr) do + [ + current_user&.cache_key, + mr.merge_status, + mr.merge_request_assignees.map(&:cache_key), + mr.merge_request_reviewers.map(&:cache_key) + ].join(":") + end, + **options end desc 'Create a merge request' do diff --git a/lib/gitlab/analytics/date_filler.rb b/lib/gitlab/analytics/date_filler.rb new file mode 100644 index 00000000000..aa3db9f3635 --- /dev/null +++ b/lib/gitlab/analytics/date_filler.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Gitlab + module Analytics + # This class generates a date => value hash without gaps in the data points. + # + # Simple usage: + # + # > # We have the following data for the last 5 day: + # > input = { 3.days.ago.to_date => 10, Date.today => 5 } + # + # > # Format this data, so we can chart the complete date range: + # > Gitlab::Analytics::DateFiller.new(input, from: 4.days.ago, to: Date.today, default_value: 0).fill + # > { + # > Sun, 28 Aug 2022=>0, + # > Mon, 29 Aug 2022=>10, + # > Tue, 30 Aug 2022=>0, + # > Wed, 31 Aug 2022=>0, + # > Thu, 01 Sep 2022=>5 + # > } + # + # Parameters: + # + # **input** + # A Hash containing data for the series or the chart. The key is a Date object + # or an object which can be converted to Date. + # + # **from** + # Start date of the range + # + # **to** + # End date of the range + # + # **period** + # Specifies the period in wich the dates should be generated. Options: + # + # - :day, generate date-value pair for each day in the given period + # - :week, generate date-value pair for each week (beginning of the week date) + # - :month, generate date-value pair for each week (beginning of the month date) + # + # Note: the Date objects in the `input` should follow the same pattern (beginning of ...) + # + # **default_value** + # + # Which value use when the `input` Hash does not contain data for the given day. + # + # **date_formatter** + # + # How to format the dates in the resulting hash. + class DateFiller + DEFAULT_DATE_FORMATTER = -> (date) { date } + PERIOD_STEPS = { + day: 1.day, + week: 1.week, + month: 1.month + }.freeze + + def initialize( + input, + from:, + to:, + period: :day, + default_value: nil, + date_formatter: DEFAULT_DATE_FORMATTER) + @input = input.transform_keys(&:to_date) + @from = from.to_date + @to = to.to_date + @period = period + @default_value = default_value + @date_formatter = date_formatter + end + + def fill + data = {} + + current_date = from + loop do + transformed_date = transform_date(current_date) + break if transformed_date > to + + formatted_date = date_formatter.call(transformed_date) + + value = input.delete(transformed_date) + data[formatted_date] = value.nil? ? default_value : value + + current_date = (current_date + PERIOD_STEPS.fetch(period)).to_date + end + + raise "Input contains values which doesn't fall under the given period!" if input.any? + + data + end + + private + + attr_reader :input, :from, :to, :period, :default_value, :date_formatter + + def transform_date(date) + case period + when :day + date.beginning_of_day.to_date + when :week + date.beginning_of_week.to_date + when :month + date.beginning_of_month.to_date + else + raise "Unknown period given: #{period}" + end + end + end + end +end diff --git a/lib/gitlab/usage/metrics/instrumentations/count_user_auth_metric.rb b/lib/gitlab/usage/metrics/instrumentations/count_user_auth_metric.rb new file mode 100644 index 00000000000..1de93ce6dfa --- /dev/null +++ b/lib/gitlab/usage/metrics/instrumentations/count_user_auth_metric.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Gitlab + module Usage + module Metrics + module Instrumentations + class CountUserAuthMetric < DatabaseMetric + operation :distinct_count, column: :user_id + + relation do + AuthenticationEvent.success + end + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9f165d1329f..856076adfb3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -41117,6 +41117,18 @@ msgstr "" msgid "Todos|Filter by project" msgstr "" +msgid "Todos|Give yourself a pat on the back!" +msgstr "" + +msgid "Todos|Good job! Looks like you don't have anything left on your To-Do List" +msgstr "" + +msgid "Todos|Henceforth, you shall be known as \"To-Do Destroyer\"" +msgstr "" + +msgid "Todos|Isn't an empty To-Do List beautiful?" +msgstr "" + msgid "Todos|It's how you always know what to work on next." msgstr "" @@ -41126,6 +41138,9 @@ msgstr "" msgid "Todos|Nothing is on your to-do list. Nice work!" msgstr "" +msgid "Todos|Nothing left to do. High five!" +msgstr "" + msgid "Todos|Undo mark all as done" msgstr "" diff --git a/package.json b/package.json index d8c5eb4749e..f5546a8f775 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,7 @@ "three": "^0.143.0", "timeago.js": "^4.0.2", "unified": "^10.1.2", + "unist-builder": "^3.0.0", "unist-util-visit-parents": "^5.1.0", "url-loader": "^4.1.1", "uuid": "8.1.0", diff --git a/spec/frontend/lib/gfm/index_spec.js b/spec/frontend/lib/gfm/index_spec.js index f53f809b799..7c383ae68a4 100644 --- a/spec/frontend/lib/gfm/index_spec.js +++ b/spec/frontend/lib/gfm/index_spec.js @@ -24,12 +24,6 @@ describe('gfm', () => { }; describe('render', () => { - it('processes Commonmark and provides an ast to the renderer function', async () => { - const result = await markdownToAST('This is text'); - - expect(result.type).toBe('root'); - }); - it('transforms raw HTML into individual nodes in the AST', async () => { const result = await markdownToAST('This is bold text'); @@ -46,216 +40,270 @@ describe('gfm', () => { ); }); - it('returns the result of executing the renderer function', async () => { - const rendered = { value: 'rendered tree' }; + describe('with custom renderer', () => { + it('processes Commonmark and provides an ast to the renderer function', async () => { + const result = await markdownToAST('This is text'); - const result = await render({ - markdown: 'This is bold text', - renderer: () => { - return rendered; - }, + expect(result.type).toBe('root'); }); - expect(result).toEqual(rendered); + it('returns the result of executing the renderer function', async () => { + const rendered = { value: 'rendered tree' }; + + const result = await render({ + markdown: 'This is bold text', + renderer: () => { + return rendered; + }, + }); + + expect(result).toEqual(rendered); + }); }); - describe('when skipping the rendering of footnote reference and definition nodes', () => { - it('transforms footnotes into footnotedefinition and footnotereference tags', async () => { - const result = await markdownToAST( - `footnote reference [^footnote] + describe('footnote references and footnote definitions', () => { + describe('when skipping the rendering of footnote reference and definition nodes', () => { + it('transforms footnotes into footnotedefinition and footnotereference tags', async () => { + const result = await markdownToAST( + `footnote reference [^footnote] [^footnote]: Footnote definition`, - ['footnoteReference', 'footnoteDefinition'], - ); + ['footnoteReference', 'footnoteDefinition'], + ); - expectInRoot( - result, - expect.objectContaining({ - children: expect.arrayContaining([ - expect.objectContaining({ - type: 'element', - tagName: 'footnotereference', - properties: { - identifier: 'footnote', - label: 'footnote', - }, - }), - ]), - }), - ); + expectInRoot( + result, + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + type: 'element', + tagName: 'footnotereference', + properties: { + identifier: 'footnote', + label: 'footnote', + }, + }), + ]), + }), + ); - expectInRoot( - result, - expect.objectContaining({ - tagName: 'footnotedefinition', - properties: { - identifier: 'footnote', - label: 'footnote', - }, - }), - ); + expectInRoot( + result, + expect.objectContaining({ + tagName: 'footnotedefinition', + properties: { + identifier: 'footnote', + label: 'footnote', + }, + }), + ); + }); }); }); - describe('when skipping the rendering of code blocks', () => { - it('transforms code nodes into codeblock html tags', async () => { - const result = await markdownToAST( - ` + describe('code blocks', () => { + describe('when skipping the rendering of code blocks', () => { + it('transforms code nodes into codeblock html tags', async () => { + const result = await markdownToAST( + ` \`\`\`javascript console.log('Hola'); \`\`\`\ `, - ['code'], - ); + ['code'], + ); - expectInRoot( - result, - expect.objectContaining({ - tagName: 'codeblock', - properties: { - language: 'javascript', - }, - }), - ); + expectInRoot( + result, + expect.objectContaining({ + tagName: 'codeblock', + properties: { + language: 'javascript', + }, + }), + ); + }); }); }); - describe('when skipping the rendering of reference definitions', () => { - it('transforms code nodes into codeblock html tags', async () => { - const result = await markdownToAST( - ` + describe('reference definitions', () => { + describe('when skipping the rendering of reference definitions', () => { + it('transforms code nodes into codeblock html tags', async () => { + const result = await markdownToAST( + ` [gitlab][gitlab] [gitlab]: https://gitlab.com "GitLab" `, - ['definition'], - ); + ['definition'], + ); - expectInRoot( - result, - expect.objectContaining({ - type: 'element', - tagName: 'referencedefinition', - properties: { - identifier: 'gitlab', - title: 'GitLab', - url: 'https://gitlab.com', - }, - children: [ - { - type: 'text', - value: '[gitlab]: https://gitlab.com "GitLab"', + expectInRoot( + result, + expect.objectContaining({ + type: 'element', + tagName: 'referencedefinition', + properties: { + identifier: 'gitlab', + title: 'GitLab', + url: 'https://gitlab.com', }, - ], - }), - ); + children: [ + { + type: 'text', + value: '[gitlab]: https://gitlab.com "GitLab"', + }, + ], + }), + ); + }); }); }); - describe('when skipping the rendering of link and image references', () => { - it('transforms linkReference and imageReference nodes into html tags', async () => { - const result = await markdownToAST( - ` + describe('link and image references', () => { + describe('when skipping the rendering of link and image references', () => { + it('transforms linkReference and imageReference nodes into html tags', async () => { + const result = await markdownToAST( + ` [gitlab][gitlab] and ![GitLab Logo][gitlab-logo] [gitlab]: https://gitlab.com "GitLab" [gitlab-logo]: https://gitlab.com/gitlab-logo.png "GitLab Logo" `, - ['linkReference', 'imageReference'], - ); + ['linkReference', 'imageReference'], + ); - expectInRoot( - result, - expect.objectContaining({ - tagName: 'p', - children: expect.arrayContaining([ - expect.objectContaining({ - type: 'element', - tagName: 'a', - properties: expect.objectContaining({ - href: 'https://gitlab.com', - isReference: 'true', - identifier: 'gitlab', - title: 'GitLab', + expectInRoot( + result, + expect.objectContaining({ + tagName: 'p', + children: expect.arrayContaining([ + expect.objectContaining({ + type: 'element', + tagName: 'a', + properties: expect.objectContaining({ + href: 'https://gitlab.com', + isReference: 'true', + identifier: 'gitlab', + title: 'GitLab', + }), }), - }), - expect.objectContaining({ - type: 'element', - tagName: 'img', - properties: expect.objectContaining({ - src: 'https://gitlab.com/gitlab-logo.png', - isReference: 'true', - identifier: 'gitlab-logo', - title: 'GitLab Logo', - alt: 'GitLab Logo', + expect.objectContaining({ + type: 'element', + tagName: 'img', + properties: expect.objectContaining({ + src: 'https://gitlab.com/gitlab-logo.png', + isReference: 'true', + identifier: 'gitlab-logo', + title: 'GitLab Logo', + alt: 'GitLab Logo', + }), }), - }), - ]), - }), - ); - }); + ]), + }), + ); + }); - it('normalizes the urls extracted from the reference definitions', async () => { - const result = await markdownToAST( - ` + it('normalizes the urls extracted from the reference definitions', async () => { + const result = await markdownToAST( + ` [gitlab][gitlab] and ![GitLab Logo][gitlab] [gitlab]: /url\\bar*baz `, - ['linkReference', 'imageReference'], - ); + ['linkReference', 'imageReference'], + ); + + expectInRoot( + result, + expect.objectContaining({ + tagName: 'p', + children: expect.arrayContaining([ + expect.objectContaining({ + type: 'element', + tagName: 'a', + properties: expect.objectContaining({ + href: '/url%5Cbar*baz', + }), + }), + expect.objectContaining({ + type: 'element', + tagName: 'img', + properties: expect.objectContaining({ + src: '/url%5Cbar*baz', + }), + }), + ]), + }), + ); + }); + }); + }); + + describe('frontmatter', () => { + describe('when skipping the rendering of frontmatter types', () => { + it.each` + type | input + ${'yaml'} | ${'---\ntitle: page\n---'} + ${'toml'} | ${'+++\ntitle: page\n+++'} + ${'json'} | ${';;;\ntitle: page\n;;;'} + `('transforms $type nodes into frontmatter html tags', async ({ input, type }) => { + const result = await markdownToAST(input, [type]); + + expectInRoot( + result, + expect.objectContaining({ + type: 'element', + tagName: 'frontmatter', + properties: { + language: type, + }, + children: [ + { + type: 'text', + value: 'title: page', + }, + ], + }), + ); + }); + }); + }); + + describe('table of contents', () => { + it.each` + markdown + ${'[[_TOC_]]'} + ${' [[_TOC_]]'} + ${'[[_TOC_]] '} + ${'[TOC]'} + ${' [TOC]'} + ${'[TOC] '} + `('parses $markdown and produces a table of contents section', async ({ markdown }) => { + const result = await markdownToAST(markdown); expectInRoot( result, expect.objectContaining({ - tagName: 'p', - children: expect.arrayContaining([ - expect.objectContaining({ - type: 'element', - tagName: 'a', - properties: expect.objectContaining({ - href: '/url%5Cbar*baz', - }), - }), - expect.objectContaining({ - type: 'element', - tagName: 'img', - properties: expect.objectContaining({ - src: '/url%5Cbar*baz', - }), - }), - ]), + type: 'element', + tagName: 'nav', + }), + ); + }); + }); + + describe('when skipping the rendering of table of contents', () => { + it('transforms table of contents nodes into html tableofcontents tags', async () => { + const result = await markdownToAST('[[_TOC_]]', ['tableOfContents']); + + expectInRoot( + result, + expect.objectContaining({ + type: 'element', + tagName: 'tableofcontents', }), ); }); }); }); - - describe('when skipping the rendering of frontmatter types', () => { - it.each` - type | input - ${'yaml'} | ${'---\ntitle: page\n---'} - ${'toml'} | ${'+++\ntitle: page\n+++'} - ${'json'} | ${';;;\ntitle: page\n;;;'} - `('transforms $type nodes into frontmatter html tags', async ({ input, type }) => { - const result = await markdownToAST(input, [type]); - - expectInRoot( - result, - expect.objectContaining({ - type: 'element', - tagName: 'frontmatter', - properties: { - language: type, - }, - children: [ - { - type: 'text', - value: 'title: page', - }, - ], - }), - ); - }); - }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/widget_content_section_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/widget_content_section_spec.js new file mode 100644 index 00000000000..c2128d3ff33 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_content_section_spec.js @@ -0,0 +1,39 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WidgetContentSection from '~/vue_merge_request_widget/components/widget/widget_content_section.vue'; +import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; + +describe('~/vue_merge_request_widget/components/widget/widget_content_section.vue', () => { + let wrapper; + + const findStatusIcon = () => wrapper.findComponent(StatusIcon); + + const createComponent = ({ propsData, slots } = {}) => { + wrapper = shallowMountExtended(WidgetContentSection, { + propsData: { + widgetName: 'MyWidget', + ...propsData, + }, + slots, + }); + }; + + it('does not render the status icon when it is not provided', () => { + createComponent(); + expect(findStatusIcon().exists()).toBe(false); + }); + + it('renders the status icon when provided', () => { + createComponent({ propsData: { statusIconName: 'failed' } }); + expect(findStatusIcon().exists()).toBe(true); + }); + + it('renders the default slot', () => { + createComponent({ + slots: { + default: 'Hello world', + }, + }); + + expect(wrapper.findByText('Hello world').exists()).toBe(true); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js index b1ed61faf66..b67b5703ad5 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js @@ -43,7 +43,7 @@ describe('MR Widget', () => { createComponent({ propsData: { fetchCollapsedData } }); await waitForPromises(); expect(fetchCollapsedData).toHaveBeenCalled(); - expect(wrapper.vm.error).toBe(null); + expect(wrapper.vm.summaryError).toBe(null); }); it('sets the error text when fetch method fails', async () => { diff --git a/spec/helpers/todos_helper_spec.rb b/spec/helpers/todos_helper_spec.rb index bbabfedc3ee..a8945424877 100644 --- a/spec/helpers/todos_helper_spec.rb +++ b/spec/helpers/todos_helper_spec.rb @@ -258,6 +258,21 @@ RSpec.describe TodosHelper do end end + describe '#no_todos_messages' do + context 'when getting todos messsages' do + it 'return these sentences' do + expected_sentences = [ + s_('Todos|Good job! Looks like you don\'t have anything left on your To-Do List'), + s_('Todos|Isn\'t an empty To-Do List beautiful?'), + s_('Todos|Give yourself a pat on the back!'), + s_('Todos|Nothing left to do. High five!'), + s_('Todos|Henceforth, you shall be known as "To-Do Destroyer"') + ] + expect(helper.no_todos_messages).to eq(expected_sentences) + end + end + end + describe '#todo_author_display?' do using RSpec::Parameterized::TableSyntax diff --git a/spec/lib/gitlab/analytics/date_filler_spec.rb b/spec/lib/gitlab/analytics/date_filler_spec.rb new file mode 100644 index 00000000000..3f547f667f2 --- /dev/null +++ b/spec/lib/gitlab/analytics/date_filler_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true +require 'fast_spec_helper' + +RSpec.describe Gitlab::Analytics::DateFiller do + let(:default_value) { 0 } + let(:formatter) { Gitlab::Analytics::DateFiller::DEFAULT_DATE_FORMATTER } + + subject(:filler_result) do + described_class.new(data, + from: from, + to: to, + period: period, + default_value: default_value, + date_formatter: formatter).fill.to_a + end + + context 'when unknown period is given' do + it 'raises error' do + input = { 3.days.ago.to_date => 10, Date.today => 5 } + + expect do + described_class.new(input, from: 4.days.ago, to: Date.today, period: :unknown).fill + end.to raise_error(/Unknown period given/) + end + end + + context 'when period=:day' do + let(:from) { Date.new(2021, 5, 25) } + let(:to) { Date.new(2021, 6, 5) } + let(:period) { :day } + + let(:expected_result) do + { + Date.new(2021, 5, 25) => 1, + Date.new(2021, 5, 26) => default_value, + Date.new(2021, 5, 27) => default_value, + Date.new(2021, 5, 28) => default_value, + Date.new(2021, 5, 29) => default_value, + Date.new(2021, 5, 30) => default_value, + Date.new(2021, 5, 31) => default_value, + Date.new(2021, 6, 1) => default_value, + Date.new(2021, 6, 2) => default_value, + Date.new(2021, 6, 3) => 10, + Date.new(2021, 6, 4) => default_value, + Date.new(2021, 6, 5) => default_value + } + end + + let(:data) do + { + Date.new(2021, 6, 3) => 10, # deliberatly not sorted + Date.new(2021, 5, 27) => nil, + Date.new(2021, 5, 25) => 1 + } + end + + it { is_expected.to eq(expected_result.to_a) } + + context 'when a custom default value is given' do + let(:default_value) { 'MISSING' } + + it do + is_expected.to eq(expected_result.to_a) + end + end + + context 'when a custom date formatter is given' do + let(:formatter) { -> (date) { date.to_s } } + + it do + expected_result.transform_keys!(&:to_s) + + is_expected.to eq(expected_result.to_a) + end + end + + context 'when the data contains dates outside of the requested period' do + before do + data[Date.new(2022, 6, 1)] = 5 + end + + it 'raises error' do + expect { filler_result }.to raise_error(/Input contains values which doesn't/) + end + end + end + + context 'when period=:week' do + let(:from) { Date.new(2021, 5, 16) } + let(:to) { Date.new(2021, 6, 7) } + let(:period) { :week } + let(:data) do + { + Date.new(2021, 5, 24) => nil, + Date.new(2021, 6, 7) => 10 + } + end + + let(:expected_result) do + { + Date.new(2021, 5, 10) => 0, + Date.new(2021, 5, 17) => 0, + Date.new(2021, 5, 24) => 0, + Date.new(2021, 5, 31) => 0, + Date.new(2021, 6, 7) => 10 + } + end + + it do + is_expected.to eq(expected_result.to_a) + end + end + + context 'when period=:month' do + let(:from) { Date.new(2021, 5, 1) } + let(:to) { Date.new(2021, 7, 1) } + let(:period) { :month } + let(:data) do + { + Date.new(2021, 5, 1) => 100 + } + end + + let(:expected_result) do + { + Date.new(2021, 5, 1) => 100, + Date.new(2021, 6, 1) => 0, + Date.new(2021, 7, 1) => 0 + } + end + + it do + is_expected.to eq(expected_result.to_a) + end + end +end diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_user_auth_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_user_auth_metric_spec.rb new file mode 100644 index 00000000000..2f49c427bd0 --- /dev/null +++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_user_auth_metric_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountUserAuthMetric do + context 'with all time frame' do + let(:expected_value) { 2 } + + before do + user = create(:user) + user2 = create(:user) + create(:authentication_event, user: user, provider: :ldapmain, result: :success) + create(:authentication_event, user: user2, provider: :ldapsecondary, result: :success) + create(:authentication_event, user: user2, provider: :group_saml, result: :success) + create(:authentication_event, user: user2, provider: :group_saml, result: :success) + create(:authentication_event, user: user, provider: :group_saml, result: :failed) + end + + it_behaves_like 'a correct instrumented metric value', { time_frame: 'all', data_source: 'database' } + end + + context 'with 28d time frame' do + let(:expected_value) { 1 } + + before do + user = create(:user) + user2 = create(:user) + + create(:authentication_event, created_at: 1.year.ago, user: user, provider: :ldapmain, result: :success) + create(:authentication_event, created_at: 1.week.ago, user: user2, provider: :ldapsecondary, result: :success) + end + + it_behaves_like 'a correct instrumented metric value', { time_frame: '28d', data_source: 'database' } + end +end diff --git a/spec/lib/gitlab/usage_data_metrics_spec.rb b/spec/lib/gitlab/usage_data_metrics_spec.rb index 1968523dc4a..ed0eabf1b4d 100644 --- a/spec/lib/gitlab/usage_data_metrics_spec.rb +++ b/spec/lib/gitlab/usage_data_metrics_spec.rb @@ -37,6 +37,10 @@ RSpec.describe Gitlab::UsageDataMetrics do expect(subject[:usage_activity_by_stage][:plan]).to include(:issues) end + it 'includes usage_activity_by_stage metrics' do + expect(subject[:usage_activity_by_stage][:manage]).to include(:count_user_auth) + end + it 'includes usage_activity_by_stage_monthly keys' do expect(subject[:usage_activity_by_stage_monthly][:plan]).to include(:issues) end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 3b0cc0fb16b..f30cc8b6c45 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -215,14 +215,28 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do groups: 2, users_created: 10, omniauth_providers: ['google_oauth2'], - user_auth_by_provider: { 'group_saml' => 2, 'ldap' => 4, 'standard' => 0, 'two-factor' => 0, 'two-factor-via-u2f-device' => 0, "two-factor-via-webauthn-device" => 0 } + user_auth_by_provider: { + 'group_saml' => 2, + 'ldap' => 4, + 'standard' => 0, + 'two-factor' => 0, + 'two-factor-via-u2f-device' => 0, + "two-factor-via-webauthn-device" => 0 + } ) expect(described_class.usage_activity_by_stage_manage(described_class.monthly_time_range_db_params)).to include( events: be_within(error_rate).percent_of(2), groups: 1, users_created: 6, omniauth_providers: ['google_oauth2'], - user_auth_by_provider: { 'group_saml' => 1, 'ldap' => 2, 'standard' => 0, 'two-factor' => 0, 'two-factor-via-u2f-device' => 0, "two-factor-via-webauthn-device" => 0 } + user_auth_by_provider: { + 'group_saml' => 1, + 'ldap' => 2, + 'standard' => 0, + 'two-factor' => 0, + 'two-factor-via-u2f-device' => 0, + "two-factor-via-webauthn-device" => 0 + } ) end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 9bd1aef9961..9d153286d14 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1023,6 +1023,22 @@ RSpec.describe API::MergeRequests do it_behaves_like 'a non-cached MergeRequest api request', 1 end + context 'when the assignees change' do + before do + merge_request.assignees << create(:user) + end + + it_behaves_like 'a non-cached MergeRequest api request', 1 + end + + context 'when the reviewers change' do + before do + merge_request.reviewers << create(:user) + end + + it_behaves_like 'a non-cached MergeRequest api request', 1 + end + context 'when another user requests' do before do sign_in(user2)