-
Notifications
You must be signed in to change notification settings - Fork 0
/
johnny_five.rb
executable file
·327 lines (269 loc) · 8.65 KB
/
johnny_five.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
#!/usr/bin/env ruby
require 'forwardable'
require 'optionparser'
class JohnnyFive
VERSION = "0.0.5"
class GitFileList
def self.files(range)
git("log --name-only --pretty=\"format:\" #{range}").split("\n").uniq.sort
end
def self.[](range)
files(range)
end
def self.commits(range)
git("log --oneline --decorate #{range}").split("\n")
end
def self.fix_range(range)
range ||= "FETCH_HEAD"
range.include?("...") ? range : "#{range}^...#{range}"
end
def self.git(args, default_value = "")
ret = `git #{args} 2> /dev/null`.chomp
$CHILD_STATUS.to_i == 0 ? ret : default_value
end
end
class Rules
extend Enumerable
extend Forwardable
def_delegators :@target, :each, :keys, :count
def initialize
@target = Hash.new { |h, k| h[k] = [] }
end
def []=(name, value)
(name.kind_of?(Array) ? name : [name]).each { |n| @target[n] << value }
end
def [](names) # rule["name"] << won't work
(names.kind_of?(Array) ? names : [names]).flat_map { |name| @target[name] }
end
def values
@target.values.flatten
end
# convert a glob to a regular expression
def self.glob2regex(glob, _options = {})
return glob if glob.kind_of?(Regexp)
Regexp.new(glob.gsub(%r{([{},.]|\*\*/\*|\*\*/|\*)}) do
case $1
when '**/*' then '.*' # directory and file match
when '*' then '[^/]*' # file match
when '**/' then '.*/' # directory match
when '{' then '(?:' # grouping of filenames
when '}' then ')' # end grouping of names
when ',' then '|' # or for grouping
when '.' then '\.' # dot in filename
end
end)
end
end
class RuleList
def initialize
@basic_rules = Rules.new
@shallow_rules = Rules.new
@basic_dependencies = Rules.new
end
# @return [Hash<String,Array<Regexp>] the files (value) that trigger a target (key)
attr_accessor :shallow_rules
# @return [Hash<String,Array<Regexp>] the files (galue) that trigger a target (key) and dependencies
attr_accessor :basic_rules
# @return [Hash<String,Array<String>] the targets (value) that trigger a target (key)
attr_accessor :basic_dependencies
# @param target [String]
# @return [Array<Regexp>] rules for all these targets
def [](target)
(basic_rules[dependencies(target)] + shallow_rules[target]).uniq.compact
end
# @return [Array<String>] list of all targets and dependent targets
def dependencies(target)
targets = [target, :all]
count = 0
while count != targets.size
count = targets.size
(targets += basic_dependencies[targets]).tap(&:compact!).tap(&:uniq!)
end
targets
end
# @return [Regexp] rule to match every file that is covered by a rule (for sanity checks)
def every
Regexp.union((basic_rules.values + shallow_rules.values).uniq)
end
end
########### FOLD ###########
# Easy dsl to populate rules (or sherlock)
class DslTranslator
extend Forwardable
def initialize(sherlock, rules = nil)
@rules = rules || sherlock.rules
@sherlock = sherlock
end
attr_accessor :rules, :sherlock
def suite(name)
@suite = name
yield self
end
def file(glob)
basic_rules[@suite] = Rules.glob2regex(glob)
end
def test(glob)
shallow_rules[@suite] = Rules.glob2regex(glob)
end
def trigger(target)
basic_dependencies[@suite] = target
end
def_delegators :@sherlock, :branch=, :branches, :branches=, :check=, :component=, :verbose=, :range= ,:pr=
def_delegators :@rules, :basic_dependencies, :basic_rules, :shallow_rules
end
# uses facts to deduce a plan
class Sherlock
extend Forwardable
def initialize(rules)
@rules = rules
@branches = []
end
# @return [String] branch being built (e.g.: master)
attr_accessor :branch
# @return [Array<String>|Nil] For a non-PR, branches that will trigger a build (all others will be ignored)
attr_accessor :branches
# @return [Boolean] true to check all files on the filesystem against rules
attr_accessor :check
# @return [String] component being built
attr_accessor :component
# @return [String] pull request number (e.g.: "555" or "false" for a branch)
attr_accessor :pr
# @return [Boolean] true to show verbose messages
attr_accessor :verbose
# @return [String] git commit range (e.g.: FETCH_HEAD^...FETCH_HEAD)
attr_accessor :range
attr_accessor :rules
def range=(range)
@range = ::JohnnyFive::GitFileList.fix_range(range)
end
def pr?
pr != false && pr != "false"
end
def branch_match?
branch.nil? || branch.empty? || branches.empty? || branches.include?(branch)
end
# @return [Boolean] true if the changed files trigger this target
def triggered?(target)
return true if component.nil? || component.empty?
regexp = Regexp.union(rules[target])
GitFileList[range].detect { |fn| regexp.match(fn) }
end
# main logic to determine what to do
def deduce
if pr?
if triggered?(component)
[true, "building PR for #{component || "none specified"}"]
else
[false, "skipping PR for unchanged: #{component}"]
end
else
if branch_match?
[true, "building branch: #{branch || "none specified"}"]
else
[false, "skipping branch: #{branch} (not #{branches.join(", ")})"]
end
end
end
def run
run_it, reason = deduce
puts "==> #{reason} <=="
exit(1) unless run_it
end
end
###### Logic and DSL above ######
# Class to parse ENV and ARGV
# see also parse method
class OptSetter
def initialize(opts, model, env)
@opts = opts
@model = model
@env = env
end
def opt(value, *args)
# support environment variable being specified
unless args[0].start_with?("-")
env = args.shift
ev = @env[env]
@model.send("#{value}=", ev) if ev
# add env value onto opts help message
args.last << " (#{env}=#{ev || "<not set>"})"
end
@opts.on(*args) { |v| @model.send("#{value}=", v) }
end
end
attr_reader :sherlock, :rules
def initialize
@rules = RuleList.new
@sherlock = Sherlock.new(@rules)
end
def opt(opts, model, env)
yield OptSetter.new(opts, model, env)
end
def parse(argv, env)
options = OptionParser.new do |opts|
opts.version = VERSION
opt(opts, sherlock, env) do |o|
o.opt(:branch, "TRAVIS_BRANCH", "--branch STRING", "Branch being built")
o.opt(:check, "CHECK", "--check", "validate that every file on the filesystem has a rule")
o.opt(:component, "COMPONENT", "--component STRING", "name of component being built")
o.opt(:pr, "TRAVIS_PULL_REQUEST", "--pr STRING", "pull request number or false")
o.opt(:range, "TRAVIS_COMMIT_RANGE", "--range SHA...SHA", "Git commit range")
o.opt(:verbose, "VERBOSE", "-v", "--verbose", "--[no-]verbose", "Run verbosely")
end
end
options.parse!(argv)
argv.each { |file_name| require File.expand_path(file_name, Dir.pwd) }
self
end
def config
DslTranslator.new(sherlock, rules)
end
def self.config
yield instance.config
end
def self.instance
@instance ||= new
end
def inform
puts "#{sherlock.pr? ? "PR" : " "} BRANCH : #{sherlock.branch}"
puts "COMPONENT : #{sherlock.component}"
puts "COMMIT_RANGE : #{sherlock.range}"
list("COMMITS") { GitFileList.commits(sherlock.range) }
list("FILES") { GitFileList.files(sherlock.range) } if sherlock.verbose
list("DETECT:") { rules.dependencies(sherlock.component) } if sherlock.verbose
list("REGEX:") { rules[sherlock.component] } if sherlock.verbose || sherlock.check
self
end
def run
inform
sanity_check if sherlock.check
sherlock.run
end
def self.run(argv, env)
instance.run(argv, env)
end
private
def sanity_check
all_files = every_file
all_rules = rules.every
list("UNCOVERED:", false) { all_files.select { |fn| !all_rules.match(fn) } }
end
def list(name, always_display = true)
entries = yield
if always_display || !entries.empty?
puts "======="
puts "#{name}"
puts entries.map { |fn| " - #{fn}" }
puts
end
end
# @return [Array<String>] all files in the current directory
def every_file
Dir['**/*'].select { |fn| File.file?(fn) } + Dir['.[a-z]*']
end
end
if __FILE__ == $PROGRAM_NAME
$stdout.sync = true
$stderr.sync = true
JohnnyFive.instance.parse(ARGV, ENV).run
end