kettle-jem 7.0.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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/lib/kettle/jem/version.rb +11 -0
- data/lib/kettle/jem.rb +2528 -0
- data/lib/kettle-jem.rb +3 -0
- data.tar.gz.sig +0 -0
- metadata +158 -0
- metadata.gz.sig +0 -0
data/lib/kettle/jem.rb
ADDED
|
@@ -0,0 +1,2528 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "find"
|
|
5
|
+
require "ruby/merge"
|
|
6
|
+
require "token/resolver"
|
|
7
|
+
require "toml-merge"
|
|
8
|
+
require "yaml"
|
|
9
|
+
require "yaml/merge"
|
|
10
|
+
require "ast/merge"
|
|
11
|
+
require_relative "jem/version"
|
|
12
|
+
|
|
13
|
+
module Kettle
|
|
14
|
+
module Jem
|
|
15
|
+
PACKAGE_NAME = "kettle-jem"
|
|
16
|
+
CONTENT_RECIPE_TRANSPORT_VERSION = Ast::Merge::STRUCTURED_EDIT_TRANSPORT_VERSION
|
|
17
|
+
MANAGED_BLOCK_OPEN = "# <<kettle-jem:generated>> do not edit below this line"
|
|
18
|
+
MANAGED_BLOCK_CLOSE = "# <</kettle-jem:generated>>"
|
|
19
|
+
OBSOLETE_GITHUB_WORKFLOWS = %w[ancient.yml legacy.yml supported.yml unsupported.yml main.yml hoary.yml].freeze
|
|
20
|
+
OPENCOLLECTIVE_DISABLED_FILES = %w[.opencollective.yml .github/workflows/opencollective.yml].freeze
|
|
21
|
+
FILE_DELETION_PRIMITIVES = %w[supplied_obsolete_file_deletion supplied_disabled_opencollective_file_deletion].freeze
|
|
22
|
+
PACKAGED_TEMPLATE_ROOT = File.expand_path("jem/templates", __dir__)
|
|
23
|
+
SUPPORTED_TEMPLATE_STRATEGIES = %i[merge accept_template keep_destination raw_copy].freeze
|
|
24
|
+
SUPPORTED_TEMPLATE_FILE_TYPES = %i[ruby gemfile appraisals gemspec rakefile yaml toml markdown text].freeze
|
|
25
|
+
RUBY_TEMPLATE_BASENAMES = %w[Gemfile Rakefile Appraisals Appraisal.root.gemfile .simplecov].freeze
|
|
26
|
+
RUBY_TEMPLATE_SUFFIXES = %w[.gemspec .gemfile].freeze
|
|
27
|
+
RUBY_TEMPLATE_EXTENSIONS = %w[.rb .rake].freeze
|
|
28
|
+
TEMPLATE_TOKEN_CONFIG = Token::Resolver::Config.new(separators: ["|", ":"]).freeze
|
|
29
|
+
EMPTY_TEMPLATE_TOKENS = %w[KJ|COPYRIGHT_PREFIX KJ|MIN_DIVERGENCE_THRESHOLD].freeze
|
|
30
|
+
README_TOP_LOGO_MODE_DEFAULT = "org_and_project"
|
|
31
|
+
README_TOP_LOGO_MODES = %w[org project org_and_project].freeze
|
|
32
|
+
README_DEFAULT_PRESERVE_SECTIONS = ["synopsis", "configuration", "basic usage"].freeze
|
|
33
|
+
README_DEFAULT_PRESERVE_PATTERNS = ["note:*"].freeze
|
|
34
|
+
README_SECTION_ALIASES = {
|
|
35
|
+
"summary" => "synopsis",
|
|
36
|
+
"usage" => "basic usage",
|
|
37
|
+
"configuration options" => "configuration",
|
|
38
|
+
"setup" => "basic usage",
|
|
39
|
+
}.freeze
|
|
40
|
+
README_STATIC_TOP_LOGO_ROW = "[![Galtzo FLOSS Logo by Aboling0, CC BY-SA 4.0][🖼️galtzo-i]][🖼️galtzo-discord] [![ruby-lang Logo, Yukihiro Matsumoto, Ruby Visual Identity Team, CC BY-SA 2.5][🖼️ruby-lang-i]][🖼️ruby-lang]"
|
|
41
|
+
README_STATIC_TOP_LOGO_REFS = [
|
|
42
|
+
"[🖼️galtzo-i]: https://logos.galtzo.com/assets/images/galtzo-floss/avatar-192px.svg",
|
|
43
|
+
"[🖼️galtzo-discord]: https://discord.gg/3qme4XHNKN",
|
|
44
|
+
"[🖼️ruby-lang-i]: https://logos.galtzo.com/assets/images/ruby-lang/avatar-192px.svg",
|
|
45
|
+
"[🖼️ruby-lang]: https://www.ruby-lang.org/",
|
|
46
|
+
].join("\n").freeze
|
|
47
|
+
RUBOCOP_VERSION_MAP = [
|
|
48
|
+
[Gem::Version.new("1.8"), "~> 0.1"],
|
|
49
|
+
[Gem::Version.new("1.9"), "~> 2.0"],
|
|
50
|
+
[Gem::Version.new("2.0"), "~> 4.0"],
|
|
51
|
+
[Gem::Version.new("2.1"), "~> 6.0"],
|
|
52
|
+
[Gem::Version.new("2.2"), "~> 8.0"],
|
|
53
|
+
[Gem::Version.new("2.3"), "~> 10.0"],
|
|
54
|
+
[Gem::Version.new("2.4"), "~> 12.0"],
|
|
55
|
+
[Gem::Version.new("2.5"), "~> 14.0"],
|
|
56
|
+
[Gem::Version.new("2.6"), "~> 16.0"],
|
|
57
|
+
[Gem::Version.new("2.7"), "~> 18.0"],
|
|
58
|
+
[Gem::Version.new("3.0"), "~> 20.0"],
|
|
59
|
+
[Gem::Version.new("3.1"), "~> 22.0"],
|
|
60
|
+
[Gem::Version.new("3.2"), "~> 24.0"],
|
|
61
|
+
[Gem::Version.new("3.3"), "~> 26.0"],
|
|
62
|
+
[Gem::Version.new("3.4"), "~> 28.0"],
|
|
63
|
+
].freeze
|
|
64
|
+
FORGE_USER_ENV_KEYS = {
|
|
65
|
+
gh_user: "KJ_GH_USER",
|
|
66
|
+
gl_user: "KJ_GL_USER",
|
|
67
|
+
cb_user: "KJ_CB_USER",
|
|
68
|
+
sh_user: "KJ_SH_USER",
|
|
69
|
+
}.freeze
|
|
70
|
+
FUNDING_TOKEN_ENV_KEYS = {
|
|
71
|
+
patreon: "KJ_FUNDING_PATREON",
|
|
72
|
+
kofi: "KJ_FUNDING_KOFI",
|
|
73
|
+
paypal: "KJ_FUNDING_PAYPAL",
|
|
74
|
+
buymeacoffee: "KJ_FUNDING_BUYMEACOFFEE",
|
|
75
|
+
polar: "KJ_FUNDING_POLAR",
|
|
76
|
+
liberapay: "KJ_FUNDING_LIBERAPAY",
|
|
77
|
+
issuehunt: "KJ_FUNDING_ISSUEHUNT",
|
|
78
|
+
}.freeze
|
|
79
|
+
SOCIAL_TOKEN_ENV_KEYS = {
|
|
80
|
+
mastodon: "KJ_SOCIAL_MASTODON",
|
|
81
|
+
bluesky: "KJ_SOCIAL_BLUESKY",
|
|
82
|
+
linktree: "KJ_SOCIAL_LINKTREE",
|
|
83
|
+
devto: "KJ_SOCIAL_DEVTO",
|
|
84
|
+
}.freeze
|
|
85
|
+
APACHE_LICENSE_COMPAT_CATEGORIES = {
|
|
86
|
+
"Apache-2.0" => :a,
|
|
87
|
+
"MIT" => :a,
|
|
88
|
+
"AGPL-3.0-only" => :x,
|
|
89
|
+
"PolyForm-Noncommercial-1.0.0" => :x,
|
|
90
|
+
"PolyForm-Small-Business-1.0.0" => :x,
|
|
91
|
+
"LicenseRef-Big-Time-Public-License" => :x,
|
|
92
|
+
}.freeze
|
|
93
|
+
APACHE_LICENSE_COMPAT_BADGE_DATA = {
|
|
94
|
+
a: {
|
|
95
|
+
alt: "Apache license compatibility: Category A",
|
|
96
|
+
label: "Apache_Compatible:_Category_A",
|
|
97
|
+
message: "\u2713",
|
|
98
|
+
color: "259D6C",
|
|
99
|
+
ref: "https://www.apache.org/legal/resolved.html#category-a",
|
|
100
|
+
},
|
|
101
|
+
b: {
|
|
102
|
+
alt: "Apache license compatibility: Category B",
|
|
103
|
+
label: "Apache_Maybe_Compatible:_Category_B",
|
|
104
|
+
message: "?",
|
|
105
|
+
color: "D9A407",
|
|
106
|
+
ref: "https://www.apache.org/legal/resolved.html#category-b",
|
|
107
|
+
},
|
|
108
|
+
x: {
|
|
109
|
+
alt: "Apache license compatibility: Category X",
|
|
110
|
+
label: "Apache_Incompatible:_Category_X",
|
|
111
|
+
message: "\u2717",
|
|
112
|
+
color: "C0392B",
|
|
113
|
+
ref: "https://www.apache.org/legal/resolved.html#category-x",
|
|
114
|
+
},
|
|
115
|
+
unknown: {
|
|
116
|
+
alt: "Apache license compatibility: Unknown",
|
|
117
|
+
label: "Apache_Compatibility",
|
|
118
|
+
message: "Unknown",
|
|
119
|
+
color: "6C757D",
|
|
120
|
+
ref: "https://www.apache.org/legal/resolved.html",
|
|
121
|
+
},
|
|
122
|
+
}.freeze
|
|
123
|
+
|
|
124
|
+
module_function
|
|
125
|
+
|
|
126
|
+
def discover_facts(project_root, env: ENV)
|
|
127
|
+
gemspec_path = Dir.glob(File.join(project_root, "*.gemspec")).sort.first
|
|
128
|
+
raise ArgumentError, "no gemspec found in #{project_root}" unless gemspec_path
|
|
129
|
+
|
|
130
|
+
gemspec = File.read(gemspec_path)
|
|
131
|
+
name = extract_gemspec_assignment(gemspec, "spec.name") || File.basename(gemspec_path, ".gemspec")
|
|
132
|
+
homepage_url = extract_gemspec_assignment(gemspec, "spec.homepage")
|
|
133
|
+
metadata_source_url = extract_metadata_value(gemspec, "source_code_uri")
|
|
134
|
+
source_url = concrete_github_url(metadata_source_url) || concrete_github_url(homepage_url) || metadata_source_url || homepage_url
|
|
135
|
+
|
|
136
|
+
kettle_config = kettle_jem_config(project_root)
|
|
137
|
+
author = author_facts(gemspec, kettle_config, env)
|
|
138
|
+
license = license_facts(kettle_config, extract_gemspec_array(gemspec, "spec.licenses"), author_email: author[:email])
|
|
139
|
+
project_runtime = project_runtime_facts(
|
|
140
|
+
kettle_config,
|
|
141
|
+
env,
|
|
142
|
+
package_name: name,
|
|
143
|
+
source_url: source_url,
|
|
144
|
+
author_domain: author[:domain],
|
|
145
|
+
min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"),
|
|
146
|
+
version: extract_gemspec_assignment(gemspec, "spec.version")
|
|
147
|
+
)
|
|
148
|
+
facts = {
|
|
149
|
+
package: compact_hash(
|
|
150
|
+
ecosystem: "rubygems",
|
|
151
|
+
name: name,
|
|
152
|
+
slug: name,
|
|
153
|
+
description: extract_gemspec_assignment(gemspec, "spec.description") ||
|
|
154
|
+
extract_gemspec_assignment(gemspec, "spec.summary"),
|
|
155
|
+
homepage_url: homepage_url,
|
|
156
|
+
source_url: source_url,
|
|
157
|
+
license_expression: license[:expression],
|
|
158
|
+
),
|
|
159
|
+
rubygems: compact_hash(
|
|
160
|
+
gemspec_path: File.basename(gemspec_path),
|
|
161
|
+
namespace: classify_namespace(name),
|
|
162
|
+
min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"),
|
|
163
|
+
),
|
|
164
|
+
}
|
|
165
|
+
bootstrap = kettle_config_bootstrap_facts(project_root, env)
|
|
166
|
+
facts[:kettle_config_bootstrap] = bootstrap if bootstrap
|
|
167
|
+
facts[:author] = author unless author.empty?
|
|
168
|
+
forge = forge_facts(kettle_config, env)
|
|
169
|
+
facts[:forge] = forge unless forge.empty?
|
|
170
|
+
social = social_facts(kettle_config, env)
|
|
171
|
+
facts[:social] = social unless social.empty?
|
|
172
|
+
opencollective_policy = opencollective_policy(kettle_config, env)
|
|
173
|
+
opencollective_disabled = opencollective_policy.fetch(:disabled)
|
|
174
|
+
open_collective_org = opencollective_org(project_root, env, opencollective_disabled: opencollective_disabled)
|
|
175
|
+
funding = compact_hash(
|
|
176
|
+
urls: funding_urls(
|
|
177
|
+
project_root,
|
|
178
|
+
gemspec,
|
|
179
|
+
name,
|
|
180
|
+
opencollective_disabled: opencollective_disabled,
|
|
181
|
+
open_collective_org: open_collective_org && open_collective_org.fetch(:org)
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
funding_tokens = funding_platform_token_facts(kettle_config, env)
|
|
185
|
+
funding[:platform_tokens] = funding_tokens unless funding_tokens.empty?
|
|
186
|
+
funding[:open_collective_disabled] = true if opencollective_disabled
|
|
187
|
+
funding[:open_collective_disabled_source] = opencollective_policy[:source] if opencollective_disabled
|
|
188
|
+
if open_collective_org
|
|
189
|
+
funding[:open_collective_org] = open_collective_org.fetch(:org)
|
|
190
|
+
funding[:open_collective_org_source] = open_collective_org.fetch(:source)
|
|
191
|
+
end
|
|
192
|
+
open_collective_files = opencollective_disabled ? opencollective_disabled_files(project_root) : []
|
|
193
|
+
funding[:open_collective_files] = open_collective_files unless open_collective_files.empty?
|
|
194
|
+
facts[:funding] = funding unless funding.empty?
|
|
195
|
+
facts[:ci] = {
|
|
196
|
+
provider: "github_actions",
|
|
197
|
+
default_branch: "main",
|
|
198
|
+
ruby_versions: github_actions_ruby_versions(facts.fetch(:rubygems).fetch(:min_ruby, nil)),
|
|
199
|
+
obsolete_workflows: github_actions_obsolete_workflows(project_root),
|
|
200
|
+
custom_workflows: github_actions_custom_workflows(project_root, opencollective_disabled: opencollective_disabled),
|
|
201
|
+
}
|
|
202
|
+
coverage_config = github_actions_coverage_config(kettle_config)
|
|
203
|
+
facts[:ci][:coverage] = coverage_config unless coverage_config.empty?
|
|
204
|
+
framework_matrix = github_actions_framework_matrix(kettle_config)
|
|
205
|
+
facts[:ci][:framework_matrix] = framework_matrix unless framework_matrix.empty?
|
|
206
|
+
template_facts = {}
|
|
207
|
+
template_preferences = template_source_preferences(project_root, kettle_config, opencollective_disabled: opencollective_disabled)
|
|
208
|
+
template_facts[:source_preferences] = template_preferences unless template_preferences.empty?
|
|
209
|
+
unless template_preferences.empty?
|
|
210
|
+
facts[:license] = license unless license.empty?
|
|
211
|
+
facts[:project_runtime] = project_runtime unless project_runtime.empty?
|
|
212
|
+
readme_logo = readme_logo_facts(kettle_config, package_name: name, github_org: project_runtime[:github_org])
|
|
213
|
+
facts[:readme_logo] = readme_logo unless readme_logo.empty?
|
|
214
|
+
template_tokens = template_tokens(facts, funding)
|
|
215
|
+
template_facts[:tokens] = template_tokens unless template_tokens.empty?
|
|
216
|
+
end
|
|
217
|
+
facts[:templates] = template_facts unless template_facts.empty?
|
|
218
|
+
facts
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def recipe_pack(facts)
|
|
222
|
+
recipes = [
|
|
223
|
+
recipe_entry("readme_metadata", "README.md", "markdown", "supplied_readme_metadata_synchronization", facts: %w[package funding readme]),
|
|
224
|
+
recipe_entry("changelog_unreleased", "CHANGELOG.md", "markdown", "changelog_unreleased_normalization", facts: %w[package changelog]),
|
|
225
|
+
recipe_entry("generated_block_sync", "gemfiles/modular/shunted.gemfile", "text", "supplied_managed_text_block_replacement", facts: %w[package generated_blocks]),
|
|
226
|
+
recipe_entry(
|
|
227
|
+
"github_funding_yml",
|
|
228
|
+
".github/FUNDING.yml",
|
|
229
|
+
"yaml",
|
|
230
|
+
"supplied_github_funding_yaml_synchronization",
|
|
231
|
+
facts: %w[package funding]
|
|
232
|
+
),
|
|
233
|
+
recipe_entry(
|
|
234
|
+
"github_actions_ci",
|
|
235
|
+
".github/workflows/ci.yml",
|
|
236
|
+
"yaml",
|
|
237
|
+
"supplied_github_actions_workflow_synchronization",
|
|
238
|
+
facts: %w[package rubygems ci]
|
|
239
|
+
),
|
|
240
|
+
]
|
|
241
|
+
if facts[:kettle_config_bootstrap]
|
|
242
|
+
recipes.unshift(kettle_config_bootstrap_recipe(facts.fetch(:kettle_config_bootstrap)))
|
|
243
|
+
end
|
|
244
|
+
if facts.dig(:ci, :framework_matrix)
|
|
245
|
+
recipes << recipe_entry(
|
|
246
|
+
"github_actions_framework_ci",
|
|
247
|
+
".github/workflows/framework-ci.yml",
|
|
248
|
+
"yaml",
|
|
249
|
+
"supplied_github_actions_framework_workflow_synchronization",
|
|
250
|
+
facts: %w[package rubygems ci]
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
if facts.dig(:ci, :coverage)
|
|
254
|
+
recipes << recipe_entry(
|
|
255
|
+
"github_actions_coverage_ci",
|
|
256
|
+
".github/workflows/coverage.yml",
|
|
257
|
+
"yaml",
|
|
258
|
+
"supplied_github_actions_coverage_workflow_synchronization",
|
|
259
|
+
facts: %w[package rubygems ci]
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
facts.dig(:ci, :obsolete_workflows).to_a.each do |workflow_path|
|
|
263
|
+
recipes << recipe_entry(
|
|
264
|
+
"github_actions_obsolete_workflow_cleanup_#{workflow_recipe_slug(workflow_path)}",
|
|
265
|
+
workflow_path,
|
|
266
|
+
"file",
|
|
267
|
+
"supplied_obsolete_file_deletion",
|
|
268
|
+
facts: %w[ci]
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
facts.dig(:funding, :open_collective_files).to_a.each do |relative_path|
|
|
272
|
+
recipes << recipe_entry(
|
|
273
|
+
"opencollective_disabled_file_cleanup_#{workflow_recipe_slug(relative_path)}",
|
|
274
|
+
relative_path,
|
|
275
|
+
"file",
|
|
276
|
+
"supplied_disabled_opencollective_file_deletion",
|
|
277
|
+
facts: %w[funding]
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
facts.dig(:ci, :custom_workflows).to_a.each do |workflow_path|
|
|
281
|
+
recipes << recipe_entry(
|
|
282
|
+
"github_actions_workflow_snippets_#{workflow_recipe_slug(workflow_path)}",
|
|
283
|
+
workflow_path,
|
|
284
|
+
"yaml",
|
|
285
|
+
"supplied_github_actions_workflow_snippet_merge",
|
|
286
|
+
facts: %w[ci]
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
facts.dig(:templates, :source_preferences).to_a.each do |preference|
|
|
290
|
+
apply_template = preference.fetch(:apply, false)
|
|
291
|
+
recipe = recipe_entry(
|
|
292
|
+
"#{apply_template ? "template_source_application" : "template_source_preference"}_#{workflow_recipe_slug(preference.fetch(:target_path))}",
|
|
293
|
+
preference.fetch(:target_path),
|
|
294
|
+
"file",
|
|
295
|
+
apply_template ? "supplied_template_source_application" : "supplied_template_source_preference",
|
|
296
|
+
facts: %w[templates funding]
|
|
297
|
+
)
|
|
298
|
+
recipe[:template_preference] = preference
|
|
299
|
+
recipe[:template_tokens] = facts.dig(:templates, :tokens) if facts.dig(:templates, :tokens)
|
|
300
|
+
recipes << recipe
|
|
301
|
+
end
|
|
302
|
+
recipes << recipe_entry(
|
|
303
|
+
"rakefile_scaffold_cleanup",
|
|
304
|
+
"Rakefile",
|
|
305
|
+
"generic_ast",
|
|
306
|
+
"supplied_source_selector_deletion",
|
|
307
|
+
provider_backend: "generic_structural_owners",
|
|
308
|
+
facts: %w[rubygems rakefile],
|
|
309
|
+
selectors: %w[rakefile_scaffold]
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
{
|
|
313
|
+
name: "kettle-jem-core",
|
|
314
|
+
version: 1,
|
|
315
|
+
ecosystem: "rubygems",
|
|
316
|
+
recipes: recipes,
|
|
317
|
+
}
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def plan_project(project_root, env: ENV)
|
|
321
|
+
facts = discover_facts(project_root, env: env)
|
|
322
|
+
pack = recipe_pack(facts)
|
|
323
|
+
files = read_project_files(project_root, pack)
|
|
324
|
+
recipe_reports = pack.fetch(:recipes).map do |recipe|
|
|
325
|
+
execute_recipe(project_root: project_root, recipe: recipe, facts: facts, files: files)
|
|
326
|
+
end
|
|
327
|
+
changed_files = recipe_reports.filter_map { |report| report[:relative_path] if report[:changed] }.sort
|
|
328
|
+
|
|
329
|
+
{
|
|
330
|
+
mode: "plan",
|
|
331
|
+
ready: true,
|
|
332
|
+
facts: facts,
|
|
333
|
+
recipe_pack: pack,
|
|
334
|
+
recipe_reports: recipe_reports,
|
|
335
|
+
changed_files: changed_files,
|
|
336
|
+
diagnostics: recipe_reports.flat_map { |report| report[:diagnostics] },
|
|
337
|
+
}
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def apply_project(project_root, env: ENV)
|
|
341
|
+
report = plan_project(project_root, env: env).merge(mode: "apply")
|
|
342
|
+
report.fetch(:recipe_reports).each do |recipe_report|
|
|
343
|
+
next unless recipe_report[:changed]
|
|
344
|
+
|
|
345
|
+
path = File.join(project_root, recipe_report.fetch(:relative_path))
|
|
346
|
+
if recipe_report.dig(:metadata, :delete_file)
|
|
347
|
+
FileUtils.rm_f(path)
|
|
348
|
+
else
|
|
349
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
350
|
+
File.write(path, recipe_report.fetch(:final_content))
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
report
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def content_recipe_execution_request(recipe_name:, recipe_version:, relative_path:, provider_family:,
|
|
357
|
+
template_content:, destination_content:, steps:, provider_backend: nil, runtime_context: nil, metadata: nil)
|
|
358
|
+
compact_hash(
|
|
359
|
+
recipe_name: recipe_name.to_s,
|
|
360
|
+
recipe_version: recipe_version.to_s,
|
|
361
|
+
relative_path: relative_path.to_s,
|
|
362
|
+
provider_family: provider_family.to_s,
|
|
363
|
+
provider_backend: provider_backend&.to_s,
|
|
364
|
+
template_content: template_content.to_s,
|
|
365
|
+
destination_content: destination_content.to_s,
|
|
366
|
+
steps: deep_dup(steps),
|
|
367
|
+
runtime_context: deep_dup(runtime_context || {}),
|
|
368
|
+
metadata: deep_dup(metadata || {}),
|
|
369
|
+
)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def content_recipe_execution_request_envelope(request)
|
|
373
|
+
{
|
|
374
|
+
kind: "content_recipe_execution_request",
|
|
375
|
+
version: CONTENT_RECIPE_TRANSPORT_VERSION,
|
|
376
|
+
request: deep_dup(request),
|
|
377
|
+
}
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def content_recipe_execution_report(request:, final_content:, changed:, step_reports:, diagnostics:, metadata: nil)
|
|
381
|
+
compact_hash(
|
|
382
|
+
request: deep_dup(request),
|
|
383
|
+
final_content: final_content.to_s,
|
|
384
|
+
changed: changed ? true : false,
|
|
385
|
+
step_reports: deep_dup(step_reports),
|
|
386
|
+
diagnostics: deep_dup(diagnostics),
|
|
387
|
+
metadata: deep_dup(metadata || {}),
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def content_recipe_execution_report_envelope(report)
|
|
392
|
+
{
|
|
393
|
+
kind: "content_recipe_execution_report",
|
|
394
|
+
version: CONTENT_RECIPE_TRANSPORT_VERSION,
|
|
395
|
+
report: deep_dup(report),
|
|
396
|
+
}
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def synchronize_readme(content, facts)
|
|
400
|
+
package = facts.fetch(:package)
|
|
401
|
+
lines = content.to_s.split("\n", -1)
|
|
402
|
+
heading = "# #{package.fetch(:name)}"
|
|
403
|
+
h1_index = lines.index { |line| line.start_with?("# ") }
|
|
404
|
+
unless h1_index
|
|
405
|
+
lines.unshift(heading, "")
|
|
406
|
+
end
|
|
407
|
+
replace_markdown_managed_block(lines.join("\n"), "kettle-jem:metadata", readme_metadata_block(facts))
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def normalize_changelog(content, facts)
|
|
411
|
+
text = content.to_s
|
|
412
|
+
title = "# Changelog"
|
|
413
|
+
text = "#{title}\n\n#{text}" unless text.lines.first&.start_with?("# ")
|
|
414
|
+
return ensure_trailing_newline(text) if text.match?(/^##\s+\[?Unreleased\]?/i)
|
|
415
|
+
|
|
416
|
+
lines = text.split("\n", -1)
|
|
417
|
+
insert_at = lines.index { |line| line.start_with?("## ") } || lines.length
|
|
418
|
+
section = [
|
|
419
|
+
"## [Unreleased]",
|
|
420
|
+
"",
|
|
421
|
+
"### Added",
|
|
422
|
+
"",
|
|
423
|
+
"### Changed",
|
|
424
|
+
"",
|
|
425
|
+
"### Fixed",
|
|
426
|
+
"",
|
|
427
|
+
]
|
|
428
|
+
lines.insert(insert_at, *section)
|
|
429
|
+
ensure_trailing_newline(lines.join("\n").gsub(/\n{3,}/, "\n\n"))
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def synchronize_managed_block(content, facts)
|
|
433
|
+
replacement = [
|
|
434
|
+
MANAGED_BLOCK_OPEN,
|
|
435
|
+
"# package: #{facts.fetch(:package).fetch(:name)}",
|
|
436
|
+
"# generated by kettle-jem vNext",
|
|
437
|
+
MANAGED_BLOCK_CLOSE,
|
|
438
|
+
"",
|
|
439
|
+
].join("\n")
|
|
440
|
+
replace_text_managed_block(content.to_s, replacement)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def execute_recipe(project_root:, recipe:, facts:, files:)
|
|
444
|
+
relative_path = recipe.fetch(:target_path)
|
|
445
|
+
original = files.fetch(relative_path, "")
|
|
446
|
+
deletion = recipe.fetch(:name) == "rakefile_scaffold_cleanup" ? delete_rakefile_scaffold(original) : nil
|
|
447
|
+
final = case recipe.fetch(:name)
|
|
448
|
+
when "readme_metadata"
|
|
449
|
+
synchronize_readme(original, facts)
|
|
450
|
+
when "changelog_unreleased"
|
|
451
|
+
normalize_changelog(original, facts)
|
|
452
|
+
when "generated_block_sync"
|
|
453
|
+
synchronize_managed_block(original, facts)
|
|
454
|
+
when "github_funding_yml"
|
|
455
|
+
synchronize_github_funding_yml(original, facts)
|
|
456
|
+
when "github_actions_ci"
|
|
457
|
+
synchronize_github_actions_ci(original, facts)
|
|
458
|
+
when "github_actions_framework_ci"
|
|
459
|
+
synchronize_github_actions_framework_ci(original, facts)
|
|
460
|
+
when "github_actions_coverage_ci"
|
|
461
|
+
synchronize_github_actions_coverage_ci(original, facts)
|
|
462
|
+
when /\Agithub_actions_obsolete_workflow_cleanup_/
|
|
463
|
+
""
|
|
464
|
+
when /\Aopencollective_disabled_file_cleanup_/
|
|
465
|
+
""
|
|
466
|
+
when /\Agithub_actions_workflow_snippets_/
|
|
467
|
+
synchronize_github_actions_workflow_snippets(original)
|
|
468
|
+
when "kettle_config_bootstrap"
|
|
469
|
+
apply_kettle_config_bootstrap(project_root, recipe)
|
|
470
|
+
when /\Atemplate_source_preference_/
|
|
471
|
+
original
|
|
472
|
+
when /\Atemplate_source_application_/
|
|
473
|
+
apply_template_source(project_root, recipe, original)
|
|
474
|
+
when "rakefile_scaffold_cleanup"
|
|
475
|
+
deletion.fetch(:content)
|
|
476
|
+
else
|
|
477
|
+
original
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
template_content = recipe_template_content(project_root, recipe)
|
|
481
|
+
request = content_recipe_execution_request(
|
|
482
|
+
recipe_name: recipe.fetch(:primitive),
|
|
483
|
+
recipe_version: "1",
|
|
484
|
+
relative_path: relative_path,
|
|
485
|
+
provider_family: recipe.fetch(:provider_family),
|
|
486
|
+
provider_backend: recipe[:provider_backend],
|
|
487
|
+
template_content: template_content,
|
|
488
|
+
destination_content: original,
|
|
489
|
+
steps: [content_recipe_step(recipe)],
|
|
490
|
+
runtime_context: recipe_runtime_context(recipe, facts, deletion),
|
|
491
|
+
metadata: { packaging_recipe: recipe.fetch(:name), project_root: project_root.to_s },
|
|
492
|
+
)
|
|
493
|
+
changed = delete_file_recipe?(recipe) || final != original
|
|
494
|
+
step_report = content_recipe_step_report(recipe: recipe, request: request, original: original, final: final, changed: changed, deletion: deletion)
|
|
495
|
+
report = content_recipe_execution_report(
|
|
496
|
+
request: request,
|
|
497
|
+
final_content: final,
|
|
498
|
+
changed: changed,
|
|
499
|
+
step_reports: [step_report],
|
|
500
|
+
diagnostics: [],
|
|
501
|
+
metadata: recipe_report_metadata(recipe),
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
{
|
|
505
|
+
recipe_name: recipe.fetch(:name),
|
|
506
|
+
relative_path: relative_path,
|
|
507
|
+
changed: changed,
|
|
508
|
+
request_envelope: content_recipe_execution_request_envelope(request),
|
|
509
|
+
report_envelope: content_recipe_execution_report_envelope(report),
|
|
510
|
+
final_content: final,
|
|
511
|
+
metadata: recipe_report_metadata(recipe),
|
|
512
|
+
diagnostics: [],
|
|
513
|
+
}
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def content_recipe_step(recipe)
|
|
517
|
+
step = {
|
|
518
|
+
step_id: recipe.fetch(:name),
|
|
519
|
+
step_kind: recipe.fetch(:primitive),
|
|
520
|
+
name: recipe.fetch(:name),
|
|
521
|
+
provider_family: recipe.fetch(:provider_family),
|
|
522
|
+
metadata: { target_path: recipe.fetch(:target_path) },
|
|
523
|
+
}
|
|
524
|
+
step[:provider_backend] = recipe[:provider_backend] if recipe[:provider_backend]
|
|
525
|
+
if recipe.fetch(:primitive) == "supplied_source_selector_deletion"
|
|
526
|
+
step[:step_kind] = "native_policy"
|
|
527
|
+
step[:policy] = {
|
|
528
|
+
policy_kind: "delete_supplied_structural_owners",
|
|
529
|
+
required_context: "delete_selectors",
|
|
530
|
+
operation: "delete",
|
|
531
|
+
selector_family: "structural_owner_range",
|
|
532
|
+
normalize_blank_lines: true,
|
|
533
|
+
}
|
|
534
|
+
end
|
|
535
|
+
step
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def content_recipe_step_report(recipe:, request:, original:, final:, changed:, deletion: nil)
|
|
539
|
+
operation_profile = Ast::Merge.structured_edit_operation_profile(
|
|
540
|
+
operation_kind: recipe.fetch(:primitive),
|
|
541
|
+
known_operation_kind: true,
|
|
542
|
+
source_requirement: "destination_content",
|
|
543
|
+
destination_requirement: "relative_path",
|
|
544
|
+
replacement_source: "runtime_context",
|
|
545
|
+
captures_source_text: false,
|
|
546
|
+
supports_if_missing: true,
|
|
547
|
+
operation_family: "kettle-jem",
|
|
548
|
+
)
|
|
549
|
+
result = Ast::Merge.structured_edit_result(
|
|
550
|
+
operation_kind: recipe.fetch(:primitive),
|
|
551
|
+
updated_content: final,
|
|
552
|
+
changed: changed,
|
|
553
|
+
operation_profile: operation_profile,
|
|
554
|
+
)
|
|
555
|
+
application = Ast::Merge.structured_edit_application(request: request, result: result)
|
|
556
|
+
{
|
|
557
|
+
step_id: recipe.fetch(:name),
|
|
558
|
+
step_kind: recipe.fetch(:primitive),
|
|
559
|
+
status: changed ? "applied" : "unchanged",
|
|
560
|
+
changed: changed,
|
|
561
|
+
input_content: original,
|
|
562
|
+
output_content: final,
|
|
563
|
+
application: application,
|
|
564
|
+
diagnostics: [],
|
|
565
|
+
metadata: step_report_metadata(recipe, deletion),
|
|
566
|
+
}
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def read_project_files(project_root, pack)
|
|
570
|
+
pack.fetch(:recipes).to_h do |recipe|
|
|
571
|
+
relative_path = recipe.fetch(:target_path)
|
|
572
|
+
path = File.join(project_root, relative_path)
|
|
573
|
+
[relative_path, File.exist?(path) ? File.read(path) : ""]
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def recipe_template_content(project_root, recipe)
|
|
578
|
+
return "" unless %w[
|
|
579
|
+
supplied_kettle_config_bootstrap
|
|
580
|
+
supplied_template_source_preference
|
|
581
|
+
supplied_template_source_application
|
|
582
|
+
].include?(recipe.fetch(:primitive))
|
|
583
|
+
|
|
584
|
+
preference = recipe.fetch(:template_preference)
|
|
585
|
+
path = File.join(
|
|
586
|
+
preference.fetch(:source_root_path, project_root),
|
|
587
|
+
preference.fetch(:source_relative_path, preference.fetch(:selected_source))
|
|
588
|
+
)
|
|
589
|
+
File.read(path)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def apply_template_source(project_root, recipe, original)
|
|
593
|
+
strategy = recipe.dig(:template_preference, :strategy).to_s
|
|
594
|
+
return original if strategy == "keep_destination"
|
|
595
|
+
|
|
596
|
+
content = recipe_template_content(project_root, recipe)
|
|
597
|
+
return content if strategy == "raw_copy"
|
|
598
|
+
|
|
599
|
+
resolved = resolve_template_tokens(
|
|
600
|
+
content,
|
|
601
|
+
recipe.fetch(:template_tokens, {}),
|
|
602
|
+
scan_unresolved: unresolved_template_scan?(recipe)
|
|
603
|
+
)
|
|
604
|
+
rescue ArgumentError => e
|
|
605
|
+
raise ArgumentError, "#{recipe.fetch(:target_path)}: #{e.message}"
|
|
606
|
+
else
|
|
607
|
+
if recipe.fetch(:target_path) == "README.md" && (strategy.empty? || strategy == "merge")
|
|
608
|
+
return merge_readme_template(
|
|
609
|
+
template_content: resolved,
|
|
610
|
+
destination_content: original,
|
|
611
|
+
preserve_config: recipe.dig(:template_preference, :readme_preserve_config) || {}
|
|
612
|
+
)
|
|
613
|
+
end
|
|
614
|
+
return merge_config_template_source(recipe, resolved, original) if strategy.empty? || strategy == "merge"
|
|
615
|
+
|
|
616
|
+
resolved
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def merge_config_template_source(recipe, template_content, destination_content)
|
|
620
|
+
file_type = template_file_type(recipe)
|
|
621
|
+
return template_content if destination_content.to_s.strip.empty?
|
|
622
|
+
return destination_content if destination_content == template_content
|
|
623
|
+
|
|
624
|
+
case file_type
|
|
625
|
+
when :gemspec
|
|
626
|
+
return merge_gemspec_template_source(template_content, destination_content)
|
|
627
|
+
when :ruby, :gemfile, :appraisals, :rakefile
|
|
628
|
+
merge_result = Ruby::Merge.merge_ruby(
|
|
629
|
+
template_content,
|
|
630
|
+
destination_content,
|
|
631
|
+
"ruby",
|
|
632
|
+
merge_template_requires: file_type == :rakefile
|
|
633
|
+
)
|
|
634
|
+
when :yaml
|
|
635
|
+
merge_result = Yaml::Merge.merge_yaml(template_content, destination_content, "yaml")
|
|
636
|
+
when :toml
|
|
637
|
+
merge_result = Toml::Merge.merge_toml(template_content, destination_content, "toml")
|
|
638
|
+
else
|
|
639
|
+
return template_content
|
|
640
|
+
end
|
|
641
|
+
return merge_result.fetch(:output) if merge_result[:ok]
|
|
642
|
+
|
|
643
|
+
diagnostics = merge_result.fetch(:diagnostics, [])
|
|
644
|
+
message = diagnostics.map { |diagnostic| diagnostic[:message] || diagnostic["message"] }.compact.join("; ")
|
|
645
|
+
raise ArgumentError, "failed to merge #{file_type} template #{recipe.fetch(:target_path)}: #{message}"
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def merge_gemspec_template_source(template_content, destination_content)
|
|
649
|
+
replacements = gemspec_preserved_assignments(destination_content)
|
|
650
|
+
merged = replacements.reduce(template_content.dup) do |content, (field, source_line)|
|
|
651
|
+
pattern = /^(\s*spec\.#{Regexp.escape(field)}\s*=\s*).*$/
|
|
652
|
+
content.match?(pattern) ? content.sub(pattern, source_line.rstrip) : content
|
|
653
|
+
end
|
|
654
|
+
preserve_gemspec_dependency_lines(merged, destination_content)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def gemspec_preserved_assignments(source)
|
|
658
|
+
%w[
|
|
659
|
+
name
|
|
660
|
+
authors
|
|
661
|
+
email
|
|
662
|
+
summary
|
|
663
|
+
description
|
|
664
|
+
homepage
|
|
665
|
+
licenses
|
|
666
|
+
required_ruby_version
|
|
667
|
+
executables
|
|
668
|
+
].each_with_object({}) do |field, assignments|
|
|
669
|
+
line = source.to_s.lines.find { |candidate| candidate.match?(/^\s*spec\.#{Regexp.escape(field)}\s*=/) }
|
|
670
|
+
next unless line
|
|
671
|
+
next if line.include?("TODO:")
|
|
672
|
+
|
|
673
|
+
assignments[field] = line
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def preserve_gemspec_dependency_lines(template_content, destination_content)
|
|
678
|
+
destination_dependencies = gemspec_dependency_line_index(destination_content)
|
|
679
|
+
return template_content if destination_dependencies.empty?
|
|
680
|
+
|
|
681
|
+
merged = replace_matching_gemspec_dependency_lines(template_content, destination_dependencies)
|
|
682
|
+
append_missing_gemspec_dependency_lines(merged, destination_dependencies)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def replace_matching_gemspec_dependency_lines(content, destination_dependencies)
|
|
686
|
+
content.to_s.lines.map do |line|
|
|
687
|
+
key = gemspec_dependency_line_key(line)
|
|
688
|
+
key && destination_dependencies[key] ? destination_dependencies[key] : line
|
|
689
|
+
end.join
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def append_missing_gemspec_dependency_lines(content, destination_dependencies)
|
|
693
|
+
existing_keys = gemspec_dependency_line_index(content).keys
|
|
694
|
+
missing_lines = destination_dependencies.reject { |key, _line| existing_keys.include?(key) }.values
|
|
695
|
+
return content if missing_lines.empty?
|
|
696
|
+
|
|
697
|
+
content.sub(/^end\s*\z/, "#{missing_lines.join}end")
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def gemspec_dependency_line_index(source)
|
|
701
|
+
source.to_s.lines.each_with_object({}) do |line, dependencies|
|
|
702
|
+
key = gemspec_dependency_line_key(line)
|
|
703
|
+
dependencies[key] ||= line if key
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def gemspec_dependency_line_key(line)
|
|
708
|
+
match = line.to_s.match(/^\s*spec\.(add_(?:development_|runtime_)?dependency)\s*\(?\s*["']([^"']+)["']/)
|
|
709
|
+
match && [match[1], match[2]]
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def template_file_type(recipe)
|
|
713
|
+
configured = recipe.dig(:template_preference, :file_type).to_s
|
|
714
|
+
return configured.to_sym unless configured.empty?
|
|
715
|
+
|
|
716
|
+
relative_path = recipe.fetch(:target_path).to_s
|
|
717
|
+
basename = File.basename(relative_path)
|
|
718
|
+
extension = File.extname(relative_path).downcase
|
|
719
|
+
return :gemfile if basename == "Gemfile" || basename.end_with?(".gemfile")
|
|
720
|
+
return :appraisals if basename.start_with?("Appraisals") || basename == "Appraisal.root.gemfile"
|
|
721
|
+
return :gemspec if basename.end_with?(".gemspec")
|
|
722
|
+
return :rakefile if basename == "Rakefile" || extension == ".rake"
|
|
723
|
+
return :ruby if RUBY_TEMPLATE_BASENAMES.include?(basename) ||
|
|
724
|
+
RUBY_TEMPLATE_SUFFIXES.any? { |suffix| basename.end_with?(suffix) } ||
|
|
725
|
+
RUBY_TEMPLATE_EXTENSIONS.include?(extension)
|
|
726
|
+
return :yaml if extension.match?(/\A\.ya?ml\z/) || File.basename(relative_path).casecmp("citation.cff").zero?
|
|
727
|
+
return :toml if extension == ".toml"
|
|
728
|
+
return :markdown if extension.match?(/\A\.md(?:own)?\z/)
|
|
729
|
+
|
|
730
|
+
:text
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def apply_kettle_config_bootstrap(project_root, recipe)
|
|
734
|
+
content = recipe_template_content(project_root, recipe)
|
|
735
|
+
tokens = stringify_template_tokens(recipe.fetch(:template_tokens, {}))
|
|
736
|
+
content.gsub("{KJ|MIN_DIVERGENCE_THRESHOLD}", tokens.fetch("KJ|MIN_DIVERGENCE_THRESHOLD", ""))
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def recipe_report_metadata(recipe)
|
|
740
|
+
metadata = { packaging_recipe: recipe.fetch(:name) }
|
|
741
|
+
metadata[:delete_file] = true if delete_file_recipe?(recipe)
|
|
742
|
+
metadata[:template_source_preference] = deep_dup(recipe[:template_preference]) if recipe[:template_preference]
|
|
743
|
+
metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens]
|
|
744
|
+
metadata[:bootstrap_file] = true if recipe.fetch(:primitive) == "supplied_kettle_config_bootstrap"
|
|
745
|
+
metadata
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def recipe_entry(name, target_path, provider_family, primitive, facts:, provider_backend: nil, selectors: [])
|
|
749
|
+
{
|
|
750
|
+
name: name,
|
|
751
|
+
target_path: target_path,
|
|
752
|
+
provider_family: provider_family,
|
|
753
|
+
provider_backend: provider_backend,
|
|
754
|
+
primitive: primitive,
|
|
755
|
+
facts: facts,
|
|
756
|
+
selectors: selectors,
|
|
757
|
+
}
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def recipe_runtime_context(recipe, facts, deletion)
|
|
761
|
+
context = deep_dup(facts)
|
|
762
|
+
if recipe.fetch(:primitive) == "supplied_source_selector_deletion" && deletion
|
|
763
|
+
context[:delete_selectors] = deletion.fetch(:delete_selectors)
|
|
764
|
+
end
|
|
765
|
+
context[:template_source_preference] = deep_dup(recipe[:template_preference]) if recipe[:template_preference]
|
|
766
|
+
context[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens]
|
|
767
|
+
context
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def step_report_metadata(recipe, deletion)
|
|
771
|
+
metadata = { target_path: recipe.fetch(:target_path) }
|
|
772
|
+
if recipe.fetch(:primitive) == "supplied_obsolete_file_deletion"
|
|
773
|
+
metadata.merge!(
|
|
774
|
+
policy_kind: "delete_obsolete_file",
|
|
775
|
+
operation: "delete",
|
|
776
|
+
deleted_file: recipe.fetch(:target_path),
|
|
777
|
+
)
|
|
778
|
+
end
|
|
779
|
+
if recipe.fetch(:primitive) == "supplied_disabled_opencollective_file_deletion"
|
|
780
|
+
metadata.merge!(
|
|
781
|
+
policy_kind: "delete_disabled_opencollective_file",
|
|
782
|
+
operation: "delete",
|
|
783
|
+
deleted_file: recipe.fetch(:target_path),
|
|
784
|
+
)
|
|
785
|
+
end
|
|
786
|
+
if recipe.fetch(:primitive) == "supplied_template_source_preference"
|
|
787
|
+
metadata.merge!(
|
|
788
|
+
policy_kind: "select_template_source",
|
|
789
|
+
operation: "select",
|
|
790
|
+
template_source_preference: deep_dup(recipe.fetch(:template_preference)),
|
|
791
|
+
)
|
|
792
|
+
metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens]
|
|
793
|
+
end
|
|
794
|
+
if recipe.fetch(:primitive) == "supplied_template_source_application"
|
|
795
|
+
metadata.merge!(
|
|
796
|
+
policy_kind: "apply_template_source",
|
|
797
|
+
operation: "replace",
|
|
798
|
+
template_source_preference: deep_dup(recipe.fetch(:template_preference)),
|
|
799
|
+
)
|
|
800
|
+
metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens]
|
|
801
|
+
end
|
|
802
|
+
if recipe.fetch(:primitive) == "supplied_kettle_config_bootstrap"
|
|
803
|
+
metadata.merge!(
|
|
804
|
+
policy_kind: "bootstrap_kettle_config",
|
|
805
|
+
operation: "create",
|
|
806
|
+
template_source_preference: deep_dup(recipe.fetch(:template_preference)),
|
|
807
|
+
)
|
|
808
|
+
metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens]
|
|
809
|
+
end
|
|
810
|
+
return metadata unless deletion
|
|
811
|
+
|
|
812
|
+
metadata.merge(
|
|
813
|
+
policy_kind: "delete_supplied_structural_owners",
|
|
814
|
+
operation: "delete",
|
|
815
|
+
consumed_context: "delete_selectors",
|
|
816
|
+
deleted_ranges: deletion.fetch(:delete_selectors).length,
|
|
817
|
+
deleted_selector_ids: deletion.fetch(:delete_selectors).map { |selector| selector.fetch(:selector_id) },
|
|
818
|
+
)
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def extract_gemspec_assignment(source, field)
|
|
822
|
+
match = source.match(/#{Regexp.escape(field)}\s*=\s*["']([^"']*)["']/)
|
|
823
|
+
match && match[1]
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
def extract_gemspec_array(source, field)
|
|
827
|
+
match = source.match(/#{Regexp.escape(field)}\s*=\s*\[([^\]]*)\]/m)
|
|
828
|
+
return [] unless match
|
|
829
|
+
|
|
830
|
+
match[1].scan(/["']([^"']+)["']/).flatten
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def extract_metadata_value(source, key)
|
|
834
|
+
match = source.match(/spec\.metadata\[\s*["']#{Regexp.escape(key)}["']\s*\]\s*=\s*["']([^"']*)["']/)
|
|
835
|
+
match && match[1]
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def funding_urls(project_root, gemspec_source, package_name, opencollective_disabled: false, open_collective_org: nil)
|
|
839
|
+
urls = [extract_metadata_value(gemspec_source, "funding_uri")]
|
|
840
|
+
path = File.join(project_root, ".github", "FUNDING.yml")
|
|
841
|
+
urls.concat(github_funding_urls(path, opencollective_disabled: opencollective_disabled)) if File.exist?(path)
|
|
842
|
+
urls << github_funding_platform_urls("open_collective", [open_collective_org]).first unless opencollective_disabled
|
|
843
|
+
urls << github_funding_platform_urls("tidelift", ["rubygems/#{package_name}"]).first
|
|
844
|
+
|
|
845
|
+
urls.compact.uniq.sort
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
def github_funding_urls(path, opencollective_disabled: false)
|
|
849
|
+
funding = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {}
|
|
850
|
+
return [] unless funding.is_a?(Hash)
|
|
851
|
+
|
|
852
|
+
funding.flat_map do |platform, value|
|
|
853
|
+
next [] if opencollective_disabled && platform.to_s == "open_collective"
|
|
854
|
+
|
|
855
|
+
github_funding_platform_urls(platform.to_s, Array(value).compact)
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
def github_funding_platform_urls(platform, values)
|
|
860
|
+
values.filter_map do |value|
|
|
861
|
+
handle = value.to_s.strip.delete_prefix("@")
|
|
862
|
+
next if handle.empty?
|
|
863
|
+
|
|
864
|
+
case platform
|
|
865
|
+
when "buy_me_a_coffee"
|
|
866
|
+
"https://www.buymeacoffee.com/#{handle}"
|
|
867
|
+
when "custom"
|
|
868
|
+
handle if handle.match?(%r{\Ahttps?://})
|
|
869
|
+
when "github"
|
|
870
|
+
"https://github.com/sponsors/#{handle}"
|
|
871
|
+
when "issuehunt"
|
|
872
|
+
"https://issuehunt.io/u/#{handle}"
|
|
873
|
+
when "ko_fi"
|
|
874
|
+
"https://ko-fi.com/#{handle}"
|
|
875
|
+
when "liberapay"
|
|
876
|
+
"https://liberapay.com/#{handle}/donate"
|
|
877
|
+
when "open_collective"
|
|
878
|
+
"https://opencollective.com/#{handle}"
|
|
879
|
+
when "patreon"
|
|
880
|
+
"https://patreon.com/#{handle}"
|
|
881
|
+
when "polar"
|
|
882
|
+
"https://polar.sh/#{handle}"
|
|
883
|
+
when "thanks_dev"
|
|
884
|
+
"https://thanks.dev/#{handle}"
|
|
885
|
+
when "tidelift"
|
|
886
|
+
"https://tidelift.com/funding/github/#{handle}"
|
|
887
|
+
end
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def github_actions_ruby_versions(min_ruby)
|
|
892
|
+
floor = min_ruby.to_s[/\d+\.\d+/] || "3.1"
|
|
893
|
+
candidates = %w[3.1 3.2 3.3 3.4]
|
|
894
|
+
selected = candidates.select { |version| Gem::Version.new(version) >= Gem::Version.new(floor) }
|
|
895
|
+
selected.empty? ? [floor] : selected
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
def github_actions_custom_workflows(project_root, opencollective_disabled: false)
|
|
899
|
+
workflow_root = File.join(project_root, ".github", "workflows")
|
|
900
|
+
return [] unless Dir.exist?(workflow_root)
|
|
901
|
+
|
|
902
|
+
Dir.glob(File.join(workflow_root, "*.{yml,yaml}")).filter_map do |path|
|
|
903
|
+
relative_path = path.delete_prefix("#{project_root}/")
|
|
904
|
+
next if opencollective_disabled && opencollective_disabled_file?(relative_path)
|
|
905
|
+
next if generated_or_obsolete_github_workflow?(relative_path)
|
|
906
|
+
|
|
907
|
+
relative_path
|
|
908
|
+
end.sort
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
def github_actions_obsolete_workflows(project_root)
|
|
912
|
+
workflow_root = File.join(project_root, ".github", "workflows")
|
|
913
|
+
OBSOLETE_GITHUB_WORKFLOWS.filter_map do |workflow|
|
|
914
|
+
relative_path = ".github/workflows/#{workflow}"
|
|
915
|
+
path = File.join(workflow_root, workflow)
|
|
916
|
+
relative_path if File.exist?(path)
|
|
917
|
+
end.sort
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def generated_or_obsolete_github_workflow?(relative_path)
|
|
921
|
+
return true if %w[.github/workflows/ci.yml .github/workflows/coverage.yml .github/workflows/framework-ci.yml].include?(relative_path)
|
|
922
|
+
|
|
923
|
+
OBSOLETE_GITHUB_WORKFLOWS.include?(File.basename(relative_path))
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
def opencollective_disabled_files(project_root)
|
|
927
|
+
OPENCOLLECTIVE_DISABLED_FILES.select do |relative_path|
|
|
928
|
+
File.exist?(File.join(project_root, relative_path))
|
|
929
|
+
end
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def opencollective_disabled_file?(relative_path)
|
|
933
|
+
OPENCOLLECTIVE_DISABLED_FILES.include?(relative_path.to_s)
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def delete_file_recipe?(recipe)
|
|
937
|
+
FILE_DELETION_PRIMITIVES.include?(recipe.fetch(:primitive))
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
def workflow_recipe_slug(workflow_path)
|
|
941
|
+
workflow_path.gsub(/[^a-zA-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def kettle_jem_config(project_root)
|
|
945
|
+
path = File.join(project_root, ".kettle-jem.yml")
|
|
946
|
+
return {} unless File.exist?(path)
|
|
947
|
+
|
|
948
|
+
config = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {}
|
|
949
|
+
config.is_a?(Hash) ? config : {}
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
def opencollective_disabled?(config, env: ENV)
|
|
953
|
+
opencollective_policy(config, env).fetch(:disabled)
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
def opencollective_policy(config, env)
|
|
957
|
+
funding = config["funding"]
|
|
958
|
+
if funding.is_a?(Hash) && funding.key?("open_collective")
|
|
959
|
+
config_value = funding["open_collective"]
|
|
960
|
+
return {
|
|
961
|
+
disabled: falsey_config?(config_value),
|
|
962
|
+
source: "config.funding.open_collective",
|
|
963
|
+
value: config_value.to_s,
|
|
964
|
+
}
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
env_falsey = opencollective_falsey_env(env)
|
|
968
|
+
return { disabled: true, source: "env.#{env_falsey.fetch(:key)}", value: env_falsey.fetch(:value).to_s } if env_falsey
|
|
969
|
+
|
|
970
|
+
{ disabled: false }
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
def opencollective_falsey_env(env)
|
|
974
|
+
%w[OPENCOLLECTIVE_HANDLE FUNDING_ORG].each do |key|
|
|
975
|
+
value = env[key]
|
|
976
|
+
return { key: key, value: value } if falsey_config?(value)
|
|
977
|
+
end
|
|
978
|
+
nil
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
def opencollective_org(project_root, env, opencollective_disabled: false)
|
|
982
|
+
return nil if opencollective_disabled
|
|
983
|
+
|
|
984
|
+
env_org = opencollective_org_env(env)
|
|
985
|
+
return env_org if env_org
|
|
986
|
+
|
|
987
|
+
opencollective_org_file(project_root)
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
def opencollective_org_env(env)
|
|
991
|
+
%w[OPENCOLLECTIVE_HANDLE FUNDING_ORG].each do |key|
|
|
992
|
+
value = env[key].to_s.strip
|
|
993
|
+
next if value.empty? || falsey_config?(value)
|
|
994
|
+
|
|
995
|
+
return { org: value, source: "env.#{key}" }
|
|
996
|
+
end
|
|
997
|
+
nil
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
def opencollective_org_file(project_root)
|
|
1001
|
+
path = File.join(project_root, ".opencollective.yml")
|
|
1002
|
+
return nil unless File.exist?(path)
|
|
1003
|
+
|
|
1004
|
+
config = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {}
|
|
1005
|
+
return nil unless config.is_a?(Hash)
|
|
1006
|
+
|
|
1007
|
+
org = config.fetch("collective", config["org"]).to_s.strip
|
|
1008
|
+
return nil if org.empty?
|
|
1009
|
+
|
|
1010
|
+
{ org: org, source: ".opencollective.yml" }
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
def template_tokens(facts, funding)
|
|
1014
|
+
package = facts.fetch(:package)
|
|
1015
|
+
rubygems = facts.fetch(:rubygems)
|
|
1016
|
+
tokens = {
|
|
1017
|
+
"KJ|GEM_NAME" => package.fetch(:name).to_s,
|
|
1018
|
+
"KJ|GEM_NAME_PATH" => package.fetch(:name).to_s.tr("-", "/"),
|
|
1019
|
+
"KJ|GEM_SHIELD" => shield_token(package.fetch(:name).to_s),
|
|
1020
|
+
"KJ|GEM_MAJOR" => gem_major_token(facts.fetch(:project_runtime, {})[:version]),
|
|
1021
|
+
"KJ|GH_ORG" => facts.fetch(:project_runtime, {})[:github_org].to_s,
|
|
1022
|
+
"KJ|NAMESPACE" => rubygems.fetch(:namespace).to_s,
|
|
1023
|
+
"KJ|NAMESPACE_SHIELD" => shield_token(rubygems.fetch(:namespace).to_s),
|
|
1024
|
+
"KJ|MIN_RUBY" => minimum_ruby_token(rubygems[:min_ruby]),
|
|
1025
|
+
"KJ|MIN_DEV_RUBY" => minimum_dev_ruby_token(rubygems[:min_ruby]),
|
|
1026
|
+
}.merge(
|
|
1027
|
+
rubocop_template_tokens(rubygems[:min_ruby])
|
|
1028
|
+
).merge(
|
|
1029
|
+
author_template_tokens(facts.fetch(:author, {}))
|
|
1030
|
+
).merge(
|
|
1031
|
+
forge_template_tokens(facts.fetch(:forge, {}))
|
|
1032
|
+
).merge(
|
|
1033
|
+
funding_template_tokens(funding)
|
|
1034
|
+
).merge(
|
|
1035
|
+
social_template_tokens(facts.fetch(:social, {}))
|
|
1036
|
+
).merge(
|
|
1037
|
+
license_template_tokens(facts.fetch(:license, {}))
|
|
1038
|
+
).merge(
|
|
1039
|
+
project_runtime_template_tokens(facts.fetch(:project_runtime, {}))
|
|
1040
|
+
).merge(
|
|
1041
|
+
readme_logo_template_tokens(facts.fetch(:readme_logo, {}))
|
|
1042
|
+
)
|
|
1043
|
+
org = funding[:open_collective_org].to_s
|
|
1044
|
+
tokens["KJ|OPENCOLLECTIVE_ORG"] = org unless org.empty?
|
|
1045
|
+
|
|
1046
|
+
tokens.reject { |key, value| value.empty? && !EMPTY_TEMPLATE_TOKENS.include?(key) }
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
def minimum_ruby_token(requirement)
|
|
1050
|
+
requirement.to_s[/\d+(?:\.\d+){1,2}/].to_s
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
def minimum_dev_ruby_token(requirement)
|
|
1054
|
+
min_ruby = minimum_ruby_token(requirement)
|
|
1055
|
+
return "" if min_ruby.empty?
|
|
1056
|
+
|
|
1057
|
+
[Gem::Version.new(min_ruby), Gem::Version.new("2.3")].max.to_s
|
|
1058
|
+
rescue ArgumentError
|
|
1059
|
+
"2.3"
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
def gem_major_token(version)
|
|
1063
|
+
Gem::Version.new(version.to_s).segments.first.to_s
|
|
1064
|
+
rescue ArgumentError
|
|
1065
|
+
"0"
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
def author_facts(gemspec_source, config, env)
|
|
1069
|
+
token_config = token_config_values(config)
|
|
1070
|
+
author_config = token_config["author"].is_a?(Hash) ? token_config["author"] : {}
|
|
1071
|
+
derived_name = extract_gemspec_array(gemspec_source, "spec.authors").first
|
|
1072
|
+
derived_email = extract_gemspec_array(gemspec_source, "spec.email").first
|
|
1073
|
+
name = preferred_template_token_value(derived_name, author_config["name"], env, "KJ_AUTHOR_NAME").to_s
|
|
1074
|
+
email = preferred_template_token_value(derived_email, author_config["email"], env, "KJ_AUTHOR_EMAIL").to_s
|
|
1075
|
+
given_names = preferred_template_token_value(author_given_names(name), author_config["given_names"], env, "KJ_AUTHOR_GIVEN_NAMES")
|
|
1076
|
+
family_names = preferred_template_token_value(author_family_names(name), author_config["family_names"], env, "KJ_AUTHOR_FAMILY_NAMES")
|
|
1077
|
+
domain = preferred_template_token_value(email.split("@", 2)[1], author_config["domain"], env, "KJ_AUTHOR_DOMAIN")
|
|
1078
|
+
orcid = preferred_template_token_value(nil, author_config["orcid"], env, "KJ_AUTHOR_ORCID")
|
|
1079
|
+
compact_hash(
|
|
1080
|
+
name: name,
|
|
1081
|
+
given_names: given_names.to_s,
|
|
1082
|
+
family_names: family_names.to_s,
|
|
1083
|
+
email: email,
|
|
1084
|
+
domain: domain.to_s,
|
|
1085
|
+
orcid: orcid.to_s
|
|
1086
|
+
)
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
def token_config_values(config)
|
|
1090
|
+
raw = config.is_a?(Hash) ? config["tokens"] : nil
|
|
1091
|
+
raw.is_a?(Hash) ? raw : {}
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
def preferred_template_token_value(derived_value, config_value, env, env_key)
|
|
1095
|
+
env_clean = env[env_key].to_s.strip
|
|
1096
|
+
return env_clean if present_template_token_value?(env_clean)
|
|
1097
|
+
|
|
1098
|
+
config_clean = config_value.to_s.strip
|
|
1099
|
+
return config_clean if present_template_token_value?(config_clean)
|
|
1100
|
+
return unless present_template_token_value?(derived_value)
|
|
1101
|
+
|
|
1102
|
+
derived_value.to_s.strip
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
def present_template_token_value?(value)
|
|
1106
|
+
clean = value.to_s.strip
|
|
1107
|
+
!clean.empty? && !token_placeholder?(clean)
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
def token_placeholder?(value)
|
|
1111
|
+
value.to_s.strip.match?(%r{\A\{KJ\|[A-Z][A-Z0-9_:]*\}\z})
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1114
|
+
def author_template_tokens(author)
|
|
1115
|
+
{
|
|
1116
|
+
"KJ|AUTHOR:NAME" => author[:name].to_s,
|
|
1117
|
+
"KJ|AUTHOR:GIVEN_NAMES" => author[:given_names].to_s,
|
|
1118
|
+
"KJ|AUTHOR:FAMILY_NAMES" => author[:family_names].to_s,
|
|
1119
|
+
"KJ|AUTHOR:EMAIL" => author[:email].to_s,
|
|
1120
|
+
"KJ|AUTHOR:DOMAIN" => author[:domain].to_s,
|
|
1121
|
+
"KJ|AUTHOR:ORCID" => author[:orcid].to_s,
|
|
1122
|
+
}
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
def forge_facts(config, env)
|
|
1126
|
+
token_config = token_config_values(config)
|
|
1127
|
+
forge_config = token_config["forge"].is_a?(Hash) ? token_config["forge"] : {}
|
|
1128
|
+
compact_hash(
|
|
1129
|
+
gh_user: forge_user_value(forge_config, env, :gh_user).to_s,
|
|
1130
|
+
gl_user: forge_user_value(forge_config, env, :gl_user).to_s,
|
|
1131
|
+
cb_user: forge_user_value(forge_config, env, :cb_user).to_s,
|
|
1132
|
+
sh_user: forge_user_value(forge_config, env, :sh_user).to_s
|
|
1133
|
+
)
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
def forge_user_value(forge_config, env, key)
|
|
1137
|
+
preferred_template_token_value(nil, forge_config[key.to_s], env, FORGE_USER_ENV_KEYS.fetch(key))
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
def forge_template_tokens(forge)
|
|
1141
|
+
{
|
|
1142
|
+
"KJ|GH:USER" => forge[:gh_user].to_s,
|
|
1143
|
+
"KJ|GL:USER" => forge[:gl_user].to_s,
|
|
1144
|
+
"KJ|CB:USER" => forge[:cb_user].to_s,
|
|
1145
|
+
"KJ|SH:USER" => forge[:sh_user].to_s,
|
|
1146
|
+
}
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
def funding_platform_token_facts(config, env)
|
|
1150
|
+
token_config = token_config_values(config)
|
|
1151
|
+
funding_config = token_config["funding"].is_a?(Hash) ? token_config["funding"] : {}
|
|
1152
|
+
compact_hash(
|
|
1153
|
+
patreon: funding_platform_token_value(funding_config, env, :patreon).to_s,
|
|
1154
|
+
kofi: funding_platform_token_value(funding_config, env, :kofi).to_s,
|
|
1155
|
+
paypal: funding_platform_token_value(funding_config, env, :paypal).to_s,
|
|
1156
|
+
buymeacoffee: funding_platform_token_value(funding_config, env, :buymeacoffee).to_s,
|
|
1157
|
+
polar: funding_platform_token_value(funding_config, env, :polar).to_s,
|
|
1158
|
+
liberapay: funding_platform_token_value(funding_config, env, :liberapay).to_s,
|
|
1159
|
+
issuehunt: funding_platform_token_value(funding_config, env, :issuehunt).to_s
|
|
1160
|
+
)
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
def funding_platform_token_value(funding_config, env, key)
|
|
1164
|
+
preferred_template_token_value(nil, funding_config[key.to_s], env, FUNDING_TOKEN_ENV_KEYS.fetch(key))
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
def funding_template_tokens(funding)
|
|
1168
|
+
platform_tokens = funding.fetch(:platform_tokens, {})
|
|
1169
|
+
{
|
|
1170
|
+
"KJ|FUNDING:PATREON" => platform_tokens[:patreon].to_s,
|
|
1171
|
+
"KJ|FUNDING:KOFI" => platform_tokens[:kofi].to_s,
|
|
1172
|
+
"KJ|FUNDING:PAYPAL" => platform_tokens[:paypal].to_s,
|
|
1173
|
+
"KJ|FUNDING:BUYMEACOFFEE" => platform_tokens[:buymeacoffee].to_s,
|
|
1174
|
+
"KJ|FUNDING:POLAR" => platform_tokens[:polar].to_s,
|
|
1175
|
+
"KJ|FUNDING:LIBERAPAY" => platform_tokens[:liberapay].to_s,
|
|
1176
|
+
"KJ|FUNDING:ISSUEHUNT" => platform_tokens[:issuehunt].to_s,
|
|
1177
|
+
}
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
def social_facts(config, env)
|
|
1181
|
+
token_config = token_config_values(config)
|
|
1182
|
+
social_config = token_config["social"].is_a?(Hash) ? token_config["social"] : {}
|
|
1183
|
+
compact_hash(
|
|
1184
|
+
mastodon: social_token_value(social_config, env, :mastodon).to_s,
|
|
1185
|
+
bluesky: social_token_value(social_config, env, :bluesky).to_s,
|
|
1186
|
+
linktree: social_token_value(social_config, env, :linktree).to_s,
|
|
1187
|
+
devto: social_token_value(social_config, env, :devto).to_s
|
|
1188
|
+
)
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
def social_token_value(social_config, env, key)
|
|
1192
|
+
preferred_template_token_value(nil, social_config[key.to_s], env, SOCIAL_TOKEN_ENV_KEYS.fetch(key))
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
def social_template_tokens(social)
|
|
1196
|
+
{
|
|
1197
|
+
"KJ|SOCIAL:MASTODON" => social[:mastodon].to_s,
|
|
1198
|
+
"KJ|SOCIAL:BLUESKY" => social[:bluesky].to_s,
|
|
1199
|
+
"KJ|SOCIAL:LINKTREE" => social[:linktree].to_s,
|
|
1200
|
+
"KJ|SOCIAL:DEVTO" => social[:devto].to_s,
|
|
1201
|
+
}
|
|
1202
|
+
end
|
|
1203
|
+
|
|
1204
|
+
def project_runtime_facts(config, env, package_name:, source_url:, author_domain:, min_ruby:, version:)
|
|
1205
|
+
run_timestamp = Time.now
|
|
1206
|
+
compact_hash(
|
|
1207
|
+
freeze_token: config.dig("defaults", "freeze_token").to_s.empty? ? "kettle-jem" : config.dig("defaults", "freeze_token").to_s,
|
|
1208
|
+
kettle_jem_version: VERSION,
|
|
1209
|
+
template_run_date: run_timestamp.strftime("%Y-%m-%d"),
|
|
1210
|
+
template_run_year: run_timestamp.year.to_s,
|
|
1211
|
+
kettle_dev_gem: "kettle-dev",
|
|
1212
|
+
yard_host: "#{package_name.to_s.tr("_", "-")}.#{author_domain.to_s.empty? ? "example.com" : author_domain}",
|
|
1213
|
+
project_emoji: preferred_template_token_value(nil, config["project_emoji"], env, "KJ_PROJECT_EMOJI").to_s,
|
|
1214
|
+
min_divergence_threshold: preferred_template_token_value(nil, config["min_divergence_threshold"], env, "KJ_MIN_DIVERGENCE_THRESHOLD").to_s,
|
|
1215
|
+
min_dev_ruby: minimum_dev_ruby_token(min_ruby),
|
|
1216
|
+
version: version.to_s,
|
|
1217
|
+
github_org: github_org_from_url(source_url).to_s
|
|
1218
|
+
)
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
def project_runtime_template_tokens(project_runtime)
|
|
1222
|
+
{
|
|
1223
|
+
"KJ|FREEZE_TOKEN" => project_runtime[:freeze_token].to_s,
|
|
1224
|
+
"KJ|KETTLE_JEM_VERSION" => project_runtime[:kettle_jem_version].to_s,
|
|
1225
|
+
"KJ|TEMPLATE_RUN_DATE" => project_runtime[:template_run_date].to_s,
|
|
1226
|
+
"KJ|TEMPLATE_RUN_YEAR" => project_runtime[:template_run_year].to_s,
|
|
1227
|
+
"KJ|KETTLE_DEV_GEM" => project_runtime[:kettle_dev_gem].to_s,
|
|
1228
|
+
"KJ|YARD_HOST" => project_runtime[:yard_host].to_s,
|
|
1229
|
+
"KJ|PROJECT_EMOJI" => project_runtime[:project_emoji].to_s,
|
|
1230
|
+
"KJ|MIN_DIVERGENCE_THRESHOLD" => project_runtime[:min_divergence_threshold].to_s,
|
|
1231
|
+
}
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
def shield_token(value)
|
|
1235
|
+
value.to_s.gsub("-", "--").gsub("_", "__").gsub("::", "%3A%3A").tr(" ", "_")
|
|
1236
|
+
end
|
|
1237
|
+
|
|
1238
|
+
def github_org_from_url(url)
|
|
1239
|
+
match = url.to_s.match(%r{\Ahttps?://github\.com/([^/]+)/})
|
|
1240
|
+
match && match[1]
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
def concrete_github_url(url)
|
|
1244
|
+
github_org_from_url(url) ? url.to_s : nil
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
def readme_logo_facts(config, package_name:, github_org:)
|
|
1248
|
+
entries = readme_top_logo_entries(readme_top_logo_mode(config), org: github_org.to_s, gem_name: package_name.to_s)
|
|
1249
|
+
compact_hash(
|
|
1250
|
+
top_logo_mode: readme_top_logo_mode(config),
|
|
1251
|
+
top_logo_row: [README_STATIC_TOP_LOGO_ROW, readme_top_logo_row(entries)].reject(&:empty?).join(" "),
|
|
1252
|
+
top_logo_refs: [README_STATIC_TOP_LOGO_REFS, readme_top_logo_refs(entries)].reject(&:empty?).join("\n")
|
|
1253
|
+
)
|
|
1254
|
+
end
|
|
1255
|
+
|
|
1256
|
+
def readme_top_logo_mode(config)
|
|
1257
|
+
raw_config = config.is_a?(Hash) ? config["readme"] : nil
|
|
1258
|
+
readme_config = raw_config.is_a?(Hash) ? raw_config : {}
|
|
1259
|
+
normalized = readme_config["top_logo_mode"].to_s.strip.downcase.tr("-", "_")
|
|
1260
|
+
return README_TOP_LOGO_MODE_DEFAULT if normalized.empty?
|
|
1261
|
+
return normalized if README_TOP_LOGO_MODES.include?(normalized)
|
|
1262
|
+
|
|
1263
|
+
README_TOP_LOGO_MODE_DEFAULT
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
def readme_top_logo_entries(mode, org:, gem_name:)
|
|
1267
|
+
return [] if org.empty?
|
|
1268
|
+
|
|
1269
|
+
entries = []
|
|
1270
|
+
if mode == "org" || mode == "org_and_project"
|
|
1271
|
+
entries << {
|
|
1272
|
+
label: org,
|
|
1273
|
+
image_ref: "#{org}-i",
|
|
1274
|
+
link_ref: org,
|
|
1275
|
+
image_url: "https://logos.galtzo.com/assets/images/#{org}/avatar-192px.svg",
|
|
1276
|
+
href: "https://github.com/#{org}",
|
|
1277
|
+
}
|
|
1278
|
+
end
|
|
1279
|
+
if mode == "project" || mode == "org_and_project"
|
|
1280
|
+
entries << {
|
|
1281
|
+
label: gem_name,
|
|
1282
|
+
image_ref: "#{gem_name}-i",
|
|
1283
|
+
link_ref: gem_name,
|
|
1284
|
+
image_url: "https://logos.galtzo.com/assets/images/#{org}/#{gem_name}/avatar-192px.svg",
|
|
1285
|
+
href: "https://github.com/#{org}/#{gem_name}",
|
|
1286
|
+
}
|
|
1287
|
+
end
|
|
1288
|
+
entries.uniq { |entry| [entry[:image_ref], entry[:link_ref], entry[:image_url], entry[:href]] }
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
def readme_top_logo_row(entries)
|
|
1292
|
+
entries.map do |entry|
|
|
1293
|
+
"[![#{entry[:label]} Logo by Aboling0, CC BY-SA 4.0][🖼️#{entry[:image_ref]}]][🖼️#{entry[:link_ref]}]"
|
|
1294
|
+
end.join(" ")
|
|
1295
|
+
end
|
|
1296
|
+
|
|
1297
|
+
def readme_top_logo_refs(entries)
|
|
1298
|
+
entries.flat_map do |entry|
|
|
1299
|
+
[
|
|
1300
|
+
"[🖼️#{entry[:image_ref]}]: #{entry[:image_url]}",
|
|
1301
|
+
"[🖼️#{entry[:link_ref]}]: #{entry[:href]}",
|
|
1302
|
+
]
|
|
1303
|
+
end.join("\n")
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
def readme_logo_template_tokens(readme_logo)
|
|
1307
|
+
{
|
|
1308
|
+
"KJ|README:TOP_LOGO_ROW" => readme_logo[:top_logo_row].to_s,
|
|
1309
|
+
"KJ|README:TOP_LOGO_REFS" => readme_logo[:top_logo_refs].to_s,
|
|
1310
|
+
}
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
def rubocop_template_tokens(min_ruby)
|
|
1314
|
+
constraint, gem_name = rubocop_tokens_for(min_ruby_version(min_ruby))
|
|
1315
|
+
{
|
|
1316
|
+
"KJ|RUBOCOP_LTS_CONSTRAINT" => constraint,
|
|
1317
|
+
"KJ|RUBOCOP_RUBY_GEM" => gem_name,
|
|
1318
|
+
}
|
|
1319
|
+
end
|
|
1320
|
+
|
|
1321
|
+
def rubocop_tokens_for(min_ruby)
|
|
1322
|
+
fallback = RUBOCOP_VERSION_MAP.first
|
|
1323
|
+
selected = nil
|
|
1324
|
+
RUBOCOP_VERSION_MAP.reverse_each do |minimum, constraint|
|
|
1325
|
+
next unless min_ruby && min_ruby >= minimum
|
|
1326
|
+
|
|
1327
|
+
selected = [minimum, constraint]
|
|
1328
|
+
break
|
|
1329
|
+
end
|
|
1330
|
+
selected ||= fallback
|
|
1331
|
+
[selected[1], "rubocop-ruby#{selected[0].segments.join("_")}"]
|
|
1332
|
+
end
|
|
1333
|
+
|
|
1334
|
+
def min_ruby_version(requirement)
|
|
1335
|
+
token = minimum_ruby_token(requirement)
|
|
1336
|
+
return nil if token.empty?
|
|
1337
|
+
|
|
1338
|
+
Gem::Version.new(token)
|
|
1339
|
+
rescue ArgumentError
|
|
1340
|
+
nil
|
|
1341
|
+
end
|
|
1342
|
+
|
|
1343
|
+
def license_facts(config, gemspec_licenses, author_email: nil)
|
|
1344
|
+
licenses = resolved_licenses(config, gemspec_licenses)
|
|
1345
|
+
primary = licenses.first
|
|
1346
|
+
compat_category = license_compat_category(licenses)
|
|
1347
|
+
compact_hash(
|
|
1348
|
+
spdx: licenses,
|
|
1349
|
+
expression: licenses.join(" OR "),
|
|
1350
|
+
primary_spdx: primary,
|
|
1351
|
+
license_md_content: license_md_content(licenses, author_email: author_email),
|
|
1352
|
+
readme_license_intro: readme_license_intro(licenses, author_email: author_email),
|
|
1353
|
+
readme_license_badge: license_badge(primary),
|
|
1354
|
+
readme_license_compat_badge: license_compat_badge(compat_category),
|
|
1355
|
+
readme_license_refs: readme_license_refs(primary, compat_category),
|
|
1356
|
+
copyright_prefix: polyform_licenses?(licenses) ? "Required Notice: " : ""
|
|
1357
|
+
)
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
def resolved_licenses(config, gemspec_licenses)
|
|
1361
|
+
config_licenses = config.is_a?(Hash) ? config["licenses"] : nil
|
|
1362
|
+
licenses = Array(config_licenses).map { |license| license.to_s.strip }.reject(&:empty?)
|
|
1363
|
+
return licenses unless licenses.empty?
|
|
1364
|
+
|
|
1365
|
+
licenses = Array(gemspec_licenses).map { |license| license.to_s.strip }.reject(&:empty?)
|
|
1366
|
+
licenses.empty? ? ["MIT"] : licenses
|
|
1367
|
+
end
|
|
1368
|
+
|
|
1369
|
+
def license_template_tokens(license)
|
|
1370
|
+
{
|
|
1371
|
+
"KJ|LICENSE_MD_CONTENT" => license[:license_md_content].to_s,
|
|
1372
|
+
"KJ|README:LICENSE_INTRO" => license[:readme_license_intro].to_s,
|
|
1373
|
+
"KJ|LICENSE:PRIMARY_SPDX" => license[:primary_spdx].to_s,
|
|
1374
|
+
"KJ|README:LICENSE_BADGE" => license[:readme_license_badge].to_s,
|
|
1375
|
+
"KJ|README:LICENSE_COMPAT_BADGE" => license[:readme_license_compat_badge].to_s,
|
|
1376
|
+
"KJ|README:LICENSE_REFS" => license[:readme_license_refs].to_s,
|
|
1377
|
+
"KJ|COPYRIGHT_PREFIX" => license[:copyright_prefix].to_s,
|
|
1378
|
+
}
|
|
1379
|
+
end
|
|
1380
|
+
|
|
1381
|
+
def license_md_content(licenses, author_email: nil)
|
|
1382
|
+
content = <<~MARKDOWN.chomp
|
|
1383
|
+
# License
|
|
1384
|
+
|
|
1385
|
+
This project is made available under the following license#{"s" if licenses.size > 1}.
|
|
1386
|
+
Choose the option that best fits your use case:
|
|
1387
|
+
|
|
1388
|
+
#{licenses.map { |license| "- #{license_link(license)}" }.join("\n")}
|
|
1389
|
+
MARKDOWN
|
|
1390
|
+
guide_table = license_use_case_guide_table(licenses, author_email: author_email)
|
|
1391
|
+
content += "\n\n## Use-case guide\n\n#{guide_table}" if guide_table
|
|
1392
|
+
content += "\n\n#{license_contact_line(author_email, context: :license_md)}" if non_mit_licenses?(licenses)
|
|
1393
|
+
content
|
|
1394
|
+
end
|
|
1395
|
+
|
|
1396
|
+
def readme_license_intro(licenses, author_email: nil)
|
|
1397
|
+
return mit_readme_license_intro if licenses == ["MIT"]
|
|
1398
|
+
|
|
1399
|
+
intro = "The gem is available under the following license#{"s" if licenses.size > 1}: " \
|
|
1400
|
+
"#{licenses.map { |license| license_link(license) }.join(", ")}.\n" \
|
|
1401
|
+
"See [LICENSE.md][#{paperclip_ref(:license)}] for details."
|
|
1402
|
+
intro += "\n\n#{license_contact_line(author_email, context: :readme)}" if non_mit_licenses?(licenses)
|
|
1403
|
+
guide_table = license_use_case_guide_table(licenses, author_email: author_email)
|
|
1404
|
+
intro += "\n\n### License use-case guide\n\n#{guide_table}" if guide_table
|
|
1405
|
+
intro
|
|
1406
|
+
end
|
|
1407
|
+
|
|
1408
|
+
def mit_readme_license_intro
|
|
1409
|
+
"The gem is available as open source under the terms of\n" \
|
|
1410
|
+
"the #{license_link("MIT")} #{license_badge("MIT")}."
|
|
1411
|
+
end
|
|
1412
|
+
|
|
1413
|
+
def license_contact_line(author_email, context:)
|
|
1414
|
+
if author_email.to_s.empty?
|
|
1415
|
+
return "If none of the above licenses fit your use case, please contact the project maintainer to discuss a custom commercial license." if context == :license_md
|
|
1416
|
+
|
|
1417
|
+
"If none of the available licenses suit your use case, please contact the project maintainer to discuss a custom commercial license."
|
|
1418
|
+
elsif context == :license_md
|
|
1419
|
+
"If none of the above licenses fit your use case, please [contact us](mailto:#{author_email}) to discuss a custom commercial license."
|
|
1420
|
+
else
|
|
1421
|
+
"If none of the available licenses suit your use case, please [contact us](mailto:#{author_email}) to discuss a custom commercial license."
|
|
1422
|
+
end
|
|
1423
|
+
end
|
|
1424
|
+
|
|
1425
|
+
def readme_license_refs(primary, compat_category)
|
|
1426
|
+
[
|
|
1427
|
+
"[#{paperclip_ref(:copyright_notice_explainer)}]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year",
|
|
1428
|
+
"[#{paperclip_ref(:license)}]: LICENSE.md",
|
|
1429
|
+
"[#{paperclip_ref(:license_ref)}]: #{license_badge_ref(primary)}",
|
|
1430
|
+
"[#{paperclip_ref(:license_img)}]: #{license_badge_img(primary)}",
|
|
1431
|
+
"[#{paperclip_ref(:license_compat)}]: #{license_compat_ref(compat_category)}",
|
|
1432
|
+
"[#{paperclip_ref(:license_compat_img)}]: #{license_compat_img(compat_category)}",
|
|
1433
|
+
].join("\n")
|
|
1434
|
+
end
|
|
1435
|
+
|
|
1436
|
+
def spdx_basename(spdx_id)
|
|
1437
|
+
spdx_id.to_s.sub(/\ALicenseRef-/, "")
|
|
1438
|
+
end
|
|
1439
|
+
|
|
1440
|
+
def license_link(spdx_id)
|
|
1441
|
+
base = spdx_basename(spdx_id)
|
|
1442
|
+
"[#{base}](#{base}.md)"
|
|
1443
|
+
end
|
|
1444
|
+
|
|
1445
|
+
def license_badge(spdx_id)
|
|
1446
|
+
base = spdx_basename(spdx_id)
|
|
1447
|
+
"[![License: #{base}][#{paperclip_ref(:license_img)}]][#{paperclip_ref(:license_ref)}]"
|
|
1448
|
+
end
|
|
1449
|
+
|
|
1450
|
+
def license_badge_ref(spdx_id)
|
|
1451
|
+
"#{spdx_basename(spdx_id)}.md"
|
|
1452
|
+
end
|
|
1453
|
+
|
|
1454
|
+
def license_badge_img(spdx_id)
|
|
1455
|
+
base = spdx_basename(spdx_id).gsub("-", "--").gsub("_", "__").tr(" ", "_")
|
|
1456
|
+
"https://img.shields.io/badge/License-#{base}-259D6C.svg"
|
|
1457
|
+
end
|
|
1458
|
+
|
|
1459
|
+
def license_compat_category(licenses)
|
|
1460
|
+
categories = Array(licenses).filter_map { |license| APACHE_LICENSE_COMPAT_CATEGORIES[license.to_s] }.uniq
|
|
1461
|
+
return :a if categories.include?(:a)
|
|
1462
|
+
return :b if categories.include?(:b)
|
|
1463
|
+
return :x if categories.any? && categories.all?(:x)
|
|
1464
|
+
|
|
1465
|
+
:unknown
|
|
1466
|
+
end
|
|
1467
|
+
|
|
1468
|
+
def license_compat_badge(category)
|
|
1469
|
+
data = APACHE_LICENSE_COMPAT_BADGE_DATA.fetch(category)
|
|
1470
|
+
"[![#{data.fetch(:alt)}][#{paperclip_ref(:license_compat_img)}]][#{paperclip_ref(:license_compat)}]"
|
|
1471
|
+
end
|
|
1472
|
+
|
|
1473
|
+
def license_compat_ref(category)
|
|
1474
|
+
APACHE_LICENSE_COMPAT_BADGE_DATA.fetch(category).fetch(:ref)
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
def license_compat_img(category)
|
|
1478
|
+
data = APACHE_LICENSE_COMPAT_BADGE_DATA.fetch(category)
|
|
1479
|
+
"https://img.shields.io/badge/#{data.fetch(:label)}-#{data.fetch(:message)}-#{data.fetch(:color)}.svg?style=flat&logo=Apache"
|
|
1480
|
+
end
|
|
1481
|
+
|
|
1482
|
+
def polyform_licenses?(licenses)
|
|
1483
|
+
licenses.any? { |license| license.to_s.start_with?("PolyForm-") }
|
|
1484
|
+
end
|
|
1485
|
+
|
|
1486
|
+
def non_mit_licenses?(licenses)
|
|
1487
|
+
licenses.any? { |license| license != "MIT" }
|
|
1488
|
+
end
|
|
1489
|
+
|
|
1490
|
+
def license_use_case_guide_table(licenses, author_email: nil)
|
|
1491
|
+
has_floss_oss = licenses.include?("MIT") || licenses.include?("AGPL-3.0-only")
|
|
1492
|
+
has_polyform = licenses.include?("PolyForm-Noncommercial-1.0.0") || licenses.include?("PolyForm-Small-Business-1.0.0")
|
|
1493
|
+
has_big_time = licenses.include?("LicenseRef-Big-Time-Public-License")
|
|
1494
|
+
return unless has_floss_oss && has_polyform && has_big_time
|
|
1495
|
+
|
|
1496
|
+
rows = license_use_case_rows(licenses, author_email: author_email)
|
|
1497
|
+
return if rows.empty?
|
|
1498
|
+
|
|
1499
|
+
"| Use case | License |\n|---|---|\n" +
|
|
1500
|
+
rows.map { |use_case, license| "| #{use_case} | #{license} |" }.join("\n")
|
|
1501
|
+
end
|
|
1502
|
+
|
|
1503
|
+
def license_use_case_rows(licenses, author_email: nil)
|
|
1504
|
+
rows = []
|
|
1505
|
+
rows << ["FLOSS (free and open source)", license_link("MIT")] if licenses.include?("MIT")
|
|
1506
|
+
rows << ["Copy-left open source", license_link("AGPL-3.0-only")] if licenses.include?("AGPL-3.0-only")
|
|
1507
|
+
noncommercial_links = %w[PolyForm-Noncommercial-1.0.0 PolyForm-Small-Business-1.0.0 LicenseRef-Big-Time-Public-License]
|
|
1508
|
+
.select { |license| licenses.include?(license) }
|
|
1509
|
+
.map { |license| license_link(license) }
|
|
1510
|
+
rows << ["Non-commercial (research, education, personal use)", noncommercial_links.join(" or ")] unless noncommercial_links.empty?
|
|
1511
|
+
small_business_links = %w[PolyForm-Small-Business-1.0.0 LicenseRef-Big-Time-Public-License]
|
|
1512
|
+
.select { |license| licenses.include?(license) }
|
|
1513
|
+
.map { |license| license_link(license) }
|
|
1514
|
+
rows << ["Small business commercial", small_business_links.join(" or ")] unless small_business_links.empty?
|
|
1515
|
+
rows << ["Larger business commercial", large_business_license_cell(author_email)] if licenses.include?("LicenseRef-Big-Time-Public-License")
|
|
1516
|
+
rows
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
def large_business_license_cell(author_email)
|
|
1520
|
+
cell = license_link("LicenseRef-Big-Time-Public-License")
|
|
1521
|
+
if author_email.to_s.empty?
|
|
1522
|
+
"#{cell} or contact us for a custom license"
|
|
1523
|
+
else
|
|
1524
|
+
"#{cell} or [contact us](mailto:#{author_email}) for a custom license"
|
|
1525
|
+
end
|
|
1526
|
+
end
|
|
1527
|
+
|
|
1528
|
+
def paperclip_ref(name)
|
|
1529
|
+
{
|
|
1530
|
+
copyright_notice_explainer: "\u{1F4C4}copyright-notice-explainer",
|
|
1531
|
+
license: "\u{1F4C4}license",
|
|
1532
|
+
license_ref: "\u{1F4C4}license-ref",
|
|
1533
|
+
license_img: "\u{1F4C4}license-img",
|
|
1534
|
+
license_compat: "\u{1F4C4}license-compat",
|
|
1535
|
+
license_compat_img: "\u{1F4C4}license-compat-img",
|
|
1536
|
+
}.fetch(name)
|
|
1537
|
+
end
|
|
1538
|
+
|
|
1539
|
+
def author_given_names(name)
|
|
1540
|
+
parts = name.to_s.strip.split(/\s+/)
|
|
1541
|
+
return "" if parts.size < 2
|
|
1542
|
+
|
|
1543
|
+
parts[0...-1].join(" ")
|
|
1544
|
+
end
|
|
1545
|
+
|
|
1546
|
+
def author_family_names(name)
|
|
1547
|
+
parts = name.to_s.strip.split(/\s+/)
|
|
1548
|
+
return "" if parts.size < 2
|
|
1549
|
+
|
|
1550
|
+
parts[-1]
|
|
1551
|
+
end
|
|
1552
|
+
|
|
1553
|
+
def resolve_template_tokens(content, tokens, scan_unresolved: true)
|
|
1554
|
+
resolver = Token::Resolver::Resolve.new(on_missing: :keep)
|
|
1555
|
+
document = Token::Resolver::Document.new(content.to_s, config: TEMPLATE_TOKEN_CONFIG)
|
|
1556
|
+
resolved = resolver.resolve(document, stringify_template_tokens(tokens))
|
|
1557
|
+
return resolved unless scan_unresolved
|
|
1558
|
+
|
|
1559
|
+
unresolved = Token::Resolver::Document.new(resolved, config: TEMPLATE_TOKEN_CONFIG).token_keys.grep(/\AKJ\|/).sort
|
|
1560
|
+
return resolved if unresolved.empty?
|
|
1561
|
+
|
|
1562
|
+
raise ArgumentError, "unresolved kettle-jem template tokens: #{unresolved.map { |token| "{#{token}}" }.join(", ")}"
|
|
1563
|
+
end
|
|
1564
|
+
|
|
1565
|
+
def unresolved_template_scan?(recipe)
|
|
1566
|
+
return false if recipe.fetch(:target_path).to_s == ".kettle-jem.yml"
|
|
1567
|
+
return false if recipe.dig(:template_preference, :skip_unresolved_scan)
|
|
1568
|
+
|
|
1569
|
+
true
|
|
1570
|
+
end
|
|
1571
|
+
|
|
1572
|
+
def stringify_template_tokens(tokens)
|
|
1573
|
+
tokens.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
1574
|
+
end
|
|
1575
|
+
|
|
1576
|
+
def falsey_config?(value)
|
|
1577
|
+
%w[false no 0].include?(value.to_s.strip.downcase)
|
|
1578
|
+
end
|
|
1579
|
+
|
|
1580
|
+
def merge_readme_template(template_content:, destination_content:, preserve_config: {})
|
|
1581
|
+
return template_content if destination_content.to_s.strip.empty?
|
|
1582
|
+
|
|
1583
|
+
preserved = preserve_readme_sections(template_content, destination_content, preserve_config)
|
|
1584
|
+
preserve_readme_h1(preserved, destination_content)
|
|
1585
|
+
end
|
|
1586
|
+
|
|
1587
|
+
def preserve_readme_sections(template_content, destination_content, preserve_config)
|
|
1588
|
+
template_sections = markdown_sections(template_content)
|
|
1589
|
+
destination_sections = markdown_sections(destination_content)
|
|
1590
|
+
destination_lookup = destination_sections.to_h { |section| [section.fetch(:base), section] }
|
|
1591
|
+
preserve_targets = readme_preserve_targets(template_sections, destination_lookup, preserve_config)
|
|
1592
|
+
return template_content if preserve_targets.empty?
|
|
1593
|
+
|
|
1594
|
+
lines = template_content.split("\n", -1)
|
|
1595
|
+
template_sections.reverse_each do |section|
|
|
1596
|
+
next unless preserve_targets.include?(section.fetch(:base))
|
|
1597
|
+
|
|
1598
|
+
destination_section = destination_lookup[section.fetch(:base)] ||
|
|
1599
|
+
aliased_readme_destination_section(section.fetch(:base), destination_lookup, preserve_config)
|
|
1600
|
+
next unless destination_section
|
|
1601
|
+
|
|
1602
|
+
replacement = "#{section.fetch(:heading)}\n#{destination_section.fetch(:body)}".split("\n", -1)
|
|
1603
|
+
lines[section.fetch(:start)..section.fetch(:end)] = replacement
|
|
1604
|
+
end
|
|
1605
|
+
lines.join("\n")
|
|
1606
|
+
end
|
|
1607
|
+
|
|
1608
|
+
def preserve_readme_h1(merged_content, destination_content)
|
|
1609
|
+
merged_h1 = markdown_sections(merged_content).find { |section| section.fetch(:level) == 1 }
|
|
1610
|
+
destination_h1 = markdown_sections(destination_content).find { |section| section.fetch(:level) == 1 }
|
|
1611
|
+
return merged_content unless merged_h1 && destination_h1
|
|
1612
|
+
return merged_content if semantic_readme_heading(destination_h1.fetch(:heading_text)) == semantic_readme_heading(merged_h1.fetch(:heading_text))
|
|
1613
|
+
|
|
1614
|
+
lines = merged_content.split("\n", -1)
|
|
1615
|
+
lines[merged_h1.fetch(:start)] = destination_h1.fetch(:heading)
|
|
1616
|
+
lines.join("\n")
|
|
1617
|
+
end
|
|
1618
|
+
|
|
1619
|
+
def markdown_sections(content)
|
|
1620
|
+
lines = content.to_s.split("\n", -1)
|
|
1621
|
+
headings = []
|
|
1622
|
+
in_fence = false
|
|
1623
|
+
fence_marker = nil
|
|
1624
|
+
lines.each_with_index do |line, index|
|
|
1625
|
+
stripped = line.lstrip
|
|
1626
|
+
if in_fence
|
|
1627
|
+
if stripped.match?(/\A#{Regexp.escape(fence_marker)}\s*\z/)
|
|
1628
|
+
in_fence = false
|
|
1629
|
+
fence_marker = nil
|
|
1630
|
+
end
|
|
1631
|
+
next
|
|
1632
|
+
end
|
|
1633
|
+
if (fence = stripped.match(/\A(`{3,}|~{3,})/))
|
|
1634
|
+
in_fence = true
|
|
1635
|
+
fence_marker = fence[1]
|
|
1636
|
+
next
|
|
1637
|
+
end
|
|
1638
|
+
next unless (heading = line.match(/\A(\#{1,6})\s+(.+?)\s*#*\s*\z/))
|
|
1639
|
+
|
|
1640
|
+
headings << {
|
|
1641
|
+
start: index,
|
|
1642
|
+
level: heading[1].length,
|
|
1643
|
+
heading: line,
|
|
1644
|
+
heading_text: heading[2],
|
|
1645
|
+
base: normalize_readme_heading(heading[2]),
|
|
1646
|
+
}
|
|
1647
|
+
end
|
|
1648
|
+
|
|
1649
|
+
headings.each_with_index.map do |heading, index|
|
|
1650
|
+
following = headings[(index + 1)..].to_a.find { |candidate| candidate.fetch(:level) <= heading.fetch(:level) }
|
|
1651
|
+
branch_end = following ? following.fetch(:start) - 1 : lines.length - 1
|
|
1652
|
+
body = (lines[(heading.fetch(:start) + 1)..branch_end] || []).join("\n")
|
|
1653
|
+
heading.merge(end: branch_end, body: body)
|
|
1654
|
+
end
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1657
|
+
def readme_preserve_targets(template_sections, destination_lookup, preserve_config)
|
|
1658
|
+
sections = Array(preserve_config[:sections]).map { |section| normalize_readme_heading(section) }
|
|
1659
|
+
sections = README_DEFAULT_PRESERVE_SECTIONS.dup if sections.empty?
|
|
1660
|
+
patterns = Array(preserve_config[:patterns]).map { |pattern| pattern.to_s.strip.downcase }
|
|
1661
|
+
patterns = README_DEFAULT_PRESERVE_PATTERNS.dup if patterns.empty?
|
|
1662
|
+
aliases = preserve_config[:aliases] || README_SECTION_ALIASES
|
|
1663
|
+
targets = sections.dup
|
|
1664
|
+
template_sections.each do |section|
|
|
1665
|
+
base = section.fetch(:base)
|
|
1666
|
+
targets << base if patterns.any? { |pattern| File.fnmatch?(pattern, base, File::FNM_PATHNAME) }
|
|
1667
|
+
end
|
|
1668
|
+
aliases.each do |from, to|
|
|
1669
|
+
targets << to if destination_lookup.key?(from) && targets.include?(to)
|
|
1670
|
+
end
|
|
1671
|
+
targets.uniq
|
|
1672
|
+
end
|
|
1673
|
+
|
|
1674
|
+
def aliased_readme_destination_section(template_base, destination_lookup, preserve_config)
|
|
1675
|
+
aliases = preserve_config[:aliases] || README_SECTION_ALIASES
|
|
1676
|
+
aliases.each do |from, to|
|
|
1677
|
+
return destination_lookup[from] if to == template_base && destination_lookup.key?(from)
|
|
1678
|
+
end
|
|
1679
|
+
nil
|
|
1680
|
+
end
|
|
1681
|
+
|
|
1682
|
+
def readme_preserve_config(config)
|
|
1683
|
+
readme = config["readme"]
|
|
1684
|
+
return {} unless readme.is_a?(Hash)
|
|
1685
|
+
|
|
1686
|
+
result = {}
|
|
1687
|
+
result[:sections] = Array(readme["preserve_sections"]) if readme.key?("preserve_sections")
|
|
1688
|
+
result[:patterns] = Array(readme["preserve_patterns"]) if readme.key?("preserve_patterns")
|
|
1689
|
+
if readme["section_aliases"].is_a?(Hash)
|
|
1690
|
+
result[:aliases] = README_SECTION_ALIASES.merge(
|
|
1691
|
+
readme["section_aliases"].transform_keys { |key| normalize_readme_heading(key) }
|
|
1692
|
+
.transform_values { |value| normalize_readme_heading(value) }
|
|
1693
|
+
)
|
|
1694
|
+
end
|
|
1695
|
+
result
|
|
1696
|
+
end
|
|
1697
|
+
|
|
1698
|
+
def normalize_readme_heading(text)
|
|
1699
|
+
strip_readme_heading_adornment(text).strip.downcase
|
|
1700
|
+
end
|
|
1701
|
+
|
|
1702
|
+
def semantic_readme_heading(text)
|
|
1703
|
+
normalize_readme_heading(text)
|
|
1704
|
+
end
|
|
1705
|
+
|
|
1706
|
+
def strip_readme_heading_adornment(text)
|
|
1707
|
+
text.to_s.sub(/\A(?:\d\uFE0F?\u20E3|[^[:alnum:][:space:]])+[ \t]*/u, "")
|
|
1708
|
+
end
|
|
1709
|
+
|
|
1710
|
+
def template_source_preferences(project_root, config, opencollective_disabled: false)
|
|
1711
|
+
templates = config["templates"]
|
|
1712
|
+
return [] unless templates.is_a?(Hash)
|
|
1713
|
+
|
|
1714
|
+
root = template_root(project_root, templates)
|
|
1715
|
+
entries = template_entries(project_root, root, templates)
|
|
1716
|
+
return [] if entries.empty?
|
|
1717
|
+
|
|
1718
|
+
apply_templates = templates["apply"] == true
|
|
1719
|
+
entries.filter_map do |entry|
|
|
1720
|
+
template_source_preference(
|
|
1721
|
+
project_root,
|
|
1722
|
+
root,
|
|
1723
|
+
entry,
|
|
1724
|
+
config,
|
|
1725
|
+
opencollective_disabled: opencollective_disabled,
|
|
1726
|
+
apply_templates: apply_templates
|
|
1727
|
+
)
|
|
1728
|
+
end
|
|
1729
|
+
end
|
|
1730
|
+
|
|
1731
|
+
def template_entries(project_root, root, templates)
|
|
1732
|
+
return templates["entries"] if templates["entries"].is_a?(Array)
|
|
1733
|
+
return [] if templates.key?("entries")
|
|
1734
|
+
|
|
1735
|
+
template_inventory_entries(project_root, root.fetch(:path))
|
|
1736
|
+
end
|
|
1737
|
+
|
|
1738
|
+
def template_inventory_entries(project_root, template_root_path)
|
|
1739
|
+
logical_paths = []
|
|
1740
|
+
Find.find(template_root_path) do |path|
|
|
1741
|
+
next if File.directory?(path)
|
|
1742
|
+
|
|
1743
|
+
relative_path = path.delete_prefix("#{template_root_path}/")
|
|
1744
|
+
logical_path = relative_path
|
|
1745
|
+
.sub(/\.no-osc\.example\z/, "")
|
|
1746
|
+
.sub(/\.example\z/, "")
|
|
1747
|
+
logical_paths << logical_path unless logical_path.empty?
|
|
1748
|
+
end
|
|
1749
|
+
|
|
1750
|
+
logical_paths.uniq.sort.map do |logical_path|
|
|
1751
|
+
target_path = template_inventory_target_path(project_root, logical_path)
|
|
1752
|
+
if target_path == logical_path
|
|
1753
|
+
logical_path
|
|
1754
|
+
else
|
|
1755
|
+
{ "source" => logical_path, "target" => target_path }
|
|
1756
|
+
end
|
|
1757
|
+
end
|
|
1758
|
+
end
|
|
1759
|
+
|
|
1760
|
+
def template_inventory_target_path(project_root, logical_path)
|
|
1761
|
+
return ".env.local.example" if logical_path == ".env.local"
|
|
1762
|
+
|
|
1763
|
+
if logical_path.end_with?(".gemspec")
|
|
1764
|
+
existing_gemspec = Dir.glob(File.join(project_root, "*.gemspec")).sort.first
|
|
1765
|
+
return File.basename(existing_gemspec) if existing_gemspec
|
|
1766
|
+
end
|
|
1767
|
+
|
|
1768
|
+
logical_path
|
|
1769
|
+
end
|
|
1770
|
+
|
|
1771
|
+
def kettle_config_bootstrap_facts(project_root, env)
|
|
1772
|
+
return if File.exist?(File.join(project_root, ".kettle-jem.yml"))
|
|
1773
|
+
|
|
1774
|
+
selected_source = preferred_template_source(PACKAGED_TEMPLATE_ROOT, ".kettle-jem.yml")
|
|
1775
|
+
return unless selected_source
|
|
1776
|
+
|
|
1777
|
+
{
|
|
1778
|
+
template_preference: {
|
|
1779
|
+
target_path: ".kettle-jem.yml",
|
|
1780
|
+
configured_source: ".kettle-jem.yml",
|
|
1781
|
+
selected_source: selected_source,
|
|
1782
|
+
source_relative_path: selected_source,
|
|
1783
|
+
source_root: "packaged",
|
|
1784
|
+
source_root_path: PACKAGED_TEMPLATE_ROOT,
|
|
1785
|
+
selection_reason: template_source_selection_reason(".kettle-jem.yml", selected_source),
|
|
1786
|
+
apply: true,
|
|
1787
|
+
},
|
|
1788
|
+
min_divergence_threshold: preferred_template_token_value(nil, nil, env, "KJ_MIN_DIVERGENCE_THRESHOLD").to_s,
|
|
1789
|
+
}
|
|
1790
|
+
end
|
|
1791
|
+
|
|
1792
|
+
def kettle_config_bootstrap_recipe(bootstrap)
|
|
1793
|
+
recipe = recipe_entry(
|
|
1794
|
+
"kettle_config_bootstrap",
|
|
1795
|
+
".kettle-jem.yml",
|
|
1796
|
+
"yaml",
|
|
1797
|
+
"supplied_kettle_config_bootstrap",
|
|
1798
|
+
facts: %w[kettle_config_bootstrap]
|
|
1799
|
+
)
|
|
1800
|
+
recipe[:template_preference] = bootstrap.fetch(:template_preference)
|
|
1801
|
+
recipe[:template_tokens] = {
|
|
1802
|
+
"KJ|MIN_DIVERGENCE_THRESHOLD" => bootstrap.fetch(:min_divergence_threshold).to_s,
|
|
1803
|
+
}
|
|
1804
|
+
recipe
|
|
1805
|
+
end
|
|
1806
|
+
|
|
1807
|
+
def template_source_preference(project_root, template_root, entry, config, opencollective_disabled: false, apply_templates: false)
|
|
1808
|
+
source_path, target_path = template_entry_paths(entry)
|
|
1809
|
+
return nil if source_path.to_s.empty? || target_path.to_s.empty?
|
|
1810
|
+
|
|
1811
|
+
selected_source = preferred_template_source(template_root.fetch(:path), source_path, opencollective_disabled: opencollective_disabled)
|
|
1812
|
+
return nil unless selected_source
|
|
1813
|
+
|
|
1814
|
+
strategy_config = template_strategy_config(config, target_path)
|
|
1815
|
+
preference = {
|
|
1816
|
+
target_path: target_path,
|
|
1817
|
+
configured_source: source_path,
|
|
1818
|
+
selected_source: template_source_display_path(template_root, selected_source),
|
|
1819
|
+
selection_reason: template_source_selection_reason(source_path, template_source_display_path(template_root, selected_source)),
|
|
1820
|
+
apply: template_entry_apply?(entry, apply_templates),
|
|
1821
|
+
}
|
|
1822
|
+
preference[:strategy] = strategy_config.fetch(:strategy).to_s if strategy_config
|
|
1823
|
+
preference[:file_type] = strategy_config.fetch(:file_type).to_s if strategy_config&.key?(:file_type)
|
|
1824
|
+
preserve_config = readme_preserve_config(config)
|
|
1825
|
+
preference[:readme_preserve_config] = preserve_config if target_path == "README.md" && !preserve_config.empty?
|
|
1826
|
+
if template_root.fetch(:kind) == "packaged"
|
|
1827
|
+
preference[:source_relative_path] = selected_source
|
|
1828
|
+
preference[:source_root] = template_root.fetch(:kind)
|
|
1829
|
+
preference[:source_root_path] = template_root.fetch(:path)
|
|
1830
|
+
end
|
|
1831
|
+
preference
|
|
1832
|
+
end
|
|
1833
|
+
|
|
1834
|
+
def template_strategy_config(config, target_path)
|
|
1835
|
+
template_file_strategy_config(config, target_path) || template_pattern_strategy_config(config, target_path)
|
|
1836
|
+
end
|
|
1837
|
+
|
|
1838
|
+
def template_file_strategy_config(config, target_path)
|
|
1839
|
+
files = config["files"]
|
|
1840
|
+
return unless files.is_a?(Hash)
|
|
1841
|
+
|
|
1842
|
+
current = files
|
|
1843
|
+
target_path.to_s.delete_prefix("./").split("/").each do |part|
|
|
1844
|
+
return unless current.is_a?(Hash) && current.key?(part)
|
|
1845
|
+
|
|
1846
|
+
current = current[part]
|
|
1847
|
+
end
|
|
1848
|
+
return unless current.is_a?(Hash) && current.key?("strategy")
|
|
1849
|
+
|
|
1850
|
+
template_strategy_entry(config, nil, current)
|
|
1851
|
+
end
|
|
1852
|
+
|
|
1853
|
+
def template_pattern_strategy_config(config, target_path)
|
|
1854
|
+
patterns = config["patterns"]
|
|
1855
|
+
return unless patterns.is_a?(Array)
|
|
1856
|
+
|
|
1857
|
+
match = patterns.find do |entry|
|
|
1858
|
+
entry.is_a?(Hash) &&
|
|
1859
|
+
File.fnmatch?(entry["path"].to_s, target_path.to_s, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
|
|
1860
|
+
end
|
|
1861
|
+
return unless match
|
|
1862
|
+
|
|
1863
|
+
template_strategy_entry(config, match["path"].to_s, match)
|
|
1864
|
+
end
|
|
1865
|
+
|
|
1866
|
+
def template_strategy_entry(config, path, entry)
|
|
1867
|
+
strategy = entry["strategy"].to_s.strip.downcase.to_sym
|
|
1868
|
+
raise ArgumentError, "unknown kettle-jem template strategy: #{entry["strategy"]}" unless SUPPORTED_TEMPLATE_STRATEGIES.include?(strategy)
|
|
1869
|
+
|
|
1870
|
+
result = { strategy: strategy }
|
|
1871
|
+
result[:path] = path if path
|
|
1872
|
+
result[:skip_unresolved_scan] = true if entry["skip_unresolved_scan"]
|
|
1873
|
+
if entry.key?("file_type")
|
|
1874
|
+
file_type = entry["file_type"].to_s.strip.downcase.tr("-", "_").to_sym
|
|
1875
|
+
raise ArgumentError, "unknown kettle-jem template file_type: #{entry["file_type"]}" unless SUPPORTED_TEMPLATE_FILE_TYPES.include?(file_type)
|
|
1876
|
+
|
|
1877
|
+
result[:file_type] = file_type
|
|
1878
|
+
end
|
|
1879
|
+
if strategy == :merge
|
|
1880
|
+
defaults = config["defaults"].is_a?(Hash) ? config["defaults"] : {}
|
|
1881
|
+
result[:preference] = (entry.key?("preference") ? entry["preference"] : defaults["preference"]).to_s if entry.key?("preference") || defaults.key?("preference")
|
|
1882
|
+
if entry.key?("add_template_only_nodes") || defaults.key?("add_template_only_nodes")
|
|
1883
|
+
result[:add_template_only_nodes] = entry.key?("add_template_only_nodes") ? entry["add_template_only_nodes"] : defaults["add_template_only_nodes"]
|
|
1884
|
+
end
|
|
1885
|
+
result[:freeze_token] = (entry.key?("freeze_token") ? entry["freeze_token"] : defaults["freeze_token"]).to_s if entry.key?("freeze_token") || defaults.key?("freeze_token")
|
|
1886
|
+
end
|
|
1887
|
+
result
|
|
1888
|
+
end
|
|
1889
|
+
|
|
1890
|
+
def template_root(project_root, templates)
|
|
1891
|
+
configured_root = templates["root"].to_s
|
|
1892
|
+
if configured_root.empty?
|
|
1893
|
+
local_root = File.join(project_root, "template")
|
|
1894
|
+
return { kind: "project", path: local_root, display_prefix: "template" } if Dir.exist?(local_root)
|
|
1895
|
+
|
|
1896
|
+
return { kind: "packaged", path: PACKAGED_TEMPLATE_ROOT }
|
|
1897
|
+
end
|
|
1898
|
+
|
|
1899
|
+
return { kind: "packaged", path: PACKAGED_TEMPLATE_ROOT } if configured_root == "packaged"
|
|
1900
|
+
|
|
1901
|
+
path = configured_root.start_with?("/") ? configured_root : File.join(project_root, configured_root)
|
|
1902
|
+
{ kind: "project", path: path, display_prefix: configured_root }
|
|
1903
|
+
end
|
|
1904
|
+
|
|
1905
|
+
def template_source_display_path(template_root, selected_source)
|
|
1906
|
+
prefix = template_root[:display_prefix].to_s
|
|
1907
|
+
return selected_source if prefix.empty?
|
|
1908
|
+
|
|
1909
|
+
File.join(prefix, selected_source)
|
|
1910
|
+
end
|
|
1911
|
+
|
|
1912
|
+
def template_entry_paths(entry)
|
|
1913
|
+
if entry.is_a?(Hash)
|
|
1914
|
+
source_path = entry.fetch("source", entry["target"]).to_s
|
|
1915
|
+
target_path = entry.fetch("target", source_path.sub(/\.example\z/, "")).to_s
|
|
1916
|
+
[source_path, target_path]
|
|
1917
|
+
else
|
|
1918
|
+
source_path = entry.to_s
|
|
1919
|
+
[source_path, source_path.sub(/\.example\z/, "")]
|
|
1920
|
+
end
|
|
1921
|
+
end
|
|
1922
|
+
|
|
1923
|
+
def template_entry_apply?(entry, apply_templates)
|
|
1924
|
+
return entry["apply"] == true if entry.is_a?(Hash) && entry.key?("apply")
|
|
1925
|
+
|
|
1926
|
+
apply_templates
|
|
1927
|
+
end
|
|
1928
|
+
|
|
1929
|
+
def preferred_template_source(template_root, configured_source, opencollective_disabled: false)
|
|
1930
|
+
base = configured_source.sub(/\.example\z/, "")
|
|
1931
|
+
candidates = []
|
|
1932
|
+
candidates << "#{base}.no-osc.example" if opencollective_disabled
|
|
1933
|
+
candidates << "#{base}.example"
|
|
1934
|
+
candidates << configured_source
|
|
1935
|
+
candidates.find { |relative_path| File.exist?(File.join(template_root, relative_path)) }
|
|
1936
|
+
end
|
|
1937
|
+
|
|
1938
|
+
def template_source_selection_reason(configured_source, selected_source)
|
|
1939
|
+
if selected_source.end_with?(".no-osc.example")
|
|
1940
|
+
"opencollective_disabled_no_osc_variant"
|
|
1941
|
+
elsif selected_source.end_with?(".example")
|
|
1942
|
+
"default_example_variant"
|
|
1943
|
+
elsif selected_source == configured_source
|
|
1944
|
+
"configured_source"
|
|
1945
|
+
else
|
|
1946
|
+
"fallback_source"
|
|
1947
|
+
end
|
|
1948
|
+
end
|
|
1949
|
+
|
|
1950
|
+
def github_actions_framework_matrix(config)
|
|
1951
|
+
workflows = config["workflows"]
|
|
1952
|
+
return {} unless workflows.is_a?(Hash) && workflows["preset"].to_s.strip.downcase == "framework"
|
|
1953
|
+
|
|
1954
|
+
raw = workflows["framework_matrix"]
|
|
1955
|
+
return {} unless raw.is_a?(Hash)
|
|
1956
|
+
|
|
1957
|
+
dimension = raw["dimension"].to_s.strip
|
|
1958
|
+
versions = raw["versions"]
|
|
1959
|
+
pattern = raw["gemfile_pattern"].to_s.strip
|
|
1960
|
+
return {} unless !dimension.empty? && versions.is_a?(Array) && !versions.empty? && !pattern.empty?
|
|
1961
|
+
|
|
1962
|
+
normalized_versions = versions.map { |version| version.to_s.strip }.reject(&:empty?)
|
|
1963
|
+
return {} if normalized_versions.empty?
|
|
1964
|
+
|
|
1965
|
+
{
|
|
1966
|
+
dimension: dimension,
|
|
1967
|
+
versions: normalized_versions,
|
|
1968
|
+
gemfile_pattern: pattern,
|
|
1969
|
+
include: normalized_versions.map do |version|
|
|
1970
|
+
gemfile = expand_framework_gemfile_pattern(pattern, version)
|
|
1971
|
+
{ framework_version: version, gemfile: framework_gemfile_path(gemfile) }
|
|
1972
|
+
end,
|
|
1973
|
+
}
|
|
1974
|
+
end
|
|
1975
|
+
|
|
1976
|
+
def github_actions_coverage_config(config)
|
|
1977
|
+
workflows = config["workflows"]
|
|
1978
|
+
return {} unless workflows.is_a?(Hash)
|
|
1979
|
+
|
|
1980
|
+
raw = workflows["coverage"]
|
|
1981
|
+
enabled = raw == true || (raw.is_a?(Hash) && raw.fetch("enabled", false) == true)
|
|
1982
|
+
return {} unless enabled
|
|
1983
|
+
|
|
1984
|
+
raw = {} unless raw.is_a?(Hash)
|
|
1985
|
+
{
|
|
1986
|
+
enabled: true,
|
|
1987
|
+
command: raw.fetch("command", "rake test").to_s,
|
|
1988
|
+
appraisal: raw.fetch("appraisal", "coverage").to_s,
|
|
1989
|
+
}
|
|
1990
|
+
end
|
|
1991
|
+
|
|
1992
|
+
def expand_framework_gemfile_pattern(pattern, version)
|
|
1993
|
+
replacement = if pattern.include?("_{version}") || pattern.include?("{version}_")
|
|
1994
|
+
version.tr(".", "_")
|
|
1995
|
+
else
|
|
1996
|
+
version
|
|
1997
|
+
end
|
|
1998
|
+
pattern.gsub("{version}", replacement)
|
|
1999
|
+
end
|
|
2000
|
+
|
|
2001
|
+
def framework_gemfile_path(gemfile)
|
|
2002
|
+
gemfile.include?("/") ? gemfile : "gemfiles/#{gemfile}"
|
|
2003
|
+
end
|
|
2004
|
+
|
|
2005
|
+
def classify_namespace(name)
|
|
2006
|
+
name.to_s.split(/[-_]/).map { |part| part[0].to_s.upcase + part[1..].to_s }.join("::")
|
|
2007
|
+
end
|
|
2008
|
+
|
|
2009
|
+
def readme_metadata_block(facts)
|
|
2010
|
+
package = facts.fetch(:package)
|
|
2011
|
+
funding_urls = facts.fetch(:funding, {}).fetch(:urls, [])
|
|
2012
|
+
rows = [
|
|
2013
|
+
["Package", package[:name]],
|
|
2014
|
+
["Description", package[:description]],
|
|
2015
|
+
["Homepage", package[:homepage_url]],
|
|
2016
|
+
["Source", package[:source_url]],
|
|
2017
|
+
["License", package[:license_expression]],
|
|
2018
|
+
["Funding", funding_urls.join(", ")],
|
|
2019
|
+
].reject { |(_, value)| value.to_s.empty? }
|
|
2020
|
+
|
|
2021
|
+
[
|
|
2022
|
+
"<!-- kettle-jem:metadata:start -->",
|
|
2023
|
+
"| Field | Value |",
|
|
2024
|
+
"|---|---|",
|
|
2025
|
+
*rows.map { |field, value| "| #{field} | #{value} |" },
|
|
2026
|
+
"<!-- kettle-jem:metadata:end -->",
|
|
2027
|
+
].join("\n")
|
|
2028
|
+
end
|
|
2029
|
+
|
|
2030
|
+
def synchronize_github_funding_yml(content, facts)
|
|
2031
|
+
funding = YAML.safe_load(content.to_s, permitted_classes: [], aliases: false) || {}
|
|
2032
|
+
funding = {} unless funding.is_a?(Hash)
|
|
2033
|
+
funding = funding.each_with_object({}) do |(key, value), memo|
|
|
2034
|
+
next if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
2035
|
+
|
|
2036
|
+
memo[key.to_s] = value
|
|
2037
|
+
end
|
|
2038
|
+
funding.delete("open_collective") if facts.fetch(:funding, {})[:open_collective_disabled]
|
|
2039
|
+
funding["tidelift"] ||= "rubygems/#{facts.fetch(:package).fetch(:name)}"
|
|
2040
|
+
YAML.dump(funding).sub(/\A---\n?/, "")
|
|
2041
|
+
end
|
|
2042
|
+
|
|
2043
|
+
def delete_rakefile_scaffold(content)
|
|
2044
|
+
selectors = rakefile_scaffold_delete_selectors(content)
|
|
2045
|
+
{
|
|
2046
|
+
content: delete_line_ranges(content.to_s, selectors),
|
|
2047
|
+
delete_selectors: selectors,
|
|
2048
|
+
}
|
|
2049
|
+
end
|
|
2050
|
+
|
|
2051
|
+
def rakefile_scaffold_delete_selectors(content)
|
|
2052
|
+
lines = content.to_s.lines
|
|
2053
|
+
selectors = []
|
|
2054
|
+
lines.each_with_index do |line, index|
|
|
2055
|
+
case line
|
|
2056
|
+
when /\A\s*require\s+["']bundler\/gem_tasks["']\s*(?:#.*)?\n?\z/
|
|
2057
|
+
selectors << rakefile_selector(
|
|
2058
|
+
"rakefile_scaffold_require_bundler_gem_tasks",
|
|
2059
|
+
index + 1,
|
|
2060
|
+
index + 1,
|
|
2061
|
+
"wrapper_selected_scaffold_require"
|
|
2062
|
+
)
|
|
2063
|
+
when /\A\s*require\s+["']rspec\/core\/rake_task["']\s*(?:#.*)?\n?\z/
|
|
2064
|
+
selectors << rakefile_selector(
|
|
2065
|
+
"rakefile_scaffold_require_rspec_core_rake_task",
|
|
2066
|
+
index + 1,
|
|
2067
|
+
index + 1,
|
|
2068
|
+
"wrapper_selected_scaffold_require"
|
|
2069
|
+
)
|
|
2070
|
+
when /\A\s*require\s+["']rubocop\/rake_task["']\s*(?:#.*)?\n?\z/
|
|
2071
|
+
selectors << rakefile_selector(
|
|
2072
|
+
"rakefile_scaffold_require_rubocop_rake_task",
|
|
2073
|
+
index + 1,
|
|
2074
|
+
index + 1,
|
|
2075
|
+
"wrapper_selected_scaffold_require"
|
|
2076
|
+
)
|
|
2077
|
+
when /\A\s*RSpec::Core::RakeTask\.new\b/
|
|
2078
|
+
selectors << rakefile_selector("rakefile_scaffold_rspec_task", index + 1, index + 1,
|
|
2079
|
+
"wrapper_selected_scaffold_task")
|
|
2080
|
+
when /\A\s*RuboCop::RakeTask\.new\b/
|
|
2081
|
+
selectors << rakefile_selector("rakefile_scaffold_rubocop_task", index + 1, index + 1,
|
|
2082
|
+
"wrapper_selected_scaffold_task")
|
|
2083
|
+
end
|
|
2084
|
+
end
|
|
2085
|
+
selectors.concat(rakefile_task_block_selectors(lines))
|
|
2086
|
+
selectors.sort_by { |selector| [selector.fetch(:start_line), selector.fetch(:end_line)] }
|
|
2087
|
+
end
|
|
2088
|
+
|
|
2089
|
+
def rakefile_task_block_selectors(lines)
|
|
2090
|
+
selectors = []
|
|
2091
|
+
index = 0
|
|
2092
|
+
while index < lines.length
|
|
2093
|
+
line = lines[index]
|
|
2094
|
+
if line.match?(/\A\s*task\s+default:/) || line.match?(/\A\s*task\s+:default\b/)
|
|
2095
|
+
unless rakefile_template_default_task?(lines, index)
|
|
2096
|
+
end_index = rakefile_block_end(lines, index)
|
|
2097
|
+
selectors << rakefile_selector("rakefile_scaffold_task_default", index + 1, end_index + 1,
|
|
2098
|
+
"wrapper_selected_scaffold_task")
|
|
2099
|
+
index = end_index + 1
|
|
2100
|
+
next
|
|
2101
|
+
end
|
|
2102
|
+
end
|
|
2103
|
+
index += 1
|
|
2104
|
+
end
|
|
2105
|
+
selectors
|
|
2106
|
+
end
|
|
2107
|
+
|
|
2108
|
+
def rakefile_template_default_task?(lines, task_index)
|
|
2109
|
+
cursor = task_index - 1
|
|
2110
|
+
cursor -= 1 while cursor >= 0 && lines[cursor].strip.empty?
|
|
2111
|
+
return false unless cursor >= 0
|
|
2112
|
+
|
|
2113
|
+
lines[cursor].strip == 'desc "Default tasks aggregator"'
|
|
2114
|
+
end
|
|
2115
|
+
|
|
2116
|
+
def rakefile_block_end(lines, start_index)
|
|
2117
|
+
return start_index unless lines[start_index].match?(/\bdo\b/)
|
|
2118
|
+
|
|
2119
|
+
depth = 0
|
|
2120
|
+
(start_index...lines.length).each do |index|
|
|
2121
|
+
stripped = lines[index].strip
|
|
2122
|
+
depth += 1 if stripped.match?(/\bdo\b/)
|
|
2123
|
+
return index if depth.positive? && stripped == "end" && (depth -= 1).zero?
|
|
2124
|
+
return index if depth.zero? && index > start_index && !stripped.empty?
|
|
2125
|
+
end
|
|
2126
|
+
lines.length - 1
|
|
2127
|
+
end
|
|
2128
|
+
|
|
2129
|
+
def rakefile_selector(selector_id, start_line, end_line, reason)
|
|
2130
|
+
{
|
|
2131
|
+
selector_id: selector_id,
|
|
2132
|
+
selector_family: "structural_owner_range",
|
|
2133
|
+
start_line: start_line,
|
|
2134
|
+
end_line: end_line,
|
|
2135
|
+
reason: reason,
|
|
2136
|
+
}
|
|
2137
|
+
end
|
|
2138
|
+
|
|
2139
|
+
def delete_line_ranges(content, selectors)
|
|
2140
|
+
lines = content.lines
|
|
2141
|
+
selectors.sort_by { |selector| -selector.fetch(:start_line) }.each do |selector|
|
|
2142
|
+
start_index = selector.fetch(:start_line) - 1
|
|
2143
|
+
end_index = selector.fetch(:end_line) - 1
|
|
2144
|
+
lines.slice!(start_index..end_index)
|
|
2145
|
+
end
|
|
2146
|
+
lines.join.gsub(/\n{3,}/, "\n\n")
|
|
2147
|
+
end
|
|
2148
|
+
|
|
2149
|
+
def synchronize_github_actions_ci(_content, facts)
|
|
2150
|
+
package = facts.fetch(:package)
|
|
2151
|
+
ci = facts.fetch(:ci)
|
|
2152
|
+
ruby_versions = ci.fetch(:ruby_versions)
|
|
2153
|
+
ruby_matrix = ruby_versions.map { |version| " - \"#{version}\"" }.join("\n")
|
|
2154
|
+
|
|
2155
|
+
<<~YAML
|
|
2156
|
+
name: CI
|
|
2157
|
+
|
|
2158
|
+
permissions:
|
|
2159
|
+
contents: read
|
|
2160
|
+
|
|
2161
|
+
on:
|
|
2162
|
+
push:
|
|
2163
|
+
branches:
|
|
2164
|
+
- "#{ci.fetch(:default_branch)}"
|
|
2165
|
+
- "*-stable"
|
|
2166
|
+
tags:
|
|
2167
|
+
- "!*" # Do not execute on tags
|
|
2168
|
+
pull_request:
|
|
2169
|
+
branches:
|
|
2170
|
+
- "*"
|
|
2171
|
+
workflow_dispatch:
|
|
2172
|
+
|
|
2173
|
+
concurrency:
|
|
2174
|
+
group: "${{ github.workflow }}-${{ github.ref }}"
|
|
2175
|
+
cancel-in-progress: true
|
|
2176
|
+
|
|
2177
|
+
jobs:
|
|
2178
|
+
test:
|
|
2179
|
+
if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')"
|
|
2180
|
+
name: Specs ${{ matrix.ruby }}
|
|
2181
|
+
runs-on: ubuntu-latest
|
|
2182
|
+
continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}
|
|
2183
|
+
strategy:
|
|
2184
|
+
fail-fast: false
|
|
2185
|
+
matrix:
|
|
2186
|
+
ruby:
|
|
2187
|
+
#{ruby_matrix}
|
|
2188
|
+
rubygems:
|
|
2189
|
+
- default
|
|
2190
|
+
bundler:
|
|
2191
|
+
- default
|
|
2192
|
+
|
|
2193
|
+
steps:
|
|
2194
|
+
- name: Checkout #{package.fetch(:name)}
|
|
2195
|
+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
2196
|
+
|
|
2197
|
+
- name: Setup Ruby & RubyGems
|
|
2198
|
+
uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0
|
|
2199
|
+
with:
|
|
2200
|
+
ruby-version: "${{ matrix.ruby }}"
|
|
2201
|
+
rubygems: "${{ matrix.rubygems }}"
|
|
2202
|
+
bundler: "${{ matrix.bundler }}"
|
|
2203
|
+
bundler-cache: true
|
|
2204
|
+
|
|
2205
|
+
- name: Tests
|
|
2206
|
+
run: bundle exec rake
|
|
2207
|
+
YAML
|
|
2208
|
+
end
|
|
2209
|
+
|
|
2210
|
+
def synchronize_github_actions_framework_ci(_content, facts)
|
|
2211
|
+
ci = facts.fetch(:ci)
|
|
2212
|
+
framework_matrix = ci.fetch(:framework_matrix)
|
|
2213
|
+
ruby_matrix = ci.fetch(:ruby_versions).map { |version| " - \"#{version}\"" }.join("\n")
|
|
2214
|
+
include_matrix = framework_matrix.fetch(:include).map do |entry|
|
|
2215
|
+
[
|
|
2216
|
+
" - framework_version: \"#{entry.fetch(:framework_version)}\"",
|
|
2217
|
+
" gemfile: \"#{entry.fetch(:gemfile)}\"",
|
|
2218
|
+
].join("\n")
|
|
2219
|
+
end.join("\n")
|
|
2220
|
+
dimension = framework_matrix.fetch(:dimension)
|
|
2221
|
+
label = dimension.split(/[-_]/).map { |part| part[0].to_s.upcase + part[1..].to_s }.join(" ")
|
|
2222
|
+
|
|
2223
|
+
<<~YAML
|
|
2224
|
+
name: #{label} CI
|
|
2225
|
+
|
|
2226
|
+
permissions:
|
|
2227
|
+
contents: read
|
|
2228
|
+
|
|
2229
|
+
on:
|
|
2230
|
+
push:
|
|
2231
|
+
branches:
|
|
2232
|
+
- "#{ci.fetch(:default_branch)}"
|
|
2233
|
+
- "*-stable"
|
|
2234
|
+
tags:
|
|
2235
|
+
- "!*" # Do not execute on tags
|
|
2236
|
+
pull_request:
|
|
2237
|
+
branches:
|
|
2238
|
+
- "*"
|
|
2239
|
+
workflow_dispatch:
|
|
2240
|
+
|
|
2241
|
+
concurrency:
|
|
2242
|
+
group: "${{ github.workflow }}-${{ github.ref }}"
|
|
2243
|
+
cancel-in-progress: true
|
|
2244
|
+
|
|
2245
|
+
jobs:
|
|
2246
|
+
test:
|
|
2247
|
+
if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')"
|
|
2248
|
+
name: Specs ${{ matrix.ruby }}@${{ matrix.framework_version }}
|
|
2249
|
+
runs-on: ubuntu-latest
|
|
2250
|
+
continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}
|
|
2251
|
+
env:
|
|
2252
|
+
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
|
|
2253
|
+
strategy:
|
|
2254
|
+
fail-fast: false
|
|
2255
|
+
matrix:
|
|
2256
|
+
ruby:
|
|
2257
|
+
#{ruby_matrix}
|
|
2258
|
+
rubygems:
|
|
2259
|
+
- default
|
|
2260
|
+
bundler:
|
|
2261
|
+
- default
|
|
2262
|
+
include:
|
|
2263
|
+
#{include_matrix}
|
|
2264
|
+
|
|
2265
|
+
steps:
|
|
2266
|
+
- name: Checkout
|
|
2267
|
+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
2268
|
+
|
|
2269
|
+
- name: Setup Ruby & RubyGems
|
|
2270
|
+
uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0
|
|
2271
|
+
with:
|
|
2272
|
+
ruby-version: "${{ matrix.ruby }}"
|
|
2273
|
+
rubygems: "${{ matrix.rubygems }}"
|
|
2274
|
+
bundler: "${{ matrix.bundler }}"
|
|
2275
|
+
bundler-cache: true
|
|
2276
|
+
|
|
2277
|
+
- name: Tests for ${{ matrix.ruby }}@${{ matrix.framework_version }}
|
|
2278
|
+
run: bundle exec rake test
|
|
2279
|
+
YAML
|
|
2280
|
+
end
|
|
2281
|
+
|
|
2282
|
+
def synchronize_github_actions_coverage_ci(_content, facts)
|
|
2283
|
+
ci = facts.fetch(:ci)
|
|
2284
|
+
coverage = ci.fetch(:coverage)
|
|
2285
|
+
<<~YAML
|
|
2286
|
+
name: Test Coverage
|
|
2287
|
+
|
|
2288
|
+
permissions:
|
|
2289
|
+
contents: read
|
|
2290
|
+
pull-requests: write
|
|
2291
|
+
id-token: write
|
|
2292
|
+
|
|
2293
|
+
env:
|
|
2294
|
+
K_SOUP_COV_MIN_BRANCH: 100
|
|
2295
|
+
K_SOUP_COV_MIN_LINE: 100
|
|
2296
|
+
K_SOUP_COV_MIN_HARD: true
|
|
2297
|
+
K_SOUP_COV_FORMATTERS: "xml,rcov,lcov,tty"
|
|
2298
|
+
K_SOUP_COV_DO: true
|
|
2299
|
+
K_SOUP_COV_MULTI_FORMATTERS: true
|
|
2300
|
+
K_SOUP_COV_COMMAND_NAME: "Test Coverage"
|
|
2301
|
+
|
|
2302
|
+
on:
|
|
2303
|
+
push:
|
|
2304
|
+
branches:
|
|
2305
|
+
- "#{ci.fetch(:default_branch)}"
|
|
2306
|
+
- "*-stable"
|
|
2307
|
+
tags:
|
|
2308
|
+
- "!*" # Do not execute on tags
|
|
2309
|
+
pull_request:
|
|
2310
|
+
branches:
|
|
2311
|
+
- "*"
|
|
2312
|
+
workflow_dispatch:
|
|
2313
|
+
|
|
2314
|
+
concurrency:
|
|
2315
|
+
group: "${{ github.workflow }}-${{ github.ref }}"
|
|
2316
|
+
cancel-in-progress: true
|
|
2317
|
+
|
|
2318
|
+
jobs:
|
|
2319
|
+
coverage:
|
|
2320
|
+
if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')"
|
|
2321
|
+
name: Code Coverage on ${{ matrix.ruby }}@current
|
|
2322
|
+
runs-on: ubuntu-latest
|
|
2323
|
+
continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }}
|
|
2324
|
+
env:
|
|
2325
|
+
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile
|
|
2326
|
+
strategy:
|
|
2327
|
+
fail-fast: false
|
|
2328
|
+
matrix:
|
|
2329
|
+
include:
|
|
2330
|
+
- ruby: "ruby"
|
|
2331
|
+
appraisal: "#{coverage.fetch(:appraisal)}"
|
|
2332
|
+
exec_cmd: "#{coverage.fetch(:command)}"
|
|
2333
|
+
gemfile: "Appraisal.root"
|
|
2334
|
+
rubygems: latest
|
|
2335
|
+
bundler: latest
|
|
2336
|
+
|
|
2337
|
+
steps:
|
|
2338
|
+
- name: Checkout
|
|
2339
|
+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
2340
|
+
|
|
2341
|
+
- name: Setup Ruby & RubyGems
|
|
2342
|
+
uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0
|
|
2343
|
+
with:
|
|
2344
|
+
ruby-version: "${{ matrix.ruby }}"
|
|
2345
|
+
rubygems: "${{ matrix.rubygems }}"
|
|
2346
|
+
bundler: "${{ matrix.bundler }}"
|
|
2347
|
+
bundler-cache: true
|
|
2348
|
+
|
|
2349
|
+
- name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}"
|
|
2350
|
+
id: bundleAppraisalAttempt1
|
|
2351
|
+
run: bundle exec appraisal ${{ matrix.appraisal }} install
|
|
2352
|
+
continue-on-error: true
|
|
2353
|
+
|
|
2354
|
+
- name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}"
|
|
2355
|
+
id: bundleAppraisalAttempt2
|
|
2356
|
+
if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }}
|
|
2357
|
+
run: bundle exec appraisal ${{ matrix.appraisal }} install
|
|
2358
|
+
|
|
2359
|
+
- name: Tests for ${{ matrix.ruby }}@current via ${{ matrix.exec_cmd }}
|
|
2360
|
+
run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }}
|
|
2361
|
+
#{github_actions_coverage_steps}
|
|
2362
|
+
YAML
|
|
2363
|
+
end
|
|
2364
|
+
|
|
2365
|
+
def synchronize_github_actions_workflow_snippets(content)
|
|
2366
|
+
updated = ensure_workflow_top_level_section(
|
|
2367
|
+
content.to_s,
|
|
2368
|
+
"permissions",
|
|
2369
|
+
"permissions:\n contents: read\n\n",
|
|
2370
|
+
before: "on"
|
|
2371
|
+
)
|
|
2372
|
+
updated = ensure_workflow_top_level_section(
|
|
2373
|
+
updated,
|
|
2374
|
+
"concurrency",
|
|
2375
|
+
"concurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\n",
|
|
2376
|
+
before: "jobs"
|
|
2377
|
+
)
|
|
2378
|
+
updated = append_github_actions_coverage_steps(updated) if github_actions_coverage_enabled?(updated)
|
|
2379
|
+
update_github_actions_pins(updated)
|
|
2380
|
+
end
|
|
2381
|
+
|
|
2382
|
+
def github_actions_coverage_enabled?(content)
|
|
2383
|
+
content.match?(/K_SOUP_COV_DO:\s*["']?true["']?/)
|
|
2384
|
+
end
|
|
2385
|
+
|
|
2386
|
+
def append_github_actions_coverage_steps(content)
|
|
2387
|
+
return content if content.include?("Upload coverage to Coveralls") || content.include?("Upload coverage to CodeCov")
|
|
2388
|
+
|
|
2389
|
+
lines = content.lines
|
|
2390
|
+
steps_index = lines.index { |line| line.match?(/^ steps:\s*$/) }
|
|
2391
|
+
return content unless steps_index
|
|
2392
|
+
|
|
2393
|
+
insert_index = lines.length
|
|
2394
|
+
((steps_index + 1)...lines.length).each do |index|
|
|
2395
|
+
line = lines[index]
|
|
2396
|
+
next if line.strip.empty?
|
|
2397
|
+
next unless line.match?(/^\S|^ \S|^ \S/) && !line.match?(/^ /)
|
|
2398
|
+
|
|
2399
|
+
insert_index = index
|
|
2400
|
+
break
|
|
2401
|
+
end
|
|
2402
|
+
lines.insert(insert_index, github_actions_coverage_steps)
|
|
2403
|
+
lines.join
|
|
2404
|
+
end
|
|
2405
|
+
|
|
2406
|
+
def github_actions_coverage_steps
|
|
2407
|
+
<<~YAML.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join
|
|
2408
|
+
- name: Upload coverage to Coveralls
|
|
2409
|
+
if: ${{ !env.ACT }}
|
|
2410
|
+
uses: coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main
|
|
2411
|
+
with:
|
|
2412
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
2413
|
+
continue-on-error: ${{ matrix.experimental != 'false' }}
|
|
2414
|
+
|
|
2415
|
+
- name: Upload coverage to QLTY
|
|
2416
|
+
if: ${{ !env.ACT }}
|
|
2417
|
+
uses: qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0
|
|
2418
|
+
with:
|
|
2419
|
+
token: ${{secrets.QLTY_COVERAGE_TOKEN}}
|
|
2420
|
+
files: coverage/.resultset.json
|
|
2421
|
+
continue-on-error: ${{ matrix.experimental != 'false' }}
|
|
2422
|
+
|
|
2423
|
+
- name: Upload coverage to CodeCov
|
|
2424
|
+
if: ${{ !env.ACT }}
|
|
2425
|
+
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
|
2426
|
+
with:
|
|
2427
|
+
use_oidc: true
|
|
2428
|
+
fail_ci_if_error: false
|
|
2429
|
+
files: coverage/lcov.info,coverage/coverage.xml
|
|
2430
|
+
verbose: true
|
|
2431
|
+
|
|
2432
|
+
- name: Code Coverage Summary Report
|
|
2433
|
+
if: ${{ !env.ACT && github.event_name == 'pull_request' }}
|
|
2434
|
+
uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0
|
|
2435
|
+
with:
|
|
2436
|
+
filename: ./coverage/coverage.xml
|
|
2437
|
+
badge: true
|
|
2438
|
+
fail_below_min: true
|
|
2439
|
+
format: markdown
|
|
2440
|
+
hide_branch_rate: false
|
|
2441
|
+
hide_complexity: true
|
|
2442
|
+
indicators: true
|
|
2443
|
+
output: both
|
|
2444
|
+
thresholds: '100 100'
|
|
2445
|
+
continue-on-error: ${{ matrix.experimental != 'false' }}
|
|
2446
|
+
|
|
2447
|
+
- name: Add Coverage PR Comment
|
|
2448
|
+
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
|
2449
|
+
if: ${{ !env.ACT && github.event_name == 'pull_request' }}
|
|
2450
|
+
with:
|
|
2451
|
+
recreate: true
|
|
2452
|
+
path: code-coverage-results.md
|
|
2453
|
+
continue-on-error: ${{ matrix.experimental != 'false' }}
|
|
2454
|
+
YAML
|
|
2455
|
+
end
|
|
2456
|
+
|
|
2457
|
+
def ensure_workflow_top_level_section(content, key, section, before:)
|
|
2458
|
+
return content if content.match?(/^#{Regexp.escape(key)}:/)
|
|
2459
|
+
|
|
2460
|
+
lines = content.lines
|
|
2461
|
+
index = lines.index { |line| line.match?(/^#{Regexp.escape(before)}:/) }
|
|
2462
|
+
if index
|
|
2463
|
+
prepared_section = index.zero? || lines[index - 1].strip.empty? ? section : "\n#{section}"
|
|
2464
|
+
lines.insert(index, prepared_section)
|
|
2465
|
+
else
|
|
2466
|
+
lines << "\n" unless lines.empty? || lines.last == "\n"
|
|
2467
|
+
lines << section
|
|
2468
|
+
end
|
|
2469
|
+
lines.join
|
|
2470
|
+
end
|
|
2471
|
+
|
|
2472
|
+
def update_github_actions_pins(content)
|
|
2473
|
+
github_actions_step_pins.reduce(content) do |updated, (action_prefix, pinned_value)|
|
|
2474
|
+
updated.gsub(/^(\s*(?:-\s*)?uses:\s*)#{Regexp.escape(action_prefix)}@\S+(?:\s+#.*)?$/) do
|
|
2475
|
+
"#{$1}#{pinned_value}"
|
|
2476
|
+
end
|
|
2477
|
+
end
|
|
2478
|
+
end
|
|
2479
|
+
|
|
2480
|
+
def github_actions_step_pins
|
|
2481
|
+
{
|
|
2482
|
+
"actions/checkout" => "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2",
|
|
2483
|
+
"ruby/setup-ruby" => "ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0",
|
|
2484
|
+
"coverallsapp/github-action" => "coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main",
|
|
2485
|
+
"qltysh/qlty-action/coverage" => "qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0",
|
|
2486
|
+
"codecov/codecov-action" => "codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0",
|
|
2487
|
+
"irongut/CodeCoverageSummary" => "irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0",
|
|
2488
|
+
"marocchino/sticky-pull-request-comment" => "marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4",
|
|
2489
|
+
}
|
|
2490
|
+
end
|
|
2491
|
+
|
|
2492
|
+
def replace_markdown_managed_block(content, marker, replacement)
|
|
2493
|
+
open = "<!-- #{marker}:start -->"
|
|
2494
|
+
close = "<!-- #{marker}:end -->"
|
|
2495
|
+
replace_between_markers(content, open, close, replacement) do
|
|
2496
|
+
[content.rstrip, "", replacement, ""].join("\n")
|
|
2497
|
+
end
|
|
2498
|
+
end
|
|
2499
|
+
|
|
2500
|
+
def replace_text_managed_block(content, replacement)
|
|
2501
|
+
replace_between_markers(content, MANAGED_BLOCK_OPEN, MANAGED_BLOCK_CLOSE, replacement) do
|
|
2502
|
+
[content.rstrip, replacement].reject(&:empty?).join("\n")
|
|
2503
|
+
end
|
|
2504
|
+
end
|
|
2505
|
+
|
|
2506
|
+
def replace_between_markers(content, open_marker, close_marker, replacement)
|
|
2507
|
+
open_index = content.index(open_marker)
|
|
2508
|
+
close_index = content.index(close_marker)
|
|
2509
|
+
return yield unless open_index && close_index && close_index >= open_index
|
|
2510
|
+
|
|
2511
|
+
close_end = close_index + close_marker.length
|
|
2512
|
+
close_end += 1 if content[close_end] == "\n"
|
|
2513
|
+
"#{content[0...open_index]}#{replacement}\n#{content[close_end..]}"
|
|
2514
|
+
end
|
|
2515
|
+
|
|
2516
|
+
def ensure_trailing_newline(text)
|
|
2517
|
+
text.end_with?("\n") ? text : "#{text}\n"
|
|
2518
|
+
end
|
|
2519
|
+
|
|
2520
|
+
def compact_hash(hash)
|
|
2521
|
+
hash.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
|
|
2522
|
+
end
|
|
2523
|
+
|
|
2524
|
+
def deep_dup(value)
|
|
2525
|
+
Marshal.load(Marshal.dump(value))
|
|
2526
|
+
end
|
|
2527
|
+
end
|
|
2528
|
+
end
|