stove 1.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.
@@ -0,0 +1,23 @@
1
+ Given /^the environment variable (.+) is "(.+)"$/ do |variable, value|
2
+ set_env(variable, value)
3
+ end
4
+
5
+ Then /^the exit status will be "(.+)"$/ do |error|
6
+ code = Stove.const_get(error).exit_code
7
+ assert_exit_status(code)
8
+ end
9
+
10
+ When /^the CLI options are all off$/ do
11
+ class Stove::Cli
12
+ private
13
+ def options
14
+ @options ||= {
15
+ :git => false,
16
+ :jira => false,
17
+ :upload => false,
18
+ :changelog => false,
19
+ :log_level => :debug,
20
+ }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ Given /^the Community Site has the cookbooks?:$/ do |table|
2
+ table.raw.each do |name, version, category|
3
+ version ||= '0.0.0'
4
+ category ||= 'Other'
5
+
6
+ CommunityZero::Cookbook.create({
7
+ name: name,
8
+ version: version,
9
+ category: category,
10
+ })
11
+ end
12
+ end
13
+
14
+ Then /^the Community Site will( not)? have the cookbooks?:$/ do |negate, table|
15
+ table.raw.each do |name, version, category|
16
+ cookbook = CommunityZero::Store.find(name, version)
17
+
18
+ if negate
19
+ expect(cookbook).to be_nil
20
+ else
21
+ expect(cookbook).to_not be_nil
22
+ expect(cookbook.category).to eql(category) if category
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ Given /^I have a cookbook named "(.+)"(?: (?:at|with) version "(.+)")?$/ do |name, version|
2
+ create_cookbook(name, version)
3
+ end
4
+
5
+ Given /^I have a cookbook named "(.+)"(?: (?:at|with) version "(.+)")? with git support$/ do |name, version|
6
+ create_cookbook(name, version, git: true)
7
+ end
8
+
9
+
10
+ # Create a new cookbook with the given name and version.
11
+ #
12
+ # @param [String] name
13
+ # @param [String] version (default: 0.0.0.0)
14
+ # @param [Hash] options
15
+ def create_cookbook(name, version, options = {})
16
+ create_dir(name)
17
+ cd(name)
18
+ write_file('CHANGELOG.md', "#{name} Cookbook CHANGELOG\n=====\n\nv0.0.0\n-----")
19
+ write_file('README.md', 'This is a README')
20
+ write_file('metadata.rb', "name '#{name}'\nversion '#{version || '0.0.0'}'")
21
+
22
+ if options[:git]
23
+ git_init(current_dir)
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ Then /^the git remote will( not)? have the commit "(.+)"$/ do |negate, message|
2
+ commits = git_commits(fake_git_remote)
3
+
4
+ if negate
5
+ expect(commits).to_not include(message)
6
+ else
7
+ expect(commits).to include(message)
8
+ end
9
+ end
10
+
11
+ Then /^the git remote will( not)? have the tag "(.+)"$/ do |negate, tag|
12
+ tags = git_tags(fake_git_remote)
13
+
14
+ if negate
15
+ expect(tags).to_not include(tag)
16
+ else
17
+ expect(tags).to include(tag)
18
+ end
19
+ end
@@ -0,0 +1,29 @@
1
+ require 'aruba/api'
2
+ require 'aruba/cucumber'
3
+ require 'aruba/in_process'
4
+ require 'rspec/expectations'
5
+ require 'stove'
6
+
7
+ require_relative '../../spec/support/community_site'
8
+ require_relative '../../spec/support/git'
9
+
10
+ World(Aruba::Api)
11
+ World(Stove::RSpec::Git)
12
+
13
+ Aruba::InProcess.main_class = Stove::Cli
14
+ Aruba.process = Aruba::InProcess
15
+
16
+ Stove.set_formatter(:silent)
17
+ Stove::RSpec::CommunitySite.start(port: 3390)
18
+ Stove::CommunitySite.base_uri(Stove::RSpec::CommunitySite.server_url)
19
+ Stove::CommunitySite.http_uri(Stove::RSpec::CommunitySite.server_url)
20
+
21
+ Before do
22
+ @dirs = [Dir.mktmpdir]
23
+ Stove::RSpec::CommunitySite.reset!
24
+ end
25
+
26
+ # The path to Aruba's "stuff"
27
+ def tmp_path
28
+ File.expand_path(@dirs.first.to_s)
29
+ end
@@ -0,0 +1,44 @@
1
+ Feature: Upload
2
+ As a stove user
3
+ In order to upload cookbooks
4
+ I want to tar and push to a Community Site
5
+
6
+ Background:
7
+ * I have a cookbook named "bacon"
8
+ * the CLI options are all off
9
+
10
+ Scenario: --no-upload
11
+ * I successfully run `bake 1.0.0 --no-upload`
12
+ * the Community Site will not have the cookbook:
13
+ | bacon | 1.0.0 |
14
+
15
+ Scenario: --upload (no category, no existing)
16
+ * I run `bake 1.0.0 --upload`
17
+ * the Community Site will not have the cookbook:
18
+ | bacon | 1.0.0 |
19
+ * the exit status will be "CookbookCategoryNotFound"
20
+
21
+ Scenario: --upload (no category, existing)
22
+ * the Community Site has the cookbook:
23
+ | bacon | 0.0.0 | Application |
24
+ * I successfully run `bake 1.0.0 --upload`
25
+ * the Community Site will have the cookbook:
26
+ | bacon | 1.0.0 | Application |
27
+
28
+ Scenario: --upload (category, no existing)
29
+ * I successfully run `bake 1.0.0 --upload --category Application`
30
+ * the Community Site will have the cookbook:
31
+ | bacon | 1.0.0 | Application |
32
+
33
+ Scenario: --upload (category, existing)
34
+ * the Community Site has the cookbook:
35
+ | bacon | 0.0.0 | Application |
36
+ * I successfully run `bake 1.0.0 --upload --category Application`
37
+ * the Community Site will have the cookbook:
38
+ | bacon | 1.0.0 | Application |
39
+
40
+ Scenario: --upload (existing version)
41
+ * the Community Site has the cookbook:
42
+ | bacon | 1.0.0 | Application |
43
+ * I run `bake 1.0.0 --upload`
44
+ * the exit status will be "UploadError"
data/lib/stove.rb ADDED
@@ -0,0 +1,26 @@
1
+ module Stove
2
+ require_relative 'stove/config'
3
+ require_relative 'stove/git'
4
+ require_relative 'stove/logger'
5
+
6
+ require_relative 'stove/cli'
7
+ require_relative 'stove/community_site'
8
+ require_relative 'stove/cookbook'
9
+ require_relative 'stove/error'
10
+ require_relative 'stove/formatter'
11
+ require_relative 'stove/jira'
12
+ require_relative 'stove/mash'
13
+ require_relative 'stove/packager'
14
+ require_relative 'stove/uploader'
15
+ require_relative 'stove/version'
16
+
17
+ class << self
18
+ def formatter
19
+ @formatter ||= Stove::Formatter::Human.new
20
+ end
21
+
22
+ def set_formatter(name)
23
+ @formatter = Stove::Formatter::Base.formatters[name.to_sym].new
24
+ end
25
+ end
26
+ end
data/lib/stove/cli.rb ADDED
@@ -0,0 +1,131 @@
1
+ require 'optparse'
2
+ require 'stove'
3
+
4
+ module Stove
5
+ class Cli
6
+ def initialize(argv, stdin=STDIN, stdout=STDOUT, stderr=STDERR, kernel=Kernel)
7
+ @argv, @stdin, @stdout, @stderr, @kernel = argv, stdin, stdout, stderr, kernel
8
+ $stdout, @stderr = @stdout, @stderr
9
+ end
10
+
11
+ def execute!
12
+ option_parser.parse!(@argv)
13
+ options[:new_version] = @argv.first
14
+
15
+ raise Stove::InvalidVersionError unless valid_version?(options[:new_version])
16
+
17
+ Stove::Cookbook.new(options).release!
18
+ @kernel.exit(0)
19
+ rescue => e
20
+ @stderr.puts "#{e.class}: #{e.message}"
21
+
22
+ if Stove::Logger.sev_threshold == ::Logger::DEBUG
23
+ @stderr.puts " #{e.backtrace.join("\n ")}"
24
+ end
25
+
26
+ @kernel.exit(e.respond_to?(:exit_code) ? e.exit_code : 500)
27
+ ensure
28
+ $stdout, $stderr = STDOUT, STDERR
29
+ end
30
+
31
+ private
32
+ # The option parser for handling command line flags.
33
+ #
34
+ # @return [OptionParser]
35
+ def option_parser
36
+ @option_parser ||= OptionParser.new do |opts|
37
+ opts.banner = "Usage: bake x.y.z"
38
+
39
+ opts.on('-l', '--log-level [LEVEL]', [:fatal, :error, :warn, :info, :debug], 'Ruby log level') do |v|
40
+ options[:log_level] = v
41
+ end
42
+
43
+ opts.on('-c', '--category [CATEGORY]', String, 'The category for the cookbook (optional for existing cookbooks)') do |v|
44
+ options[:category] = v
45
+ end
46
+
47
+ opts.on('-p', '--path [PATH]', String, 'The path to the cookbook to release (default: PWD)') do |v|
48
+ options[:path] = v
49
+ end
50
+
51
+ opts.on('--[no-]git', 'Automatically tag and push to git (default: true)') do |v|
52
+ options[:git] = v
53
+ end
54
+
55
+ opts.on('-r', '--remote', String, 'The name of the git remote to push to') do |v|
56
+ options[:remote] = v
57
+ end
58
+
59
+ opts.on('-b', '--branch', String, 'The name of the git branch to push to') do |v|
60
+ options[:branch] = v
61
+ end
62
+
63
+ opts.on('--[no-]jira', 'Automatically populate the CHANGELOG from JIRA tickets and close them (default: false)') do |v|
64
+ options[:jira] = v
65
+ end
66
+
67
+ opts.on('--[no-]upload', 'Upload the cookbook to the Opscode Community Site (default: true)') do |v|
68
+ options[:upload] = v
69
+ end
70
+
71
+ opts.on('--[no-]changelog', 'Automatically generate a CHANGELOG (default: true)') do |v|
72
+ options[:changelog] = v
73
+ end
74
+
75
+ opts.on_tail('-h', '--help', 'Show this message') do
76
+ puts opts
77
+ exit
78
+ end
79
+
80
+ opts.on_tail('-v', '--version', 'Show version') do
81
+ puts Stove::VERSION
82
+ exit(0)
83
+ end
84
+ end
85
+ end
86
+
87
+ # The options to pass to the cookbook. Includes default values
88
+ # that are manipulated by the option parser.
89
+ #
90
+ # @return [Hash]
91
+ def options
92
+ @options ||= {
93
+ :path => Dir.pwd,
94
+ :git => true,
95
+ :remote => 'origin',
96
+ :branch => 'master',
97
+ :jira => false,
98
+ :upload => true,
99
+ :changelog => true,
100
+ :log_level => :warn,
101
+ }
102
+ end
103
+
104
+ # Determine if the given string is a valid version string.
105
+ #
106
+ # @return [Boolean]
107
+ def valid_version?(version)
108
+ version.to_s =~ /^\d+\.\d+\.\d+$/
109
+ end
110
+
111
+ # Convert a string to it's logger constant.
112
+ #
113
+ # @return [Object]
114
+ def level_to_constant(level)
115
+ case level.to_s.strip.downcase.to_sym
116
+ when :fatal
117
+ ::Logger::FATAL
118
+ when :error
119
+ ::Logger::ERROR
120
+ when :warn
121
+ ::Logger::WARN
122
+ when :info
123
+ ::Logger::INFO
124
+ when :debug
125
+ ::Logger::DEBUG
126
+ else
127
+ ::Logger::INFO
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,85 @@
1
+ require 'httparty'
2
+
3
+ module Stove
4
+ class CommunitySite
5
+ include HTTParty
6
+ base_uri 'https://cookbooks.opscode.com/api/v1'
7
+ headers 'Content-Type' => 'application/json', 'Accept' => 'application/json'
8
+
9
+ class << self
10
+ # The URI for the web-based version of the site. (default:
11
+ # https://community.opscode.com).
12
+ #
13
+ # If a parameter is given, the {http_uri} is set to that value.
14
+ #
15
+ # @return [String]
16
+ def http_uri(arg = nil)
17
+ if arg.nil?
18
+ @http_uri ||= 'https://community.opscode.com'
19
+ else
20
+ @http_uri = arg
21
+ @http_uri
22
+ end
23
+ end
24
+
25
+ # Get and cache a community cookbook's JSON response from the given name
26
+ # and version.
27
+ #
28
+ # @example Find a cookbook by name
29
+ # CommunitySite.cookbook('apache2') #=> {...}
30
+ #
31
+ # @example Find a cookbook by name and version
32
+ # CommunitySite.cookbook('apache2', '1.0.0') #=> {...}
33
+ #
34
+ # @example Find a non-existent cookbook
35
+ # CommunitySite.cookbook('not-real') #=> CommunitySite::BadResponse
36
+ #
37
+ # @raise [CommunitySite::BadResponse]
38
+ # if the given cookbook (or cookbook version) does not exist on the community site
39
+ #
40
+ # @param [String] name
41
+ # the name of the cookbook on the community site
42
+ # @param [String] version (optional)
43
+ # the version of the cookbook to find
44
+ def cookbook(name, version = nil)
45
+ if version.nil?
46
+ get("/cookbooks/#{name}")
47
+ else
48
+ get("/cookbooks/#{name}/versions/#{format_version(version)}")
49
+ end
50
+ end
51
+
52
+ private
53
+ # Convert a version string (x.y.z) to a community-site friendly format
54
+ # (x_y_z).
55
+ #
56
+ # @example Convert a version to a version string
57
+ # format_version('1.2.3') #=> 1_2_3
58
+ #
59
+ # @param [#to_s] version
60
+ # the version string to convert
61
+ #
62
+ # @return [String]
63
+ def format_version(version)
64
+ version.gsub('.', '_')
65
+ end
66
+
67
+ # @override [HTTParty.get]
68
+ def get(path, options = {}, &block)
69
+ cache[path] ||= begin
70
+ Stove::Logger.debug "Getting #{path}"
71
+ response = super(path)
72
+ raise Stove::BadResponse.new(response) unless response.ok?
73
+ response.parsed_response
74
+ end
75
+ end
76
+
77
+ # A small, unpersisted cache for storing responses
78
+ #
79
+ # @return [Hash]
80
+ def cache
81
+ @cache ||= {}
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,18 @@
1
+ module Stove
2
+ class Config < ::Hash
3
+ class << self
4
+ def [](thing)
5
+ instance[thing]
6
+ end
7
+
8
+ def instance
9
+ @instance ||= load!
10
+ end
11
+
12
+ private
13
+ def load!
14
+ JSON.parse(File.read(File.expand_path("~/.stove")))
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,280 @@
1
+ require 'fileutils'
2
+ require 'time'
3
+
4
+ module Stove
5
+ class Cookbook
6
+ require_relative 'cookbook/metadata'
7
+
8
+ include Stove::Git
9
+
10
+ # The path to this cookbook.
11
+ #
12
+ # @return [String]
13
+ attr_reader :path
14
+
15
+ # The name of the cookbook (must correspond to the name of the
16
+ # cookbook on the community site).
17
+ #
18
+ # @return [String]
19
+ attr_reader :name
20
+
21
+ # The version of this cookbook (originally).
22
+ #
23
+ # @return [String]
24
+ attr_reader :version
25
+
26
+ # The new version of the cookbook.
27
+ #
28
+ # @return [String]
29
+ attr_reader :new_version
30
+
31
+ # The metadata for this cookbook.
32
+ #
33
+ # @return [Stove::Cookbook::Metadata]
34
+ attr_reader :metadata
35
+
36
+ # The list of options passed to the cookbook.
37
+ #
38
+ # @return [Hash]
39
+ attr_reader :options
40
+
41
+ # Create a new wrapper around the cookbook object.
42
+ #
43
+ # @param [Hash] options
44
+ # the list of options
45
+ def initialize(options = {})
46
+ @path = options[:path] || Dir.pwd
47
+ @new_version = options[:new_version]
48
+ @options = options
49
+
50
+ load_metadata!
51
+ end
52
+
53
+ # The category for this cookbook on the community site.
54
+ #
55
+ # @return [String]
56
+ def category
57
+ @category ||= options[:category] || Stove::CommunitySite.cookbook(name)['category']
58
+ rescue
59
+ raise Stove::CookbookCategoryNotFound
60
+ end
61
+
62
+ # The URL for the cookbook on the Community Site.
63
+ #
64
+ # @return [String]
65
+ def url
66
+ "#{Stove::CommunitySite.http_uri}/cookbooks/#{name}"
67
+ end
68
+
69
+ # Deterine if this cookbook version is released on the community site
70
+ def released?
71
+ @_released ||= begin
72
+ Stove::CommunitySite.cookbook(name, version)
73
+ true
74
+ rescue Stove::BadResponse
75
+ false
76
+ end
77
+ end
78
+
79
+ # The unreleased JIRA tickets for this cookbook.
80
+ #
81
+ # @return [Hashie::Dash, Array]
82
+ def unreleased_tickets
83
+ @unreleased_tickets ||= Stove::JIRA.unreleased_tickets_for(name)
84
+ end
85
+
86
+ #
87
+ def release!
88
+ if options[:git]
89
+ validate_git_repo!
90
+ validate_git_clean!
91
+ end
92
+
93
+ version_bump
94
+
95
+ if options[:changelog]
96
+ update_changelog
97
+ end
98
+
99
+ if options[:git]
100
+ Dir.chdir(path) do
101
+ git "add metadata.rb"
102
+ git "add CHANGELOG.md"
103
+ git "commit -m 'Version bump to v#{version}'"
104
+ git "push #{options[:remote] || 'origin'} #{options[:branch] || 'master'}"
105
+
106
+ git "tag v#{version}"
107
+ git "push #{options[:remote] || 'origin'} v#{version}"
108
+ end
109
+ end
110
+
111
+ if options[:upload]
112
+ upload
113
+ end
114
+
115
+ if options[:jira]
116
+ resolve_jira_issues
117
+ end
118
+ end
119
+
120
+ #
121
+ def upload
122
+ return true unless options[:upload]
123
+ Stove::Uploader.new(self).upload!
124
+ end
125
+
126
+ private
127
+ # Load the metadata and set the @metadata instance variable.
128
+ #
129
+ # @raise [ArgumentError]
130
+ # if there is no metadata.rb
131
+ #
132
+ # @return [String]
133
+ # the path to the metadata file
134
+ def load_metadata!
135
+ metadata_path = File.expand_path(File.join(path, 'metadata.rb'))
136
+
137
+ @metadata = Stove::Cookbook::Metadata.from_file(metadata_path)
138
+ @name = @metadata.name
139
+ @version = @metadata.version
140
+
141
+ metadata_path
142
+ end
143
+ alias_method :reload_metadata!, :load_metadata!
144
+
145
+ # Create a new CHANGELOG in markdown format.
146
+ #
147
+ # @example given a cookbook named bacon
148
+ # cookbook.create_changelog #=> <<EOH
149
+ # bacon Cookbook CHANGELOG
150
+ # ------------------------
151
+ # EOH
152
+ #
153
+ # @return [String]
154
+ # the path to the new CHANGELOG
155
+ def create_changelog
156
+ destination = File.join(path, 'CHANGELOG.md')
157
+
158
+ # Be idempotent :)
159
+ return destination if File.exists?(destination)
160
+
161
+ header = "#{name} Cookbook CHANGELOG\n"
162
+ header << "#{'='*header.length}\n"
163
+ header << "This file is used to list changes made in each version of the #{cookbook.name} cookbook.\n\n"
164
+
165
+ File.open(destination, 'wb') do |file|
166
+ file.write(header)
167
+ end
168
+
169
+ destination
170
+ end
171
+
172
+ # Update the CHANGELOG with the new contents, but inserting
173
+ # the newest version's CHANGELOG at the top of the file (after
174
+ # the header)
175
+ def update_changelog
176
+ changelog = create_changelog
177
+ contents = File.readlines(changelog)
178
+
179
+ index = contents.find_index { |line| line =~ /(--)+/ } - 2
180
+ contents.insert(index, "\n" + generate_changelog)
181
+
182
+ Dir.mktmpdir do |dir|
183
+ tmpfile = File.join(dir, 'CHANGELOG.md')
184
+ File.open(tmpfile, 'w') { |f| f.write(contents.join('')) }
185
+ response = shellout("$EDITOR #{tmpfile}")
186
+
187
+ unless response.success?
188
+ Stove::Logger.debug response.stderr
189
+ raise Stove::Error, response.stderr
190
+ end
191
+
192
+ FileUtils.mv(tmpfile, File.join(path, 'CHANGELOG.md'))
193
+ end
194
+ rescue SystemExit, Interrupt
195
+ raise Stove::UserCanceledError
196
+ end
197
+
198
+ # Generate a CHANGELOG in markdown format.
199
+ #
200
+ # @param [String] version
201
+ # the version string in x.y.z format
202
+ #
203
+ # @return [String]
204
+ def generate_changelog
205
+ contents = []
206
+ contents << "v#{version}"
207
+ contents << '-'*(version.length+1)
208
+
209
+ if options[:jira]
210
+ by_type = unreleased_tickets.inject({}) do |hash, ticket|
211
+ issue_type = ticket.fields.current['issuetype']['name']
212
+ hash[issue_type] ||= []
213
+ hash[issue_type] << {
214
+ number: ticket.jira_key,
215
+ details: ticket.fields.current['summary'],
216
+ }
217
+
218
+ hash
219
+ end
220
+
221
+ by_type.each do |issue_type, tickets|
222
+ contents << "### #{issue_type}"
223
+ tickets.sort { |a,b| b[:number].to_i <=> a[:number].to_i }.each do |ticket|
224
+ contents << "- **[#{ticket[:number]}](#{Stove::JIRA::JIRA_URL}/browse/#{ticket[:number]})** - #{ticket[:details]}"
225
+ end
226
+ contents << ""
227
+ end
228
+ else
229
+ contents << "_Enter CHANGELOG for #{name} (#{version}) here_"
230
+ contents << ""
231
+ end
232
+
233
+ contents.join("\n")
234
+ end
235
+
236
+ # Bump the version in the metdata.rb to the specified
237
+ # parameter.
238
+ #
239
+ # @return [String]
240
+ # the new version string
241
+ def version_bump
242
+ return true if new_version.to_s == version.to_s
243
+
244
+ metadata_path = File.join(path, 'metadata.rb')
245
+ contents = File.read(metadata_path)
246
+
247
+ contents.sub!(/^version(\s+)('|")#{version.to_s}('|")/, "version\\1\\2#{new_version.to_s}\\3")
248
+
249
+ File.open(metadata_path, 'w') { |f| f.write(contents) }
250
+ reload_metadata!
251
+ end
252
+
253
+ # Resolve all the JIRA issues that have been merged.
254
+ def resolve_jira_issues
255
+ unreleased_tickets.collect do |ticket|
256
+ Thread.new { Stove::JIRA.comment_and_close(ticket, self) }
257
+ end.map(&:join)
258
+ end
259
+
260
+ # Validate that the current working directory is git repo.
261
+ #
262
+ # @raise [Stove::GitError::NotARepo]
263
+ # if this is not currently a git repo
264
+ def validate_git_repo!
265
+ Dir.chdir(path) do
266
+ raise Stove::GitError::NotARepo unless git_repo?
267
+ end
268
+ end
269
+
270
+ # Validate that the current.
271
+ #
272
+ # @raise [Stove::GitError::DirtyRepo]
273
+ # if the current working directory is not clean
274
+ def validate_git_clean!
275
+ Dir.chdir(path) do
276
+ raise Stove::GitError::DirtyRepo unless git_repo_clean?
277
+ end
278
+ end
279
+ end
280
+ end