mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-18 20:40:03 +08:00
Followup d9359c1256
Fixes an issue were plugins were not loading because,
in absence of LOAD_PLUGINS=1, they do not load in a
test env, which is the env of the workflow of the container.
Also limits history lookup to 1 year to speed up process
of finding original commit authors.
504 lines
16 KiB
Ruby
Vendored
504 lines
16 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
require "json"
|
|
require "open3"
|
|
require "optparse"
|
|
require "pathname"
|
|
require "psych"
|
|
require "tempfile"
|
|
|
|
module UpcomingChanges
|
|
class StatusReport
|
|
SETTINGS_PATH = "config/site_settings.yml"
|
|
PLUGIN_SETTINGS_PATH_PATTERN = "plugins/*/config/settings.yml"
|
|
STATUS_PATTERN = /\A(\s*)status:\s*(["']?)([^"'\s#]+)\2([^\n]*)(\n?)\z/
|
|
# Bounds how far back git history is scanned. Upcoming changes are short-lived, so a
|
|
# year is plenty and avoids re-parsing thousands of historical settings revisions.
|
|
DEFAULT_HISTORY_SINCE = "1 year ago"
|
|
|
|
Commit = Struct.new(:sha, :date, :author_name, :author_email, :subject, keyword_init: true)
|
|
HistoryEntry = Struct.new(:commit, :status, keyword_init: true)
|
|
|
|
class Git
|
|
def initialize(repo_path:, since: nil)
|
|
@repo_path = repo_path
|
|
@since = since
|
|
end
|
|
|
|
# Only commits within @since are returned. For a change whose status last moved
|
|
# before the window, the oldest in-window commit becomes the apparent first/last
|
|
# status change. Eligibility is unaffected (its age is still well past the staleness
|
|
# threshold); only the reported original author/PR may point at the window boundary.
|
|
def commits_for(path)
|
|
args = %w[log --format=%H%x00%aI%x00%an%x00%ae%x00%s]
|
|
args << "--since=#{@since}" if @since.present?
|
|
args += ["--", path]
|
|
output = capture(*args)
|
|
|
|
output.lines.filter_map do |line|
|
|
sha, date, author_name, author_email, subject = line.chomp.split("\0", 5)
|
|
next if sha.blank?
|
|
|
|
Commit.new(sha:, date:, author_name:, author_email:, subject:)
|
|
end
|
|
end
|
|
|
|
def show_file(commit_sha, path)
|
|
capture("show", "#{commit_sha}:#{path}")
|
|
end
|
|
|
|
private
|
|
|
|
def capture(*args)
|
|
stdout, stderr, status = Open3.capture3("git", "-C", @repo_path, *args)
|
|
return stdout if status.success?
|
|
|
|
raise "git #{args.join(" ")} failed: #{stderr}"
|
|
end
|
|
end
|
|
|
|
class MetadataLoader
|
|
def self.from_content(content)
|
|
return {} if content.blank?
|
|
|
|
Tempfile.create(%w[upcoming-change-settings .yml]) do |file|
|
|
file.write(content)
|
|
file.close
|
|
|
|
from_file(file.path, strict: false)
|
|
end
|
|
end
|
|
|
|
def self.from_file(path, strict:)
|
|
site_settings = Class.new { extend SiteSettingExtension }
|
|
site_settings.load_settings(path)
|
|
site_settings.upcoming_change_metadata
|
|
rescue => error
|
|
raise if strict
|
|
|
|
Rails.logger.warn("Failed to parse historical site settings from #{path}: #{error.message}")
|
|
{}
|
|
end
|
|
end
|
|
|
|
class SourceIndex
|
|
def initialize(repo_path:, settings_path:, settings_paths:)
|
|
@repo_path = repo_path
|
|
@settings_path = settings_path
|
|
@configured_settings_paths = settings_paths
|
|
end
|
|
|
|
def changes
|
|
@changes ||=
|
|
settings_paths
|
|
.each_with_object({}) do |settings_path, result|
|
|
MetadataLoader
|
|
.from_file(File.join(@repo_path, settings_path), strict: true)
|
|
.each { |name, metadata| result[name.to_sym] = metadata.merge(settings_path:) }
|
|
end
|
|
.sort
|
|
.to_h
|
|
end
|
|
|
|
def change_names
|
|
changes.keys
|
|
end
|
|
|
|
def explicit_paths?
|
|
@configured_settings_paths.present?
|
|
end
|
|
|
|
def metadata_for(change_name)
|
|
changes[change_name.to_sym]
|
|
end
|
|
|
|
def settings_path_for(change_name)
|
|
metadata_for(change_name)&.fetch(:settings_path) || plugin_settings_path_for(change_name) ||
|
|
@settings_path
|
|
end
|
|
|
|
private
|
|
|
|
def settings_paths
|
|
@settings_paths ||=
|
|
normalize_paths(@configured_settings_paths.presence || default_settings_paths)
|
|
end
|
|
|
|
def default_settings_paths
|
|
[@settings_path, *Dir.glob(File.join(@repo_path, PLUGIN_SETTINGS_PATH_PATTERN)).sort]
|
|
end
|
|
|
|
def normalize_paths(paths)
|
|
paths
|
|
.map { |path| File.expand_path(path, @repo_path) }
|
|
.map { |path| Pathname.new(path).relative_path_from(Pathname.new(@repo_path)).to_s }
|
|
.uniq
|
|
end
|
|
|
|
def plugin_settings_path_for(change_name)
|
|
plugin_name = SiteSetting.plugins[change_name.to_sym]
|
|
return if plugin_name.blank?
|
|
|
|
"plugins/#{plugin_name}/config/settings.yml"
|
|
end
|
|
end
|
|
|
|
class CurrentChanges
|
|
def initialize(repo_path:, source_index:)
|
|
@repo_path = repo_path
|
|
@source_index = source_index
|
|
end
|
|
|
|
def call
|
|
current_change_names
|
|
.each_with_object({}) do |change_name, result|
|
|
metadata = source_metadata_for(change_name).merge(core_metadata_for(change_name))
|
|
result[change_name] = metadata.merge(settings_path: settings_path_for(change_name))
|
|
end
|
|
.sort
|
|
.to_h
|
|
end
|
|
|
|
private
|
|
|
|
def current_change_names
|
|
return @source_index.change_names.sort if source_only?
|
|
|
|
(@source_index.change_names + SiteSetting.upcoming_change_site_settings)
|
|
.map(&:to_sym)
|
|
.uniq
|
|
.sort
|
|
end
|
|
|
|
def source_only?
|
|
@source_index.explicit_paths? ||
|
|
File.expand_path(@repo_path) != File.expand_path(Rails.root.to_s)
|
|
end
|
|
|
|
def source_metadata_for(change_name)
|
|
@source_index.metadata_for(change_name) || {}
|
|
end
|
|
|
|
def core_metadata_for(change_name)
|
|
return {} if source_only?
|
|
|
|
SiteSetting.upcoming_change_metadata[change_name.to_sym] || {}
|
|
end
|
|
|
|
def settings_path_for(change_name)
|
|
source_metadata_for(change_name)[:settings_path] ||
|
|
@source_index.settings_path_for(change_name)
|
|
end
|
|
end
|
|
|
|
class GitHistory
|
|
def initialize(git:)
|
|
@git = git
|
|
end
|
|
|
|
def by_change(changes)
|
|
changes
|
|
.group_by { |_, metadata| metadata[:settings_path] }
|
|
.each_with_object(
|
|
Hash.new { |hash, key| hash[key] = [] },
|
|
) do |(settings_path, entries), result|
|
|
add_history_for_settings_file(result, settings_path, entries.map(&:first))
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def add_history_for_settings_file(result, settings_path, change_names)
|
|
@git
|
|
.commits_for(settings_path)
|
|
.reverse_each do |commit|
|
|
statuses = statuses_at(commit, settings_path)
|
|
|
|
change_names.each do |change_name|
|
|
next if !statuses.key?(change_name)
|
|
|
|
result[change_name] << HistoryEntry.new(commit:, status: statuses[change_name])
|
|
end
|
|
end
|
|
end
|
|
|
|
def statuses_at(commit, settings_path)
|
|
MetadataLoader
|
|
.from_content(@git.show_file(commit.sha, settings_path))
|
|
.transform_values { |metadata| metadata[:status]&.to_s }
|
|
end
|
|
end
|
|
|
|
class PullRequestPlan
|
|
BRANCH_PREFIX = "dev/upcoming-change-status-bump"
|
|
LABEL = "upcoming-change"
|
|
|
|
def self.add_to(record, stale_after_days:)
|
|
return record if !record[:eligible]
|
|
|
|
record.merge(
|
|
branch: "#{BRANCH_PREFIX}/#{record[:name]}",
|
|
title: "FEATURE: Bump #{record[:name]} upcoming change to #{record[:next_status]}",
|
|
pr_label: LABEL,
|
|
pr_body: body_for(record, stale_after_days:),
|
|
)
|
|
end
|
|
|
|
def self.body_for(record, stale_after_days:)
|
|
<<~MD.chomp
|
|
<!-- upcoming-change-status-pr:#{record[:name]} -->
|
|
|
|
This automated PR moves `#{record[:name]}` from `#{record[:current_status]}` to `#{record[:next_status]}` after #{stale_after_days}+ days without a status change.
|
|
|
|
- Last status change commit: #{commit_link(record[:last_status_change_commit])}
|
|
- Last status change date: `#{record[:last_status_change_date]}`
|
|
- Settings file: `#{record[:settings_path]}`
|
|
- Original author: #{original_author(record)}
|
|
- Original PR: #{original_pr(record)}
|
|
MD
|
|
end
|
|
|
|
def self.commit_link(commit_sha)
|
|
return "N/A" if commit_sha.blank?
|
|
|
|
"[`#{commit_sha}`](https://github.com/discourse/discourse/commit/#{commit_sha})"
|
|
end
|
|
|
|
def self.original_author(record)
|
|
return "N/A" if record[:original_author_name].blank?
|
|
|
|
"#{record[:original_author_name]} (<#{record[:original_author_email]}>)"
|
|
end
|
|
|
|
def self.original_pr(record)
|
|
return "N/A" if record[:original_pr_number].blank?
|
|
|
|
"##{record[:original_pr_number]}"
|
|
end
|
|
end
|
|
|
|
class SourceStatusUpdater
|
|
def initialize(settings_file:)
|
|
@settings_file = settings_file
|
|
end
|
|
|
|
def update!(change_name:, next_status:)
|
|
lines = File.readlines(@settings_file)
|
|
status_line = status_line_for(change_name)
|
|
raise "Could not locate status line for upcoming change: #{change_name}" if status_line.nil?
|
|
|
|
original_line = lines.fetch(status_line)
|
|
lines[status_line] = updated_status_line(original_line, next_status)
|
|
if original_line == lines[status_line]
|
|
raise "Status line for upcoming change #{change_name} did not change"
|
|
end
|
|
|
|
File.write(@settings_file, lines.join)
|
|
end
|
|
|
|
private
|
|
|
|
def updated_status_line(line, next_status)
|
|
match = STATUS_PATTERN.match(line)
|
|
raise "Could not parse status line: #{line.strip}" if match.nil?
|
|
|
|
indentation, quote, _old_status, suffix, newline = match.captures
|
|
"#{indentation}status: #{quote}#{next_status}#{quote}#{suffix}#{newline}"
|
|
end
|
|
|
|
def status_line_for(change_name)
|
|
root = Psych.parse_file(@settings_file).root
|
|
return if !root.is_a?(Psych::Nodes::Mapping)
|
|
|
|
each_pair(root) do |_category_key, category_value|
|
|
next if !category_value.is_a?(Psych::Nodes::Mapping)
|
|
|
|
each_pair(category_value) do |setting_key, setting_value|
|
|
next if setting_key.value != change_name
|
|
next if !setting_value.is_a?(Psych::Nodes::Mapping)
|
|
|
|
return status_line_in(setting_value)
|
|
end
|
|
end
|
|
|
|
nil
|
|
end
|
|
|
|
def status_line_in(setting_node)
|
|
each_pair(setting_node) do |setting_key, setting_value|
|
|
next if setting_key.value != "upcoming_change"
|
|
next if !setting_value.is_a?(Psych::Nodes::Mapping)
|
|
|
|
each_pair(setting_value) do |metadata_key, metadata_value|
|
|
return metadata_value.start_line if metadata_key.value == "status"
|
|
end
|
|
end
|
|
|
|
nil
|
|
end
|
|
|
|
def each_pair(mapping)
|
|
mapping.children.each_slice(2) { |key, value| yield key, value }
|
|
end
|
|
end
|
|
|
|
def initialize(
|
|
repo_path: Rails.root.to_s,
|
|
settings_path: SETTINGS_PATH,
|
|
settings_paths: nil,
|
|
stale_after_days: 14,
|
|
history_since: DEFAULT_HISTORY_SINCE,
|
|
now: Time.current
|
|
)
|
|
@repo_path = repo_path
|
|
@settings_path = settings_path
|
|
@stale_after_days = stale_after_days.to_i
|
|
@now = now
|
|
@source_index = SourceIndex.new(repo_path:, settings_path:, settings_paths:)
|
|
@git = Git.new(repo_path:, since: history_since)
|
|
end
|
|
|
|
def report
|
|
current_changes.map do |name, metadata|
|
|
build_record(name, metadata, history_by_change.fetch(name, []))
|
|
end
|
|
end
|
|
|
|
def apply(change_name)
|
|
record = report.find { |change| change[:name] == change_name.to_s }
|
|
raise "Unknown upcoming change: #{change_name}" if record.nil?
|
|
raise "Upcoming change is not eligible: #{record[:eligibility_reason]}" if !record[:eligible]
|
|
|
|
SourceStatusUpdater.new(settings_file: File.join(@repo_path, record[:settings_path])).update!(
|
|
change_name: change_name.to_s,
|
|
next_status: record[:next_status],
|
|
)
|
|
|
|
record.merge(applied: true)
|
|
end
|
|
|
|
private
|
|
|
|
def build_record(name, metadata, history)
|
|
original_commit = history.first
|
|
last_status_change = last_status_change_for(history)
|
|
current_status = metadata[:status]&.to_s
|
|
next_status = UpcomingChanges.next_status(current_status)&.to_s
|
|
eligible, reason = eligibility_for(current_status:, next_status:, last_status_change:)
|
|
|
|
PullRequestPlan.add_to(
|
|
{
|
|
name: name.to_s,
|
|
settings_path: metadata[:settings_path],
|
|
current_status:,
|
|
next_status:,
|
|
eligible:,
|
|
eligibility_reason: reason,
|
|
days_since_status_change: days_since(last_status_change),
|
|
last_status_change_commit: last_status_change&.commit&.sha,
|
|
last_status_change_date: last_status_change&.commit&.date,
|
|
original_commit: original_commit&.commit&.sha,
|
|
original_commit_date: original_commit&.commit&.date,
|
|
original_author_name: original_commit&.commit&.author_name,
|
|
original_author_email: original_commit&.commit&.author_email,
|
|
original_pr_number: original_pr_number(original_commit),
|
|
},
|
|
stale_after_days: @stale_after_days,
|
|
)
|
|
end
|
|
|
|
def current_changes
|
|
@current_changes ||=
|
|
CurrentChanges.new(repo_path: @repo_path, source_index: @source_index).call
|
|
end
|
|
|
|
def changes_needing_history
|
|
current_changes.select do |_, metadata|
|
|
UpcomingChanges.next_status(metadata[:status]).present?
|
|
end
|
|
end
|
|
|
|
def history_by_change
|
|
@history_by_change ||= GitHistory.new(git: @git).by_change(changes_needing_history)
|
|
end
|
|
|
|
def last_status_change_for(history)
|
|
history
|
|
.each_cons(2)
|
|
.reduce(history.first) do |last_change, (previous, current)|
|
|
previous.status == current.status ? last_change : current
|
|
end
|
|
end
|
|
|
|
def eligibility_for(current_status:, next_status:, last_status_change:)
|
|
return false, "missing_status" if current_status.blank?
|
|
return false, "unknown_status" if UpcomingChanges.statuses[current_status.to_sym].nil?
|
|
return false, "terminal_status" if next_status.blank?
|
|
return false, "missing_git_history" if last_status_change.nil?
|
|
|
|
return false, "status_changed_recently" if days_since(last_status_change) < @stale_after_days
|
|
|
|
[true, "status_unchanged_for_#{@stale_after_days}_days"]
|
|
end
|
|
|
|
def days_since(history_entry)
|
|
return nil if history_entry.nil?
|
|
|
|
(@now - Time.iso8601(history_entry.commit.date)).to_i / 1.day
|
|
end
|
|
|
|
def original_pr_number(history_entry)
|
|
subject = history_entry&.commit&.subject
|
|
subject&.match(/\(#(\d+)\)\z/)&.[](1) || subject&.match(/#(\d+)/)&.[](1)
|
|
end
|
|
|
|
class CLI
|
|
def self.run(args)
|
|
options = {
|
|
repo_path: Rails.root.to_s,
|
|
settings_paths: nil,
|
|
stale_after_days: 14,
|
|
history_since: DEFAULT_HISTORY_SINCE,
|
|
pretty: false,
|
|
}
|
|
|
|
parser =
|
|
OptionParser.new do |opts|
|
|
opts.banner =
|
|
"Usage: bin/rails runner script/upcoming_changes_status_report -- [options]"
|
|
|
|
opts.on(
|
|
"--apply NAME",
|
|
"Apply the next status for one eligible upcoming change",
|
|
) { |name| options[:apply] = name }
|
|
|
|
opts.on("--repo PATH", "Git repository path") { |path| options[:repo_path] = path }
|
|
opts.on(
|
|
"--settings-path PATH",
|
|
"Only inspect one settings YAML path; defaults to core and plugin settings",
|
|
) { |path| options[:settings_paths] = [path] }
|
|
opts.on("--stale-after-days DAYS", Integer, "Minimum unchanged age") do |days|
|
|
options[:stale_after_days] = days
|
|
end
|
|
opts.on(
|
|
"--history-since VALUE",
|
|
"How far back to scan git history (any git --since value); defaults to '#{DEFAULT_HISTORY_SINCE}'",
|
|
) { |value| options[:history_since] = value }
|
|
opts.on("--pretty", "Pretty-print JSON output") { options[:pretty] = true }
|
|
end
|
|
|
|
parser.parse!(args)
|
|
|
|
status_report =
|
|
StatusReport.new(
|
|
repo_path: options[:repo_path],
|
|
settings_paths: options[:settings_paths],
|
|
stale_after_days: options[:stale_after_days],
|
|
history_since: options[:history_since],
|
|
)
|
|
output = options[:apply] ? status_report.apply(options[:apply]) : status_report.report
|
|
puts(options[:pretty] ? JSON.pretty_generate(output) : JSON.generate(output))
|
|
end
|
|
end
|
|
end
|
|
end
|