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.
- checksums.yaml +4 -4
- data/.github/workflows/rake.yml +18 -0
- data/.github/workflows/release.yml +31 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +29 -0
- data/CLAUDE.md +56 -0
- data/README.adoc +467 -14
- data/bin/console +1 -1
- data/bin/rspec +29 -0
- data/cimas.gemspec +4 -5
- data/exe/cimas +9 -201
- data/lib/cimas/cli/command.rb +837 -245
- data/lib/cimas/cli/error.rb +8 -0
- data/lib/cimas/cli/runner.rb +239 -0
- data/lib/cimas/cli.rb +7 -0
- data/lib/cimas/github.rb +47 -0
- data/lib/cimas/orphan_files.rb +37 -0
- data/lib/cimas/patch.rb +25 -0
- data/lib/cimas/release_preflight.rb +140 -0
- data/lib/cimas/repository.rb +33 -0
- data/lib/cimas/version.rb +1 -1
- data/lib/cimas/working_copy.rb +136 -0
- data/lib/cimas.rb +29 -2
- data/plans/cimas-revival-and-release-workflow-realignment.md +2158 -0
- metadata +40 -12
- data/Gemfile.lock +0 -86
data/lib/cimas/cli/command.rb
CHANGED
|
@@ -1,32 +1,118 @@
|
|
|
1
|
-
require 'json'
|
|
2
1
|
require 'yaml'
|
|
3
|
-
require '
|
|
4
|
-
require '
|
|
5
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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(
|
|
109
|
+
@data = YAML.load(File.read(options['config_file_path'])) || {}
|
|
28
110
|
|
|
29
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
135
|
-
source_path = File.join(config_master_path,
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
354
|
+
yield repo
|
|
355
|
+
end
|
|
356
|
+
end
|
|
168
357
|
|
|
169
|
-
|
|
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] #{
|
|
365
|
+
puts "[ERROR] #{repo.name} is missing in #{repos_path}, skipping #{command_name} for it."
|
|
172
366
|
next
|
|
173
367
|
end
|
|
174
368
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
452
|
+
msg
|
|
292
453
|
end
|
|
293
454
|
|
|
294
455
|
def pr_message
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
337
|
-
# g.reset_hard(attribs['branch'])
|
|
513
|
+
drift_pushes += 1
|
|
338
514
|
|
|
339
|
-
dry_run("Pushing branch #{push_to_branch} (commit #{
|
|
340
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
522
|
+
if wc.clean?
|
|
523
|
+
puts "Skipping commit on #{repo_name}, no changes detected." if verbose
|
|
348
524
|
else
|
|
349
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
396
|
-
|
|
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
|
|
713
|
+
repo.branch,
|
|
405
714
|
branch,
|
|
406
715
|
message,
|
|
407
|
-
|
|
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.
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
|
493
|
-
fo
|
|
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
|
-
|
|
520
|
-
|
|
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
|