stove 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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