mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-18 18:54:34 +08:00
Introduces a [scheduled workflow](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule) that runs weekly to find upcoming changes which have not changed status in the 14+ days. If found, the following happens: * A branch for the bump is made, which is used as a unique key to avoid duplicate PRs * The status change is made in the relevant settings.yml file * A commit & PR is made for the bump, and it is assigned to the person who originally introduced the upcoming change The status report & script used by the workflow can be called in a dry run, which will give an output of the actions that will be taken by the workflow. Here is how to run the report: ```bash SKIP_DB_AND_REDIS=1 RAILS_DB=nonexistent bin/rails runner script/upcoming_changes_status_report -- --stale-after-days 14 --pretty > /tmp/upcoming_changes_status_report.json ``` An example of the report output: ```json { "name": "ai_bot_enable_docked_composer", "settings_path": "plugins/discourse-ai/config/settings.yml", "current_status": "alpha", "next_status": "beta", "eligible": true, "eligibility_reason": "status_unchanged_for_14_days", "days_since_status_change": 22, "last_status_change_commit": "b3b561e5fa", "last_status_change_date": "2026-05-04T09:26:32-07:00", "original_commit": "b3b561e5fa", "original_commit_date": "2026-05-04T09:26:32-07:00", "original_author_name": "Keegan George", "original_author_email": "kgeorge13@gmail.com", "original_pr_number": "39708" }, ``` And here is the result of a dry run of the script (it prints out this set of commands for every upcoming change that will be bumped). The dry run command: ```bash REPORT_FILE=/tmp/upcoming_changes_status_report.json BASE_BRANCH=main DRY_RUN=true STALE_AFTER_DAYS=14 script/create_upcoming_change_status_prs ``` And the result: ```bash git checkout -B dev/upcoming-change-status-bump/simple_email_subject origin/main SKIP_DB_AND_REDIS=1 RAILS_DB=nonexistent bin/rails runner script/upcoming_changes_status_report -- --stale-after-days 14 --apply simple_email_subject git add config/site_settings.yml git commit -m "DEV: Bump simple_email_subject upcoming change to beta" git push -f origin dev/upcoming-change-status-bump/simple_email_subject gh pr create --base main --head dev/upcoming-change-status-bump/simple_email_subject --title "DEV: Bump simple_email_subject upcoming change to beta" --body-file /tmp/upcoming-change-simple_email_subject-body.md --label upcoming-change --assignee dbattersby ``` And here is an example of the body of the PR: ```markdown <!-- upcoming-change-status-pr:simple_email_subject --> This automated PR moves `simple_email_subject` from `alpha` to `beta` after 14+ days without a status change. - Last status change commit: [`695b393dac0ee829f12d54ba741f847c0621980d`](695b393dac) - Last status change date: `2026-05-07T12:58:02+04:00` - Settings file: `config/site_settings.yml` - Original author: David Battersby (<info@davidbattersby.com>) - Original PR: #36040 ```
182 lines
4.8 KiB
Ruby
Executable file
Vendored
182 lines
4.8 KiB
Ruby
Executable file
Vendored
#!/usr/bin/env ruby
|
|
# frozen_string_literal: true
|
|
|
|
require "json"
|
|
require "open3"
|
|
require "shellwords"
|
|
|
|
class UpcomingChangeStatusPRs
|
|
def initialize(env: ENV)
|
|
@report_file = env.fetch("REPORT_FILE") { abort "REPORT_FILE is required" }
|
|
@base_branch = env.fetch("BASE_BRANCH") { abort "BASE_BRANCH is required" }
|
|
@dry_run = env.fetch("DRY_RUN", "true") == "true"
|
|
@stale_after_days = env.fetch("STALE_AFTER_DAYS", "14")
|
|
@github_repository = env.fetch("GITHUB_REPOSITORY", "")
|
|
@github_step_summary = env.fetch("GITHUB_STEP_SUMMARY", "/dev/stdout")
|
|
@rails_command = env.fetch("RAILS_COMMAND", "bin/rails")
|
|
end
|
|
|
|
def run
|
|
if !@dry_run && @github_repository != "discourse/discourse"
|
|
abort "Refusing to create pull requests outside discourse/discourse."
|
|
end
|
|
|
|
run!("git", "fetch", "origin", @base_branch)
|
|
|
|
eligible_changes.each { |change| process(change) }
|
|
end
|
|
|
|
private
|
|
|
|
def eligible_changes
|
|
JSON.parse(File.read(@report_file)).select { |change| change["eligible"] }
|
|
end
|
|
|
|
def process(change)
|
|
name = change["name"]
|
|
settings_path = change["settings_path"]
|
|
original_pr_number = change["original_pr_number"]
|
|
branch = change["branch"]
|
|
title = change["title"]
|
|
pr_label = change["pr_label"]
|
|
body_file = "/tmp/upcoming-change-#{name}-body.md"
|
|
assignee = resolve_assignee(original_pr_number)
|
|
|
|
File.write(body_file, change["pr_body"])
|
|
|
|
if @dry_run
|
|
write_dry_run_summary(
|
|
name:,
|
|
branch:,
|
|
title:,
|
|
body_file:,
|
|
assignee:,
|
|
settings_path:,
|
|
pr_label:,
|
|
)
|
|
return
|
|
end
|
|
|
|
if open_pr_exists?(branch)
|
|
puts "Open PR already exists for #{branch}; skipping."
|
|
return
|
|
end
|
|
|
|
run!("git", "checkout", "-B", branch, "origin/#{@base_branch}")
|
|
rails_runner(
|
|
"script/upcoming_changes_status_report",
|
|
"--",
|
|
"--stale-after-days",
|
|
@stale_after_days,
|
|
"--apply",
|
|
name,
|
|
)
|
|
run!("git", "add", settings_path)
|
|
run!("git", "commit", "-m", title)
|
|
run!("git", "push", "-f", "origin", branch)
|
|
|
|
create_pr(branch:, title:, body_file:, pr_label:, assignee:)
|
|
end
|
|
|
|
def resolve_assignee(original_pr_number)
|
|
return if original_pr_number.nil? || original_pr_number.to_s.empty?
|
|
|
|
login, status =
|
|
Open3.capture2(
|
|
"gh",
|
|
"pr",
|
|
"view",
|
|
original_pr_number.to_s,
|
|
"--json",
|
|
"author",
|
|
"--jq",
|
|
".author.login",
|
|
)
|
|
status.success? ? login.strip : nil
|
|
end
|
|
|
|
def open_pr_exists?(branch)
|
|
count =
|
|
capture!(
|
|
"gh",
|
|
"pr",
|
|
"list",
|
|
"--head",
|
|
branch,
|
|
"--state",
|
|
"open",
|
|
"--json",
|
|
"number",
|
|
"--jq",
|
|
"length",
|
|
).strip
|
|
count != "0"
|
|
end
|
|
|
|
def create_pr(branch:, title:, body_file:, pr_label:, assignee:)
|
|
args = [
|
|
"gh",
|
|
"pr",
|
|
"create",
|
|
"--base",
|
|
@base_branch,
|
|
"--head",
|
|
branch,
|
|
"--title",
|
|
title,
|
|
"--body-file",
|
|
body_file,
|
|
"--label",
|
|
pr_label,
|
|
]
|
|
args.push("--assignee", assignee) if assignee && !assignee.empty?
|
|
run!(*args)
|
|
end
|
|
|
|
def rails_runner(*args)
|
|
run!(@rails_command, "runner", *args, env: { "SKIP_DB_AND_REDIS" => "1", "RAILS_DB" => "nonexistent" })
|
|
end
|
|
|
|
def write_dry_run_summary(name:, branch:, title:, body_file:, assignee:, settings_path:, pr_label:)
|
|
create_pr_line =
|
|
if assignee && !assignee.empty?
|
|
"gh pr create --base #{@base_branch} --head #{branch} --title \"#{title}\" --body-file #{body_file} --label #{pr_label} --assignee #{assignee}"
|
|
else
|
|
"gh pr create --base #{@base_branch} --head #{branch} --title \"#{title}\" --body-file #{body_file} --label #{pr_label}"
|
|
end
|
|
|
|
summary = <<~SUMMARY
|
|
### #{name}
|
|
|
|
```bash
|
|
git checkout -B #{branch} origin/#{@base_branch}
|
|
SKIP_DB_AND_REDIS=1 RAILS_DB=nonexistent bin/rails runner script/upcoming_changes_status_report -- --stale-after-days #{@stale_after_days} --apply #{name}
|
|
git add #{settings_path}
|
|
git commit -m "#{title}"
|
|
git push -f origin #{branch}
|
|
#{create_pr_line}
|
|
```
|
|
|
|
SUMMARY
|
|
|
|
File.open(@github_step_summary, "a") { |file| file.write(summary) }
|
|
end
|
|
|
|
# Runs a command, streaming output, and aborts on failure (set -e equivalent).
|
|
def run!(*args, env: {})
|
|
args = args.map(&:to_s)
|
|
unless system(env, *args)
|
|
abort "Command failed (#{$?.exitstatus}): #{args.shelljoin}"
|
|
end
|
|
end
|
|
|
|
# Runs a command and returns its stdout, aborting on failure (set -e + pipefail).
|
|
def capture!(*args)
|
|
args = args.map(&:to_s)
|
|
stdout, status = Open3.capture2(*args)
|
|
abort "Command failed (#{status.exitstatus}): #{args.shelljoin}" unless status.success?
|
|
stdout
|
|
end
|
|
end
|
|
|
|
UpcomingChangeStatusPRs.new.run if $PROGRAM_NAME == __FILE__
|