cutting_edge 0.0.1 → 0.1

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +7 -2
  3. data/Gemfile.lock +130 -0
  4. data/LICENSE +68 -81
  5. data/Procfile +1 -0
  6. data/README.md +272 -74
  7. data/Rakefile +0 -5
  8. data/bin/cutting_edge +60 -45
  9. data/config.rb +55 -0
  10. data/cutting_edge.gemspec +33 -4
  11. data/heroku.config.rb +20 -0
  12. data/lib/cutting_edge.rb +1 -1
  13. data/lib/cutting_edge/app.rb +95 -19
  14. data/lib/cutting_edge/langs.rb +7 -1
  15. data/lib/cutting_edge/langs/python.rb +5 -1
  16. data/lib/cutting_edge/langs/ruby.rb +5 -1
  17. data/lib/cutting_edge/langs/rust.rb +5 -1
  18. data/lib/cutting_edge/public/images/error.svg +24 -0
  19. data/lib/cutting_edge/public/images/languages/python.svg +1 -0
  20. data/lib/cutting_edge/public/images/languages/ruby.svg +1 -0
  21. data/lib/cutting_edge/public/images/languages/rust.svg +1 -0
  22. data/lib/cutting_edge/public/images/ok.svg +24 -0
  23. data/lib/cutting_edge/public/javascript/clipboard.min.js +7 -0
  24. data/lib/cutting_edge/public/javascript/cuttingedge.js +53 -0
  25. data/lib/cutting_edge/public/stylesheets/primer.css +22 -0
  26. data/lib/cutting_edge/repo.rb +124 -18
  27. data/lib/cutting_edge/templates/_footer.html.erb +3 -0
  28. data/lib/cutting_edge/templates/_header.html.erb +8 -0
  29. data/lib/cutting_edge/templates/_overview.html.erb +9 -0
  30. data/lib/cutting_edge/templates/badge.svg.erb +39 -0
  31. data/lib/cutting_edge/templates/index.html.erb +62 -0
  32. data/lib/cutting_edge/templates/info.html.erb +101 -0
  33. data/lib/cutting_edge/templates/mail.html.erb +163 -0
  34. data/lib/cutting_edge/workers/badge.rb +33 -11
  35. data/lib/cutting_edge/workers/dependency.rb +36 -16
  36. data/lib/cutting_edge/workers/helpers.rb +8 -0
  37. data/lib/cutting_edge/workers/mail.rb +38 -0
  38. data/projects.yml +25 -0
  39. data/spec/app_spec.rb +115 -0
  40. data/spec/badge_worker_spec.rb +77 -0
  41. data/spec/dependency_worker_spec.rb +132 -0
  42. data/spec/email_worker_spec.rb +43 -0
  43. data/spec/fixtures.rb +180 -0
  44. data/spec/fixtures/projects.yml +27 -0
  45. data/spec/langs/python_spec.rb +47 -5
  46. data/spec/langs/ruby_spec.rb +105 -0
  47. data/spec/langs/rust_spec.rb +31 -0
  48. data/spec/repo_spec.rb +52 -0
  49. data/spec/spec_helper.rb +9 -1
  50. metadata +43 -15
  51. data/lib/cutting_edge/badge.rb +0 -46
@@ -0,0 +1,180 @@
1
+ def mock_dependencies(name)
2
+ {
3
+ 'ok' =>
4
+ {
5
+ :locations => {
6
+ 'ok.gemspec' =>
7
+ {:outdated_major=>[],
8
+ :outdated_patch=>[],
9
+ :ok=>
10
+ [
11
+ {:name=>'gollum-lib',
12
+ :required=>'~> 5.0',
13
+ :latest=>'5.0.3',
14
+ :type=>:runtime},
15
+ {:name => 'foobar',
16
+ :required => '= 1.0',
17
+ :latest => '1.0',
18
+ :type => :runtime
19
+ }
20
+ ],
21
+ :no_requirement=>[],
22
+ :unknown=>[]},
23
+ },
24
+ :ok=>2,
25
+ :outdated=>:up_to_date,
26
+ :outdated_major=>0,
27
+ :outdated_minor=>0,
28
+ :outdated_patch=>0,
29
+ :outdated_total=>0,
30
+ :unknown=>0,
31
+ :no_requirement => 0
32
+ },
33
+ 'gollum' =>
34
+ {
35
+ :locations=>
36
+ {'gollum.gemspec'=>
37
+ {:outdated_major=>
38
+ [{:name=>'mustache',
39
+ :required=>'>= 0.99.5, < 1.0.0',
40
+ :latest=>'1.1.1',
41
+ :type=>:runtime,
42
+ :url=>'https://rubygems.org/gems/mustache'},
43
+ {:name=>'octicons',
44
+ :required=>'~> 8.5',
45
+ :latest=>'9.6.0',
46
+ :type=>:runtime,
47
+ :url=>'https://rubygems.org/gems/octicons'}],
48
+ :outdated_minor=>
49
+ [{:name=>'kramdown-parser-gfm',
50
+ :required=>'~> 1.0.0',
51
+ :latest=>'1.1.0',
52
+ :type=>:runtime,
53
+ :url=>'https://rubygems.org/gems/kramdown-parser-gfm'}],
54
+ :outdated_patch=>[],
55
+ :ok=>
56
+ [{:name=>'gollum-lib',
57
+ :required=>'~> 5.0',
58
+ :latest=>'5.0.3',
59
+ :type=>:runtime,
60
+ :url=>'https://rubygems.org/gems/gollum-lib'},
61
+ {:name=>'foobar',
62
+ :required=>'= 1.0',
63
+ :latest=>'1.0',
64
+ :type=>:runtime,
65
+ :url=>'https://rubygems.org/gems/foobar'}],
66
+ :no_requirement=>[],
67
+ :unknown=>[]},
68
+ 'Gemfile'=>
69
+ {:outdated_major=>
70
+ [{:name=>'rake',
71
+ :required=>'~> 12.3, >= 12.3.3',
72
+ :latest=>'13.0.1',
73
+ :type=>:runtime,
74
+ :url=>'https://rubygems.org/gems/rake'}],
75
+ :outdated_minor=>[],
76
+ :outdated_patch=>[],
77
+ :ok=>
78
+ [{:name=>'warbler',
79
+ :required=>'>= 0',
80
+ :latest=>'2.0.5',
81
+ :type=>:runtime,
82
+ :url=>'https://rubygems.org/gems/warbler'}],
83
+ :no_requirement=>[],
84
+ :unknown=>[]}},
85
+ :ok=>3,
86
+ :outdated=>:outdated_major,
87
+ :outdated_major=>3,
88
+ :outdated_minor=>1,
89
+ :outdated_patch=>0,
90
+ :outdated_total=>4,
91
+ :unknown=>0,
92
+ :no_requirement => 0
93
+ },
94
+ 'gollum-updated' =>
95
+ {
96
+ :locations =>
97
+ {'gollum.gemspec'=>
98
+ {:outdated_major=>
99
+ [{:name=>'mustache',
100
+ :required=>'>= 0.99.5, < 1.0.0',
101
+ :latest=>'2.1.1',
102
+ :type=>:runtime,
103
+ :url=>'https://rubygems.org/gems/mustache'},
104
+ {:name=>'octicons',
105
+ :required=>'~> 8.5',
106
+ :latest=>'10.6.0',
107
+ :type=>:runtime,
108
+ :url=>'https://rubygems.org/gems/octicons'},
109
+ {:name=>'kramdown-parser-gfm',
110
+ :required=>'~> 1.0.0',
111
+ :latest=>'2.1.0',
112
+ :type=>:runtime,
113
+ :url=>'https://rubygems.org/gems/kramdown-parser-gfm'},
114
+ {:name=>'gollum-lib',
115
+ :required=>'~> 5.0',
116
+ :latest=>'6.0.3',
117
+ :type=>:runtime,
118
+ :url=>'https://rubygems.org/gems/gollum-lib'},
119
+ {:name=>'foobar',
120
+ :required=>'= 1.0',
121
+ :latest=>'2.0',
122
+ :type=>:runtime,
123
+ :url=>'https://rubygems.org/gems/foobar'}],
124
+ :outdated_minor=>[],
125
+ :outdated_patch=>[],
126
+ :ok=>[],
127
+ :no_requirement=>[],
128
+ :unknown=>[]},
129
+ 'Gemfile'=>
130
+ {:outdated_major=>
131
+ [{:name=>'rake',
132
+ :required=>'~> 12.3, >= 12.3.3',
133
+ :latest=>'14.0.1',
134
+ :type=>:runtime,
135
+ :url=>'https://rubygems.org/gems/rake'}],
136
+ :outdated_minor=>[],
137
+ :outdated_patch=>[],
138
+ :ok=>
139
+ [{:name=>'warbler',
140
+ :required=>'>= 0',
141
+ :latest=>'3.0.5',
142
+ :type=>:runtime,
143
+ :url=>'https://rubygems.org/gems/warbler'}],
144
+ :no_requirement=>[],
145
+ :unknown=>[]}},
146
+ :no_requirement=>0,
147
+ :ok=>1,
148
+ :outdated=>:outdated_major,
149
+ :outdated_major=>6,
150
+ :outdated_minor=>0,
151
+ :outdated_patch=>0,
152
+ :outdated_total=>6,
153
+ :unknown=>0,
154
+ :no_requirement => 0
155
+ }
156
+ }[name]
157
+ end
158
+
159
+ def mock_dependencies_no_stats(name)
160
+ stats = DependencyWorker::STATUS_TYPES + [:outdated_total, :outdated]
161
+ mock_dependencies(name).reject {|k,v| stats.include?(k) }
162
+ end
163
+
164
+ def mock_fetched_requirements(name, location, update_latest = false)
165
+ dependencies = mock_dependencies(name)[:locations][location]
166
+ results = []
167
+ dependencies.each_value do |dependency_type|
168
+ dependency_type.each do |dependency|
169
+ gem_dependency = Gem::Dependency.new(dependency[:name], dependency[:required].split(','), dependency[:type])
170
+ latest_version = Gem::Version.new(dependency[:latest])
171
+ if update_latest
172
+ segments = latest_version.segments
173
+ segments[0] = segments[0] + 1
174
+ latest_version = Gem::Version.new(segments.join('.'))
175
+ end
176
+ results << [gem_dependency, latest_version]
177
+ end
178
+ end
179
+ results
180
+ end
@@ -0,0 +1,27 @@
1
+ # Example projects.yml file for CuttingEdge
2
+ # For more information and options, see: https://github.com/repotag/cutting_edge
3
+
4
+ github:
5
+ repotag:
6
+ cutting_edge:
7
+ language: ruby
8
+ gollum:
9
+ gollum:
10
+ language: ruby
11
+ gollum-lib:
12
+ locations: [gemspec.rb]
13
+ dependency_types: [runtime, development]
14
+ singingwolfboy:
15
+ flask-dance:
16
+ language: python
17
+ email: false
18
+ rust-lang:
19
+ crates.io:
20
+ language: rust
21
+ gitlab:
22
+ cthowl01:
23
+ team-chess-ruby:
24
+ locations: [Gemfile]
25
+ gitlab-org:
26
+ gitlab-foss:
27
+ hide: mysecret
@@ -1,5 +1,5 @@
1
1
  REQUIREMENT_TXT = <<EOF
2
- requests>=2.0
2
+ requests >= 2.0
3
3
  oauthlib
4
4
  requests-oauthlib>=1.0.0 [PDF]
5
5
  -e svn+http://myrepo/svn/MyApp#egg=MyApp
@@ -36,12 +36,52 @@ unittest2 = {version = ">=1.0,<3.0", markers="python_version < '2.7.9' or (pytho
36
36
  EOF
37
37
 
38
38
  describe PythonLang do
39
- context 'requirements.txt' do
40
- it 'expects the default dependency files to be requirements.txt and Pipfile' do
41
- expect(PythonLang.locations).to eq ['requirements.txt', 'Pipfile']
42
- end
39
+
40
+ let(:requirements_latest_versions) {
41
+ {
42
+ 'requests': Gem::Version.new('2.24.0'),
43
+ 'oauthlib': Gem::Version.new('3.1.0'),
44
+ 'requests-oauthlib': Gem::Version.new('1.3.0'),
45
+ 'Flask': Gem::Version.new('1.1.2'),
46
+ 'urlobject': Gem::Version.new('2.4.3'),
47
+ 'six': Gem::Version.new('1.15.0'),
48
+ }
49
+ }
50
+ let(:pipfile_latest_versions) {
51
+ {
52
+ 'records': Gem::Version.new('0.5.3'),
53
+ 'foo': Gem::Version.new('0.1'),
54
+ 'bar': Gem::Version.new('0.2.1'),
55
+ 'baz': Gem::Version.new('0.2.6'),
56
+ 'pywinusb': Gem::Version.new('0.4.2'),
57
+ 'nose': Gem::Version.new('1.3.7'),
58
+ 'unittest2': Gem::Version.new('1.1.0')
59
+ }
60
+ }
61
+
62
+ it 'expects the default dependency files to be requirements.txt and Pipfile' do
63
+ expect(PythonLang.locations).to eq ['requirements.txt', 'Pipfile']
64
+ end
65
+
66
+ it 'returns a website for a dependency' do
67
+ expect(PythonLang.website('foobar')).to eq 'https://pypi.org/project/foobar'
68
+ end
69
+
70
+ it 'fetches latest version' do
71
+ mock = OpenStruct.new(
72
+ parse: {'info' => {'version' => '1.0.0'}}
73
+ )
74
+ allow_any_instance_of(HTTP::Client).to receive(:get).with('https://pypi.org/pypi/sinatra/json').and_return(mock)
75
+ expect(Gem::Version).to receive(:new).with('1.0.0').and_call_original
76
+ PythonLang.latest_version('sinatra')
77
+
78
+ allow_any_instance_of(HTTP::Client).to receive(:get).and_raise(HTTP::Error)
79
+ expect(PythonLang.latest_version('fail')).to be_nil
80
+ end
43
81
 
82
+ context 'requirements.txt' do
44
83
  it 'parses requirements.txt' do
84
+ expect(PythonLang).to receive(:latest_version).and_return(*requirements_latest_versions.values)
45
85
  result = PythonLang.parse_file('requirements.txt', REQUIREMENT_TXT)
46
86
  expect(result).to be_a Array
47
87
  expect(result.length).to eq 6
@@ -59,6 +99,8 @@ describe PythonLang do
59
99
  end
60
100
 
61
101
  it 'parses Pipefile' do
102
+ expect(PythonLang).to receive(:latest_version).and_return(*pipfile_latest_versions.values)
103
+
62
104
  result = PythonLang.parse_file('Pipfile', PIPFILE)
63
105
  expect(result).to be_a Array
64
106
 
@@ -0,0 +1,105 @@
1
+ GEMSPEC = <<EOF
2
+ s.rubygems_version = '1.3.5'
3
+ s.required_ruby_version = '>= 2.4'
4
+
5
+ s.name = 'cutting_edge'
6
+ s.version = '0.0.1'
7
+ s.date = '2020-02-19'
8
+ s.license = 'GPL-3.0-only'
9
+
10
+ s.summary = 'Self-hosted dependency monitoring, including shiny badges.'
11
+ s.description = 'Self-hosted dependency monitoring, including shiny badges.'
12
+
13
+ s.authors = ['Dawa Ometto', 'Bart Kamphorst']
14
+ s.email = 'd.ometto@gmail.com'
15
+ s.homepage = 'http://github.com/repotag/cutting_edge'
16
+
17
+ s.require_paths = %w[lib]
18
+
19
+ s.executables = ['cutting_edge']
20
+
21
+ s.add_dependency 'gemnasium-parser', '~> 0.1.9'
22
+ s.add_dependency 'http', '~> 4.3'
23
+ s.add_dependency 'sucker_punch', '~> 2.1'
24
+ s.add_dependency 'sinatra', '~> 2.0'
25
+ s.add_dependency 'moneta', '~> 1.2'
26
+ s.add_dependency 'rufus-scheduler', '~> 3.6'
27
+ s.add_dependency 'sinatra-logger', '~> 0.3'
28
+ s.add_dependency 'toml-rb', '~> 2.0'
29
+ EOF
30
+
31
+ GEMFILE = <<EOF
32
+ source 'https://rubygems.org'
33
+
34
+ gem 'redis', require: false
35
+ gem 'mail'
36
+
37
+ gem 'hashdiff'
38
+
39
+ gem 'rspec', '~> 3.9', :group => :development
40
+ gem 'simplecov', :group => :development
41
+
42
+ gem 'coveralls', '~>0.8.23', require: false
43
+
44
+ gemspec
45
+ EOF
46
+
47
+ describe RubyLang do
48
+ let(:gemfile_latest_versions) {
49
+ {
50
+ 'redis': Gem::Version.new('4.2.2'),
51
+ 'mail': Gem::Version.new('2.7.1'),
52
+ 'hashdiff': Gem::Version.new('1.0.1'),
53
+ 'rspec': Gem::Version.new('3.9.0'),
54
+ 'simplecov': Gem::Version.new('0.19.0'),
55
+ 'coveralls': Gem::Version.new('0.8.23'),
56
+ }
57
+ }
58
+ let(:gemspec_latest_versions) {
59
+ {
60
+ 'gemnasium-parser': Gem::Version.new('0.1.9'),
61
+ 'http': Gem::Version.new('4.4.1'),
62
+ 'sucker_punch': Gem::Version.new('2.1.2'),
63
+ 'sinatra': Gem::Version.new('2.1.0'),
64
+ 'moneta': Gem::Version.new('1.4.0'),
65
+ 'rufus-scheduler': Gem::Version.new('3.6.0'),
66
+ 'sinatra-logger': Gem::Version.new('0.3.2'),
67
+ 'toml-rb': Gem::Version.new('2.0.1'),
68
+ }
69
+ }
70
+
71
+ it 'expects the default dependency files to be gemspec and Gemfile' do
72
+ expect(RubyLang.locations('test')).to eq ['test.gemspec', 'Gemfile']
73
+ end
74
+
75
+ it 'returns a website for a dependency' do
76
+ expect(RubyLang.website('foobar')).to eq 'https://rubygems.org/gems/foobar'
77
+ end
78
+
79
+ it 'fetches latest version' do
80
+ expect(Gem::SpecFetcher.fetcher).to receive(:spec_for_dependency).and_return([])
81
+ RubyLang.latest_version('sinatra')
82
+
83
+ allow(Gem::SpecFetcher).to receive(:fetcher).and_raise(StandardError)
84
+ expect(RubyLang.latest_version('fail')).to be_nil
85
+ end
86
+
87
+ it 'parses gemspec' do
88
+ expect(RubyLang).to receive(:latest_version).and_return(*gemspec_latest_versions.values)
89
+ results = RubyLang.parse_file('test.gemspec', GEMSPEC)
90
+ expect(results.length).to eq 8
91
+ expect(results.last.first).to be_a Bundler::Dependency
92
+ expect(results.last.first.name).to eq 'toml-rb'
93
+ expect(results.last.last).to be_a Gem::Version
94
+ expect(results.last.last.to_s).to eq '2.0.1'
95
+ end
96
+
97
+ it 'parses gemfile' do
98
+ expect(RubyLang).to receive(:latest_version).and_return(*gemfile_latest_versions.values)
99
+ results = RubyLang.parse_file('Gemfile', GEMFILE)
100
+ expect(results.length).to eq 6
101
+ expect(results.last.first).to be_a Bundler::Dependency
102
+ expect(results.last.first.name).to eq 'coveralls'
103
+ expect(results.last.last.to_s).to eq '0.8.23'
104
+ end
105
+ end
@@ -20,11 +20,42 @@ def translate_req(str)
20
20
  end
21
21
 
22
22
  describe RustLang do
23
+ let(:rust_latest_versions) {
24
+ {
25
+ 'log': Gem::Version.new('0.4.11'),
26
+ 'regex': Gem::Version.new('1.3.9'),
27
+ 'termcolor': Gem::Version.new('1.1.0'),
28
+ 'humantime': Gem::Version.new('2.0.1'),
29
+ 'atty': Gem::Version.new('0.2.14'),
30
+ 'uuid': Gem::Version.new('0.8.1'),
31
+ 'tempdir': Gem::Version.new('0.3.7'),
32
+ 'cc': Gem::Version.new('1.0.59'),
33
+ }
34
+ }
35
+
23
36
  it 'expects the default dependency files to be Cargo.toml' do
24
37
  expect(RustLang.locations).to eq ['Cargo.toml']
25
38
  end
39
+
40
+ it 'returns a website for a dependency' do
41
+ expect(RustLang.website('foobar')).to eq 'https://crates.io/crates/foobar'
42
+ end
43
+
44
+ it 'fetches latest version' do
45
+ mock = OpenStruct.new(
46
+ parse: {'crate' => {'max_version' => '1.0.0'}}
47
+ )
48
+ allow_any_instance_of(HTTP::Client).to receive(:get).with('https://crates.io/api/v1/crates/sinatra').and_return(mock)
49
+ expect(Gem::Version).to receive(:new).with('1.0.0').and_call_original
50
+ RustLang.latest_version('sinatra')
51
+
52
+ allow_any_instance_of(HTTP::Client).to receive(:get).and_raise(HTTP::Error)
53
+ expect(RustLang.latest_version('fail')).to be_nil
54
+ end
26
55
 
27
56
  it 'parses Cargo.toml' do
57
+ expect(RustLang).to receive(:latest_version).and_return(*rust_latest_versions.values)
58
+
28
59
  results = RustLang.parse_file('Cargo.toml', CARGO)
29
60
  expect(results.length).to eq 9
30
61
 
@@ -0,0 +1,52 @@
1
+ describe CuttingEdge::Repository do
2
+
3
+ it 'is capable of being hidden' do
4
+ expect(CuttingEdge::Repository.new(org: 'org', name: 'name').hidden?).to be false
5
+ hide_token = 'mytoken'
6
+ repo_with_hide = CuttingEdge::Repository.new(org: 'org', name: 'name', hide: hide_token)
7
+ expect(repo_with_hide.hidden?).to be true
8
+ expect(repo_with_hide.hidden_token).to eq hide_token
9
+ end
10
+
11
+ it 'has a headers method' do
12
+ expect(CuttingEdge::Repository.headers(nil)).to eq ({})
13
+ end
14
+
15
+ context 'GitHub' do
16
+ it 'has a headers method' do
17
+ expect(CuttingEdge::GithubRepository.headers(nil)).to eq ({:accept => 'application/vnd.github.v3.raw'})
18
+ expect(CuttingEdge::GithubRepository.headers('token')).to eq ({:accept => 'application/vnd.github.v3.raw', :authorization => 'token token'})
19
+ end
20
+
21
+ it 'defines a class for GitHub.com' do
22
+ expect(CuttingEdge::GithubRepository).to_not be_nil
23
+ github = CuttingEdge::GithubRepository.new(org: 'org', name: 'name')
24
+ expect(github.source).to eq 'github'
25
+ expect(github.url_for_file('file')).to eq 'https://api.github.com/repos/org/name/contents/file?ref=master'
26
+ expect(github.url_for_project).to eq 'https://github.com/org/name'
27
+ end
28
+ end
29
+
30
+ context 'dynamic definitions' do
31
+
32
+ it 'defines a class for GitLab.com' do
33
+ expect(CuttingEdge::GitlabRepository).to_not be_nil
34
+ expect(CuttingEdge::GitlabRepository.headers(nil)).to eq ({})
35
+ expect(CuttingEdge::GitlabRepository.headers('token')).to eq ({:authorization => 'Bearer token'})
36
+ gitlab = CuttingEdge::GitlabRepository.new(org: 'org', name: 'name')
37
+ expect(gitlab.source).to eq 'gitlab'
38
+ expect(gitlab.url_for_file('file')).to eq 'https://gitlab.com/api/v4/projects/org%2fname/repository/files/file/raw?ref=master'
39
+ expect(gitlab.url_for_project).to eq 'https://gitlab.com/org/name'
40
+ end
41
+
42
+ it 'dynamically defines providers' do
43
+ expect(defined?(CuttingEdge::GiteaRepository)).to be_nil
44
+ define_gitea_server('gitea', 'https://mydependencymonitoring.com')
45
+ expect(defined?(CuttingEdge::GiteaRepository)).to_not be_nil
46
+ gitea = CuttingEdge::GiteaRepository.new(org: 'org', name: 'name')
47
+ expect(gitea.source).to eq 'gitea'
48
+ expect(gitea.url_for_file('file')).to eq 'https://mydependencymonitoring.com/api/v1/repos/org/name/raw/master/file'
49
+ expect(gitea.url_for_project).to eq 'https://mydependencymonitoring.com/org/name'
50
+ end
51
+ end
52
+ end