mirror of
https://github.com/discourse/discourse.git
synced 2026-08-04 10:39:43 +08:00
Fixes the following error when generating the upcoming changes status
report
on a weekly basis in GitHub actions:
```
/__w/discourse/discourse/lib/upcoming_changes/status_report.rb:56:in 'UpcomingChanges::StatusReport::Git#capture': git show 45535887f1231b40f8a21b8c154e58cef8670063:plugins/discourse-workflows/config/settings.yml failed: fatal: path 'plugins/discourse-workflows/config/settings.yml' exists on disk, but not in '45535887f1' (RuntimeError)
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:47:in 'UpcomingChanges::StatusReport::Git#show_file'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:227:in 'UpcomingChanges::StatusReport::GitHistory#statuses_at'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:215:in 'block in UpcomingChanges::StatusReport::GitHistory#add_history_for_settings_file'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:214:in 'Array#reverse_each'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:214:in 'UpcomingChanges::StatusReport::GitHistory#add_history_for_settings_file'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:205:in 'block in UpcomingChanges::StatusReport::GitHistory#by_change'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:202:in 'Hash#each'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:202:in 'Enumerable#each_with_object'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:202:in 'UpcomingChanges::StatusReport::GitHistory#by_change'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:422:in 'UpcomingChanges::StatusReport#history_by_change'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:363:in 'block in UpcomingChanges::StatusReport#report'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:362:in 'Hash#each'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:362:in 'Enumerable#map'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:362:in 'UpcomingChanges::StatusReport#report'
from /__w/discourse/discourse/lib/upcoming_changes/status_report.rb:499:in 'UpcomingChanges::StatusReport::CLI.run'
from /__w/discourse/discourse/script/upcoming_changes_status_report:9:in '<main>'
```
This happened because we were trying to run `git sho` on a file that was
reverted in a commit. Now, we check whether the file exists for the
specific commit hash before running git show, and continue with the
next valid commit for the file.
512 lines
16 KiB
Ruby
Vendored
512 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)
|
|
return if !file_exists_at?(commit_sha, path)
|
|
|
|
capture("show", "#{commit_sha}:#{path}")
|
|
end
|
|
|
|
private
|
|
|
|
def file_exists_at?(commit_sha, path)
|
|
_stdout, _stderr, status =
|
|
Open3.capture3("git", "-C", @repo_path, "cat-file", "-e", "#{commit_sha}:#{path}")
|
|
status.success?
|
|
end
|
|
|
|
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
|