cimas 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,33 +1,118 @@
1
- require 'json'
2
1
  require 'yaml'
3
- require 'net/http'
4
- require 'git'
5
- require 'cimas'
6
- # require 'travis/client/session'
2
+ require 'octokit'
3
+ require 'ostruct'
4
+ require 'erb'
5
+ require 'fileutils'
6
+ require 'set'
7
7
 
8
8
  module Cimas
9
9
  module Cli
10
10
  class Command
11
- attr_accessor :github_client, :config
12
-
13
11
  DEFAULT_CONFIG = {
14
12
  'dry_run' => false,
15
13
  'verbose' => false,
16
- 'groups' => ['all'],
17
- 'pull_branch' => 'master',
14
+ # nil, not ['all']: defaulting a wave to the whole fleet is how a
15
+ # forgotten -g fans branches/PRs out to every repo in the config.
16
+ # filtered_repo_names still falls back to all repos for local-only
17
+ # commands; remote-mutating ones refuse at dispatch (see COMMANDS
18
+ # and `execute`).
19
+ 'groups' => nil,
18
20
  'force_push' => false,
19
21
  'assignees' => [],
20
- 'reviewers' => []
22
+ 'reviewers' => [],
23
+ 'keep_changes' => false,
24
+ 'add_auto_merge_label' => true,
25
+ 'cooldown_count' => 10,
26
+ 'cooldown_time' => 3 * 60
21
27
  }
22
28
 
29
+ # One registry entry per subcommand, the single classification that
30
+ # drives dispatch-time behavior:
31
+ # :remote_mutating — refuses to run without an explicit -g and
32
+ # prints a pre-flight scope line (provision branches, open PRs,
33
+ # delete branches, run arbitrary shell).
34
+ # :remote_mutating_if — same, but only when the mapped config
35
+ # flag opts in.
36
+ # :requires — config keys that must be set; validated eagerly at
37
+ # dispatch so a missing -b/-m fails fast instead of exiting 0
38
+ # when every repo happens to be skipped.
39
+ # :requires_if — additional required keys under a config flag.
40
+ # Adding a subcommand = adding one entry here.
41
+ COMMANDS = {
42
+ 'setup' => {},
43
+ 'sync' => {},
44
+ 'diff' => {},
45
+ 'pull' => {},
46
+ 'push' => {
47
+ remote_mutating: true,
48
+ requires: %w[push_to_branch commit_message],
49
+ },
50
+ 'open-prs' => {
51
+ remote_mutating: true,
52
+ requires: %w[merge_branch pr_message],
53
+ },
54
+ 'for-each' => {
55
+ remote_mutating: true,
56
+ requires: %w[shell_cmd],
57
+ },
58
+ 'cleanup-merged-prs' => {
59
+ remote_mutating: true,
60
+ requires: %w[push_to_branch],
61
+ },
62
+ 'cleanup-closed-prs' => { remote_mutating: true },
63
+ 'cleanup-orphan-files' => {
64
+ remote_mutating_if: 'cleanup_push_after',
65
+ requires_if: ['cleanup_push_after', %w[push_to_branch pr_message]],
66
+ },
67
+ 'release-preflight' => { requires: %w[target_repo] },
68
+ }.freeze
69
+
70
+ # Config key → CLI flag spelling, for required-option messages.
71
+ OPTION_FLAGS = {
72
+ 'push_to_branch' => '-b/--push-branch',
73
+ 'commit_message' => '-m/--message',
74
+ 'merge_branch' => '-b/--merge-branch',
75
+ 'pr_message' => '-m/--message',
76
+ 'shell_cmd' => '-c/--shell-cmd',
77
+ 'target_repo' => '--repo',
78
+ }.freeze
79
+
80
+ # How many repository names the pre-flight scope line lists before
81
+ # collapsing to a count.
82
+ SCOPE_LIST_LIMIT = 30
83
+
84
+ def self.command_meta(command_name)
85
+ COMMANDS[command_name] || {}
86
+ end
87
+
88
+ def self.remote_mutating?(command_name, config = {})
89
+ meta = command_meta(command_name)
90
+ return true if meta[:remote_mutating]
91
+
92
+ flag = meta[:remote_mutating_if]
93
+ !flag.nil? && config[flag] == true
94
+ end
95
+
96
+ def self.missing_required_options(command_name, config)
97
+ meta = command_meta(command_name)
98
+ required = meta[:requires] || []
99
+ flag, conditional = meta[:requires_if] || [nil, []]
100
+ required += conditional if flag && config[flag] == true
101
+ required.reject { |key| config[key] }
102
+ end
103
+
23
104
  def initialize(options)
24
105
  unless options['config_file_path'].exist?
25
106
  raise "[ERROR] config_file_path #{options['config_file_path']} does not exist, aborting."
26
107
  end
27
108
 
28
- @data = YAML.load(IO.read(options['config_file_path']))
109
+ @data = YAML.load(File.read(options['config_file_path'])) || {}
29
110
 
30
- @config = DEFAULT_CONFIG.merge(settings).merge(options)
111
+ unless repositories.is_a?(Hash) && !repositories.empty?
112
+ raise "[ERROR] no `repositories:` section in #{options['config_file_path']} — nothing to operate on, aborting."
113
+ end
114
+
115
+ @config = DEFAULT_CONFIG.merge(settings || {}).merge(options)
31
116
 
32
117
  unless repos_path.exist?
33
118
  FileUtils.mkdir_p repos_path
@@ -38,16 +123,55 @@ module Cimas
38
123
  end
39
124
  end
40
125
 
126
+ # Single dispatch entrypoint (`exe/cimas` calls this). Scope guard,
127
+ # required-option validation and the scope announcement all derive
128
+ # from the one COMMANDS classification, so every remote-mutating
129
+ # subcommand is guarded AND announces its blast radius uniformly,
130
+ # and every required flag fails fast — before any repo iteration.
131
+ # Calling a subcommand method directly bypasses the guard by design
132
+ # — it protects CLI operators, not library callers.
133
+ def execute(command_name)
134
+ require_explicit_scope!(command_name)
135
+ validate_required_options!(command_name)
136
+ announce_scope(command_name) if self.class.remote_mutating?(command_name, config)
137
+
138
+ public_send(command_name.tr('-', '_'))
139
+ end
140
+
41
141
  def settings
42
142
  data['settings']
43
143
  end
44
144
 
145
+ # Octokit boundary lives in Cimas::GitHub; these delegators keep
146
+ # the orchestrator's vocabulary (slug from remote, cached
147
+ # visibility per repository).
148
+ # Inject a stand-in via config['github'] for offline specs;
149
+ # production always builds a real Cimas::GitHub.
150
+ def github
151
+ @github ||= config['github'] || Cimas::GitHub.new(token: config['github_token'])
152
+ end
153
+
45
154
  def github_client
46
- require 'octokit'
47
- if config['github_token'].nil?
48
- raise "[ERROR] Please set GITHUB_TOKEN environment variable to use GitHub functions."
49
- end
50
- @github_client ||= Octokit::Client.new(access_token: config['github_token'])
155
+ github.client
156
+ end
157
+
158
+ def git_remote_to_github_name(remote)
159
+ github.slug_for(remote)
160
+ end
161
+
162
+ def fetch_repo_visibility(slug)
163
+ github.fetch_visibility(slug)
164
+ end
165
+
166
+ # Returns true if the repo is GitHub-private, false if public.
167
+ # Cached per invocation so a wave sync makes at most one call per
168
+ # repo.
169
+ def repo_visibility_private?(repo)
170
+ @visibility_cache ||= {}
171
+ slug = git_remote_to_github_name(repo.remote)
172
+ return @visibility_cache[slug] if @visibility_cache.key?(slug)
173
+
174
+ @visibility_cache[slug] = fetch_repo_visibility(slug)
51
175
  end
52
176
 
53
177
  def config
@@ -61,12 +185,11 @@ module Cimas
61
185
  def setup
62
186
  repositories.each_pair do |repo_name, attribs|
63
187
  repo_dir = File.join(repos_path, repo_name)
64
- # puts "attribs #{attribs.inspect}"
65
188
  unless File.exist?(repo_dir) && File.exist?(File.join(repo_dir, '.git'))
66
189
  puts "Git cloning #{repo_name} from #{attribs['remote']}..."
67
- Git.clone(attribs['remote'], repo_name, path: repos_path)
190
+ WorkingCopy.clone(attribs['remote'], repo_name, path: repos_path)
68
191
  else
69
- puts "Skip cloning #{repo_name}, already exists."
192
+ puts "Skip cloning #{repo_name}, #{repo_dir} already exists." if verbose
70
193
  end
71
194
  end
72
195
  end
@@ -85,7 +208,9 @@ module Cimas
85
208
 
86
209
  return true if unsynced.empty?
87
210
 
88
- raise "[ERROR] These repositories have not been setup, please run `setup` first: #{unsynced.inspect}"
211
+ # Advisory only execution continues (pure-API commands such as
212
+ # cleanup-merged-prs legitimately run with no clones present).
213
+ warn "[WARNING] These repositories have not been setup, please run `setup` first: #{unsynced.inspect}"
89
214
  end
90
215
 
91
216
  def config_master_path
@@ -100,179 +225,217 @@ module Cimas
100
225
  data['repositories']
101
226
  end
102
227
 
228
+ def verbose
229
+ config['verbose']
230
+ end
231
+
103
232
  def sync
104
233
  sanity_check
105
234
  unless config['config_master_path'].exist?
106
235
  raise "[ERROR] config_master_path not set, aborting."
107
236
  end
108
237
 
109
- filtered_repo_names.each do |repo_name|
110
-
111
- repo = repo_by_name(repo_name)
112
- if repo.nil?
113
- puts "[WARNING] #{repo_name} not configured, skipping."
114
- next
115
- end
116
-
117
- repo_dir = File.join(repos_path, repo_name)
118
- unless File.exist?(repo_dir)
119
- puts "[ERROR] #{repo_name} is missing in #{repos_path}, skipping sync for it."
120
- next
121
- end
238
+ each_target_repo('sync') do |repo, repo_dir|
239
+ repo_name = repo.name
122
240
 
123
241
  dry_run("Copying files to #{repo_name} and staging them") do
124
- g = Git.open(repo_dir)
125
- g.checkout(repo.branch)
126
- g.reset_hard(repo.branch)
127
- g.clean(force: true)
242
+ wc = WorkingCopy.open(repo_dir)
243
+
244
+ wc.reset_clean(repo.branch) unless keep_changes
128
245
 
129
246
  puts "Syncing and staging files in #{repo_name}..."
130
247
 
131
248
  repo.files.each do |target, source|
132
- # puts "file #{source} => #{target}"
133
- source_path = File.join(config_master_path, source)
249
+ resolved_source = resolve_source(source, repo)
250
+ source_path = File.join(config_master_path, resolved_source)
134
251
  target_path = File.join(repos_path, repo_name, target)
135
- # puts "file #{source_path} => #{target_path}"
252
+ puts "file #{source_path} => #{target_path}" if verbose
136
253
 
137
- copy_file(source_path, target_path)
138
- g.add(target_path)
254
+ if source_path.end_with? ".erb"
255
+ write_rendered(render_erb_template(source_path, repo), target_path)
256
+ else
257
+ copy_file(source_path, target_path)
258
+ end
259
+
260
+ wc.stage(target)
139
261
  end
140
262
 
141
- # Debugging to see if files have been changed
142
- # g.status.changed.each do |file, status|
143
- # puts "Updated files in #{repo_name}:"
144
- # puts status.blob(:index).contents
145
- # end
263
+ apply_patches(repo_name, repo_dir, wc)
264
+
265
+ if verbose
266
+ wc.each_staged_change do |_file, contents|
267
+ puts "Updated files in #{repo_name}:"
268
+ puts contents
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
274
+
275
+ def patches
276
+ (data['patches'] || {}).map { |name, attrs| Cimas::Patch.new(name, attrs) }
277
+ end
278
+
279
+ def apply_patches(repo_name, repo_dir, working_copy)
280
+ patches.each do |patch|
281
+ target_repo_names = patch.group_names.flat_map { |g| group_repo_names(g) }.uniq
282
+ next unless target_repo_names.include?(repo_name)
283
+
284
+ patch.globs.each do |glob|
285
+ matched = Dir.glob(File.join(repo_dir, glob))
286
+ if matched.empty?
287
+ puts "[WARNING] Patch '#{patch.name}' on #{repo_name}: no files matched glob '#{glob}'."
288
+ next
289
+ end
290
+
291
+ matched.each do |file_path|
292
+ rel_path = file_path.sub(/\A#{Regexp.escape(repo_dir)}\/?/, '')
293
+ original = File.read(file_path)
294
+ # Distinguish two cases that previously both logged the same
295
+ # misleading "pattern did not match, file unchanged" warning
296
+ # (see metanorma/cimas#49 Bug 3):
297
+ # - `find` regex doesn't appear in the file at all (the line
298
+ # this patch wants to update is genuinely absent — e.g. a
299
+ # gemspec with no `required_ruby_version` line, the NOVER
300
+ # case). WARNING-level: maintainer may want to add the line.
301
+ # - `find` matches but gsub produces identical text (the
302
+ # file is already at the target value). INFO-level: this is
303
+ # a normal idempotent no-op, not a problem.
304
+ unless patch.matches?(original)
305
+ 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)."
306
+ next
307
+ end
308
+
309
+ updated = patch.apply(original)
310
+ if original == updated
311
+ puts "[INFO] Patch '#{patch.name}' on #{repo_name}:#{rel_path}: already at target value, no-op."
312
+ next
313
+ end
314
+
315
+ dry_run("Patching #{rel_path} in #{repo_name} (patch '#{patch.name}')") do
316
+ File.write(file_path, updated)
317
+ working_copy.stage(rel_path)
318
+ end
319
+ end
146
320
  end
147
321
  end
148
322
  end
149
323
 
150
324
  def diff
151
325
  sanity_check
152
- unless config['config_master_path'].exist?
153
- raise "[ERROR] config_master_path not set, aborting."
326
+
327
+ each_target_repo('diff') do |repo, repo_dir|
328
+ puts "======================= DIFF FOR #{repo.name} ========================="
329
+ puts WorkingCopy.open(repo_dir).diff_patch
154
330
  end
331
+ end
155
332
 
156
- filtered_repo_names.each do |repo_name|
333
+ def filtered_repo_names
334
+ @filtered_repo_names ||= if config['groups']
335
+ config['groups'].inject([]) do |acc, group|
336
+ acc + group_repo_names(group)
337
+ end.uniq
338
+ else
339
+ repositories.keys
340
+ end
341
+ end
157
342
 
343
+ # Iterates the wave's resolved target repos, skipping any name that
344
+ # is not a configured repository (`-g typo` resolves to a repo name
345
+ # that cimas.yml doesn't define).
346
+ def each_configured_repo
347
+ filtered_repo_names.each do |repo_name|
158
348
  repo = repo_by_name(repo_name)
159
349
  if repo.nil?
160
350
  puts "[WARNING] #{repo_name} not configured, skipping."
161
351
  next
162
352
  end
163
353
 
164
- repo_dir = File.join(repos_path, repo_name)
354
+ yield repo
355
+ end
356
+ end
357
+
358
+ # `each_configured_repo` plus the clone-presence check, for commands
359
+ # that operate on the working copy. Skip messages are uniform across
360
+ # subcommands (`skipping <command> for it`).
361
+ def each_target_repo(command_name)
362
+ each_configured_repo do |repo|
363
+ repo_dir = File.join(repos_path, repo.name)
165
364
  unless File.exist?(repo_dir)
166
- puts "[ERROR] #{repo_name} is missing in #{repos_path}, skipping diff for it."
365
+ puts "[ERROR] #{repo.name} is missing in #{repos_path}, skipping #{command_name} for it."
167
366
  next
168
367
  end
169
368
 
170
- g = Git.open(repo_dir)
171
- # g.checkout(branch)
172
- # g.reset_hard(branch)
173
- # g.clean(force: true)
174
-
175
- # puts "Syncing files in #{repo_name}..."
176
- #
177
- # files.each do |target, source|
178
- # # puts "file #{source} => #{target}"
179
- # source_path = File.join(config_master_path, source)
180
- # target_path = File.join(repos_path, repo_name, target)
181
- # # puts "file #{source_path} => #{target_path}"
182
- #
183
- # copy_file(source_path, target_path)
184
- # # g.add(target_path)
185
- # end
369
+ yield repo, repo_dir
370
+ end
371
+ end
186
372
 
187
- puts "======================= DIFF FOR #{repo_name} ========================="
188
- # Debugging to see if files have been changed
189
- diff = g.diff
190
- puts diff.patch
373
+ # Remote-mutating subcommands refuse to run unless -g is given and
374
+ # resolves to at least one repository. Raises Cimas::Cli::Error so
375
+ # the CLI reports a clean message without a backtrace.
376
+ def require_explicit_scope!(command_name)
377
+ return unless self.class.remote_mutating?(command_name, config)
378
+
379
+ groups = config['groups']
380
+ if groups.nil?
381
+ raise Cimas::Cli::Error,
382
+ "#{command_name}: no -g given — would target all " \
383
+ "#{repositories.size} repositories in #{config['config_file_path']}. " \
384
+ "Pass -g <group(s)> or -g <repo-name> to scope, or -g all to " \
385
+ "target the whole fleet deliberately."
386
+ end
387
+ if groups.empty?
388
+ raise Cimas::Cli::Error,
389
+ "#{command_name}: -g given but empty (e.g. `-g ''`) — pass " \
390
+ "-g <group(s)>, -g <repo-name>, or -g all to target the " \
391
+ "whole fleet deliberately."
392
+ end
191
393
 
192
- # g.status.changed.each do |file, status|
193
- # puts "Updated files in #{repo_name}:"
194
- # puts status.blob(:index).contents
195
- # end
394
+ names = filtered_repo_names
395
+ if names.empty?
396
+ raise Cimas::Cli::Error,
397
+ "#{command_name}: -g #{Array(groups).join(',')} resolves to 0 " \
398
+ "repositories in #{config['config_file_path']} — check the " \
399
+ "groups: section or the repo name."
196
400
  end
197
401
  end
198
402
 
199
- # def lint(options)
200
- # config_master_path = options['config_master_path']
201
- # appveyor_token = options['appveyor_token']
202
- #
203
- # config = YAML.load_file(File.join(config_master_path, 'ci.yml'))
204
- #
205
- # validated = []
206
- #
207
- # config['repos'].each do |_, repo_ci|
208
- # travisci, appveyor = repo_ci.values_at('.travis.yml', 'appveyor.yml')
209
- #
210
- # if travisci && !validated.include?(travisci)
211
- # valid = system("travis lint #{File.join(config_master_path, travisci)}", :out => :close)
212
- # puts "#{travisci} valid: #{valid}"
213
- # validated << travisci
214
- # end
215
- #
216
- # if appveyor && !validated.include?(appveyor)
217
- # uri = URI('https://ci.appveyor.com/api/projects/validate-yaml')
218
- # http = Net::HTTP.new(uri.host, uri.port)
219
- # http.use_ssl = true
220
- #
221
- # req = Net::HTTP::Post.new(uri.path, {
222
- # "Content-Type" => "application/json",
223
- # "Authorization" => "Bearer #{appveyor_token}"
224
- # })
225
- # req.body = File.read(File.join(config_master_path, appveyor))
226
- #
227
- # valid = http.request(req).kind_of? Net::HTTPSuccess
228
- #
229
- # puts "#{appveyor} valid: #{valid}"
230
- # validated << appveyor
231
- # end
232
- # end
233
- # end
403
+ # Eager fail-fast for required flags, before any repo iteration —
404
+ # lazy accessor validation alone let `push -g data` (no -b) exit 0
405
+ # whenever every repo happened to be skipped.
406
+ def validate_required_options!(command_name)
407
+ missing = self.class.missing_required_options(command_name, config)
408
+ return if missing.empty?
234
409
 
235
- def filtered_repo_names
236
- return repositories unless config['groups']
410
+ flags = missing.map { |key| OPTION_FLAGS.fetch(key, key) }.join(', ')
411
+ raise Cimas::Cli::Error,
412
+ "#{command_name}: missing required option(s): #{flags}"
413
+ end
237
414
 
238
- # puts "config['groups'] #{config['groups'].inspect}"
239
- config['groups'].inject([]) do |acc, group|
240
- acc + group_repo_names(group)
241
- end.uniq
415
+ def announce_scope(command_name)
416
+ names = filtered_repo_names
417
+ label = if names.size <= SCOPE_LIST_LIMIT
418
+ names.join(', ')
419
+ else
420
+ "(list omitted, >#{SCOPE_LIST_LIMIT} repos)"
421
+ end
422
+ puts "Scope for #{command_name}: #{names.size} repo(s): #{label}"
242
423
  end
243
424
 
244
425
  def repo_by_name(name)
245
- Cimas::Repository.new(name, data["repositories"][name])
426
+ attributes = repositories[name]
427
+ return nil unless attributes
428
+
429
+ Cimas::Repository.new(name, attributes)
246
430
  end
247
431
 
248
432
  def pull
249
433
  sanity_check
250
- filtered_repo_names.each do |repo_name|
251
-
252
- repo = repo_by_name(repo_name)
253
434
 
254
- if repo.nil?
255
- puts "[WARNING] #{repo_name} not configured, skipping."
256
- next
257
- end
258
-
259
- repo_dir = File.join(repos_path, repo_name)
260
- unless File.exist?(repo_dir)
261
- puts(
262
- "[ERROR] #{repo_name} is missing in #{repos_path}, " \
263
- " skipping pull for it."
264
- )
265
-
266
- next
267
- end
268
-
269
- g = Git.open(repo_dir)
270
-
271
- dry_run("Pulling from #{repo_name}...") do
272
- puts "Pulling from #{repo_name}..."
273
- g.reset_hard(repo.branch)
274
- g.checkout(repo.branch)
275
- g.pull
435
+ each_target_repo('pull') do |repo, repo_dir|
436
+ dry_run("Pulling from #{repo.name}/#{repo.branch}...") do
437
+ puts "Pulling from #{repo.name}/#{repo.branch}..."
438
+ WorkingCopy.open(repo_dir).fetch_reset_pull(repo.branch)
276
439
  end
277
440
  end
278
441
 
@@ -280,119 +443,266 @@ module Cimas
280
443
  end
281
444
 
282
445
  def commit_message
283
- if config['commit_message'].nil?
284
- raise OptionParser::MissingArgument, "Missing -m/--message value"
446
+ msg = required_option('commit_message', '-m/--message')
447
+ unless msg.include? "request-checks:"
448
+ # https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks#checks
449
+ # Thor freezes option strings — never mutate, always rebuild.
450
+ msg = "#{msg}\n\nrequest-checks: true"
285
451
  end
286
- config['commit_message']
452
+ msg
287
453
  end
288
454
 
289
455
  def pr_message
290
- if config['pr_message'].nil?
291
- raise OptionParser::MissingArgument, "Missing -m/--message value"
292
- end
293
- config['pr_message']
456
+ required_option('pr_message', '-m/--message')
294
457
  end
295
458
 
296
459
  def push_to_branch
297
- if config['push_to_branch'].nil?
298
- raise OptionParser::MissingArgument, "Missing -b/--push-branch value"
299
- end
300
- config['push_to_branch']
460
+ required_option('push_to_branch', '-b/--push-branch')
301
461
  end
302
462
 
303
463
  def merge_branch
304
- if config['merge_branch'].nil?
305
- raise OptionParser::MissingArgument, "Missing -b/--merge-branch value"
306
- end
307
- config['merge_branch']
464
+ required_option('merge_branch', '-b/--merge-branch')
465
+ end
466
+
467
+ def shell_cmd
468
+ required_option('shell_cmd', '-c/--shell-cmd')
308
469
  end
309
470
 
471
+ def add_auto_merge_label
472
+ config['add_auto_merge_label']
473
+ end
310
474
 
311
475
  def force_push
312
476
  config['force_push']
313
477
  end
314
478
 
479
+ def keep_changes
480
+ config['keep_changes']
481
+ end
482
+
315
483
  def push
316
484
  sanity_check
317
-
318
- filtered_repo_names.each do |repo_name|
319
- repo = repo_by_name(repo_name)
320
-
321
- if repo.nil?
322
- puts "[WARNING] #{repo_name} not configured, skipping."
485
+ drift_pushes = 0
486
+ skipped_no_op = 0
487
+
488
+ each_target_repo('push') do |repo, repo_dir|
489
+ repo_name = repo.name
490
+ wc = WorkingCopy.open(repo_dir)
491
+
492
+ # Skip repos with no drift. The historical "always push even
493
+ # without changes" behavior was there to guarantee the wave
494
+ # branch exists on remote for the next-stage `cimas open-prs`.
495
+ # But open_prs already handles missing wave branches gracefully
496
+ # (see the /field: head\s+code: invalid/ rescue in
497
+ # `open_prs`, which skips with a WARNING) and also handles the
498
+ # "branch present but empty PR" case (/message: No commits
499
+ # between/). So we can safely skip pushing wave branches for
500
+ # repos that have no drift — the whole no-op notification-noise
501
+ # class disappears without breaking open_prs.
502
+ #
503
+ # Assumes `cimas sync` has been run against this work-dir first,
504
+ # so wd status reflects the drift state (matches the ordering
505
+ # documented in README "End-to-end workflow").
506
+ unless wc.drift?
507
+ skipped_no_op += 1
508
+ msg = "Skipping no-op push to #{repo_name} (no drift)"
509
+ puts config['dry_run'] ? "dry run: #{msg}" : msg
323
510
  next
324
511
  end
325
512
 
326
- repo_dir = File.join(repos_path, repo_name)
327
- unless File.exist?(repo_dir)
328
- puts "[ERROR] #{repo_name} is missing in #{repos_path}, skipping push for it."
329
- next
330
- end
513
+ drift_pushes += 1
331
514
 
332
- g = Git.open(repo_dir)
333
- dry_run("Pushing branch #{push_to_branch} (commit #{g.object('HEAD').sha}) to #{g.remotes.first}:#{repo_name}") do
334
- puts "repo.branch #{repo.branch}"
335
- g.checkout(repo.branch)
336
- g.reset(repo.branch)
337
- g.branch(push_to_branch).delete
338
- g.add(all: true)
339
- g.branch(push_to_branch).checkout
515
+ dry_run("Pushing branch #{push_to_branch} (commit #{wc.head_sha}) to #{wc.remote_name}:#{repo_name}") do
516
+ puts "repo.branch #{repo.branch}" if verbose
340
517
 
341
- if g.status.changed.empty? &&
342
- g.status.added.empty? &&
343
- g.status.deleted.empty?
518
+ wc.reset_onto(repo.branch, discard_branch: push_to_branch) unless keep_changes
519
+ wc.switch_branch(push_to_branch)
520
+ wc.stage(*repo.files.keys)
344
521
 
345
- puts "Skipping commit on #{repo_name}, no changes detected."
522
+ if wc.clean?
523
+ puts "Skipping commit on #{repo_name}, no changes detected." if verbose
346
524
  else
347
525
  puts "Committing on #{repo_name}."
348
- g.commit_all(commit_message)#, amend: true)
526
+ wc.commit_all(commit_message)
349
527
  end
350
528
 
351
529
  # Still push even if there was no commit, as the remote branch
352
530
  # may have been deleted. If the remote branch is deleted we can't
353
- # make PRs in the next stage.
354
-
355
- if force_push
356
- puts "Force-pushing branch #{push_to_branch} (commit #{g.object('HEAD').sha}) to #{g.remotes.first}:#{repo_name}."
357
- g.push(g.remotes.first, push_to_branch, force: true)
531
+ # make PRs in the next stage. (Guard above ensures this branch
532
+ # only runs when either the wd has drift OR the remote branch is
533
+ # actually missing.)
534
+ action = force_push ? "Force-pushing" : "Pushing"
535
+ puts "#{action} branch #{push_to_branch} (commit #{wc.head_sha}) to #{wc.remote_name}:#{repo_name}."
536
+ outcome = wc.push(push_to_branch, force: force_push)
537
+ if outcome == :pushed
538
+ nil
539
+ elsif outcome == :behind_remote
540
+ puts "[WARNING] branch #{push_to_branch} already exists on remote. If you wanna force push, pass --force"
358
541
  else
359
- puts "Pushing branch #{push_to_branch} (commit #{g.object('HEAD').sha}) to #{g.remotes.first}:#{repo_name}."
360
- g.push(g.remotes.first, push_to_branch)
542
+ _status, error = outcome
543
+ puts "An error of type #{error.class} happened, message is #{error.message}"
361
544
  end
362
545
  end
363
546
  end
364
547
 
365
- # do two separate `git add` because one of it may be missing
366
- # run_cmd("git -C #{repos_path} multi -c add .travis.yml", dry_run)
367
- # run_cmd("git -C #{repos_path} multi -c add appveyor.yml")
548
+ puts ""
549
+ puts "Push summary:"
550
+ puts " Pushed with drift: #{drift_pushes}"
551
+ puts " Skipped (no drift): #{skipped_no_op}"
368
552
  end
369
553
 
370
- def git_remote_to_github_name(remote)
371
- remote.match(/github.com\/(.*)/)[1]
554
+ # Label + comment (+ optionally close) a superseded prior-wave PR.
555
+ # Called from the open_prs loop for each stale PR detected via
556
+ # --supersede-stale / --flatten-stale (Gap 4 of metanorma/ci#300).
557
+ def handle_superseded_pr(github_slug, stale, new_number, new_branch,
558
+ flatten:)
559
+ label = flatten ? "superseded-closed-by-##{new_number}" \
560
+ : "superseded-by-##{new_number}"
561
+ github_client.add_labels_to_an_issue(
562
+ github_slug, stale.number, [label]
563
+ )
564
+ github_client.add_comment(
565
+ github_slug, stale.number,
566
+ supersede_comment_body(new_number, new_branch, flatten: flatten)
567
+ )
568
+ if flatten
569
+ github_client.close_pull_request(github_slug, stale.number)
570
+ puts " flattened #{github_slug}##{stale.number} " \
571
+ "(labelled + commented + closed)"
572
+ else
573
+ puts " superseded #{github_slug}##{stale.number} " \
574
+ "(labelled + commented)"
575
+ end
576
+ end
577
+
578
+ def supersede_comment_body(new_number, new_branch, flatten:)
579
+ if flatten
580
+ "Auto-closed as superseded by ##{new_number} from a later " \
581
+ "cimas-sync wave (`#{new_branch}`). If part of this PR's " \
582
+ "content should have been preserved before flattening, " \
583
+ "rebase this branch elsewhere and reopen. " \
584
+ "(--flatten-stale, metanorma/ci#300 Gap 4 full)"
585
+ else
586
+ "Superseded by ##{new_number} from a later cimas-sync wave " \
587
+ "(`#{new_branch}`). This PR was **not auto-closed** by cimas " \
588
+ "— the reviewer keeps authority over the close decision. " \
589
+ "Close after merging ##{new_number}, or rebase this branch " \
590
+ "onto something else if part of its content should still be " \
591
+ "preserved. (metanorma/ci#300 Gap 4)"
592
+ end
593
+ end
594
+
595
+ # For metanorma/ci#347 Option B: a `files:` value can be either the
596
+ # legacy String (a single template path) or a Hash of the shape
597
+ # `{ 'if_public' => path1, 'if_private' => path2 }`. In the Hash
598
+ # case, cimas picks the concrete template at sync time from the
599
+ # target repo's GitHub visibility, so the same cimas.yml entry
600
+ # tracks both public and private variants of e.g. docker.yml.
601
+ # See ci#347 (private-vs-public docker split) for the design.
602
+ def resolve_source(source, repo)
603
+ return source unless source.is_a?(Hash)
604
+
605
+ unless source.key?("if_public") && source.key?("if_private")
606
+ raise "[ERROR] visibility-conditional source needs both " \
607
+ "`if_public` and `if_private` keys; got: #{source.inspect}"
608
+ end
609
+
610
+ is_private = repo_visibility_private?(repo)
611
+ is_private ? source["if_private"] : source["if_public"]
612
+ end
613
+
614
+ # PR body from --body-file (preferred), --body inline, or the
615
+ # legacy "As title." placeholder. See metanorma/cimas#49 Bug 1: the
616
+ # previous open-prs unconditionally used `-m` as the title and a
617
+ # hard-coded body placeholder, so multi-line PR bodies were
618
+ # impossible — and passing a long markdown body via `-m` made it
619
+ # the title, triggering HTTP 422 "title is too long (max 256
620
+ # chars)" and aborting the whole open-prs loop. Force UTF-8 on the
621
+ # file read: locale-default (US-ASCII on some Ruby configs)
622
+ # mis-tags the string, and Octokit → Sawyer → JSON.dump then blows
623
+ # up on non-ASCII bytes (em dash, curly quotes) with `"\xE2" on
624
+ # US-ASCII`. PR bodies are markdown and routinely contain UTF-8;
625
+ # encoding-tagging at read time is the right place to fix it.
626
+ def resolve_pr_body
627
+ if config['pr_body_file'] && config['pr_body']
628
+ raise Cimas::Cli::Error, "--body and --body-file are mutually exclusive"
629
+ end
630
+
631
+ if config['pr_body_file']
632
+ File.read(config['pr_body_file'], encoding: 'UTF-8')
633
+ elsif config['pr_body']
634
+ config['pr_body'].dup.force_encoding('UTF-8')
635
+ else
636
+ "As title. \n\n _Generated by Cimas_."
637
+ end
638
+ end
639
+
640
+ # GitHub rejects self-review requests with HTTP 422 "Review cannot
641
+ # be requested from pull request author." Pre-filter the token
642
+ # user out so the other reviewers still get requested (#7); when
643
+ # the token user can't be resolved, proceed as configured.
644
+ def reviewers_excluding_token_user(reviewers)
645
+ token_user = github_client.user.login
646
+ if reviewers.include?(token_user)
647
+ puts "[INFO] open-prs: excluding token user " \
648
+ "'#{token_user}' from reviewers (cannot self-review)"
649
+ reviewers.reject { |r| r == token_user }
650
+ else
651
+ reviewers
652
+ end
653
+ rescue Octokit::Error => e
654
+ puts "[WARNING] open-prs: could not resolve token user " \
655
+ "for self-review filter (#{e.message}); " \
656
+ "proceeding with reviewers as configured"
657
+ reviewers
372
658
  end
373
659
 
374
660
  def open_prs
375
661
  sanity_check
376
662
  branch = merge_branch
377
663
  message = pr_message
378
- assignees = config['assignees']
379
- reviewers = config['reviewers']
380
-
381
- filtered_repo_names.each do |repo_name|
382
- repo = repo_by_name(repo_name)
383
- if repo.nil?
384
- puts "[WARNING] #{repo_name} not configured, skipping."
385
- next
386
- end
664
+ body = resolve_pr_body
665
+ # Coerce to an Array of handles via string_list: accepts an Array
666
+ # from cimas.yml settings or a (possibly comma-separated) String
667
+ # from the `-a` / `-w` CLI flags. Without coercion, `-a opoudjis`
668
+ # reached this block as a bare String and `.join(',')` further
669
+ # down crashed with NoMethodError, aborting `cimas open-prs`
670
+ # before any PR could be created.
671
+ assignees = string_list(config['assignees'])
672
+ reviewers = reviewers_excluding_token_user(string_list(config['reviewers']))
673
+
674
+ cooldown_count = config['cooldown_count']
675
+ cooldown_time = config['cooldown_time']
676
+
677
+ cooldown_counter = 0
678
+
679
+ each_target_repo('open-prs') do |repo, _repo_dir|
680
+ repo_name = repo.name
681
+ github_slug = git_remote_to_github_name(repo.remote)
387
682
 
388
- repo_dir = File.join(repos_path, repo_name)
389
- unless File.exist?(repo_dir)
390
- puts "[ERROR] #{repo_name} is missing in #{repos_path}, skipping sync_and_commit for it."
391
- next
683
+ # --supersede-stale: detect prior open cimas-sync-* PRs on this repo.
684
+ # See metanorma/ci#300 Gap 4. Cheaper-version (no strict-superset
685
+ # check): we label-and-comment-but-do-not-close the old PRs, letting
686
+ # the reviewer keep authority over the close decision. The new PR's
687
+ # body is prepended with a "Supersedes #X, #Y" note so the reviewer
688
+ # sees the full picture in the most recent PR.
689
+ stale_prs = []
690
+ if config['supersede_stale']
691
+ begin
692
+ stale_prs = github_client.pull_requests(github_slug, state: 'open').select do |stale|
693
+ stale.head.ref.start_with?('cimas-sync-') && stale.head.ref != branch
694
+ end
695
+ rescue Octokit::Error => e
696
+ puts "[WARNING] #{github_slug}: could not list open PRs for --supersede-stale (#{e.message}); proceeding without."
697
+ stale_prs = []
698
+ end
392
699
  end
393
-
394
- g = Git.open(repo_dir)
395
- github_slug = git_remote_to_github_name(repo.remote)
700
+ final_body = if stale_prs.any?
701
+ supersede_list = stale_prs.map { |p| "##{p.number}" }.join(", ")
702
+ "_Supersedes #{supersede_list} from prior cimas-sync waves._\n\n#{body}"
703
+ else
704
+ body
705
+ end
396
706
 
397
707
  dry_run("Opening GitHub PR: #{github_slug}, branch #{repo.branch} <- #{branch}, message '#{message}'") do
398
708
  puts "Opening GitHub PR: #{github_slug}, branch #{repo.branch} <- #{branch}, message '#{message}'"
@@ -403,19 +713,37 @@ module Cimas
403
713
  repo.branch,
404
714
  branch,
405
715
  message,
406
- "As title. \n\n _Generated by Cimas_."
716
+ final_body,
407
717
  )
408
718
  number = pr['number']
719
+
720
+ github_client.add_labels_to_an_issue(github_slug, number, ['automerge']) if add_auto_merge_label
721
+
722
+ # Label-and-comment (--supersede-stale, Gap 4 cheaper) OR
723
+ # label-and-comment-and-close (--flatten-stale, Gap 4 full).
724
+ # The flatten-stale path auto-closes the superseded PRs on the
725
+ # assumption that every cimas-sync wave regenerates the same
726
+ # files from cimas.yml, so a newer wave strictly supersedes
727
+ # any older wave's PR on the same repo.
728
+ stale_prs.each do |stale|
729
+ begin
730
+ handle_superseded_pr(
731
+ github_slug, stale, number, branch,
732
+ flatten: config['flatten_stale'] == true,
733
+ )
734
+ rescue Octokit::Error => e
735
+ puts " [WARNING] could not process supersede on " \
736
+ "#{github_slug}\##{stale.number}: #{e.message}"
737
+ end
738
+ end
739
+
409
740
  puts "PR #{github_slug}\##{number} created"
410
741
 
411
742
  rescue Octokit::Error => e
412
- # puts e.inspect
413
- # puts '------'
414
- # puts "e.message #{e.message}"
415
-
416
743
  case e.message
417
744
  when /A pull request already exists/
418
745
  puts "[WARNING] PR already exists for #{branch}."
746
+ next
419
747
 
420
748
  when /field: head\s+code: invalid/
421
749
  puts "[WARNING] Branch #{branch} does not exist on #{github_slug}. Did you run `push`? Skipping."
@@ -425,23 +753,27 @@ module Cimas
425
753
  puts "[WARNING] Target branch (#{repo.branch}) is on par with new branch (#{branch}). Skipping."
426
754
  next
427
755
 
756
+ when /Repository was archived so is read-only/
757
+ puts "[WARNING] Reporitory #{branch} is readonly. Skipping."
758
+ next
759
+
428
760
  else
429
761
  raise e
430
762
  end
431
763
  end
432
764
 
433
- # puts pr.inspect
434
-
435
765
  unless pr
436
766
  puts "[WARNING] Detecting PR from GitHub..."
437
- github_branch_owner = github_slug.match(/(.*)\/.*/)[1]
767
+ github_branch_owner = github_slug.split('/').first
438
768
  prs = github_client.pull_requests(github_slug, head: "#{github_branch_owner}:#{branch}")
439
769
  pr = prs.first
770
+ unless pr
771
+ puts "[WARNING] Failed to detect PR from GitHub for #{github_slug} repo. Skipping."
772
+ next
773
+ end
440
774
  puts "[WARNING] Detected PR to be #{github_slug}\##{pr['number']}, continue processing."
441
775
  end
442
776
 
443
- # TODO: Catch
444
-
445
777
  number = pr['number']
446
778
 
447
779
  unless reviewers.empty?
@@ -454,10 +786,6 @@ module Cimas
454
786
  )
455
787
 
456
788
  rescue Octokit::Error => e
457
- # puts e.inspect
458
- # puts '------'
459
- # puts "e.message #{e.message}"
460
-
461
789
  # TODO: When command is first run, should exclude the PR author from 'reviewers'
462
790
  case e.message
463
791
  when /Review cannot be requested from pull request author./
@@ -479,25 +807,284 @@ module Cimas
479
807
  )
480
808
  end
481
809
 
810
+ cooldown_counter += 1
811
+ if cooldown_counter % cooldown_count == 0
812
+ puts "Cool down for #{cooldown_time}sec to not abuse GitHub API..."
813
+ sleep(cooldown_time)
814
+ end
482
815
  end
483
816
  end
484
817
  end
485
818
 
819
+ # Per-wave local cleanup: delete the branch named by `push_to_branch`
820
+ # from each target repo on origin IF the corresponding PR has merged.
821
+ # Open PRs are left alone (their branch is still in use). Branches with
822
+ # no PR are deleted too (a wave that opened no PR for the repo, e.g.
823
+ # because cimas detected "no commits" at push time, leaves a stale
824
+ # branch on origin we shouldn't keep). Requires only standard `repo`
825
+ # scope on each target repo — no admin scope, since branch deletion
826
+ # against a merged PR is a push-level operation.
827
+ def cleanup_merged_prs
828
+ sanity_check
829
+ branch = push_to_branch
830
+
831
+ each_configured_repo do |repo|
832
+ github_slug = git_remote_to_github_name(repo.remote)
833
+ owner = github_slug.split('/').first
834
+
835
+ begin
836
+ prs = github_client.pull_requests(
837
+ github_slug,
838
+ head: "#{owner}:#{branch}",
839
+ state: 'all'
840
+ )
841
+ rescue Octokit::Error => e
842
+ puts "[ERROR] #{github_slug}: PR lookup failed (#{e.class}): #{e.message}"
843
+ next
844
+ end
845
+
846
+ pr = prs.first
847
+
848
+ if pr.nil?
849
+ # No PR for this branch — attempt to delete if the branch exists
850
+ delete_remote_branch(github_slug, branch, "no PR found")
851
+ next
852
+ end
853
+
854
+ if pr.merged_at
855
+ delete_remote_branch(github_slug, branch, "PR ##{pr.number} merged")
856
+ elsif pr.state == 'open'
857
+ puts "[skip-open] #{github_slug}:#{branch} (PR ##{pr.number} still open)"
858
+ else
859
+ # Closed-without-merge — keep branch by default; closing without merge
860
+ # often means someone intends to revisit. Operator can clean up manually.
861
+ puts "[skip-closed] #{github_slug}:#{branch} (PR ##{pr.number} closed without merge)"
862
+ end
863
+ end
864
+ end
865
+
866
+ # Sibling of `cleanup_merged_prs` for the closed-not-merged case.
867
+ #
868
+ # `cleanup_merged_prs` operates on ONE wave branch supplied via `-b`
869
+ # and asks "did the PR merge? if so, delete the branch." This
870
+ # subcommand operates on ALL branches whose names match a prefix
871
+ # (default `cimas-sync-`), across the whole scope, and deletes the
872
+ # ones whose PR was closed-without-merge — regardless of wave.
873
+ #
874
+ # Motivation (metanorma/ci#347 follow-up): when a wave PR is closed
875
+ # without merge, cleanup-merged-prs leaves the branch alone by design
876
+ # (someone may want to revisit). But wave PRs closed as superseded
877
+ # (via `--flatten-stale`) or as unwanted (ci#347) accumulate orphan
878
+ # branches on remotes. This sweeps them.
879
+ #
880
+ # Safety: only deletes branches whose head matches the prefix AND
881
+ # whose PR is *closed* (state == 'closed', merged_at is nil). Open
882
+ # PRs and merged PRs are left alone.
883
+ def cleanup_closed_prs
884
+ sanity_check
885
+ prefix = config['cleanup_branch_prefix'] || 'cimas-sync-'
886
+
887
+ each_configured_repo do |repo|
888
+ github_slug = git_remote_to_github_name(repo.remote)
889
+
890
+ # Page all closed PRs; API caps at 100/page but that's fine for
891
+ # cimas-sync-* accumulation which is bounded by wave count.
892
+ begin
893
+ closed_prs = github_client.pull_requests(
894
+ github_slug,
895
+ state: 'closed',
896
+ per_page: 100,
897
+ )
898
+ rescue Octokit::Error => e
899
+ puts "[ERROR] #{github_slug}: PR lookup failed (#{e.class}): #{e.message}"
900
+ next
901
+ end
902
+
903
+ candidates = closed_prs.select do |pr|
904
+ pr.head&.ref&.start_with?(prefix) && pr.merged_at.nil?
905
+ end
906
+
907
+ if candidates.empty?
908
+ puts "[none] #{github_slug}: no closed-not-merged '#{prefix}*' branches"
909
+ next
910
+ end
911
+
912
+ candidates.each do |pr|
913
+ delete_remote_branch(
914
+ github_slug, pr.head.ref,
915
+ "PR ##{pr.number} closed-not-merged #{pr.closed_at}"
916
+ )
917
+ end
918
+ end
919
+ end
920
+
921
+ # Inverse of `sync`. Where `sync` writes cimas.yml-mapped files to
922
+ # each repo's working tree, `cleanup_orphan_files` finds files that
923
+ # (a) carry the Cimas auto-generated header comment, so they were
924
+ # written by cimas at some point, and (b) are no longer in the
925
+ # repo's `files:` mapping, so cimas is no longer regenerating them.
926
+ # These files are orphans — they only exist because they were
927
+ # sync'd on a prior config version and never cleaned up.
928
+ #
929
+ # Motivation (metanorma/ci#347 follow-up): dropping a file from a
930
+ # repo's `files:` mapping (e.g. removing `.github/workflows/generate.yml`
931
+ # from all non-mn-samples-* doc repos, per ci#347's docker-only rule)
932
+ # stops future regeneration but leaves the existing file in the
933
+ # repo, where its CI keeps failing. This subcommand purges those.
934
+ #
935
+ # Safety: only deletes files whose first ~500 bytes contain the
936
+ # cimas header marker. Files without the header (custom CI, docs,
937
+ # sources) are never touched.
938
+ def cleanup_orphan_files
939
+ sanity_check
940
+ push_after = config['cleanup_push_after'] == true
941
+ # `push_to_branch` / `pr_message` raise when their underlying config
942
+ # key is nil, so only resolve them when `--push-after` actually needs
943
+ # them. Without `--push-after` the subcommand is a local-only stage,
944
+ # which is the correct shape for a dry-run scan or a review-before-blast
945
+ # workflow.
946
+ branch = push_after ? push_to_branch : nil
947
+ message = push_after ? pr_message : nil
948
+ # `--only-target=path[,path...]` narrows the sweep to specific target
949
+ # paths so a wave can be scoped to just one class of orphan (e.g.
950
+ # `.github/workflows/generate.yml` for the ci#347 cleanup). nil means
951
+ # no filter — surface all orphan cimas-managed files.
952
+ only_targets = config['cleanup_only_targets'] &&
953
+ string_list(config['cleanup_only_targets']).to_set
954
+
955
+ each_target_repo('cleanup-orphan-files') do |repo, repo_dir|
956
+ repo_name = repo.name
957
+ wc = WorkingCopy.open(repo_dir)
958
+ wc.reset_clean(repo.branch, include_untracked: true) unless keep_changes
959
+
960
+ mapped_targets = (repo.files || {}).keys.to_set
961
+ orphans = Cimas::OrphanFiles.find(repo_dir, mapped_targets, only_targets)
962
+
963
+ if orphans.empty?
964
+ puts "[clean] #{repo_name}"
965
+ next
966
+ end
967
+
968
+ puts "[#{orphans.size} orphan(s)] #{repo_name}:"
969
+ orphans.each { |o| puts " - #{o}" }
970
+
971
+ if push_after
972
+ dry_run("Commit + push deletion of #{orphans.size} orphan(s) in #{repo_name} on #{branch}") do
973
+ wc.switch_branch(branch, fresh: true)
974
+ wc.remove(*orphans)
975
+ wc.commit(message)
976
+ if wc.push(branch, force: true) == :pushed
977
+ puts "[pushed] #{repo_name}:#{branch}"
978
+ else
979
+ puts "[ERROR] #{repo_name}:#{branch} push failed"
980
+ end
981
+ end
982
+ else
983
+ # Local-only mode: stage the deletions for the operator to
984
+ # inspect and push manually. Useful for a review-before-blast
985
+ # workflow.
986
+ dry_run("Stage deletion of #{orphans.size} orphan(s) in #{repo_name} (local only, no push)") do
987
+ wc.remove(*orphans)
988
+ end
989
+ end
990
+ end
991
+ end
992
+
993
+ def release_preflight
994
+ Cimas::ReleasePreflight.new(self, runner: config["release_preflight_runner"]).run
995
+ end
996
+
997
+ def for_each
998
+ sanity_check
999
+ cmd = shell_cmd
1000
+ failures = []
1001
+
1002
+ each_target_repo('for-each') do |repo, repo_dir|
1003
+ Dir.chdir(repo_dir) do
1004
+ puts "Execute '#{cmd}' for #{repo.name} repository..."
1005
+ system(cmd)
1006
+ unless $?.success?
1007
+ failures << repo.name
1008
+ puts "[ERROR] '#{cmd}' failed in #{repo.name} (exit #{$?.exitstatus})"
1009
+ end
1010
+ end
1011
+ end
1012
+
1013
+ return if failures.empty?
1014
+
1015
+ raise "[ERROR] for-each command failed in #{failures.size} repo(s): #{failures.join(', ')}"
1016
+ end
1017
+
486
1018
  private
487
1019
 
1020
+ def required_option(key, flag)
1021
+ value = config[key]
1022
+ raise Cimas::Cli::Error, "Missing #{flag} value" if value.nil?
1023
+
1024
+ value
1025
+ end
1026
+
1027
+ # Coerces a CLI/cimas.yml value that may be a (comma-separated)
1028
+ # String, a bare value, or an Array into a flat list of strings.
1029
+ def string_list(value)
1030
+ Array(value).flat_map { |item| item.is_a?(String) ? item.split(',') : item }
1031
+ end
1032
+
1033
+ # Deletes a remote branch, reporting the outcome with a uniform
1034
+ # [deleted]/[absent]/[ERROR] prefix; `reason` carries the
1035
+ # justification into the log lines.
1036
+ def delete_remote_branch(github_slug, branch, reason)
1037
+ dry_run("Delete branch #{github_slug}:#{branch} (#{reason})") do
1038
+ github_client.delete_branch(github_slug, branch)
1039
+ puts "[deleted] #{github_slug}:#{branch} (#{reason})"
1040
+ end
1041
+ rescue Octokit::UnprocessableEntity, Octokit::NotFound
1042
+ puts "[absent] #{github_slug}:#{branch} (#{reason}; branch already gone)"
1043
+ rescue Octokit::Error => e
1044
+ puts "[ERROR] #{github_slug}:#{branch} delete failed (#{e.class}): #{e.message}"
1045
+ end
1046
+
1047
+ # Renders an ERB template from the config master against a repo's
1048
+ # binding context: legacy `template: binding:` values become
1049
+ # OpenStruct dot-notation methods (e.g. `<%= flavor %>`); the
1050
+ # `with:` block (metanorma/ci#300 Gap 1) is exposed as
1051
+ # `with_values` — a Hash — so templates can access keys that
1052
+ # aren't valid Ruby identifiers (e.g. `private-fonts`) via
1053
+ # `<%= with_values['private-fonts'] %>`. trim_mode '-' lets
1054
+ # templates trim conditional blocks without stray blank lines.
1055
+ def render_erb_template(source_path, repo)
1056
+ template = ERB.new(File.read(source_path), trim_mode: "-")
1057
+ params = OpenStruct.new(
1058
+ repo.binding.merge("with_values" => repo.with_values)
1059
+ ).instance_eval { binding }
1060
+ template.result(params)
1061
+ end
1062
+
488
1063
  def copy_file(from, to)
489
- dry_run("copying file #{from} -> #{to}") do
490
- to_dir = File.dirname(to)
491
- unless File.directory?(to_dir)
492
- FileUtils.mkdir_p(to_dir)
1064
+ write_managed(to, "copying file #{from} -> #{to}") do |out|
1065
+ File.foreach(from) do |line|
1066
+ out.puts line
493
1067
  end
1068
+ end
1069
+ end
1070
+
1071
+ def write_rendered(content, to)
1072
+ write_managed(to, "writing rendered template -> #{to}") do |out|
1073
+ out.puts content
1074
+ end
1075
+ end
1076
+
1077
+ # One writer for every cimas-managed file: creates the target
1078
+ # directory, prefixes the generated header, then yields the file
1079
+ # handle for the body (copied lines or rendered template).
1080
+ def write_managed(to, description)
1081
+ dry_run(description) do
1082
+ to_dir = File.dirname(to)
1083
+ FileUtils.mkdir_p(to_dir) unless File.directory?(to_dir)
494
1084
 
495
1085
  File.open(to, 'w+') do |fo|
496
- fo.puts '# Auto-generated by Cimas: Do not edit it manually!'
497
- fo.puts '# See https://github.com/metanorma/cimas'
498
- File.foreach(from) do |li|
499
- fo.puts li
500
- end
1086
+ fo.puts Cimas::GENERATED_HEADER
1087
+ yield fo
501
1088
  end
502
1089
  end
503
1090
  end
@@ -511,20 +1098,21 @@ module Cimas
511
1098
  end
512
1099
 
513
1100
  def groups
514
- data['groups']
1101
+ data['groups'] || {}
515
1102
  end
516
1103
 
517
1104
  def group_repo_names(group)
518
- # puts "group #{group}"
519
1105
  case group
520
1106
  when 'all'
521
1107
  repositories.keys
522
1108
  else
523
- # puts "groups #{groups.inspect}"
524
- groups[group]
1109
+ if groups[group]
1110
+ groups[group]
1111
+ else
1112
+ [group] # if group is the repo by itself
1113
+ end
525
1114
  end
526
1115
  end
527
-
528
1116
  end
529
1117
  end
530
1118
  end