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