cimas 0.1.3 → 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,32 +1,118 @@
1
- require 'json'
2
1
  require 'yaml'
3
- require 'net/http'
4
- require 'git'
5
- # require 'travis/client/session'
2
+ require 'octokit'
3
+ require 'ostruct'
4
+ require 'erb'
5
+ require 'fileutils'
6
+ require 'set'
6
7
 
7
8
  module Cimas
8
9
  module Cli
9
10
  class Command
10
- attr_accessor :github_client, :config
11
-
12
11
  DEFAULT_CONFIG = {
13
12
  'dry_run' => false,
14
13
  'verbose' => false,
15
- 'groups' => ['all'],
16
- '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,
17
20
  'force_push' => false,
18
21
  'assignees' => [],
19
- 'reviewers' => []
22
+ 'reviewers' => [],
23
+ 'keep_changes' => false,
24
+ 'add_auto_merge_label' => true,
25
+ 'cooldown_count' => 10,
26
+ 'cooldown_time' => 3 * 60
20
27
  }
21
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
+
22
104
  def initialize(options)
23
105
  unless options['config_file_path'].exist?
24
106
  raise "[ERROR] config_file_path #{options['config_file_path']} does not exist, aborting."
25
107
  end
26
108
 
27
- @data = YAML.load(IO.read(options['config_file_path']))
109
+ @data = YAML.load(File.read(options['config_file_path'])) || {}
28
110
 
29
- @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)
30
116
 
31
117
  unless repos_path.exist?
32
118
  FileUtils.mkdir_p repos_path
@@ -37,16 +123,55 @@ module Cimas
37
123
  end
38
124
  end
39
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
+
40
141
  def settings
41
142
  data['settings']
42
143
  end
43
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
+
44
154
  def github_client
45
- require 'octokit'
46
- if config['github_token'].nil?
47
- raise "[ERROR] Please set GITHUB_TOKEN environment variable to use GitHub functions."
48
- end
49
- @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)
50
175
  end
51
176
 
52
177
  def config
@@ -60,12 +185,11 @@ module Cimas
60
185
  def setup
61
186
  repositories.each_pair do |repo_name, attribs|
62
187
  repo_dir = File.join(repos_path, repo_name)
63
- # puts "attribs #{attribs.inspect}"
64
188
  unless File.exist?(repo_dir) && File.exist?(File.join(repo_dir, '.git'))
65
189
  puts "Git cloning #{repo_name} from #{attribs['remote']}..."
66
- Git.clone(attribs['remote'], repo_name, path: repos_path)
190
+ WorkingCopy.clone(attribs['remote'], repo_name, path: repos_path)
67
191
  else
68
- puts "Skip cloning #{repo_name}, already exists."
192
+ puts "Skip cloning #{repo_name}, #{repo_dir} already exists." if verbose
69
193
  end
70
194
  end
71
195
  end
@@ -84,7 +208,9 @@ module Cimas
84
208
 
85
209
  return true if unsynced.empty?
86
210
 
87
- 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}"
88
214
  end
89
215
 
90
216
  def config_master_path
@@ -99,185 +225,217 @@ module Cimas
99
225
  data['repositories']
100
226
  end
101
227
 
228
+ def verbose
229
+ config['verbose']
230
+ end
231
+
102
232
  def sync
103
233
  sanity_check
104
234
  unless config['config_master_path'].exist?
105
235
  raise "[ERROR] config_master_path not set, aborting."
106
236
  end
107
237
 
108
- filtered_repo_names.each do |repo_name|
109
-
110
- repo = repo_by_name(repo_name)
111
- if repo.nil?
112
- puts "[WARNING] #{repo_name} not configured, skipping."
113
- next
114
- end
115
-
116
- branch = repo['branch']
117
- files = repo['files']
118
-
119
- repo_dir = File.join(repos_path, repo_name)
120
- unless File.exist?(repo_dir)
121
- puts "[ERROR] #{repo_name} is missing in #{repos_path}, skipping sync for it."
122
- next
123
- end
238
+ each_target_repo('sync') do |repo, repo_dir|
239
+ repo_name = repo.name
124
240
 
125
241
  dry_run("Copying files to #{repo_name} and staging them") do
126
- g = Git.open(repo_dir)
127
- g.checkout(branch)
128
- g.reset_hard(branch)
129
- g.clean(force: true)
242
+ wc = WorkingCopy.open(repo_dir)
243
+
244
+ wc.reset_clean(repo.branch) unless keep_changes
130
245
 
131
246
  puts "Syncing and staging files in #{repo_name}..."
132
247
 
133
- files.each do |target, source|
134
- # puts "file #{source} => #{target}"
135
- source_path = File.join(config_master_path, source)
248
+ repo.files.each do |target, source|
249
+ resolved_source = resolve_source(source, repo)
250
+ source_path = File.join(config_master_path, resolved_source)
136
251
  target_path = File.join(repos_path, repo_name, target)
137
- # puts "file #{source_path} => #{target_path}"
252
+ puts "file #{source_path} => #{target_path}" if verbose
253
+
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)
261
+ end
262
+
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
138
278
 
139
- copy_file(source_path, target_path)
140
- g.add(target_path)
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
141
289
  end
142
290
 
143
- # Debugging to see if files have been changed
144
- # g.status.changed.each do |file, status|
145
- # puts "Updated files in #{repo_name}:"
146
- # puts status.blob(:index).contents
147
- # end
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
148
320
  end
149
321
  end
150
322
  end
151
323
 
152
324
  def diff
153
325
  sanity_check
154
- unless config['config_master_path'].exist?
155
- 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
156
330
  end
331
+ end
157
332
 
158
- 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
159
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|
160
348
  repo = repo_by_name(repo_name)
161
349
  if repo.nil?
162
350
  puts "[WARNING] #{repo_name} not configured, skipping."
163
351
  next
164
352
  end
165
353
 
166
- branch = repo['branch']
167
- files = repo['files']
354
+ yield repo
355
+ end
356
+ end
168
357
 
169
- repo_dir = File.join(repos_path, repo_name)
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)
170
364
  unless File.exist?(repo_dir)
171
- 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."
172
366
  next
173
367
  end
174
368
 
175
- g = Git.open(repo_dir)
176
- # g.checkout(branch)
177
- # g.reset_hard(branch)
178
- # g.clean(force: true)
179
-
180
- # puts "Syncing files in #{repo_name}..."
181
- #
182
- # files.each do |target, source|
183
- # # puts "file #{source} => #{target}"
184
- # source_path = File.join(config_master_path, source)
185
- # target_path = File.join(repos_path, repo_name, target)
186
- # # puts "file #{source_path} => #{target_path}"
187
- #
188
- # copy_file(source_path, target_path)
189
- # # g.add(target_path)
190
- # end
369
+ yield repo, repo_dir
370
+ end
371
+ end
191
372
 
192
- puts "======================= DIFF FOR #{repo_name} ========================="
193
- # Debugging to see if files have been changed
194
- diff = g.diff
195
- 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
196
393
 
197
- # g.status.changed.each do |file, status|
198
- # puts "Updated files in #{repo_name}:"
199
- # puts status.blob(:index).contents
200
- # 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."
201
400
  end
202
401
  end
203
402
 
204
- # def lint(options)
205
- # config_master_path = options['config_master_path']
206
- # appveyor_token = options['appveyor_token']
207
- #
208
- # config = YAML.load_file(File.join(config_master_path, 'ci.yml'))
209
- #
210
- # validated = []
211
- #
212
- # config['repos'].each do |_, repo_ci|
213
- # travisci, appveyor = repo_ci.values_at('.travis.yml', 'appveyor.yml')
214
- #
215
- # if travisci && !validated.include?(travisci)
216
- # valid = system("travis lint #{File.join(config_master_path, travisci)}", :out => :close)
217
- # puts "#{travisci} valid: #{valid}"
218
- # validated << travisci
219
- # end
220
- #
221
- # if appveyor && !validated.include?(appveyor)
222
- # uri = URI('https://ci.appveyor.com/api/projects/validate-yaml')
223
- # http = Net::HTTP.new(uri.host, uri.port)
224
- # http.use_ssl = true
225
- #
226
- # req = Net::HTTP::Post.new(uri.path, {
227
- # "Content-Type" => "application/json",
228
- # "Authorization" => "Bearer #{appveyor_token}"
229
- # })
230
- # req.body = File.read(File.join(config_master_path, appveyor))
231
- #
232
- # valid = http.request(req).kind_of? Net::HTTPSuccess
233
- #
234
- # puts "#{appveyor} valid: #{valid}"
235
- # validated << appveyor
236
- # end
237
- # end
238
- # 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?
239
409
 
240
- def filtered_repo_names
241
- 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
242
414
 
243
- # puts "config['groups'] #{config['groups'].inspect}"
244
- config['groups'].inject([]) do |acc, group|
245
- acc + group_repo_names(group)
246
- 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}"
247
423
  end
248
424
 
249
425
  def repo_by_name(name)
250
- # puts "getting repository for #{name}"
251
- data['repositories'][name]
426
+ attributes = repositories[name]
427
+ return nil unless attributes
428
+
429
+ Cimas::Repository.new(name, attributes)
252
430
  end
253
431
 
254
432
  def pull
255
433
  sanity_check
256
- filtered_repo_names.each do |repo_name|
257
-
258
- repo = repo_by_name(repo_name)
259
- if repo.nil?
260
- puts "[WARNING] #{repo_name} not configured, skipping."
261
- next
262
- end
263
-
264
- branch = repo['branch']
265
- files = repo['files']
266
-
267
- repo_dir = File.join(repos_path, repo_name)
268
- unless File.exist?(repo_dir)
269
- puts "[ERROR] #{repo_name} is missing in #{repos_path}, skipping pull for it."
270
- next
271
- end
272
-
273
- g = Git.open(repo_dir)
274
434
 
275
- dry_run("Pulling from #{repo_name}...") do
276
- puts "Pulling from #{repo_name}..."
277
- g.reset_hard(branch)
278
- g.checkout(branch)
279
- g.pull
280
- # g.fetch(g.remotes.first)
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)
281
439
  end
282
440
  end
283
441
 
@@ -285,159 +443,337 @@ module Cimas
285
443
  end
286
444
 
287
445
  def commit_message
288
- if config['commit_message'].nil?
289
- 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"
290
451
  end
291
- config['commit_message']
452
+ msg
292
453
  end
293
454
 
294
455
  def pr_message
295
- if config['pr_message'].nil?
296
- raise OptionParser::MissingArgument, "Missing -m/--message value"
297
- end
298
- config['pr_message']
456
+ required_option('pr_message', '-m/--message')
299
457
  end
300
458
 
301
459
  def push_to_branch
302
- if config['push_to_branch'].nil?
303
- raise OptionParser::MissingArgument, "Missing -b/--push-branch value"
304
- end
305
- config['push_to_branch']
460
+ required_option('push_to_branch', '-b/--push-branch')
306
461
  end
307
462
 
308
463
  def merge_branch
309
- if config['merge_branch'].nil?
310
- raise OptionParser::MissingArgument, "Missing -b/--merge-branch value"
311
- end
312
- 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')
313
469
  end
314
470
 
471
+ def add_auto_merge_label
472
+ config['add_auto_merge_label']
473
+ end
315
474
 
316
475
  def force_push
317
476
  config['force_push']
318
477
  end
319
478
 
479
+ def keep_changes
480
+ config['keep_changes']
481
+ end
482
+
320
483
  def push
321
484
  sanity_check
322
-
323
- filtered_repo_names.each do |repo_name|
324
- repo = repo_by_name(repo_name)
325
- if repo.nil?
326
- puts "[WARNING] #{repo_name} not configured, skipping."
327
- next
328
- end
329
-
330
- repo_dir = File.join(repos_path, repo_name)
331
- unless File.exist?(repo_dir)
332
- puts "[ERROR] #{repo_name} is missing in #{repos_path}, skipping push for it."
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
333
510
  next
334
511
  end
335
512
 
336
- g = Git.open(repo_dir)
337
- # g.reset_hard(attribs['branch'])
513
+ drift_pushes += 1
338
514
 
339
- dry_run("Pushing branch #{push_to_branch} (commit #{g.object('HEAD').sha}) to #{g.remotes.first}:#{repo_name}") do
340
- g.branch(push_to_branch).checkout
341
- g.add(all: true)
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
342
517
 
343
- if g.status.changed.empty? &&
344
- g.status.added.empty? &&
345
- 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)
346
521
 
347
- 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
348
524
  else
349
- g.commit_all(commit_message)
525
+ puts "Committing on #{repo_name}."
526
+ wc.commit_all(commit_message)
350
527
  end
351
528
 
352
529
  # Still push even if there was no commit, as the remote branch
353
530
  # may have been deleted. If the remote branch is deleted we can't
354
- # make PRs in the next stage.
355
-
356
- if force_push
357
- # TODO implement
358
- raise "[ERROR] Force pushing with commit amend is not yet implemented."
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"
359
541
  else
360
- puts "Pushing branch #{push_to_branch} (commit #{g.object('HEAD').sha}) to #{g.remotes.first}:#{repo_name}"
361
- 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}"
362
544
  end
363
545
  end
364
546
  end
365
547
 
366
- # do two separate `git add` because one of it may be missing
367
- # run_cmd("git -C #{repos_path} multi -c add .travis.yml", dry_run)
368
- # 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}"
369
552
  end
370
553
 
371
- def git_remote_to_github_name(remote)
372
- 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
373
658
  end
374
659
 
375
660
  def open_prs
376
661
  sanity_check
377
662
  branch = merge_branch
378
663
  message = pr_message
379
- assignees = config['assignees']
380
- reviewers = config['reviewers']
381
-
382
- filtered_repo_names.each do |repo_name|
383
- repo = repo_by_name(repo_name)
384
- if repo.nil?
385
- puts "[WARNING] #{repo_name} not configured, skipping."
386
- next
387
- end
388
-
389
- repo_dir = File.join(repos_path, repo_name)
390
- unless File.exist?(repo_dir)
391
- puts "[ERROR] #{repo_name} is missing in #{repos_path}, skipping sync_and_commit for it."
392
- next
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)
682
+
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
393
699
  end
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
394
706
 
395
- g = Git.open(repo_dir)
396
- github_slug = git_remote_to_github_name(repo['remote'])
397
-
398
- dry_run("Opening GitHub PR: #{github_slug}, branch #{repo['branch']} <- #{branch}, message '#{message}'") do
399
- puts "Opening GitHub PR: #{github_slug}, branch #{repo['branch']} <- #{branch}, message '#{message}'"
707
+ dry_run("Opening GitHub PR: #{github_slug}, branch #{repo.branch} <- #{branch}, message '#{message}'") do
708
+ puts "Opening GitHub PR: #{github_slug}, branch #{repo.branch} <- #{branch}, message '#{message}'"
400
709
 
401
710
  begin
402
711
  pr = github_client.create_pull_request(
403
712
  github_slug,
404
- repo['branch'],
713
+ repo.branch,
405
714
  branch,
406
715
  message,
407
- "As title. \n\n _Generated by Cimas_."
716
+ final_body,
408
717
  )
409
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
+
410
740
  puts "PR #{github_slug}\##{number} created"
411
741
 
412
742
  rescue Octokit::Error => e
413
- # puts e.inspect
414
- # puts '------'
415
- # puts "e.message #{e.message}"
416
-
417
743
  case e.message
418
744
  when /A pull request already exists/
419
745
  puts "[WARNING] PR already exists for #{branch}."
746
+ next
420
747
 
421
748
  when /field: head\s+code: invalid/
422
749
  puts "[WARNING] Branch #{branch} does not exist on #{github_slug}. Did you run `push`? Skipping."
423
750
  next
751
+
752
+ when /message: No commits between/
753
+ puts "[WARNING] Target branch (#{repo.branch}) is on par with new branch (#{branch}). Skipping."
754
+ next
755
+
756
+ when /Repository was archived so is read-only/
757
+ puts "[WARNING] Reporitory #{branch} is readonly. Skipping."
758
+ next
759
+
424
760
  else
425
761
  raise e
426
762
  end
427
763
  end
428
764
 
429
- # puts pr.inspect
430
-
431
765
  unless pr
432
766
  puts "[WARNING] Detecting PR from GitHub..."
433
- github_branch_owner = github_slug.match(/(.*)\/.*/)[1]
767
+ github_branch_owner = github_slug.split('/').first
434
768
  prs = github_client.pull_requests(github_slug, head: "#{github_branch_owner}:#{branch}")
435
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
436
774
  puts "[WARNING] Detected PR to be #{github_slug}\##{pr['number']}, continue processing."
437
775
  end
438
776
 
439
- # TODO: Catch
440
-
441
777
  number = pr['number']
442
778
 
443
779
  unless reviewers.empty?
@@ -450,10 +786,6 @@ module Cimas
450
786
  )
451
787
 
452
788
  rescue Octokit::Error => e
453
- # puts e.inspect
454
- # puts '------'
455
- # puts "e.message #{e.message}"
456
-
457
789
  # TODO: When command is first run, should exclude the PR author from 'reviewers'
458
790
  case e.message
459
791
  when /Review cannot be requested from pull request author./
@@ -475,25 +807,284 @@ module Cimas
475
807
  )
476
808
  end
477
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
815
+ end
816
+ end
817
+ end
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)"
478
862
  end
479
863
  end
480
864
  end
481
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
+
482
1018
  private
483
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
+
484
1063
  def copy_file(from, to)
485
- dry_run("copying file #{from} -> #{to}") do
486
- to_dir = File.dirname(to)
487
- unless File.directory?(to_dir)
488
- 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
489
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)
490
1084
 
491
1085
  File.open(to, 'w+') do |fo|
492
- fo.puts '# Auto-generated by Cimas: Do not edit it manually!'
493
- fo.puts '# See https://github.com/metanorma/cimas'
494
- File.foreach(from) do |li|
495
- fo.puts li
496
- end
1086
+ fo.puts Cimas::GENERATED_HEADER
1087
+ yield fo
497
1088
  end
498
1089
  end
499
1090
  end
@@ -507,20 +1098,21 @@ module Cimas
507
1098
  end
508
1099
 
509
1100
  def groups
510
- data['groups']
1101
+ data['groups'] || {}
511
1102
  end
512
1103
 
513
1104
  def group_repo_names(group)
514
- # puts "group #{group}"
515
1105
  case group
516
1106
  when 'all'
517
1107
  repositories.keys
518
1108
  else
519
- # puts "groups #{groups.inspect}"
520
- groups[group]
1109
+ if groups[group]
1110
+ groups[group]
1111
+ else
1112
+ [group] # if group is the repo by itself
1113
+ end
521
1114
  end
522
1115
  end
523
-
524
1116
  end
525
1117
  end
526
1118
  end