252 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			Ruby
		
	
	
		
			Executable File
		
	
	
			
		
		
	
	
			252 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			Ruby
		
	
	
		
			Executable File
		
	
	
#!/usr/bin/env ruby
 | 
						|
 | 
						|
# frozen_string_literal: true
 | 
						|
 | 
						|
#
 | 
						|
# https://docs.gitlab.com/ee/development/documentation/redirects.html
 | 
						|
#
 | 
						|
require 'net/http'
 | 
						|
require 'uri'
 | 
						|
require 'json'
 | 
						|
require 'cgi'
 | 
						|
require 'yaml'
 | 
						|
 | 
						|
class LintDocsRedirect
 | 
						|
  COLOR_CODE_RED = "\e[31m"
 | 
						|
  COLOR_CODE_RESET = "\e[0m"
 | 
						|
  # All the projects we want this script to run
 | 
						|
  PROJECT_PATHS = ['gitlab-org/gitlab',
 | 
						|
    'gitlab-org/gitlab-runner',
 | 
						|
    'gitlab-org/omnibus-gitlab',
 | 
						|
    'gitlab-org/charts/gitlab',
 | 
						|
    'gitlab-org/cloud-native/gitlab-operator'].freeze
 | 
						|
 | 
						|
  def execute
 | 
						|
    return unless project_supported?
 | 
						|
 | 
						|
    abort_unless_merge_request_iid_exists
 | 
						|
 | 
						|
    check_renamed_deleted_files
 | 
						|
    check_for_circular_redirects
 | 
						|
  end
 | 
						|
 | 
						|
  private
 | 
						|
 | 
						|
  # Project slug based on project path
 | 
						|
  # Taken from https://gitlab.com/gitlab-org/gitlab/-/blob/daaa5b6f79049e5bb28cdafaa11d3a0a84d64ab3/scripts/trigger-build.rb#L298-313
 | 
						|
  def project_slug
 | 
						|
    case ENV['CI_PROJECT_PATH']
 | 
						|
    when 'gitlab-org/gitlab'
 | 
						|
      'ee'
 | 
						|
    when 'gitlab-org/gitlab-runner'
 | 
						|
      'runner'
 | 
						|
    when 'gitlab-org/omnibus-gitlab'
 | 
						|
      'omnibus'
 | 
						|
    when 'gitlab-org/charts/gitlab'
 | 
						|
      'charts'
 | 
						|
    when 'gitlab-org/cloud-native/gitlab-operator'
 | 
						|
      'operator'
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def navigation_file
 | 
						|
    @navigation_file ||= begin
 | 
						|
      url = URI('https://gitlab.com/gitlab-org/gitlab-docs/-/raw/main/content/_data/navigation.yaml')
 | 
						|
      response = Net::HTTP.get_response(url)
 | 
						|
 | 
						|
      raise "Could not download navigation.yaml. Response code: #{response.code}" if response.code != '200'
 | 
						|
 | 
						|
      # response.body should be memoized in a method, so that it doesn't
 | 
						|
      # need to be downloaded multiple times in one CI job.
 | 
						|
      response.body
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  ##
 | 
						|
  ## Check if the deleted/renamed file exists in
 | 
						|
  ## https://gitlab.com/gitlab-org/gitlab-docs/-/blob/main/content/_data/navigation.yaml.
 | 
						|
  ##
 | 
						|
  ## We need to first convert the Markdown file to HTML. There are two cases:
 | 
						|
  ##
 | 
						|
  ## - A source doc entry with index.md looks like: doc/administration/index.md
 | 
						|
  ##   The navigation.yaml equivalent is:           ee/administration/
 | 
						|
  ## - A source doc entry without index.md looks like: doc/administration/appearance.md
 | 
						|
  ##   The navigation.yaml equivalent is:              ee/administration/appearance.html
 | 
						|
  ##
 | 
						|
  def check_for_missing_nav_entry(file)
 | 
						|
    file_sub = file["old_path"].gsub('doc', project_slug).gsub('index.md', '').gsub('.md', '.html')
 | 
						|
 | 
						|
    result = navigation_file.include?(file_sub)
 | 
						|
    return unless result
 | 
						|
 | 
						|
    warning(file)
 | 
						|
 | 
						|
    abort
 | 
						|
  end
 | 
						|
 | 
						|
  def warning(file)
 | 
						|
    warn <<~WARNING
 | 
						|
      #{COLOR_CODE_RED}✖ ERROR: Missing redirect for a deleted or moved page#{COLOR_CODE_RESET}
 | 
						|
 | 
						|
      The following file is linked in the global navigation for docs.gitlab.com:
 | 
						|
 | 
						|
      => #{file['old_path']}
 | 
						|
 | 
						|
      Unless you add a redirect or remove the page from the global navigation,
 | 
						|
      this change will break pipelines in the 'gitlab/gitlab-docs' project.
 | 
						|
 | 
						|
      #{rake_command(file)}
 | 
						|
 | 
						|
      For more information, see:
 | 
						|
      - Create a redirect   : https://docs.gitlab.com/ee/development/documentation/redirects.html
 | 
						|
      - Edit the global nav : https://docs.gitlab.com/ee/development/documentation/site_architecture/global_nav.html#add-a-navigation-entry
 | 
						|
    WARNING
 | 
						|
  end
 | 
						|
 | 
						|
  # Rake task to use depending on the file being deleted or renamed
 | 
						|
  def rake_command(file)
 | 
						|
    # The Rake task is only available for gitlab-org/gitlab
 | 
						|
    return unless project_slug == 'ee'
 | 
						|
 | 
						|
    if renamed_doc_file?(file)
 | 
						|
      rake = "bundle exec rake \"gitlab:docs:redirect[#{file['old_path']}, #{file['new_path']}]\""
 | 
						|
      msg = "It seems you renamed a page, run the following Rake task locally and commit the changes.\n"
 | 
						|
    elsif deleted_doc_file?(file)
 | 
						|
      rake = "bundle exec rake \"gitlab:docs:redirect[#{file['old_path']}, doc/new/path.md]\""
 | 
						|
      msg = "It seems you deleted a page. Run the following Rake task by replacing\n" \
 | 
						|
            "'doc/new/path.md' with the page to redirect to, and commit the changes.\n"
 | 
						|
    end
 | 
						|
 | 
						|
    <<~MSG
 | 
						|
      #{msg}
 | 
						|
      #{rake}
 | 
						|
    MSG
 | 
						|
  end
 | 
						|
 | 
						|
  # GitLab API URL
 | 
						|
  def gitlab_api_url
 | 
						|
    ENV.fetch('CI_API_V4_URL', 'https://gitlab.com/api/v4')
 | 
						|
  end
 | 
						|
 | 
						|
  # Take the project path from the CI_PROJECT_PATH predefined variable.
 | 
						|
  def url_encoded_project_path
 | 
						|
    project_path = ENV.fetch('CI_PROJECT_PATH', nil)
 | 
						|
    return unless project_path
 | 
						|
 | 
						|
    CGI.escape(project_path)
 | 
						|
  end
 | 
						|
 | 
						|
  # Take the merge request ID from the CI_MERGE_REQUEST_IID predefined
 | 
						|
  # variable.
 | 
						|
  def merge_request_iid
 | 
						|
    ENV.fetch('CI_MERGE_REQUEST_IID', nil)
 | 
						|
  end
 | 
						|
 | 
						|
  def abort_unless_merge_request_iid_exists
 | 
						|
    abort("Error: CI_MERGE_REQUEST_IID environment variable is missing") if merge_request_iid.nil?
 | 
						|
  end
 | 
						|
 | 
						|
  # Skip if CI_PROJECT_PATH is not in the designated project paths
 | 
						|
  def project_supported?
 | 
						|
    PROJECT_PATHS.include? ENV['CI_PROJECT_PATH']
 | 
						|
  end
 | 
						|
 | 
						|
  # Fetch the merge request diff JSON object
 | 
						|
  def merge_request_diff
 | 
						|
    @merge_request_diff ||= begin
 | 
						|
      uri = URI.parse(
 | 
						|
        "#{gitlab_api_url}/projects/#{url_encoded_project_path}/merge_requests/#{merge_request_iid}/diffs?per_page=30"
 | 
						|
      )
 | 
						|
      response = Net::HTTP.get_response(uri)
 | 
						|
 | 
						|
      unless response.code == '200'
 | 
						|
        raise "API call to get MR diffs failed. Response code: #{response.code}. Response message: #{response.message}"
 | 
						|
      end
 | 
						|
 | 
						|
      JSON.parse(response.body)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def doc_file?(file)
 | 
						|
    file['old_path'].start_with?('doc/') && file['old_path'].end_with?('.md')
 | 
						|
  end
 | 
						|
 | 
						|
  def renamed_doc_file?(file)
 | 
						|
    file['renamed_file'] == true && doc_file?(file)
 | 
						|
  end
 | 
						|
 | 
						|
  def deleted_doc_file?(file)
 | 
						|
    file['deleted_file'] == true && doc_file?(file)
 | 
						|
  end
 | 
						|
 | 
						|
  # Create a list of hashes of the renamed documentation files
 | 
						|
  def check_renamed_deleted_files
 | 
						|
    renamed_files = merge_request_diff.select do |file|
 | 
						|
      renamed_doc_file?(file)
 | 
						|
    end
 | 
						|
 | 
						|
    deleted_files = merge_request_diff.select do |file|
 | 
						|
      deleted_doc_file?(file)
 | 
						|
    end
 | 
						|
 | 
						|
    # Merge the two arrays
 | 
						|
    all_files = renamed_files + deleted_files
 | 
						|
 | 
						|
    return if all_files.empty?
 | 
						|
 | 
						|
    all_files.each do |file|
 | 
						|
      status = deleted_doc_file?(file) ? 'deleted' : 'renamed'
 | 
						|
      puts "Checking #{status} file..."
 | 
						|
      puts "=> Old_path: #{file['old_path']}"
 | 
						|
      puts "=> New_path: #{file['new_path']}"
 | 
						|
      puts
 | 
						|
 | 
						|
      check_for_missing_nav_entry(file)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  # Search for '+redirect_to' in the diff to find the new value. It should
 | 
						|
  # return a string of "+redirect_to: 'file.md'", in which case, delete the
 | 
						|
  # '+' prefix. If not found, skip and go to next file.
 | 
						|
  def redirect_to(diff_file)
 | 
						|
    redirect_to = diff_file["diff"]
 | 
						|
                    .lines
 | 
						|
                    .find { |e| e.include?('+redirect_to') }
 | 
						|
                    &.delete_prefix('+')
 | 
						|
 | 
						|
    return if redirect_to.nil?
 | 
						|
 | 
						|
    YAML.safe_load(redirect_to)['redirect_to']
 | 
						|
  end
 | 
						|
 | 
						|
  def all_doc_files
 | 
						|
    merge_request_diff.select do |file|
 | 
						|
      doc_file?(file)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  # Check if a page redirects to itself
 | 
						|
  def check_for_circular_redirects
 | 
						|
    all_doc_files.each do |file|
 | 
						|
      next if redirect_to(file).nil?
 | 
						|
 | 
						|
      basename = File.basename(file['old_path'])
 | 
						|
 | 
						|
      # Fail if the 'redirect_to' value is the same as the file's basename.
 | 
						|
      next unless redirect_to(file) == basename
 | 
						|
 | 
						|
      warn <<~WARNING
 | 
						|
        #{COLOR_CODE_RED}✖ ERROR: Circular redirect detected. The 'redirect_to' value points to the same file.#{COLOR_CODE_RESET}
 | 
						|
      WARNING
 | 
						|
 | 
						|
      puts
 | 
						|
      puts "File        : #{file['old_path']}"
 | 
						|
      puts "Redirect to : #{redirect_to(file)}"
 | 
						|
 | 
						|
      abort
 | 
						|
    end
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
LintDocsRedirect.new.execute if $PROGRAM_NAME == __FILE__
 |