summaryrefslogtreecommitdiff
path: root/tooling
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-06-16 18:25:58 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-06-16 18:25:58 +0000
commita5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 (patch)
treefb69158581673816a8cd895f9d352dcb3c678b1e /tooling
parentd16b2e8639e99961de6ddc93909f3bb5c1445ba1 (diff)
downloadgitlab-ce-a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4.tar.gz
Add latest changes from gitlab-org/gitlab@14-0-stable-eev14.0.0-rc42
Diffstat (limited to 'tooling')
-rw-r--r--tooling/danger/changelog.rb161
-rw-r--r--tooling/danger/product_intelligence.rb102
-rw-r--r--tooling/danger/project_helper.rb13
-rw-r--r--tooling/graphql/docs/helper.rb438
-rw-r--r--tooling/graphql/docs/renderer.rb54
-rw-r--r--tooling/graphql/docs/templates/default.md.haml224
6 files changed, 868 insertions, 124 deletions
diff --git a/tooling/danger/changelog.rb b/tooling/danger/changelog.rb
index e11799b268d..c053d366199 100644
--- a/tooling/danger/changelog.rb
+++ b/tooling/danger/changelog.rb
@@ -13,66 +13,31 @@ module Tooling
'meta'
].freeze
NO_CHANGELOG_CATEGORIES = %i[docs none].freeze
- CHANGELOG_TRAILER_REGEX = /^Changelog:\s*(?<category>.+)$/.freeze
- CREATE_CHANGELOG_COMMAND = 'bin/changelog -m %<mr_iid>s "%<mr_title>s"'
- CREATE_EE_CHANGELOG_COMMAND = 'bin/changelog --ee -m %<mr_iid>s "%<mr_title>s"'
+ CHANGELOG_TRAILER_REGEX = /^(?<name>Changelog):\s*(?<category>.+)$/i.freeze
+ CHANGELOG_EE_TRAILER_REGEX = /^EE: true$/.freeze
CHANGELOG_MODIFIED_URL_TEXT = "**CHANGELOG.md was edited.** Please remove the additions and follow the [changelog guidelines](https://docs.gitlab.com/ee/development/changelog.html).\n\n"
CHANGELOG_MISSING_URL_TEXT = "**[CHANGELOG missing](https://docs.gitlab.com/ee/development/changelog.html)**:\n\n"
OPTIONAL_CHANGELOG_MESSAGE = {
local: "If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message.",
ci: <<~MSG
- If you want to create a changelog entry for GitLab FOSS, run the following:
+ If you want to create a changelog entry for GitLab FOSS, add the `Changelog` trailer to the commit message you want to add to the changelog.
- #{CREATE_CHANGELOG_COMMAND}
-
- If you want to create a changelog entry for GitLab EE, run the following instead:
-
- #{CREATE_EE_CHANGELOG_COMMAND}
+ If you want to create a changelog entry for GitLab EE, also [add the `EE: true` trailer](https://docs.gitlab.com/ee/development/changelog.html#gitlab-enterprise-changes) to your commit message.
If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message.
MSG
}.freeze
- CHANGELOG_NEW_WORKFLOW_MESSAGE = <<~MSG
- We are in the process of rolling out a new workflow for adding changelog entries. This new workflow uses Git commit subjects and Git trailers to generate changelogs. This new approach will soon replace the current YAML based approach.
-
- To ease the transition process, we recommend you start using both the old and new approach in parallel. This is not required at this time, but will make it easier to transition to the new approach in the future. To do so, pick the commit that should go in the changelog and add a `Changelog` trailer to it. For example:
-
- ```
- This is my commit's subject line
-
- This is the optional commit body.
-
- Changelog: added
- ```
-
- The value of the `Changelog` trailer should be one of the following: added, fixed, changed, deprecated, removed, security, performance, other.
-
- For more information, take a look at the following resources:
-
- - `https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1564`
- - https://docs.gitlab.com/ee/api/repositories.html#generate-changelog-data
-
- If you'd like to see the new approach in action, take a look at the commits in [the Omnibus repository](https://gitlab.com/gitlab-org/omnibus-gitlab/-/commits/master).
- MSG
SEE_DOC = "See the [changelog documentation](https://docs.gitlab.com/ee/development/changelog.html)."
- SUGGEST_MR_COMMENT = <<~SUGGEST_COMMENT
- ```suggestion
- merge_request: %<mr_iid>s
- ```
-
- #{SEE_DOC}
- SUGGEST_COMMENT
REQUIRED_CHANGELOG_REASONS = {
+ db_changes: 'introduces a database migration',
feature_flag_removed: 'removes a feature flag'
}.freeze
REQUIRED_CHANGELOG_MESSAGE = {
local: "This merge request requires a changelog entry because it [%<reason>s](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry).",
ci: <<~MSG
- To create a changelog entry, run the following:
-
- #{CREATE_CHANGELOG_COMMAND}
+ To create a changelog entry, add the `Changelog` trailer to one of your Git commit messages.
This merge request requires a changelog entry because it [%<reason>s](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry).
MSG
@@ -131,7 +96,6 @@ module Tooling
end
if present?
- add_danger_messages(check_changelog_yaml)
add_danger_messages(check_changelog_path)
elsif required?
required_texts.each { |_, text| fail(text) } # rubocop:disable Lint/UnreachableLoop
@@ -139,23 +103,7 @@ module Tooling
message optional_text
end
- return unless helper.ci?
-
- if required? || optional?
- checked = 0
-
- git.commits.each do |commit|
- check_result = check_changelog_trailer(commit)
- next if check_result.nil?
-
- checked += 1
- add_danger_messages(check_result)
- end
-
- if checked == 0
- message CHANGELOG_NEW_WORKFLOW_MESSAGE
- end
- end
+ check_changelog_commit_categories
end
# rubocop:enable Style/SignalException
@@ -168,70 +116,42 @@ module Tooling
end
# rubocop:enable Style/SignalException
+ def check_changelog_commit_categories
+ changelog_commits.each do |commit|
+ add_danger_messages(check_changelog_trailer(commit))
+ end
+ end
+
def check_changelog_trailer(commit)
trailer = commit.message.match(CHANGELOG_TRAILER_REGEX)
-
- return if trailer.nil? || trailer[:category].nil?
-
+ name = trailer[:name]
category = trailer[:category]
+ unless name == 'Changelog'
+ return ChangelogCheckResult.error("The changelog trailer for commit #{commit.sha} must be `Changelog` (starting with a capital C), not `#{name}`")
+ end
+
return ChangelogCheckResult.empty if CATEGORIES.include?(category)
ChangelogCheckResult.error("Commit #{commit.sha} uses an invalid changelog category: #{category}")
end
- def check_changelog_yaml
- check_result = ChangelogCheckResult.empty
- return check_result unless present?
-
- raw_file = read_file(changelog_path)
- yaml = YAML.safe_load(raw_file)
- yaml_merge_request = yaml["merge_request"].to_s
-
- check_result.error("`title` should be set, in #{helper.html_link(changelog_path)}! #{SEE_DOC}") if yaml["title"].nil?
- check_result.error("`type` should be set, in #{helper.html_link(changelog_path)}! #{SEE_DOC}") if yaml["type"].nil?
-
- return check_result if helper.security_mr? || helper.mr_iid.empty?
-
- check_changelog_yaml_merge_request(raw_file: raw_file, yaml_merge_request: yaml_merge_request, check_result: check_result)
- rescue Psych::Exception
- # YAML could not be parsed, fail the build.
- ChangelogCheckResult.error("#{helper.html_link(changelog_path)} isn't valid YAML! #{SEE_DOC}")
- rescue StandardError => e
- ChangelogCheckResult.warning("There was a problem trying to check the Changelog. Exception: #{e.class.name} - #{e.message}")
- end
-
- def check_changelog_yaml_merge_request(raw_file:, yaml_merge_request:, check_result:)
- cherry_pick_against_stable_branch = helper.cherry_pick_mr? && helper.stable_branch?
-
- if yaml_merge_request.empty?
- mr_line = raw_file.lines.find_index { |line| line =~ /merge_request:\s*\n/ }
-
- if mr_line
- check_result.markdown(msg: format(SUGGEST_MR_COMMENT, mr_iid: helper.mr_iid), file: changelog_path, line: mr_line.succ)
- else
- check_result.message("Consider setting `merge_request` to #{helper.mr_iid} in #{helper.html_link(changelog_path)}. #{SEE_DOC}")
- end
- elsif yaml_merge_request != helper.mr_iid && !cherry_pick_against_stable_branch
- check_result.error("Merge request ID was not set to #{helper.mr_iid}! #{SEE_DOC}")
- end
-
- check_result
- end
-
def check_changelog_path
check_result = ChangelogCheckResult.empty
return check_result unless present?
ee_changes = project_helper.all_ee_changes.dup
- ee_changes.delete(changelog_path)
if ee_changes.any? && !ee_changelog? && !required?
- check_result.warning("This MR has a Changelog file outside `ee/`, but code changes in `ee/`. Consider moving the Changelog file into `ee/`.")
+ check_result.warning("This MR changes code in `ee/`, but its Changelog commit is missing the [`EE: true` trailer](https://docs.gitlab.com/ee/development/changelog.html#gitlab-enterprise-changes). Consider adding it to your Changelog commits.")
end
if ee_changes.empty? && ee_changelog?
- check_result.warning("This MR has a Changelog file in `ee/`, but no code changes in `ee/`. Consider moving the Changelog file outside `ee/`.")
+ check_result.warning("This MR has a Changelog commit for EE, but no code changes in `ee/`. Consider removing the `EE: true` trailer from your commits.")
+ end
+
+ if ee_changes.any? && ee_changelog? && required_reasons.include?(:db_changes)
+ check_result.warning("This MR has a Changelog commit with the `EE: true` trailer, but there are database changes which [requires](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry) the Changelog commit to not have the `EE: true` trailer. Consider removing the `EE: true` trailer from your commits.")
end
check_result
@@ -239,6 +159,7 @@ module Tooling
def required_reasons
[].tap do |reasons|
+ reasons << :db_changes if project_helper.changes.added.has_category?(:migration)
reasons << :feature_flag_removed if project_helper.changes.deleted.has_category?(:feature_flag)
end
end
@@ -252,33 +173,45 @@ module Tooling
end
def present?
- !!changelog_path
+ valid_changelog_commits.any?
end
- def ee_changelog?
- changelog_path.start_with?('ee/')
+ def changelog_commits
+ git.commits.select do |commit|
+ commit.message.match?(CHANGELOG_TRAILER_REGEX)
+ end
+ end
+
+ def valid_changelog_commits
+ changelog_commits.select do |commit|
+ trailer = commit.message.match(CHANGELOG_TRAILER_REGEX)
+
+ CATEGORIES.include?(trailer[:category])
+ end
end
- def changelog_path
- @changelog_path ||= project_helper.changes.added.by_category(:changelog).files.first
+ def ee_changelog?
+ changelog_commits.any? do |commit|
+ commit.message.match?(CHANGELOG_EE_TRAILER_REGEX)
+ end
end
def modified_text
CHANGELOG_MODIFIED_URL_TEXT +
- (helper.ci? ? format(OPTIONAL_CHANGELOG_MESSAGE[:ci], mr_iid: helper.mr_iid, mr_title: sanitized_mr_title) : OPTIONAL_CHANGELOG_MESSAGE[:local])
+ (helper.ci? ? format(OPTIONAL_CHANGELOG_MESSAGE[:ci]) : OPTIONAL_CHANGELOG_MESSAGE[:local])
end
def required_texts
required_reasons.each_with_object({}) do |required_reason, memo|
memo[required_reason] =
CHANGELOG_MISSING_URL_TEXT +
- (helper.ci? ? format(REQUIRED_CHANGELOG_MESSAGE[:ci], reason: REQUIRED_CHANGELOG_REASONS.fetch(required_reason), mr_iid: helper.mr_iid, mr_title: sanitized_mr_title) : REQUIRED_CHANGELOG_MESSAGE[:local])
+ (helper.ci? ? format(REQUIRED_CHANGELOG_MESSAGE[:ci], reason: REQUIRED_CHANGELOG_REASONS.fetch(required_reason)) : REQUIRED_CHANGELOG_MESSAGE[:local])
end
end
def optional_text
CHANGELOG_MISSING_URL_TEXT +
- (helper.ci? ? format(OPTIONAL_CHANGELOG_MESSAGE[:ci], mr_iid: helper.mr_iid, mr_title: sanitized_mr_title) : OPTIONAL_CHANGELOG_MESSAGE[:local])
+ (helper.ci? ? format(OPTIONAL_CHANGELOG_MESSAGE[:ci]) : OPTIONAL_CHANGELOG_MESSAGE[:local])
end
private
@@ -287,10 +220,6 @@ module Tooling
File.read(path)
end
- def sanitized_mr_title
- Gitlab::Dangerfiles::TitleLinting.sanitize_mr_title(helper.mr_title)
- end
-
def categories_need_changelog?
(project_helper.changes.categories - NO_CHANGELOG_CATEGORIES).any?
end
diff --git a/tooling/danger/product_intelligence.rb b/tooling/danger/product_intelligence.rb
new file mode 100644
index 00000000000..0f77f6c6a71
--- /dev/null
+++ b/tooling/danger/product_intelligence.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+# rubocop:disable Style/SignalException
+
+module Tooling
+ module Danger
+ module ProductIntelligence
+ WORKFLOW_LABELS = [
+ 'product intelligence::approved',
+ 'product intelligence::review pending'
+ ].freeze
+
+ TRACKING_FILES = [
+ 'lib/gitlab/tracking.rb',
+ 'spec/lib/gitlab/tracking_spec.rb',
+ 'app/helpers/tracking_helper.rb',
+ 'spec/helpers/tracking_helper_spec.rb',
+ 'app/assets/javascripts/tracking.js',
+ 'spec/frontend/tracking_spec.js',
+ 'generator_templates/usage_metric_definition/metric_definition.yml',
+ 'lib/generators/gitlab/usage_metric/usage_metric_generator.rb',
+ 'lib/generators/gitlab/usage_metric_definition_generator.rb',
+ 'lib/generators/gitlab/usage_metric_definition/redis_hll_generator.rb',
+ 'spec/lib/generators/gitlab/usage_metric_generator_spec.rb',
+ 'spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb',
+ 'spec/lib/generators/gitlab/usage_metric_definition/redis_hll_generator_spec.rb',
+ 'config/metrics/schema.json'
+ ].freeze
+
+ def missing_labels
+ return [] unless helper.ci?
+
+ labels = []
+ labels << 'product intelligence' unless helper.mr_has_labels?('product intelligence')
+ labels << 'product intelligence::review pending' unless helper.mr_has_labels?(WORKFLOW_LABELS)
+
+ labels
+ end
+
+ def matching_changed_files
+ tracking_changed_files = all_changed_files & TRACKING_FILES
+ usage_data_changed_files = all_changed_files.grep(%r{(usage_data)})
+
+ usage_data_changed_files + tracking_changed_files + metrics_changed_files + dictionary_changed_file + snowplow_changed_files
+ end
+
+ def need_dictionary_changes?
+ required_dictionary_update_changed_files.any? && dictionary_changed_file.empty?
+ end
+
+ private
+
+ def all_changed_files
+ helper.all_changed_files
+ end
+
+ def dictionary_changed_file
+ all_changed_files.grep(%r{(doc/development/usage_ping/dictionary\.md)})
+ end
+
+ def metrics_changed_files
+ all_changed_files.grep(%r{((ee/)?config/metrics/.*\.yml)})
+ end
+
+ def matching_files?(file, extension:, pattern:)
+ return unless file.end_with?(extension)
+
+ helper.changed_lines(file).grep(pattern).any?
+ end
+
+ def snowplow_changed_files
+ js_patterns = Regexp.union(
+ 'Tracking.event',
+ /\btrack\(/,
+ 'data-track-event',
+ 'data-track-action'
+ )
+ all_changed_files.select do |file|
+ matching_files?(file, extension: '.rb', pattern: %r{Gitlab::Tracking\.(event|enabled\?|snowplow_options)$}) ||
+ matching_files?(file, extension: '.js', pattern: js_patterns) ||
+ matching_files?(file, extension: '.vue', pattern: js_patterns) ||
+ matching_files?(file, extension: '.haml', pattern: %r{data: \{ track})
+ end
+ end
+
+ def required_dictionary_update_changed_files
+ dictionary_pattern = Regexp.union(
+ 'key_path:',
+ 'description:',
+ 'product_section:',
+ 'product_stage:',
+ 'product_group:',
+ 'status:',
+ 'tier:'
+ )
+
+ metrics_changed_files.select do |file|
+ matching_files?(file, extension: '.yml', pattern: dictionary_pattern)
+ end
+ end
+ end
+ end
+end
diff --git a/tooling/danger/project_helper.rb b/tooling/danger/project_helper.rb
index c6aaf82ef35..a29dc5e5bed 100644
--- a/tooling/danger/project_helper.rb
+++ b/tooling/danger/project_helper.rb
@@ -5,7 +5,6 @@ module Tooling
module ProjectHelper
LOCAL_RULES ||= %w[
changelog
- commit_messages
database
datateam
documentation
@@ -38,8 +37,6 @@ module Tooling
%r{\A(ee/)?config/feature_flags/} => :feature_flag,
- %r{\A(ee/)?(changelogs/unreleased)(-ee)?/} => :changelog,
-
%r{\Adoc/development/usage_ping/dictionary\.md\z} => [:docs, :product_intelligence],
%r{\Adoc/.*(\.(md|png|gif|jpg))\z} => :docs,
%r{\A(CONTRIBUTING|LICENSE|MAINTENANCE|PHILOSOPHY|PROCESS|README)(\.md)?\z} => :docs,
@@ -76,11 +73,11 @@ module Tooling
)\z}x => %i[frontend engineering_productivity],
%r{\A(ee/)?db/(geo/)?(migrate|post_migrate)/} => [:database, :migration],
- %r{\A(ee/)?db/(?!fixtures)[^/]+} => :database,
- %r{\A(ee/)?lib/gitlab/(database|background_migration|sql|github_import)(/|\.rb)} => :database,
- %r{\A(app/services/authorized_project_update/find_records_due_for_refresh_service)(/|\.rb)} => :database,
- %r{\A(app/models/project_authorization|app/services/users/refresh_authorized_projects_service)(/|\.rb)} => :database,
- %r{\A(ee/)?app/finders/} => :database,
+ %r{\A(ee/)?db/(?!fixtures)[^/]+} => [:database],
+ %r{\A(ee/)?lib/gitlab/(database|background_migration|sql|github_import)(/|\.rb)} => [:database, :backend],
+ %r{\A(app/services/authorized_project_update/find_records_due_for_refresh_service)(/|\.rb)} => [:database, :backend],
+ %r{\A(app/models/project_authorization|app/services/users/refresh_authorized_projects_service)(/|\.rb)} => [:database, :backend],
+ %r{\A(ee/)?app/finders/} => [:database, :backend],
%r{\Arubocop/cop/migration(/|\.rb)} => :database,
%r{\A(\.gitlab-ci\.yml\z|\.gitlab\/ci)} => :engineering_productivity,
diff --git a/tooling/graphql/docs/helper.rb b/tooling/graphql/docs/helper.rb
new file mode 100644
index 00000000000..4a41930df46
--- /dev/null
+++ b/tooling/graphql/docs/helper.rb
@@ -0,0 +1,438 @@
+# frozen_string_literal: true
+
+require 'gitlab/utils/strong_memoize'
+
+module Tooling
+ module Graphql
+ module Docs
+ # We assume a few things about the schema. We use the graphql-ruby gem, which enforces:
+ # - All mutations have a single input field named 'input'
+ # - All mutations have a payload type, named after themselves
+ # - All mutations have an input type, named after themselves
+ # If these things change, then some of this code will break. Such places
+ # are guarded with an assertion that our assumptions are not violated.
+ ViolatedAssumption = Class.new(StandardError)
+
+ SUGGESTED_ACTION = <<~MSG
+ We expect it to be impossible to violate our assumptions about
+ how mutation arguments work.
+
+ If that is not the case, then something has probably changed in the
+ way we generate our schema, perhaps in the library we use: graphql-ruby
+
+ Please ask for help in the #f_graphql or #backend channels.
+ MSG
+
+ CONNECTION_ARGS = %w[after before first last].to_set
+
+ FIELD_HEADER = <<~MD
+ #### Fields
+
+ | Name | Type | Description |
+ | ---- | ---- | ----------- |
+ MD
+
+ ARG_HEADER = <<~MD
+ # Arguments
+
+ | Name | Type | Description |
+ | ---- | ---- | ----------- |
+ MD
+
+ CONNECTION_NOTE = <<~MD
+ This field returns a [connection](#connections). It accepts the
+ four standard [pagination arguments](#connection-pagination-arguments):
+ `before: String`, `after: String`, `first: Int`, `last: Int`.
+ MD
+
+ # Helper with functions to be used by HAML templates
+ # This includes graphql-docs gem helpers class.
+ # You can check the included module on: https://github.com/gjtorikian/graphql-docs/blob/v1.6.0/lib/graphql-docs/helpers.rb
+ module Helper
+ include GraphQLDocs::Helpers
+ include Gitlab::Utils::StrongMemoize
+
+ def auto_generated_comment
+ <<-MD.strip_heredoc
+ ---
+ stage: Plan
+ group: Project Management
+ info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+ ---
+
+ <!---
+ This documentation is auto generated by a script.
+
+ Please do not edit this file directly, check compile_docs task on lib/tasks/gitlab/graphql.rake.
+ --->
+ MD
+ end
+
+ # Template methods:
+ # Methods that return chunks of Markdown for insertion into the document
+
+ def render_full_field(field, heading_level: 3, owner: nil)
+ conn = connection?(field)
+ args = field[:arguments].reject { |arg| conn && CONNECTION_ARGS.include?(arg[:name]) }
+ arg_owner = [owner, field[:name]]
+
+ chunks = [
+ render_name_and_description(field, level: heading_level, owner: owner),
+ render_return_type(field),
+ render_input_type(field),
+ render_connection_note(field),
+ render_argument_table(heading_level, args, arg_owner),
+ render_return_fields(field, owner: owner)
+ ]
+
+ join(:block, chunks)
+ end
+
+ def render_argument_table(level, args, owner)
+ arg_header = ('#' * level) + ARG_HEADER
+ render_field_table(arg_header, args, owner)
+ end
+
+ def render_name_and_description(object, owner: nil, level: 3)
+ content = []
+
+ heading = '#' * level
+ name = [owner, object[:name]].compact.join('.')
+
+ content << "#{heading} `#{name}`"
+ content << render_description(object, owner, :block)
+
+ join(:block, content)
+ end
+
+ def render_object_fields(fields, owner:, level_bump: 0)
+ return if fields.blank?
+
+ (with_args, no_args) = fields.partition { |f| args?(f) }
+ type_name = owner[:name] if owner
+ header_prefix = '#' * level_bump
+ sections = [
+ render_simple_fields(no_args, type_name, header_prefix),
+ render_fields_with_arguments(with_args, type_name, header_prefix)
+ ]
+
+ join(:block, sections)
+ end
+
+ def render_enum_value(enum, value)
+ render_row(render_name(value, enum[:name]), render_description(value, enum[:name], :inline))
+ end
+
+ def render_union_member(member)
+ "- [`#{member}`](##{member.downcase})"
+ end
+
+ # QUERIES:
+
+ # Methods that return parts of the schema, or related information:
+
+ def connection_object_types
+ objects.select { |t| t[:is_edge] || t[:is_connection] }
+ end
+
+ def object_types
+ objects.reject { |t| t[:is_edge] || t[:is_connection] || t[:is_payload] }
+ end
+
+ def interfaces
+ graphql_interface_types.map { |t| t.merge(fields: t[:fields] + t[:connections]) }
+ end
+
+ def fields_of(type_name)
+ graphql_operation_types
+ .find { |type| type[:name] == type_name }
+ .values_at(:fields, :connections)
+ .flatten
+ .then { |fields| sorted_by_name(fields) }
+ end
+
+ # Place the arguments of the input types on the mutation itself.
+ # see: `#input_types` - this method must not call `#input_types` to avoid mutual recursion
+ def mutations
+ @mutations ||= sorted_by_name(graphql_mutation_types).map do |t|
+ inputs = t[:input_fields]
+ input = inputs.first
+ name = t[:name]
+
+ assert!(inputs.one?, "Expected exactly 1 input field named #{name}. Found #{inputs.count} instead.")
+ assert!(input[:name] == 'input', "Expected the input of #{name} to be named 'input'")
+
+ input_type_name = input[:type][:name]
+ input_type = graphql_input_object_types.find { |t| t[:name] == input_type_name }
+ assert!(input_type.present?, "Cannot find #{input_type_name} for #{name}.input")
+
+ arguments = input_type[:input_fields]
+ seen_type!(input_type_name)
+ t.merge(arguments: arguments)
+ end
+ end
+
+ # We assume that the mutations have been processed first, marking their
+ # inputs as `seen_type?`
+ def input_types
+ mutations # ensure that mutations have seen their inputs first
+ graphql_input_object_types.reject { |t| seen_type?(t[:name]) }
+ end
+
+ # We ignore the built-in enum types, and sort values by name
+ def enums
+ graphql_enum_types
+ .reject { |type| type[:values].empty? }
+ .reject { |enum_type| enum_type[:name].start_with?('__') }
+ .map { |type| type.merge(values: sorted_by_name(type[:values])) }
+ end
+
+ private # DO NOT CALL THESE METHODS IN TEMPLATES
+
+ # Template methods
+
+ def render_return_type(query)
+ return unless query[:type] # for example, mutations
+
+ "Returns #{render_field_type(query[:type])}."
+ end
+
+ def render_simple_fields(fields, type_name, header_prefix)
+ render_field_table(header_prefix + FIELD_HEADER, fields, type_name)
+ end
+
+ def render_fields_with_arguments(fields, type_name, header_prefix)
+ return if fields.empty?
+
+ level = 5 + header_prefix.length
+ sections = sorted_by_name(fields).map do |f|
+ render_full_field(f, heading_level: level, owner: type_name)
+ end
+
+ <<~MD.chomp
+ #{header_prefix}#### Fields with arguments
+
+ #{join(:block, sections)}
+ MD
+ end
+
+ def render_field_table(header, fields, owner)
+ return if fields.empty?
+
+ fields = sorted_by_name(fields)
+ header + join(:table, fields.map { |f| render_field(f, owner) })
+ end
+
+ def render_field(field, owner)
+ render_row(
+ render_name(field, owner),
+ render_field_type(field[:type]),
+ render_description(field, owner, :inline)
+ )
+ end
+
+ def render_return_fields(mutation, owner:)
+ fields = mutation[:return_fields]
+ return if fields.blank?
+
+ name = owner.to_s + mutation[:name]
+ render_object_fields(fields, owner: { name: name })
+ end
+
+ def render_connection_note(field)
+ return unless connection?(field)
+
+ CONNECTION_NOTE.chomp
+ end
+
+ def render_row(*values)
+ "| #{values.map { |val| val.to_s.squish }.join(' | ')} |"
+ end
+
+ def render_name(object, owner = nil)
+ rendered_name = "`#{object[:name]}`"
+ rendered_name += ' **{warning-solid}**' if deprecated?(object, owner)
+
+ return rendered_name unless owner
+
+ owner = Array.wrap(owner).join('')
+ id = (owner + object[:name]).downcase
+
+ %(<a id="#{id}"></a>) + rendered_name
+ end
+
+ # Returns the object description. If the object has been deprecated,
+ # the deprecation reason will be returned in place of the description.
+ def render_description(object, owner = nil, context = :block)
+ if deprecated?(object, owner)
+ render_deprecation(object, owner, context)
+ else
+ render_description_of(object, owner, context)
+ end
+ end
+
+ def deprecated?(object, owner)
+ return true if object[:is_deprecated] # only populated for fields, not arguments!
+
+ key = [*Array.wrap(owner), object[:name]].join('.')
+ deprecations.key?(key)
+ end
+
+ def render_description_of(object, owner, context = nil)
+ desc = if object[:is_edge]
+ base = object[:name].chomp('Edge')
+ "The edge type for [`#{base}`](##{base.downcase})."
+ elsif object[:is_connection]
+ base = object[:name].chomp('Connection')
+ "The connection type for [`#{base}`](##{base.downcase})."
+ else
+ object[:description]&.strip
+ end
+
+ return if desc.blank?
+
+ desc += '.' unless desc.ends_with?('.')
+ see = doc_reference(object, owner)
+ desc += " #{see}" if see
+ desc += " (see [Connections](#connections))" if connection?(object) && context != :block
+ desc
+ end
+
+ def doc_reference(object, owner)
+ field = schema_field(owner, object[:name]) if owner
+ return unless field
+
+ ref = field.try(:doc_reference)
+ return if ref.blank?
+
+ parts = ref.to_a.map do |(title, url)|
+ "[#{title.strip}](#{url.strip})"
+ end
+
+ "See #{parts.join(', ')}."
+ end
+
+ def render_deprecation(object, owner, context)
+ buff = []
+ deprecation = schema_deprecation(owner, object[:name])
+
+ buff << (deprecation&.original_description || render_description_of(object, owner)) if context == :block
+ buff << if deprecation
+ deprecation.markdown(context: context)
+ else
+ "**Deprecated:** #{object[:deprecation_reason]}"
+ end
+
+ join(context, buff)
+ end
+
+ def render_field_type(type)
+ "[`#{type[:info]}`](##{type[:name].downcase})"
+ end
+
+ def join(context, chunks)
+ chunks.compact!
+ return if chunks.blank?
+
+ case context
+ when :block
+ chunks.join("\n\n")
+ when :inline
+ chunks.join(" ").squish.presence
+ when :table
+ chunks.join("\n")
+ end
+ end
+
+ # Queries
+
+ def sorted_by_name(objects)
+ return [] unless objects.present?
+
+ objects.sort_by { |o| o[:name] }
+ end
+
+ def connection?(field)
+ type_name = field.dig(:type, :name)
+ type_name.present? && type_name.ends_with?('Connection')
+ end
+
+ # We are ignoring connections and built in types for now,
+ # they should be added when queries are generated.
+ def objects
+ strong_memoize(:objects) do
+ mutations = schema.mutation&.fields&.keys&.to_set || []
+
+ graphql_object_types
+ .reject { |object_type| object_type[:name]["__"] || object_type[:name] == 'Subscription' } # We ignore introspection and subscription types.
+ .map do |type|
+ name = type[:name]
+ type.merge(
+ is_edge: name.ends_with?('Edge'),
+ is_connection: name.ends_with?('Connection'),
+ is_payload: name.ends_with?('Payload') && mutations.include?(name.chomp('Payload').camelcase(:lower)),
+ fields: type[:fields] + type[:connections]
+ )
+ end
+ end
+ end
+
+ def args?(field)
+ args = field[:arguments]
+ return false if args.blank?
+ return true unless connection?(field)
+
+ args.any? { |arg| CONNECTION_ARGS.exclude?(arg[:name]) }
+ end
+
+ # returns the deprecation information for a field or argument
+ # See: Gitlab::Graphql::Deprecation
+ def schema_deprecation(type_name, field_name)
+ key = [*Array.wrap(type_name), field_name].join('.')
+ deprecations[key]
+ end
+
+ def render_input_type(query)
+ input_field = query[:input_fields]&.first
+ return unless input_field
+
+ "Input type: `#{input_field[:type][:name]}`"
+ end
+
+ def schema_field(type_name, field_name)
+ type = schema.types[type_name]
+ return unless type && type.kind.fields?
+
+ type.fields[field_name]
+ end
+
+ def deprecations
+ strong_memoize(:deprecations) do
+ mapping = {}
+
+ schema.types.each do |type_name, type|
+ if type.kind.fields?
+ type.fields.each do |field_name, field|
+ mapping["#{type_name}.#{field_name}"] = field.try(:deprecation)
+ field.arguments.each do |arg_name, arg|
+ mapping["#{type_name}.#{field_name}.#{arg_name}"] = arg.try(:deprecation)
+ end
+ end
+ elsif type.kind.enum?
+ type.values.each do |member_name, enum|
+ mapping["#{type_name}.#{member_name}"] = enum.try(:deprecation)
+ end
+ end
+ end
+
+ mapping.compact
+ end
+ end
+
+ def assert!(claim, message)
+ raise ViolatedAssumption, "#{message}\n#{SUGGESTED_ACTION}" unless claim
+ end
+ end
+ end
+ end
+end
diff --git a/tooling/graphql/docs/renderer.rb b/tooling/graphql/docs/renderer.rb
new file mode 100644
index 00000000000..0c2e8cb3b86
--- /dev/null
+++ b/tooling/graphql/docs/renderer.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require_relative 'helper'
+
+module Tooling
+ module Graphql
+ module Docs
+ # Gitlab renderer for graphql-docs.
+ # Uses HAML templates to parse markdown and generate .md files.
+ # It uses graphql-docs helpers and schema parser, more information in https://github.com/gjtorikian/graphql-docs.
+ #
+ # Arguments:
+ # schema - the GraphQL schema definition. For GitLab should be: GitlabSchema
+ # output_dir: The folder where the markdown files will be saved
+ # template: The path of the haml template to be parsed
+ class Renderer
+ include Tooling::Graphql::Docs::Helper
+
+ attr_reader :schema
+
+ def initialize(schema, output_dir:, template:)
+ @output_dir = output_dir
+ @template = template
+ @layout = Haml::Engine.new(File.read(template))
+ @parsed_schema = GraphQLDocs::Parser.new(schema.graphql_definition, {}).parse
+ @schema = schema
+ @seen = Set.new
+ end
+
+ def contents
+ # Render and remove an extra trailing new line
+ @contents ||= @layout.render(self).sub!(/\n(?=\Z)/, '')
+ end
+
+ def write
+ filename = File.join(@output_dir, 'index.md')
+
+ FileUtils.mkdir_p(@output_dir)
+ File.write(filename, contents)
+ end
+
+ private
+
+ def seen_type?(name)
+ @seen.include?(name)
+ end
+
+ def seen_type!(name)
+ @seen << name
+ end
+ end
+ end
+ end
+end
diff --git a/tooling/graphql/docs/templates/default.md.haml b/tooling/graphql/docs/templates/default.md.haml
new file mode 100644
index 00000000000..7d42fb3a9f8
--- /dev/null
+++ b/tooling/graphql/docs/templates/default.md.haml
@@ -0,0 +1,224 @@
+-# haml-lint:disable UnnecessaryStringOutput
+
+= auto_generated_comment
+
+:plain
+ # GraphQL API Resources
+
+ This documentation is self-generated based on GitLab current GraphQL schema.
+
+ The API can be explored interactively using the [GraphiQL IDE](../index.md#graphiql).
+
+ Each table below documents a GraphQL type. Types match loosely to models, but not all
+ fields and methods on a model are available via GraphQL.
+
+ WARNING:
+ Fields that are deprecated are marked with **{warning-solid}**.
+ Items (fields, enums, etc) that have been removed according to our [deprecation process](../index.md#deprecation-and-removal-process) can be found
+ in [Removed Items](../removed_items.md).
+
+ <!-- vale off -->
+ <!-- Docs linting disabled after this line. -->
+ <!-- See https://docs.gitlab.com/ee/development/documentation/testing.html#disable-vale-tests -->
+\
+
+:plain
+ ## `Query` type
+
+ The `Query` type contains the API's top-level entry points for all executable queries.
+\
+
+- fields_of('Query').each do |field|
+ = render_full_field(field, heading_level: 3, owner: 'Query')
+ \
+
+:plain
+ ## `Mutation` type
+
+ The `Mutation` type contains all the mutations you can execute.
+
+ All mutations receive their arguments in a single input object named `input`, and all mutations
+ support at least a return field `errors` containing a list of error messages.
+
+ All input objects may have a `clientMutationId: String` field, identifying the mutation.
+
+ For example:
+
+ ```graphql
+ mutation($id: NoteableID!, $body: String!) {
+ createNote(input: { noteableId: $id, body: $body }) {
+ errors
+ }
+ }
+ ```
+\
+
+- mutations.each do |field|
+ = render_full_field(field, heading_level: 3, owner: 'Mutation')
+ \
+
+:plain
+ ## Connections
+
+ Some types in our schema are `Connection` types - they represent a paginated
+ collection of edges between two nodes in the graph. These follow the
+ [Relay cursor connections specification](https://relay.dev/graphql/connections.htm).
+
+ ### Pagination arguments {#connection-pagination-arguments}
+
+ All connection fields support the following pagination arguments:
+
+ | Name | Type | Description |
+ |------|------|-------------|
+ | `after` | [`String`](#string) | Returns the elements in the list that come after the specified cursor. |
+ | `before` | [`String`](#string) | Returns the elements in the list that come before the specified cursor. |
+ | `first` | [`Int`](#int) | Returns the first _n_ elements from the list. |
+ | `last` | [`Int`](#int) | Returns the last _n_ elements from the list. |
+
+ Since these arguments are common to all connection fields, they are not repeated for each connection.
+
+ ### Connection fields
+
+ All connections have at least the following fields:
+
+ | Name | Type | Description |
+ |------|------|-------------|
+ | `pageInfo` | [`PageInfo!`](#pageinfo) | Pagination information. |
+ | `edges` | `[edge!]` | The edges. |
+ | `nodes` | `[item!]` | The items in the current page. |
+
+ The precise type of `Edge` and `Item` depends on the kind of connection. A
+ [`ProjectConnection`](#projectconnection) will have nodes that have the type
+ [`[Project!]`](#project), and edges that have the type [`ProjectEdge`](#projectedge).
+
+ ### Connection types
+
+ Some of the types in the schema exist solely to model connections. Each connection
+ has a distinct, named type, with a distinct named edge type. These are listed separately
+ below.
+\
+
+- connection_object_types.each do |type|
+ = render_name_and_description(type, level: 4)
+ \
+ = render_object_fields(type[:fields], owner: type, level_bump: 1)
+ \
+
+:plain
+ ## Object types
+
+ Object types represent the resources that the GitLab GraphQL API can return.
+ They contain _fields_. Each field has its own type, which will either be one of the
+ basic GraphQL [scalar types](https://graphql.org/learn/schema/#scalar-types)
+ (e.g.: `String` or `Boolean`) or other object types. Fields may have arguments.
+ Fields with arguments are exactly like top-level queries, and are listed beneath
+ the table of fields for each object type.
+
+ For more information, see
+ [Object Types and Fields](https://graphql.org/learn/schema/#object-types-and-fields)
+ on `graphql.org`.
+\
+
+- object_types.each do |type|
+ = render_name_and_description(type)
+ \
+ = render_object_fields(type[:fields], owner: type)
+ \
+
+:plain
+ ## Enumeration types
+
+ Also called _Enums_, enumeration types are a special kind of scalar that
+ is restricted to a particular set of allowed values.
+
+ For more information, see
+ [Enumeration Types](https://graphql.org/learn/schema/#enumeration-types)
+ on `graphql.org`.
+\
+
+- enums.each do |enum|
+ = render_name_and_description(enum)
+ \
+ ~ "| Value | Description |"
+ ~ "| ----- | ----------- |"
+ - enum[:values].each do |value|
+ = render_enum_value(enum, value)
+ \
+
+:plain
+ ## Scalar types
+
+ Scalar values are atomic values, and do not have fields of their own.
+ Basic scalars include strings, boolean values, and numbers. This schema also
+ defines various custom scalar values, such as types for times and dates.
+
+ This schema includes custom scalar types for identifiers, with a specific type for
+ each kind of object.
+
+ For more information, read about [Scalar Types](https://graphql.org/learn/schema/#scalar-types) on `graphql.org`.
+\
+
+- graphql_scalar_types.each do |type|
+ = render_name_and_description(type)
+ \
+
+:plain
+ ## Abstract types
+
+ Abstract types (unions and interfaces) are ways the schema can represent
+ values that may be one of several concrete types.
+
+ - A [`Union`](https://graphql.org/learn/schema/#union-types) is a set of possible types.
+ The types might not have any fields in common.
+ - An [`Interface`](https://graphql.org/learn/schema/#interfaces) is a defined set of fields.
+ Types may `implement` an interface, which
+ guarantees that they have all the fields in the set. A type may implement more than
+ one interface.
+
+ See the [GraphQL documentation](https://graphql.org/learn/) for more information on using
+ abstract types.
+\
+
+:plain
+ ### Unions
+\
+
+- graphql_union_types.each do |type|
+ = render_name_and_description(type, level: 4)
+ \
+ One of:
+ \
+ - type[:possible_types].each do |member|
+ = render_union_member(member)
+ \
+
+:plain
+ ### Interfaces
+\
+
+- interfaces.each do |type|
+ = render_name_and_description(type, level: 4)
+ \
+ Implementations:
+ \
+ - type[:implemented_by].each do |type_name|
+ ~ "- [`#{type_name}`](##{type_name.downcase})"
+ \
+ = render_object_fields(type[:fields], owner: type, level_bump: 1)
+ \
+
+:plain
+ ## Input types
+
+ Types that may be used as arguments (all scalar types may also
+ be used as arguments).
+
+ Only general use input types are listed here. For mutation input types,
+ see the associated mutation type above.
+\
+
+- input_types.each do |type|
+ = render_name_and_description(type)
+ \
+ = render_argument_table(3, type[:input_fields], type[:name])
+ \