Class: Cimas::Cli::Command

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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(options)
  unless options['config_file_path'].exist?
    raise "[ERROR] config_file_path #{options['config_file_path']} does not exist, aborting."
  end

  @data = YAML.load(File.read(options['config_file_path'])) || {}

  unless repositories.is_a?(Hash) && !repositories.empty?
    raise "[ERROR] no `repositories:` section in #{options['config_file_path']} — nothing to operate on, aborting."
  end

  @config = DEFAULT_CONFIG.merge(settings || {}).merge(options)

  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_meta(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.missing_required_options(command_name, config)
  meta = command_meta(command_name)
  required = meta[:requires] || []
  flag, conditional = meta[:requires_if] || [nil, []]
  required += conditional if flag && config[flag] == true
  required.reject { |key| config[key] }
end

.remote_mutating?(command_name, config = {}) ⇒ Boolean

Returns:

  • (Boolean)


88
89
90
91
92
93
94
# File 'lib/cimas/cli/command.rb', line 88

def self.remote_mutating?(command_name, config = {})
  meta = command_meta(command_name)
  return true if meta[:remote_mutating]

  flag = meta[:remote_mutating_if]
  !flag.nil? && config[flag] == true
end

Instance Method Details

#add_auto_merge_labelObject



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_prsObject

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.message}"
      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_prsObject

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.message}"
      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_filesObject

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
  message = push_after ? pr_message : 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(message)
        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_messageObject



445
446
447
448
449
450
451
452
453
# File 'lib/cimas/cli/command.rb', line 445

def commit_message
  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

#configObject



177
178
179
# File 'lib/cimas/cli/command.rb', line 177

def config
  @config
end

#config_master_pathObject



216
217
218
# File 'lib/cimas/cli/command.rb', line 216

def config_master_path
  config['config_master_path']
end

#dataObject



181
182
183
# File 'lib/cimas/cli/command.rb', line 181

def data
  @data
end

#diffObject



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_repoObject

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)
  validate_required_options!(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_namesObject



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_eachObject



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_pushObject



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

#githubObject

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_clientObject



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_changesObject



479
480
481
# File 'lib/cimas/cli/command.rb', line 479

def keep_changes
  config['keep_changes']
end

#merge_branchObject



463
464
465
# File 'lib/cimas/cli/command.rb', line 463

def merge_branch
  required_option('merge_branch', '-b/--merge-branch')
end

#open_prsObject



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
  message = pr_message
  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.message}); 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 '#{message}'") do
      puts "Opening GitHub PR: #{github_slug}, branch #{repo.branch} <- #{branch}, message '#{message}'"

      begin
        pr = github_client.create_pull_request(
          github_slug,
          repo.branch,
          branch,
          message,
          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.message}"
          end
        end

        puts "PR #{github_slug}\##{number} created"

      rescue Octokit::Error => e
        case e.message
        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.message
          when /Review cannot be requested from pull request author./
            puts "[WARNING] #{e.message}, 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

#patchesObject



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_messageObject



455
456
457
# File 'lib/cimas/cli/command.rb', line 455

def pr_message
  required_option('pr_message', '-m/--message')
end

#pullObject



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

#pushObject



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(commit_message)
      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.message}"
      end
    end
  end

  puts ""
  puts "Push summary:"
  puts "  Pushed with drift: #{drift_pushes}"
  puts "  Skipped (no drift): #{skipped_no_op}"
end

#push_to_branchObject



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_preflightObject



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.

Returns:

  • (Boolean)


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_pathObject



220
221
222
# File 'lib/cimas/cli/command.rb', line 220

def repos_path
  config['repos_path']
end

#repositoriesObject



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_bodyObject

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.
  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.message}); " \
       "proceeding with reviewers as configured"
  reviewers
end

#sanity_checkObject



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

#settingsObject



141
142
143
# File 'lib/cimas/cli/command.rb', line 141

def settings
  data['settings']
end

#setupObject



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_cmdObject



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

#syncObject



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.

Raises:



406
407
408
409
410
411
412
413
# File 'lib/cimas/cli/command.rb', line 406

def validate_required_options!(command_name)
  missing = self.class.missing_required_options(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

#verboseObject



228
229
230
# File 'lib/cimas/cli/command.rb', line 228

def verbose
  config['verbose']
end