nomos 0.1.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
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +346 -0
- data/Rakefile +8 -0
- data/docs/logo-header.svg +40 -0
- data/examples/.nomos/rules.rb +9 -0
- data/examples/nomos.yml +30 -0
- data/exe/nomos +7 -0
- data/lib/nomos/cache.rb +39 -0
- data/lib/nomos/cli.rb +220 -0
- data/lib/nomos/config.rb +58 -0
- data/lib/nomos/context.rb +26 -0
- data/lib/nomos/context_loader.rb +66 -0
- data/lib/nomos/finding.rb +65 -0
- data/lib/nomos/github_client.rb +98 -0
- data/lib/nomos/reporters/console.rb +24 -0
- data/lib/nomos/reporters/github.rb +168 -0
- data/lib/nomos/reporters/json.rb +32 -0
- data/lib/nomos/rules/base.rb +18 -0
- data/lib/nomos/rules/builtin/forbid_paths.rb +32 -0
- data/lib/nomos/rules/builtin/no_large_pr.rb +28 -0
- data/lib/nomos/rules/builtin/require_file_change.rb +31 -0
- data/lib/nomos/rules/builtin/require_labels.rb +30 -0
- data/lib/nomos/rules/builtin/todo_guard.rb +32 -0
- data/lib/nomos/rules/ruby_dsl.rb +102 -0
- data/lib/nomos/rules/ruby_file.rb +19 -0
- data/lib/nomos/rules.rb +55 -0
- data/lib/nomos/runner.rb +67 -0
- data/lib/nomos/timing.rb +33 -0
- data/lib/nomos/version.rb +5 -0
- data/lib/nomos.rb +21 -0
- data/sig/nomos.rbs +4 -0
- data/site/assets/logo.png +0 -0
- data/site/assets/styles.css +375 -0
- data/site/content/index.md +7 -0
- data/site/craze.yml +19 -0
- data/site/templates/layouts/default.html.erb +20 -0
- data/site/templates/layouts/home.html.erb +198 -0
- metadata +85 -0
data/examples/nomos.yml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
|
|
3
|
+
reporter:
|
|
4
|
+
github: true
|
|
5
|
+
console: true
|
|
6
|
+
json:
|
|
7
|
+
path: nomos-report.json
|
|
8
|
+
|
|
9
|
+
performance:
|
|
10
|
+
concurrency: 4
|
|
11
|
+
cache: true
|
|
12
|
+
lazy_diff: true
|
|
13
|
+
timing: false
|
|
14
|
+
|
|
15
|
+
rules:
|
|
16
|
+
- name: no_large_pr
|
|
17
|
+
type: builtin.no_large_pr
|
|
18
|
+
params:
|
|
19
|
+
max_changed_lines: 800
|
|
20
|
+
|
|
21
|
+
- name: require_changelog
|
|
22
|
+
type: builtin.require_file_change
|
|
23
|
+
params:
|
|
24
|
+
patterns:
|
|
25
|
+
- CHANGELOG.md
|
|
26
|
+
|
|
27
|
+
- name: custom_rules
|
|
28
|
+
type: ruby.file
|
|
29
|
+
params:
|
|
30
|
+
path: .nomos/rules.rb
|
data/exe/nomos
ADDED
data/lib/nomos/cache.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Nomos
|
|
6
|
+
class Cache
|
|
7
|
+
def initialize(path:, enabled: true)
|
|
8
|
+
@path = path
|
|
9
|
+
@enabled = enabled
|
|
10
|
+
@data = enabled ? load_data : {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch(key)
|
|
14
|
+
return yield unless @enabled
|
|
15
|
+
return @data[key] if @data.key?(key)
|
|
16
|
+
|
|
17
|
+
value = yield
|
|
18
|
+
@data[key] = value
|
|
19
|
+
persist
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def load_data
|
|
26
|
+
return {} unless File.exist?(@path)
|
|
27
|
+
|
|
28
|
+
JSON.parse(File.read(@path))
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
{}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def persist
|
|
34
|
+
dir = File.dirname(@path)
|
|
35
|
+
Dir.mkdir(dir) unless Dir.exist?(dir)
|
|
36
|
+
File.write(@path, JSON.pretty_generate(@data))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/nomos/cli.rb
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "config"
|
|
6
|
+
require_relative "context_loader"
|
|
7
|
+
require_relative "github_client"
|
|
8
|
+
require_relative "runner"
|
|
9
|
+
require_relative "reporters/console"
|
|
10
|
+
require_relative "reporters/github"
|
|
11
|
+
require_relative "timing"
|
|
12
|
+
|
|
13
|
+
module Nomos
|
|
14
|
+
class CLI
|
|
15
|
+
def self.run(argv)
|
|
16
|
+
command = argv.shift || "run"
|
|
17
|
+
|
|
18
|
+
case command
|
|
19
|
+
when "run"
|
|
20
|
+
new.run(argv)
|
|
21
|
+
when "init"
|
|
22
|
+
new.init(argv)
|
|
23
|
+
when "doctor"
|
|
24
|
+
new.doctor(argv)
|
|
25
|
+
else
|
|
26
|
+
warn "Unknown command: #{command}"
|
|
27
|
+
1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run(argv)
|
|
32
|
+
options = {
|
|
33
|
+
config: Config::DEFAULT_PATH,
|
|
34
|
+
strict: false,
|
|
35
|
+
debug: false,
|
|
36
|
+
reporter: nil,
|
|
37
|
+
no_cache: false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
parser = OptionParser.new do |opts|
|
|
41
|
+
opts.banner = "Usage: nomos run [options]"
|
|
42
|
+
opts.on("--config PATH", "Config path (default: nomos.yml)") { |path| options[:config] = path }
|
|
43
|
+
opts.on("--strict", "Treat warns as failures") { options[:strict] = true }
|
|
44
|
+
opts.on("--debug", "Show debug details") { options[:debug] = true }
|
|
45
|
+
opts.on("--no-cache", "Disable cache and lazy diff for this run") { options[:no_cache] = true }
|
|
46
|
+
opts.on("--reporter LIST", "Comma-separated reporters (github,console,json)") do |list|
|
|
47
|
+
options[:reporter] = list.split(",").map(&:strip)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
parser.parse!(argv)
|
|
52
|
+
|
|
53
|
+
timing = Timing.new
|
|
54
|
+
config = timing.measure("config") { Config.load(options[:config]) }
|
|
55
|
+
performance = config.performance
|
|
56
|
+
if options[:no_cache]
|
|
57
|
+
performance = performance.merge(cache: false, lazy_diff: false)
|
|
58
|
+
end
|
|
59
|
+
context = timing.measure("context") { ContextLoader.load(performance: performance) }
|
|
60
|
+
findings = timing.measure("rules") { Runner.new(config, context).run }
|
|
61
|
+
findings = upgrade_warnings(findings) if options[:strict]
|
|
62
|
+
|
|
63
|
+
reporters = timing.measure("reporters") { build_reporters(config, context, options[:reporter]) }
|
|
64
|
+
timing.measure("report_outputs") { reporters.each { |reporter| reporter.report(findings) } }
|
|
65
|
+
|
|
66
|
+
timing.report if config.performance.fetch(:timing, false)
|
|
67
|
+
|
|
68
|
+
exit_code(findings, options[:strict])
|
|
69
|
+
rescue Nomos::Error => e
|
|
70
|
+
warn e.message
|
|
71
|
+
raise if options[:debug]
|
|
72
|
+
|
|
73
|
+
1
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def init(_argv)
|
|
77
|
+
config_path = Config::DEFAULT_PATH
|
|
78
|
+
rules_path = ".nomos/rules.rb"
|
|
79
|
+
|
|
80
|
+
if File.exist?(config_path)
|
|
81
|
+
warn "#{config_path} already exists"
|
|
82
|
+
else
|
|
83
|
+
File.write(config_path, default_config)
|
|
84
|
+
puts "Created #{config_path}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
unless File.exist?(File.dirname(rules_path))
|
|
88
|
+
Dir.mkdir(File.dirname(rules_path))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if File.exist?(rules_path)
|
|
92
|
+
warn "#{rules_path} already exists"
|
|
93
|
+
else
|
|
94
|
+
File.write(rules_path, default_rules)
|
|
95
|
+
puts "Created #{rules_path}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
0
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def doctor(_argv)
|
|
102
|
+
config_path = Config::DEFAULT_PATH
|
|
103
|
+
ok = true
|
|
104
|
+
|
|
105
|
+
unless File.exist?(config_path)
|
|
106
|
+
warn "Missing config: #{config_path}"
|
|
107
|
+
ok = false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if ENV["GITHUB_TOKEN"].to_s.empty?
|
|
111
|
+
warn "Missing GITHUB_TOKEN"
|
|
112
|
+
ok = false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if ENV["GITHUB_EVENT_PATH"].to_s.empty? && (ENV["NOMOS_PR_NUMBER"].to_s.empty? || ENV["NOMOS_REPOSITORY"].to_s.empty?)
|
|
116
|
+
warn "Missing PR context (GITHUB_EVENT_PATH or NOMOS_PR_NUMBER/NOMOS_REPOSITORY)"
|
|
117
|
+
ok = false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
puts ok ? "Nomos doctor: OK" : "Nomos doctor: issues found"
|
|
121
|
+
ok ? 0 : 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def build_reporters(config, context, override)
|
|
127
|
+
enabled = if override
|
|
128
|
+
override.map(&:downcase)
|
|
129
|
+
else
|
|
130
|
+
config.reporters.map { |name, value| name.to_s if value }.compact
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
enabled = ["console"] if enabled.empty?
|
|
134
|
+
|
|
135
|
+
enabled.map do |name|
|
|
136
|
+
case name
|
|
137
|
+
when "console"
|
|
138
|
+
Reporters::Console.new
|
|
139
|
+
when "github"
|
|
140
|
+
client = GitHubClient.new(token: ENV["GITHUB_TOKEN"], api_url: ENV["GITHUB_API_URL"] || "https://api.github.com")
|
|
141
|
+
Reporters::GitHub.new(
|
|
142
|
+
client: client,
|
|
143
|
+
repo: context.repo,
|
|
144
|
+
pr_number: context.pull_request.fetch("number"),
|
|
145
|
+
pull_request: context.pull_request,
|
|
146
|
+
context: context
|
|
147
|
+
)
|
|
148
|
+
when "json"
|
|
149
|
+
Reporters::Json.new(path: json_report_path(config))
|
|
150
|
+
else
|
|
151
|
+
raise Nomos::Error, "Unknown reporter: #{name}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def exit_code(findings, strict)
|
|
157
|
+
return 1 if findings.any? { |finding| finding.severity == :fail }
|
|
158
|
+
|
|
159
|
+
0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def upgrade_warnings(findings)
|
|
163
|
+
findings.map do |finding|
|
|
164
|
+
finding.severity == :warn ? finding.with_severity(:fail) : finding
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def default_config
|
|
169
|
+
<<~YAML
|
|
170
|
+
version: 1
|
|
171
|
+
|
|
172
|
+
reporter:
|
|
173
|
+
github: true
|
|
174
|
+
console: true
|
|
175
|
+
json:
|
|
176
|
+
path: nomos-report.json
|
|
177
|
+
|
|
178
|
+
performance:
|
|
179
|
+
concurrency: 4
|
|
180
|
+
cache: true
|
|
181
|
+
lazy_diff: true
|
|
182
|
+
timing: false
|
|
183
|
+
|
|
184
|
+
rules:
|
|
185
|
+
- name: no_large_pr
|
|
186
|
+
type: builtin.no_large_pr
|
|
187
|
+
params:
|
|
188
|
+
max_changed_lines: 800
|
|
189
|
+
|
|
190
|
+
- name: require_changelog
|
|
191
|
+
type: builtin.require_file_change
|
|
192
|
+
params:
|
|
193
|
+
patterns:
|
|
194
|
+
- CHANGELOG.md
|
|
195
|
+
YAML
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def default_rules
|
|
199
|
+
<<~RUBY
|
|
200
|
+
# frozen_string_literal: true
|
|
201
|
+
|
|
202
|
+
# Custom rules will be supported in Phase 2.
|
|
203
|
+
RUBY
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def json_report_path(config)
|
|
207
|
+
json = config.reporters[:json]
|
|
208
|
+
case json
|
|
209
|
+
when Hash
|
|
210
|
+
json[:path] || "nomos-report.json"
|
|
211
|
+
when String
|
|
212
|
+
json
|
|
213
|
+
when true
|
|
214
|
+
"nomos-report.json"
|
|
215
|
+
else
|
|
216
|
+
"nomos-report.json"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
data/lib/nomos/config.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Nomos
|
|
6
|
+
class Config
|
|
7
|
+
DEFAULT_PATH = "nomos.yml"
|
|
8
|
+
|
|
9
|
+
attr_reader :path, :data
|
|
10
|
+
|
|
11
|
+
def self.load(path = DEFAULT_PATH)
|
|
12
|
+
raw = read_yaml(path)
|
|
13
|
+
new(path, raw)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(path, raw)
|
|
17
|
+
@path = path
|
|
18
|
+
@data = symbolize_keys(raw || {})
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reporters
|
|
22
|
+
data.fetch(:reporter, {})
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def performance
|
|
26
|
+
data.fetch(:performance, {})
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def rules
|
|
30
|
+
Array(data[:rules])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def self.read_yaml(path)
|
|
36
|
+
unless File.exist?(path)
|
|
37
|
+
raise Nomos::Error, "Config not found: #{path}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {}
|
|
41
|
+
rescue Psych::SyntaxError => e
|
|
42
|
+
raise Nomos::Error, "Invalid YAML in #{path}: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def symbolize_keys(obj)
|
|
46
|
+
case obj
|
|
47
|
+
when Hash
|
|
48
|
+
obj.each_with_object({}) do |(key, value), memo|
|
|
49
|
+
memo[key.to_sym] = symbolize_keys(value)
|
|
50
|
+
end
|
|
51
|
+
when Array
|
|
52
|
+
obj.map { |value| symbolize_keys(value) }
|
|
53
|
+
else
|
|
54
|
+
obj
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nomos
|
|
4
|
+
class Context
|
|
5
|
+
attr_reader :pull_request, :changed_files, :repo, :base_branch, :ci, :changed_lines
|
|
6
|
+
|
|
7
|
+
def initialize(pull_request:, changed_files:, patches:, repo:, base_branch:, ci:, changed_lines:, patch_fetcher: nil)
|
|
8
|
+
@pull_request = pull_request
|
|
9
|
+
@changed_files = changed_files
|
|
10
|
+
@patches = patches
|
|
11
|
+
@patch_fetcher = patch_fetcher
|
|
12
|
+
@repo = repo
|
|
13
|
+
@base_branch = base_branch
|
|
14
|
+
@ci = ci
|
|
15
|
+
@changed_lines = changed_lines
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def diff(file)
|
|
20
|
+
return @patches[file] if @patches.key?(file)
|
|
21
|
+
return unless @patch_fetcher
|
|
22
|
+
|
|
23
|
+
@patches[file] = @patch_fetcher.call(file)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "cache"
|
|
6
|
+
require_relative "context"
|
|
7
|
+
require_relative "github_client"
|
|
8
|
+
|
|
9
|
+
module Nomos
|
|
10
|
+
class ContextLoader
|
|
11
|
+
def self.load(env: ENV, performance: {})
|
|
12
|
+
event = read_event(env["GITHUB_EVENT_PATH"])
|
|
13
|
+
pr_number = event.dig("pull_request", "number") || event["number"] || env["NOMOS_PR_NUMBER"]
|
|
14
|
+
repo = env["GITHUB_REPOSITORY"] || env["NOMOS_REPOSITORY"]
|
|
15
|
+
|
|
16
|
+
unless pr_number && repo
|
|
17
|
+
raise Nomos::Error, "Missing PR context. Set GITHUB_EVENT_PATH/GITHUB_REPOSITORY or NOMOS_PR_NUMBER/NOMOS_REPOSITORY."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
token = env["GITHUB_TOKEN"]
|
|
21
|
+
api_url = env["GITHUB_API_URL"] || "https://api.github.com"
|
|
22
|
+
|
|
23
|
+
client = GitHubClient.new(token: token, api_url: api_url)
|
|
24
|
+
|
|
25
|
+
cache_enabled = performance.fetch(:cache, false)
|
|
26
|
+
cache_path = performance.fetch(:cache_path, ".nomos/cache.json")
|
|
27
|
+
cache = Cache.new(path: cache_path, enabled: cache_enabled)
|
|
28
|
+
|
|
29
|
+
pr_cache_key = "pr:#{repo}##{pr_number}"
|
|
30
|
+
files_cache_key = "files:#{repo}##{pr_number}"
|
|
31
|
+
|
|
32
|
+
pr = cache.fetch(pr_cache_key) { client.pull_request(repo, pr_number) }
|
|
33
|
+
files = cache.fetch(files_cache_key) { client.pull_request_files(repo, pr_number) }
|
|
34
|
+
|
|
35
|
+
changed_files = files.map { |file| file.fetch("filename") }
|
|
36
|
+
lazy_diff = performance.fetch(:lazy_diff, false)
|
|
37
|
+
patches = lazy_diff ? {} : files.each_with_object({}) { |file, memo| memo[file["filename"]] = file["patch"] }
|
|
38
|
+
patch_fetcher = lazy_diff ? lambda { |filename| files.find { |file| file["filename"] == filename }&.fetch("patch", nil) } : nil
|
|
39
|
+
changed_lines = files.sum { |file| file.fetch("additions", 0) + file.fetch("deletions", 0) }
|
|
40
|
+
|
|
41
|
+
Context.new(
|
|
42
|
+
pull_request: pr,
|
|
43
|
+
changed_files: changed_files,
|
|
44
|
+
patches: patches,
|
|
45
|
+
patch_fetcher: patch_fetcher,
|
|
46
|
+
repo: repo,
|
|
47
|
+
base_branch: pr.dig("base", "ref"),
|
|
48
|
+
ci: {
|
|
49
|
+
"workflow" => env["GITHUB_WORKFLOW"],
|
|
50
|
+
"run_id" => env["GITHUB_RUN_ID"],
|
|
51
|
+
"actor" => env["GITHUB_ACTOR"]
|
|
52
|
+
},
|
|
53
|
+
changed_lines: changed_lines
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.read_event(path)
|
|
58
|
+
return {} unless path && File.exist?(path)
|
|
59
|
+
|
|
60
|
+
JSON.parse(File.read(path))
|
|
61
|
+
rescue JSON::ParserError => e
|
|
62
|
+
raise Nomos::Error, "Invalid event JSON at #{path}: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
private_class_method :read_event
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nomos
|
|
4
|
+
class Finding
|
|
5
|
+
SEVERITIES = %i[message warn fail].freeze
|
|
6
|
+
LEVEL_MAP = {
|
|
7
|
+
"note" => :message,
|
|
8
|
+
"warning" => :warn,
|
|
9
|
+
"caution" => :fail
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :severity, :text, :file, :line, :code, :source
|
|
13
|
+
|
|
14
|
+
def self.message(text, **opts)
|
|
15
|
+
new(:message, text, **opts)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.warn(text, **opts)
|
|
19
|
+
new(:warn, text, **opts)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.fail(text, **opts)
|
|
23
|
+
new(:fail, text, **opts)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(severity, text, file: nil, line: nil, code: nil, source: "")
|
|
27
|
+
unless SEVERITIES.include?(severity)
|
|
28
|
+
raise ArgumentError, "Unknown severity: #{severity}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@severity = severity
|
|
32
|
+
@text = text
|
|
33
|
+
@file = file
|
|
34
|
+
@line = line
|
|
35
|
+
@code = code
|
|
36
|
+
@source = source
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.severity_for_level(level)
|
|
41
|
+
key = level.to_s.strip.downcase
|
|
42
|
+
severity = LEVEL_MAP[key]
|
|
43
|
+
raise ArgumentError, "Unknown level: #{level}" unless severity
|
|
44
|
+
|
|
45
|
+
severity
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def with_severity(new_severity)
|
|
49
|
+
return self if new_severity == severity
|
|
50
|
+
|
|
51
|
+
self.class.new(new_severity, text, file: file, line: line, code: code, source: source)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_h
|
|
55
|
+
{
|
|
56
|
+
severity: severity,
|
|
57
|
+
text: text,
|
|
58
|
+
file: file,
|
|
59
|
+
line: line,
|
|
60
|
+
code: code,
|
|
61
|
+
source: source
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Nomos
|
|
8
|
+
class GitHubClient
|
|
9
|
+
def initialize(token:, api_url:)
|
|
10
|
+
@token = token
|
|
11
|
+
@api_url = api_url
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def pull_request(repo, number)
|
|
15
|
+
get_json("/repos/#{repo}/pulls/#{number}")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def pull_request_files(repo, number)
|
|
19
|
+
get_paginated("/repos/#{repo}/pulls/#{number}/files")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def list_issue_comments(repo, number)
|
|
23
|
+
get_paginated("/repos/#{repo}/issues/#{number}/comments")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_comment(repo, number, body)
|
|
27
|
+
post_json("/repos/#{repo}/issues/#{number}/comments", body: body)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def update_comment(repo, comment_id, body)
|
|
31
|
+
patch_json("/repos/#{repo}/issues/comments/#{comment_id}", body: body)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create_review(repo, number, body:, event: "COMMENT", comments: [], commit_id: nil)
|
|
35
|
+
payload = { body: body, event: event, comments: comments }
|
|
36
|
+
payload[:commit_id] = commit_id if commit_id
|
|
37
|
+
post_json("/repos/#{repo}/pulls/#{number}/reviews", payload)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def get_paginated(path)
|
|
43
|
+
results = []
|
|
44
|
+
page = 1
|
|
45
|
+
|
|
46
|
+
loop do
|
|
47
|
+
data, headers = request(:get, "#{path}?per_page=100&page=#{page}")
|
|
48
|
+
results.concat(data)
|
|
49
|
+
break unless headers["link"]&.include?("rel=\"next\"")
|
|
50
|
+
|
|
51
|
+
page += 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
results
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def get_json(path)
|
|
58
|
+
data, = request(:get, path)
|
|
59
|
+
data
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def post_json(path, payload)
|
|
63
|
+
data, = request(:post, path, payload)
|
|
64
|
+
data
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def patch_json(path, payload)
|
|
68
|
+
data, = request(:patch, path, payload)
|
|
69
|
+
data
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def request(method, path, payload = nil)
|
|
73
|
+
uri = URI.join(@api_url, path)
|
|
74
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
75
|
+
http.use_ssl = uri.scheme == "https"
|
|
76
|
+
|
|
77
|
+
request_class = Net::HTTP.const_get(method.to_s.capitalize)
|
|
78
|
+
request = request_class.new(uri)
|
|
79
|
+
request["Accept"] = "application/vnd.github+json"
|
|
80
|
+
request["User-Agent"] = "nomos"
|
|
81
|
+
request["Authorization"] = "Bearer #{@token}" if @token
|
|
82
|
+
request["Content-Type"] = "application/json" if payload
|
|
83
|
+
request.body = JSON.generate(payload) if payload
|
|
84
|
+
|
|
85
|
+
response = http.request(request)
|
|
86
|
+
data = response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
87
|
+
|
|
88
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
89
|
+
message = data.is_a?(Hash) ? data["message"] : response.message
|
|
90
|
+
raise Nomos::Error, "GitHub API error (#{response.code}): #{message}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
[data, response.each_header.to_h]
|
|
94
|
+
rescue JSON::ParserError => e
|
|
95
|
+
raise Nomos::Error, "GitHub API JSON error: #{e.message}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nomos
|
|
4
|
+
module Reporters
|
|
5
|
+
class Console
|
|
6
|
+
def report(findings)
|
|
7
|
+
if findings.empty?
|
|
8
|
+
puts "Nomos: no findings"
|
|
9
|
+
return
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
findings.each do |finding|
|
|
13
|
+
location = if finding.file
|
|
14
|
+
" (#{finding.file}#{finding.line ? ":#{finding.line}" : ""})"
|
|
15
|
+
else
|
|
16
|
+
""
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
puts "[#{finding.severity}] #{finding.text}#{location}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|