Class: Cimas::Cli::Command
- Inherits:
-
Object
- Object
- Cimas::Cli::Command
- Defined in:
- lib/cimas/cli/command.rb
Constant Summary collapse
- DEFAULT_CONFIG =
{ 'dry_run' => false, 'verbose' => false, # nil, not ['all']: defaulting a wave to the whole fleet is how a # forgotten -g fans branches/PRs out to every repo in the config. # filtered_repo_names still falls back to all repos for local-only # commands; remote-mutating ones refuse at dispatch (see COMMANDS # and `execute`). 'groups' => nil, 'force_push' => false, 'assignees' => [], 'reviewers' => [], 'keep_changes' => false, 'add_auto_merge_label' => true, 'cooldown_count' => 10, 'cooldown_time' => 3 * 60 }
- COMMANDS =
One registry entry per subcommand, the single classification that drives dispatch-time behavior:
:remote_mutating — refuses to run without an explicit -g and prints a pre-flight scope line (provision branches, open PRs, delete branches, run arbitrary shell). :remote_mutating_if — same, but only when the mapped config flag opts in. :requires — config keys that must be set; validated eagerly at dispatch so a missing -b/-m fails fast instead of exiting 0 when every repo happens to be skipped. :requires_if — additional required keys under a config flag.Adding a subcommand = adding one entry here.
{ 'setup' => {}, 'sync' => {}, 'diff' => {}, 'pull' => {}, 'push' => { remote_mutating: true, requires: %w[push_to_branch commit_message], }, 'open-prs' => { remote_mutating: true, requires: %w[merge_branch pr_message], }, 'for-each' => { remote_mutating: true, requires: %w[shell_cmd], }, 'cleanup-merged-prs' => { remote_mutating: true, requires: %w[push_to_branch], }, 'cleanup-closed-prs' => { remote_mutating: true }, 'cleanup-orphan-files' => { remote_mutating_if: 'cleanup_push_after', requires_if: ['cleanup_push_after', %w[push_to_branch pr_message]], }, 'release-preflight' => { requires: %w[target_repo] }, }.freeze
- OPTION_FLAGS =
Config key → CLI flag spelling, for required-option messages.
{ 'push_to_branch' => '-b/--push-branch', 'commit_message' => '-m/--message', 'merge_branch' => '-b/--merge-branch', 'pr_message' => '-m/--message', 'shell_cmd' => '-c/--shell-cmd', 'target_repo' => '--repo', }.freeze
- SCOPE_LIST_LIMIT =
How many repository names the pre-flight scope line lists before collapsing to a count.
30
Class Method Summary collapse
- .command_meta(command_name) ⇒ Object
- .missing_required_options(command_name, config) ⇒ Object
- .remote_mutating?(command_name, config = {}) ⇒ Boolean
Instance Method Summary collapse
- #add_auto_merge_label ⇒ Object
- #announce_scope(command_name) ⇒ Object
- #apply_patches(repo_name, repo_dir, working_copy) ⇒ Object
-
#cleanup_closed_prs ⇒ Object
Sibling of
cleanup_merged_prsfor the closed-not-merged case. -
#cleanup_merged_prs ⇒ Object
Per-wave local cleanup: delete the branch named by
push_to_branchfrom each target repo on origin IF the corresponding PR has merged. -
#cleanup_orphan_files ⇒ Object
Inverse of
sync. - #commit_message ⇒ Object
- #config ⇒ Object
- #config_master_path ⇒ Object
- #data ⇒ Object
- #diff ⇒ Object
-
#each_configured_repo ⇒ Object
Iterates the wave's resolved target repos, skipping any name that is not a configured repository (
-g typoresolves to a repo name that cimas.yml doesn't define). -
#each_target_repo(command_name) ⇒ Object
each_configured_repoplus the clone-presence check, for commands that operate on the working copy. -
#execute(command_name) ⇒ Object
Single dispatch entrypoint (
exe/cimascalls this). - #fetch_repo_visibility(slug) ⇒ Object
- #filtered_repo_names ⇒ Object
- #for_each ⇒ Object
- #force_push ⇒ Object
- #git_remote_to_github_name(remote) ⇒ Object
-
#github ⇒ Object
Octokit boundary lives in Cimas::GitHub; these delegators keep the orchestrator's vocabulary (slug from remote, cached visibility per repository).
- #github_client ⇒ Object
-
#handle_superseded_pr(github_slug, stale, new_number, new_branch, flatten:) ⇒ Object
Label + comment (+ optionally close) a superseded prior-wave PR.
-
#initialize(options) ⇒ Command
constructor
A new instance of Command.
- #keep_changes ⇒ Object
- #merge_branch ⇒ Object
- #open_prs ⇒ Object
- #patches ⇒ Object
- #pr_message ⇒ Object
- #pull ⇒ Object
- #push ⇒ Object
- #push_to_branch ⇒ Object
- #release_preflight ⇒ Object
- #repo_by_name(name) ⇒ Object
-
#repo_visibility_private?(repo) ⇒ Boolean
Returns true if the repo is GitHub-private, false if public.
- #repos_path ⇒ Object
- #repositories ⇒ Object
-
#require_explicit_scope!(command_name) ⇒ Object
Remote-mutating subcommands refuse to run unless -g is given and resolves to at least one repository.
-
#resolve_pr_body ⇒ Object
PR body from --body-file (preferred), --body inline, or the legacy "As title." placeholder.
-
#resolve_source(source, repo) ⇒ Object
For metanorma/ci#347 Option B: a
files:value can be either the legacy String (a single template path) or a Hash of the shape{ 'if_public' => path1, 'if_private' => path2 }. -
#reviewers_excluding_token_user(reviewers) ⇒ Object
GitHub rejects self-review requests with HTTP 422 "Review cannot be requested from pull request author." Pre-filter the token user out so the other reviewers still get requested (#7); when the token user can't be resolved, proceed as configured.
- #sanity_check ⇒ Object
- #settings ⇒ Object
- #setup ⇒ Object
- #shell_cmd ⇒ Object
- #supersede_comment_body(new_number, new_branch, flatten:) ⇒ Object
- #sync ⇒ Object
-
#validate_required_options!(command_name) ⇒ Object
Eager fail-fast for required flags, before any repo iteration — lazy accessor validation alone let
push -g data(no -b) exit 0 whenever every repo happened to be skipped. - #verbose ⇒ Object
Constructor Details
#initialize(options) ⇒ Command
Returns a new instance of Command.
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/cimas/cli/command.rb', line 104 def initialize() unless ['config_file_path'].exist? raise "[ERROR] config_file_path #{['config_file_path']} does not exist, aborting." end @data = YAML.load(File.read(['config_file_path'])) || {} unless repositories.is_a?(Hash) && !repositories.empty? raise "[ERROR] no `repositories:` section in #{['config_file_path']} — nothing to operate on, aborting." end @config = DEFAULT_CONFIG.merge(settings || {}).merge() unless repos_path.exist? FileUtils.mkdir_p repos_path end if ENV["GITHUB_TOKEN"] @config['github_token'] ||= ENV["GITHUB_TOKEN"] end end |
Class Method Details
.command_meta(command_name) ⇒ Object
84 85 86 |
# File 'lib/cimas/cli/command.rb', line 84 def self.(command_name) COMMANDS[command_name] || {} end |
.missing_required_options(command_name, config) ⇒ Object
96 97 98 99 100 101 102 |
# File 'lib/cimas/cli/command.rb', line 96 def self.(command_name, config) = (command_name) required = [:requires] || [] flag, conditional = [:requires_if] || [nil, []] required += conditional if flag && config[flag] == true required.reject { |key| config[key] } end |
.remote_mutating?(command_name, config = {}) ⇒ Boolean
88 89 90 91 92 93 94 |
# File 'lib/cimas/cli/command.rb', line 88 def self.remote_mutating?(command_name, config = {}) = (command_name) return true if [:remote_mutating] flag = [:remote_mutating_if] !flag.nil? && config[flag] == true end |
Instance Method Details
#add_auto_merge_label ⇒ Object
471 472 473 |
# File 'lib/cimas/cli/command.rb', line 471 def add_auto_merge_label config['add_auto_merge_label'] end |
#announce_scope(command_name) ⇒ Object
415 416 417 418 419 420 421 422 423 |
# File 'lib/cimas/cli/command.rb', line 415 def announce_scope(command_name) names = filtered_repo_names label = if names.size <= SCOPE_LIST_LIMIT names.join(', ') else "(list omitted, >#{SCOPE_LIST_LIMIT} repos)" end puts "Scope for #{command_name}: #{names.size} repo(s): #{label}" end |
#apply_patches(repo_name, repo_dir, working_copy) ⇒ Object
279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 |
# File 'lib/cimas/cli/command.rb', line 279 def apply_patches(repo_name, repo_dir, working_copy) patches.each do |patch| target_repo_names = patch.group_names.flat_map { |g| group_repo_names(g) }.uniq next unless target_repo_names.include?(repo_name) patch.globs.each do |glob| matched = Dir.glob(File.join(repo_dir, glob)) if matched.empty? puts "[WARNING] Patch '#{patch.name}' on #{repo_name}: no files matched glob '#{glob}'." next end matched.each do |file_path| rel_path = file_path.sub(/\A#{Regexp.escape(repo_dir)}\/?/, '') original = File.read(file_path) # Distinguish two cases that previously both logged the same # misleading "pattern did not match, file unchanged" warning # (see metanorma/cimas#49 Bug 3): # - `find` regex doesn't appear in the file at all (the line # this patch wants to update is genuinely absent — e.g. a # gemspec with no `required_ruby_version` line, the NOVER # case). WARNING-level: maintainer may want to add the line. # - `find` matches but gsub produces identical text (the # file is already at the target value). INFO-level: this is # a normal idempotent no-op, not a problem. unless patch.matches?(original) puts "[WARNING] Patch '#{patch.name}' on #{repo_name}:#{rel_path}: pattern not present in file (line absent — consider whether the patch should also handle insertion)." next end updated = patch.apply(original) if original == updated puts "[INFO] Patch '#{patch.name}' on #{repo_name}:#{rel_path}: already at target value, no-op." next end dry_run("Patching #{rel_path} in #{repo_name} (patch '#{patch.name}')") do File.write(file_path, updated) working_copy.stage(rel_path) end end end end end |
#cleanup_closed_prs ⇒ Object
Sibling of cleanup_merged_prs for the closed-not-merged case.
cleanup_merged_prs operates on ONE wave branch supplied via -b
and asks "did the PR merge? if so, delete the branch." This
subcommand operates on ALL branches whose names match a prefix
(default cimas-sync-), across the whole scope, and deletes the
ones whose PR was closed-without-merge — regardless of wave.
Motivation (metanorma/ci#347 follow-up): when a wave PR is closed
without merge, cleanup-merged-prs leaves the branch alone by design
(someone may want to revisit). But wave PRs closed as superseded
(via --flatten-stale) or as unwanted (ci#347) accumulate orphan
branches on remotes. This sweeps them.
Safety: only deletes branches whose head matches the prefix AND whose PR is closed (state == 'closed', merged_at is nil). Open PRs and merged PRs are left alone.
883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 |
# File 'lib/cimas/cli/command.rb', line 883 def cleanup_closed_prs sanity_check prefix = config['cleanup_branch_prefix'] || 'cimas-sync-' each_configured_repo do |repo| github_slug = git_remote_to_github_name(repo.remote) # Page all closed PRs; API caps at 100/page but that's fine for # cimas-sync-* accumulation which is bounded by wave count. begin closed_prs = github_client.pull_requests( github_slug, state: 'closed', per_page: 100, ) rescue Octokit::Error => e puts "[ERROR] #{github_slug}: PR lookup failed (#{e.class}): #{e.}" next end candidates = closed_prs.select do |pr| pr.head&.ref&.start_with?(prefix) && pr.merged_at.nil? end if candidates.empty? puts "[none] #{github_slug}: no closed-not-merged '#{prefix}*' branches" next end candidates.each do |pr| delete_remote_branch( github_slug, pr.head.ref, "PR ##{pr.number} closed-not-merged #{pr.closed_at}" ) end end end |
#cleanup_merged_prs ⇒ Object
Per-wave local cleanup: delete the branch named by push_to_branch
from each target repo on origin IF the corresponding PR has merged.
Open PRs are left alone (their branch is still in use). Branches with
no PR are deleted too (a wave that opened no PR for the repo, e.g.
because cimas detected "no commits" at push time, leaves a stale
branch on origin we shouldn't keep). Requires only standard repo
scope on each target repo — no admin scope, since branch deletion
against a merged PR is a push-level operation.
827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 |
# File 'lib/cimas/cli/command.rb', line 827 def cleanup_merged_prs sanity_check branch = push_to_branch each_configured_repo do |repo| github_slug = git_remote_to_github_name(repo.remote) owner = github_slug.split('/').first begin prs = github_client.pull_requests( github_slug, head: "#{owner}:#{branch}", state: 'all' ) rescue Octokit::Error => e puts "[ERROR] #{github_slug}: PR lookup failed (#{e.class}): #{e.}" next end pr = prs.first if pr.nil? # No PR for this branch — attempt to delete if the branch exists delete_remote_branch(github_slug, branch, "no PR found") next end if pr.merged_at delete_remote_branch(github_slug, branch, "PR ##{pr.number} merged") elsif pr.state == 'open' puts "[skip-open] #{github_slug}:#{branch} (PR ##{pr.number} still open)" else # Closed-without-merge — keep branch by default; closing without merge # often means someone intends to revisit. Operator can clean up manually. puts "[skip-closed] #{github_slug}:#{branch} (PR ##{pr.number} closed without merge)" end end end |
#cleanup_orphan_files ⇒ Object
Inverse of sync. Where sync writes cimas.yml-mapped files to
each repo's working tree, cleanup_orphan_files finds files that
(a) carry the Cimas auto-generated header comment, so they were
written by cimas at some point, and (b) are no longer in the
repo's files: mapping, so cimas is no longer regenerating them.
These files are orphans — they only exist because they were
sync'd on a prior config version and never cleaned up.
Motivation (metanorma/ci#347 follow-up): dropping a file from a
repo's files: mapping (e.g. removing .github/workflows/generate.yml
from all non-mn-samples-* doc repos, per ci#347's docker-only rule)
stops future regeneration but leaves the existing file in the
repo, where its CI keeps failing. This subcommand purges those.
Safety: only deletes files whose first ~500 bytes contain the cimas header marker. Files without the header (custom CI, docs, sources) are never touched.
938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 |
# File 'lib/cimas/cli/command.rb', line 938 def cleanup_orphan_files sanity_check push_after = config['cleanup_push_after'] == true # `push_to_branch` / `pr_message` raise when their underlying config # key is nil, so only resolve them when `--push-after` actually needs # them. Without `--push-after` the subcommand is a local-only stage, # which is the correct shape for a dry-run scan or a review-before-blast # workflow. branch = push_after ? push_to_branch : nil = push_after ? : nil # `--only-target=path[,path...]` narrows the sweep to specific target # paths so a wave can be scoped to just one class of orphan (e.g. # `.github/workflows/generate.yml` for the ci#347 cleanup). nil means # no filter — surface all orphan cimas-managed files. only_targets = config['cleanup_only_targets'] && string_list(config['cleanup_only_targets']).to_set each_target_repo('cleanup-orphan-files') do |repo, repo_dir| repo_name = repo.name wc = WorkingCopy.open(repo_dir) wc.reset_clean(repo.branch, include_untracked: true) unless keep_changes mapped_targets = (repo.files || {}).keys.to_set orphans = Cimas::OrphanFiles.find(repo_dir, mapped_targets, only_targets) if orphans.empty? puts "[clean] #{repo_name}" next end puts "[#{orphans.size} orphan(s)] #{repo_name}:" orphans.each { |o| puts " - #{o}" } if push_after dry_run("Commit + push deletion of #{orphans.size} orphan(s) in #{repo_name} on #{branch}") do wc.switch_branch(branch, fresh: true) wc.remove(*orphans) wc.commit() if wc.push(branch, force: true) == :pushed puts "[pushed] #{repo_name}:#{branch}" else puts "[ERROR] #{repo_name}:#{branch} push failed" end end else # Local-only mode: stage the deletions for the operator to # inspect and push manually. Useful for a review-before-blast # workflow. dry_run("Stage deletion of #{orphans.size} orphan(s) in #{repo_name} (local only, no push)") do wc.remove(*orphans) end end end end |
#commit_message ⇒ Object
445 446 447 448 449 450 451 452 453 |
# File 'lib/cimas/cli/command.rb', line 445 def msg = required_option('commit_message', '-m/--message') unless msg.include? "request-checks:" # https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks#checks # Thor freezes option strings — never mutate, always rebuild. msg = "#{msg}\n\nrequest-checks: true" end msg end |
#config ⇒ Object
177 178 179 |
# File 'lib/cimas/cli/command.rb', line 177 def config @config end |
#config_master_path ⇒ Object
216 217 218 |
# File 'lib/cimas/cli/command.rb', line 216 def config_master_path config['config_master_path'] end |
#data ⇒ Object
181 182 183 |
# File 'lib/cimas/cli/command.rb', line 181 def data @data end |
#diff ⇒ Object
324 325 326 327 328 329 330 331 |
# File 'lib/cimas/cli/command.rb', line 324 def diff sanity_check each_target_repo('diff') do |repo, repo_dir| puts "======================= DIFF FOR #{repo.name} =========================" puts WorkingCopy.open(repo_dir).diff_patch end end |
#each_configured_repo ⇒ Object
Iterates the wave's resolved target repos, skipping any name that
is not a configured repository (-g typo resolves to a repo name
that cimas.yml doesn't define).
346 347 348 349 350 351 352 353 354 355 356 |
# File 'lib/cimas/cli/command.rb', line 346 def each_configured_repo filtered_repo_names.each do |repo_name| repo = repo_by_name(repo_name) if repo.nil? puts "[WARNING] #{repo_name} not configured, skipping." next end yield repo end end |
#each_target_repo(command_name) ⇒ Object
each_configured_repo plus the clone-presence check, for commands
that operate on the working copy. Skip messages are uniform across
subcommands (skipping <command> for it).
361 362 363 364 365 366 367 368 369 370 371 |
# File 'lib/cimas/cli/command.rb', line 361 def each_target_repo(command_name) each_configured_repo do |repo| repo_dir = File.join(repos_path, repo.name) unless File.exist?(repo_dir) puts "[ERROR] #{repo.name} is missing in #{repos_path}, skipping #{command_name} for it." next end yield repo, repo_dir end end |
#execute(command_name) ⇒ Object
Single dispatch entrypoint (exe/cimas calls this). Scope guard,
required-option validation and the scope announcement all derive
from the one COMMANDS classification, so every remote-mutating
subcommand is guarded AND announces its blast radius uniformly,
and every required flag fails fast — before any repo iteration.
Calling a subcommand method directly bypasses the guard by design
— it protects CLI operators, not library callers.
133 134 135 136 137 138 139 |
# File 'lib/cimas/cli/command.rb', line 133 def execute(command_name) require_explicit_scope!(command_name) (command_name) announce_scope(command_name) if self.class.remote_mutating?(command_name, config) public_send(command_name.tr('-', '_')) end |
#fetch_repo_visibility(slug) ⇒ Object
162 163 164 |
# File 'lib/cimas/cli/command.rb', line 162 def fetch_repo_visibility(slug) github.fetch_visibility(slug) end |
#filtered_repo_names ⇒ Object
333 334 335 336 337 338 339 340 341 |
# File 'lib/cimas/cli/command.rb', line 333 def filtered_repo_names @filtered_repo_names ||= if config['groups'] config['groups'].inject([]) do |acc, group| acc + group_repo_names(group) end.uniq else repositories.keys end end |
#for_each ⇒ Object
997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 |
# File 'lib/cimas/cli/command.rb', line 997 def for_each sanity_check cmd = shell_cmd failures = [] each_target_repo('for-each') do |repo, repo_dir| Dir.chdir(repo_dir) do puts "Execute '#{cmd}' for #{repo.name} repository..." system(cmd) unless $?.success? failures << repo.name puts "[ERROR] '#{cmd}' failed in #{repo.name} (exit #{$?.exitstatus})" end end end return if failures.empty? raise "[ERROR] for-each command failed in #{failures.size} repo(s): #{failures.join(', ')}" end |
#force_push ⇒ Object
475 476 477 |
# File 'lib/cimas/cli/command.rb', line 475 def force_push config['force_push'] end |
#git_remote_to_github_name(remote) ⇒ Object
158 159 160 |
# File 'lib/cimas/cli/command.rb', line 158 def git_remote_to_github_name(remote) github.slug_for(remote) end |
#github ⇒ Object
Octokit boundary lives in Cimas::GitHub; these delegators keep the orchestrator's vocabulary (slug from remote, cached visibility per repository). Inject a stand-in via config for offline specs; production always builds a real Cimas::GitHub.
150 151 152 |
# File 'lib/cimas/cli/command.rb', line 150 def github @github ||= config['github'] || Cimas::GitHub.new(token: config['github_token']) end |
#github_client ⇒ Object
154 155 156 |
# File 'lib/cimas/cli/command.rb', line 154 def github_client github.client end |
#handle_superseded_pr(github_slug, stale, new_number, new_branch, flatten:) ⇒ Object
Label + comment (+ optionally close) a superseded prior-wave PR. Called from the open_prs loop for each stale PR detected via --supersede-stale / --flatten-stale (Gap 4 of metanorma/ci#300).
557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 |
# File 'lib/cimas/cli/command.rb', line 557 def handle_superseded_pr(github_slug, stale, new_number, new_branch, flatten:) label = flatten ? "superseded-closed-by-##{new_number}" \ : "superseded-by-##{new_number}" github_client.add_labels_to_an_issue( github_slug, stale.number, [label] ) github_client.add_comment( github_slug, stale.number, supersede_comment_body(new_number, new_branch, flatten: flatten) ) if flatten github_client.close_pull_request(github_slug, stale.number) puts " flattened #{github_slug}##{stale.number} " \ "(labelled + commented + closed)" else puts " superseded #{github_slug}##{stale.number} " \ "(labelled + commented)" end end |
#keep_changes ⇒ Object
479 480 481 |
# File 'lib/cimas/cli/command.rb', line 479 def keep_changes config['keep_changes'] end |
#merge_branch ⇒ Object
463 464 465 |
# File 'lib/cimas/cli/command.rb', line 463 def merge_branch required_option('merge_branch', '-b/--merge-branch') end |
#open_prs ⇒ Object
660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 |
# File 'lib/cimas/cli/command.rb', line 660 def open_prs sanity_check branch = merge_branch = body = resolve_pr_body # Coerce to an Array of handles via string_list: accepts an Array # from cimas.yml settings or a (possibly comma-separated) String # from the `-a` / `-w` CLI flags. Without coercion, `-a opoudjis` # reached this block as a bare String and `.join(',')` further # down crashed with NoMethodError, aborting `cimas open-prs` # before any PR could be created. assignees = string_list(config['assignees']) reviewers = reviewers_excluding_token_user(string_list(config['reviewers'])) cooldown_count = config['cooldown_count'] cooldown_time = config['cooldown_time'] cooldown_counter = 0 each_target_repo('open-prs') do |repo, _repo_dir| repo_name = repo.name github_slug = git_remote_to_github_name(repo.remote) # --supersede-stale: detect prior open cimas-sync-* PRs on this repo. # See metanorma/ci#300 Gap 4. Cheaper-version (no strict-superset # check): we label-and-comment-but-do-not-close the old PRs, letting # the reviewer keep authority over the close decision. The new PR's # body is prepended with a "Supersedes #X, #Y" note so the reviewer # sees the full picture in the most recent PR. stale_prs = [] if config['supersede_stale'] begin stale_prs = github_client.pull_requests(github_slug, state: 'open').select do |stale| stale.head.ref.start_with?('cimas-sync-') && stale.head.ref != branch end rescue Octokit::Error => e puts "[WARNING] #{github_slug}: could not list open PRs for --supersede-stale (#{e.}); proceeding without." stale_prs = [] end end final_body = if stale_prs.any? supersede_list = stale_prs.map { |p| "##{p.number}" }.join(", ") "_Supersedes #{supersede_list} from prior cimas-sync waves._\n\n#{body}" else body end dry_run("Opening GitHub PR: #{github_slug}, branch #{repo.branch} <- #{branch}, message '#{}'") do puts "Opening GitHub PR: #{github_slug}, branch #{repo.branch} <- #{branch}, message '#{}'" begin pr = github_client.create_pull_request( github_slug, repo.branch, branch, , final_body, ) number = pr['number'] github_client.add_labels_to_an_issue(github_slug, number, ['automerge']) if add_auto_merge_label # Label-and-comment (--supersede-stale, Gap 4 cheaper) OR # label-and-comment-and-close (--flatten-stale, Gap 4 full). # The flatten-stale path auto-closes the superseded PRs on the # assumption that every cimas-sync wave regenerates the same # files from cimas.yml, so a newer wave strictly supersedes # any older wave's PR on the same repo. stale_prs.each do |stale| begin handle_superseded_pr( github_slug, stale, number, branch, flatten: config['flatten_stale'] == true, ) rescue Octokit::Error => e puts " [WARNING] could not process supersede on " \ "#{github_slug}\##{stale.number}: #{e.}" end end puts "PR #{github_slug}\##{number} created" rescue Octokit::Error => e case e. when /A pull request already exists/ puts "[WARNING] PR already exists for #{branch}." next when /field: head\s+code: invalid/ puts "[WARNING] Branch #{branch} does not exist on #{github_slug}. Did you run `push`? Skipping." next when /message: No commits between/ puts "[WARNING] Target branch (#{repo.branch}) is on par with new branch (#{branch}). Skipping." next when /Repository was archived so is read-only/ puts "[WARNING] Reporitory #{branch} is readonly. Skipping." next else raise e end end unless pr puts "[WARNING] Detecting PR from GitHub..." github_branch_owner = github_slug.split('/').first prs = github_client.pull_requests(github_slug, head: "#{github_branch_owner}:#{branch}") pr = prs.first unless pr puts "[WARNING] Failed to detect PR from GitHub for #{github_slug} repo. Skipping." next end puts "[WARNING] Detected PR to be #{github_slug}\##{pr['number']}, continue processing." end number = pr['number'] unless reviewers.empty? puts "Requesting #{github_slug}\##{number} review from: [#{reviewers.join(',')}]" begin github_client.request_pull_request_review( github_slug, number, reviewers: reviewers ) rescue Octokit::Error => e # TODO: When command is first run, should exclude the PR author from 'reviewers' case e. when /Review cannot be requested from pull request author./ puts "[WARNING] #{e.}, skipping." next else raise e end end end unless assignees.empty? puts "Assigning #{github_slug}\##{number} to: [#{assignees.join(',')}]" github_client.add_assignees( github_slug, number, assignees ) end cooldown_counter += 1 if cooldown_counter % cooldown_count == 0 puts "Cool down for #{cooldown_time}sec to not abuse GitHub API..." sleep(cooldown_time) end end end end |
#patches ⇒ Object
275 276 277 |
# File 'lib/cimas/cli/command.rb', line 275 def patches (data['patches'] || {}).map { |name, attrs| Cimas::Patch.new(name, attrs) } end |
#pr_message ⇒ Object
455 456 457 |
# File 'lib/cimas/cli/command.rb', line 455 def required_option('pr_message', '-m/--message') end |
#pull ⇒ Object
432 433 434 435 436 437 438 439 440 441 442 443 |
# File 'lib/cimas/cli/command.rb', line 432 def pull sanity_check each_target_repo('pull') do |repo, repo_dir| dry_run("Pulling from #{repo.name}/#{repo.branch}...") do puts "Pulling from #{repo.name}/#{repo.branch}..." WorkingCopy.open(repo_dir).fetch_reset_pull(repo.branch) end end puts "Done!" end |
#push ⇒ Object
483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 |
# File 'lib/cimas/cli/command.rb', line 483 def push sanity_check drift_pushes = 0 skipped_no_op = 0 each_target_repo('push') do |repo, repo_dir| repo_name = repo.name wc = WorkingCopy.open(repo_dir) # Skip repos with no drift. The historical "always push even # without changes" behavior was there to guarantee the wave # branch exists on remote for the next-stage `cimas open-prs`. # But open_prs already handles missing wave branches gracefully # (see the /field: head\s+code: invalid/ rescue in # `open_prs`, which skips with a WARNING) and also handles the # "branch present but empty PR" case (/message: No commits # between/). So we can safely skip pushing wave branches for # repos that have no drift — the whole no-op notification-noise # class disappears without breaking open_prs. # # Assumes `cimas sync` has been run against this work-dir first, # so wd status reflects the drift state (matches the ordering # documented in README "End-to-end workflow"). unless wc.drift? skipped_no_op += 1 msg = "Skipping no-op push to #{repo_name} (no drift)" puts config['dry_run'] ? "dry run: #{msg}" : msg next end drift_pushes += 1 dry_run("Pushing branch #{push_to_branch} (commit #{wc.head_sha}) to #{wc.remote_name}:#{repo_name}") do puts "repo.branch #{repo.branch}" if verbose wc.reset_onto(repo.branch, discard_branch: push_to_branch) unless keep_changes wc.switch_branch(push_to_branch) wc.stage(*repo.files.keys) if wc.clean? puts "Skipping commit on #{repo_name}, no changes detected." if verbose else puts "Committing on #{repo_name}." wc.commit_all() end # Still push even if there was no commit, as the remote branch # may have been deleted. If the remote branch is deleted we can't # make PRs in the next stage. (Guard above ensures this branch # only runs when either the wd has drift OR the remote branch is # actually missing.) action = force_push ? "Force-pushing" : "Pushing" puts "#{action} branch #{push_to_branch} (commit #{wc.head_sha}) to #{wc.remote_name}:#{repo_name}." outcome = wc.push(push_to_branch, force: force_push) if outcome == :pushed nil elsif outcome == :behind_remote puts "[WARNING] branch #{push_to_branch} already exists on remote. If you wanna force push, pass --force" else _status, error = outcome puts "An error of type #{error.class} happened, message is #{error.}" end end end puts "" puts "Push summary:" puts " Pushed with drift: #{drift_pushes}" puts " Skipped (no drift): #{skipped_no_op}" end |
#push_to_branch ⇒ Object
459 460 461 |
# File 'lib/cimas/cli/command.rb', line 459 def push_to_branch required_option('push_to_branch', '-b/--push-branch') end |
#release_preflight ⇒ Object
993 994 995 |
# File 'lib/cimas/cli/command.rb', line 993 def release_preflight Cimas::ReleasePreflight.new(self, runner: config["release_preflight_runner"]).run end |
#repo_by_name(name) ⇒ Object
425 426 427 428 429 430 |
# File 'lib/cimas/cli/command.rb', line 425 def repo_by_name(name) attributes = repositories[name] return nil unless attributes Cimas::Repository.new(name, attributes) end |
#repo_visibility_private?(repo) ⇒ Boolean
Returns true if the repo is GitHub-private, false if public. Cached per invocation so a wave sync makes at most one call per repo.
169 170 171 172 173 174 175 |
# File 'lib/cimas/cli/command.rb', line 169 def repo_visibility_private?(repo) @visibility_cache ||= {} slug = git_remote_to_github_name(repo.remote) return @visibility_cache[slug] if @visibility_cache.key?(slug) @visibility_cache[slug] = fetch_repo_visibility(slug) end |
#repos_path ⇒ Object
220 221 222 |
# File 'lib/cimas/cli/command.rb', line 220 def repos_path config['repos_path'] end |
#repositories ⇒ Object
224 225 226 |
# File 'lib/cimas/cli/command.rb', line 224 def repositories data['repositories'] end |
#require_explicit_scope!(command_name) ⇒ Object
Remote-mutating subcommands refuse to run unless -g is given and resolves to at least one repository. Raises Cimas::Cli::Error so the CLI reports a clean message without a backtrace.
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 |
# File 'lib/cimas/cli/command.rb', line 376 def require_explicit_scope!(command_name) return unless self.class.remote_mutating?(command_name, config) groups = config['groups'] if groups.nil? raise Cimas::Cli::Error, "#{command_name}: no -g given — would target all " \ "#{repositories.size} repositories in #{config['config_file_path']}. " \ "Pass -g <group(s)> or -g <repo-name> to scope, or -g all to " \ "target the whole fleet deliberately." end if groups.empty? raise Cimas::Cli::Error, "#{command_name}: -g given but empty (e.g. `-g ''`) — pass " \ "-g <group(s)>, -g <repo-name>, or -g all to target the " \ "whole fleet deliberately." end names = filtered_repo_names if names.empty? raise Cimas::Cli::Error, "#{command_name}: -g #{Array(groups).join(',')} resolves to 0 " \ "repositories in #{config['config_file_path']} — check the " \ "groups: section or the repo name." end end |
#resolve_pr_body ⇒ Object
PR body from --body-file (preferred), --body inline, or the
legacy "As title." placeholder. See metanorma/cimas#49 Bug 1: the
previous open-prs unconditionally used -m as the title and a
hard-coded body placeholder, so multi-line PR bodies were
impossible — and passing a long markdown body via -m made it
the title, triggering HTTP 422 "title is too long (max 256
chars)" and aborting the whole open-prs loop. Force UTF-8 on the
file read: locale-default (US-ASCII on some Ruby configs)
mis-tags the string, and Octokit → Sawyer → JSON.dump then blows
up on non-ASCII bytes (em dash, curly quotes) with "\xE2" on US-ASCII. PR bodies are markdown and routinely contain UTF-8;
encoding-tagging at read time is the right place to fix it.
626 627 628 629 630 631 632 633 634 635 636 637 638 |
# File 'lib/cimas/cli/command.rb', line 626 def resolve_pr_body if config['pr_body_file'] && config['pr_body'] raise Cimas::Cli::Error, "--body and --body-file are mutually exclusive" end if config['pr_body_file'] File.read(config['pr_body_file'], encoding: 'UTF-8') elsif config['pr_body'] config['pr_body'].dup.force_encoding('UTF-8') else "As title. \n\n _Generated by Cimas_." end end |
#resolve_source(source, repo) ⇒ Object
For metanorma/ci#347 Option B: a files: value can be either the
legacy String (a single template path) or a Hash of the shape
{ 'if_public' => path1, 'if_private' => path2 }. In the Hash
case, cimas picks the concrete template at sync time from the
target repo's GitHub visibility, so the same cimas.yml entry
tracks both public and private variants of e.g. docker.yml.
See ci#347 (private-vs-public docker split) for the design.
602 603 604 605 606 607 608 609 610 611 612 |
# File 'lib/cimas/cli/command.rb', line 602 def resolve_source(source, repo) return source unless source.is_a?(Hash) unless source.key?("if_public") && source.key?("if_private") raise "[ERROR] visibility-conditional source needs both " \ "`if_public` and `if_private` keys; got: #{source.inspect}" end is_private = repo_visibility_private?(repo) is_private ? source["if_private"] : source["if_public"] end |
#reviewers_excluding_token_user(reviewers) ⇒ Object
GitHub rejects self-review requests with HTTP 422 "Review cannot be requested from pull request author." Pre-filter the token user out so the other reviewers still get requested (#7); when the token user can't be resolved, proceed as configured.
644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 |
# File 'lib/cimas/cli/command.rb', line 644 def reviewers_excluding_token_user(reviewers) token_user = github_client.user.login if reviewers.include?(token_user) puts "[INFO] open-prs: excluding token user " \ "'#{token_user}' from reviewers (cannot self-review)" reviewers.reject { |r| r == token_user } else reviewers end rescue Octokit::Error => e puts "[WARNING] open-prs: could not resolve token user " \ "for self-review filter (#{e.}); " \ "proceeding with reviewers as configured" reviewers end |
#sanity_check ⇒ Object
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/cimas/cli/command.rb', line 197 def sanity_check unsynced = [] repositories.each_pair do |repo_name, attribs| repo_dir = File.join(repos_path, repo_name) unless File.exist?(repo_dir) && File.exist?(File.join(repo_dir, '.git')) unsynced << repo_name end end unsynced.uniq! return true if unsynced.empty? # Advisory only — execution continues (pure-API commands such as # cleanup-merged-prs legitimately run with no clones present). warn "[WARNING] These repositories have not been setup, please run `setup` first: #{unsynced.inspect}" end |
#settings ⇒ Object
141 142 143 |
# File 'lib/cimas/cli/command.rb', line 141 def settings data['settings'] end |
#setup ⇒ Object
185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/cimas/cli/command.rb', line 185 def setup repositories.each_pair do |repo_name, attribs| repo_dir = File.join(repos_path, repo_name) unless File.exist?(repo_dir) && File.exist?(File.join(repo_dir, '.git')) puts "Git cloning #{repo_name} from #{attribs['remote']}..." WorkingCopy.clone(attribs['remote'], repo_name, path: repos_path) else puts "Skip cloning #{repo_name}, #{repo_dir} already exists." if verbose end end end |
#shell_cmd ⇒ Object
467 468 469 |
# File 'lib/cimas/cli/command.rb', line 467 def shell_cmd required_option('shell_cmd', '-c/--shell-cmd') end |
#supersede_comment_body(new_number, new_branch, flatten:) ⇒ Object
578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 |
# File 'lib/cimas/cli/command.rb', line 578 def supersede_comment_body(new_number, new_branch, flatten:) if flatten "Auto-closed as superseded by ##{new_number} from a later " \ "cimas-sync wave (`#{new_branch}`). If part of this PR's " \ "content should have been preserved before flattening, " \ "rebase this branch elsewhere and reopen. " \ "(--flatten-stale, metanorma/ci#300 Gap 4 full)" else "Superseded by ##{new_number} from a later cimas-sync wave " \ "(`#{new_branch}`). This PR was **not auto-closed** by cimas " \ "— the reviewer keeps authority over the close decision. " \ "Close after merging ##{new_number}, or rebase this branch " \ "onto something else if part of its content should still be " \ "preserved. (metanorma/ci#300 Gap 4)" end end |
#sync ⇒ Object
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 |
# File 'lib/cimas/cli/command.rb', line 232 def sync sanity_check unless config['config_master_path'].exist? raise "[ERROR] config_master_path not set, aborting." end each_target_repo('sync') do |repo, repo_dir| repo_name = repo.name dry_run("Copying files to #{repo_name} and staging them") do wc = WorkingCopy.open(repo_dir) wc.reset_clean(repo.branch) unless keep_changes puts "Syncing and staging files in #{repo_name}..." repo.files.each do |target, source| resolved_source = resolve_source(source, repo) source_path = File.join(config_master_path, resolved_source) target_path = File.join(repos_path, repo_name, target) puts "file #{source_path} => #{target_path}" if verbose if source_path.end_with? ".erb" write_rendered(render_erb_template(source_path, repo), target_path) else copy_file(source_path, target_path) end wc.stage(target) end apply_patches(repo_name, repo_dir, wc) if verbose wc.each_staged_change do |_file, contents| puts "Updated files in #{repo_name}:" puts contents end end end end end |
#validate_required_options!(command_name) ⇒ Object
Eager fail-fast for required flags, before any repo iteration —
lazy accessor validation alone let push -g data (no -b) exit 0
whenever every repo happened to be skipped.
406 407 408 409 410 411 412 413 |
# File 'lib/cimas/cli/command.rb', line 406 def (command_name) missing = self.class.(command_name, config) return if missing.empty? flags = missing.map { |key| OPTION_FLAGS.fetch(key, key) }.join(', ') raise Cimas::Cli::Error, "#{command_name}: missing required option(s): #{flags}" end |
#verbose ⇒ Object
228 229 230 |
# File 'lib/cimas/cli/command.rb', line 228 def verbose config['verbose'] end |