octopolo 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/.gitignore +21 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +6 -0
- data/Gemfile +3 -0
- data/Guardfile +5 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +55 -0
- data/Rakefile +38 -0
- data/bash_completion.sh +13 -0
- data/bin/octopolo +21 -0
- data/bin/op +21 -0
- data/lib/octopolo.rb +15 -0
- data/lib/octopolo/changelog.rb +27 -0
- data/lib/octopolo/cli.rb +210 -0
- data/lib/octopolo/commands/accept_pull.rb +8 -0
- data/lib/octopolo/commands/compare_release.rb +9 -0
- data/lib/octopolo/commands/deployable.rb +8 -0
- data/lib/octopolo/commands/github_auth.rb +5 -0
- data/lib/octopolo/commands/new_branch.rb +9 -0
- data/lib/octopolo/commands/new_deployable.rb +8 -0
- data/lib/octopolo/commands/new_staging.rb +8 -0
- data/lib/octopolo/commands/octopolo_setup.rb +5 -0
- data/lib/octopolo/commands/pivotal_auth.rb +5 -0
- data/lib/octopolo/commands/pull_request.rb +13 -0
- data/lib/octopolo/commands/signoff.rb +10 -0
- data/lib/octopolo/commands/stage_up.rb +8 -0
- data/lib/octopolo/commands/stale_branches.rb +11 -0
- data/lib/octopolo/commands/sync_branch.rb +11 -0
- data/lib/octopolo/commands/tag_release.rb +13 -0
- data/lib/octopolo/config.rb +146 -0
- data/lib/octopolo/convenience_wrappers.rb +46 -0
- data/lib/octopolo/dated_branch_creator.rb +81 -0
- data/lib/octopolo/git.rb +262 -0
- data/lib/octopolo/github.rb +95 -0
- data/lib/octopolo/github/commit.rb +45 -0
- data/lib/octopolo/github/pull_request.rb +126 -0
- data/lib/octopolo/github/pull_request_creator.rb +127 -0
- data/lib/octopolo/github/user.rb +40 -0
- data/lib/octopolo/jira/story_commenter.rb +26 -0
- data/lib/octopolo/pivotal.rb +44 -0
- data/lib/octopolo/pivotal/story_commenter.rb +19 -0
- data/lib/octopolo/pull_request_merger.rb +99 -0
- data/lib/octopolo/renderer.rb +37 -0
- data/lib/octopolo/reports.rb +18 -0
- data/lib/octopolo/scripts.rb +23 -0
- data/lib/octopolo/scripts/accept_pull.rb +67 -0
- data/lib/octopolo/scripts/compare_release.rb +52 -0
- data/lib/octopolo/scripts/deployable.rb +27 -0
- data/lib/octopolo/scripts/github_auth.rb +87 -0
- data/lib/octopolo/scripts/new_branch.rb +34 -0
- data/lib/octopolo/scripts/new_deployable.rb +14 -0
- data/lib/octopolo/scripts/new_staging.rb +15 -0
- data/lib/octopolo/scripts/octopolo_setup.rb +55 -0
- data/lib/octopolo/scripts/pivotal_auth.rb +44 -0
- data/lib/octopolo/scripts/pull_request.rb +127 -0
- data/lib/octopolo/scripts/signoff.rb +85 -0
- data/lib/octopolo/scripts/stage_up.rb +26 -0
- data/lib/octopolo/scripts/stale_branches.rb +54 -0
- data/lib/octopolo/scripts/sync_branch.rb +37 -0
- data/lib/octopolo/scripts/tag_release.rb +70 -0
- data/lib/octopolo/templates/pull_request_body.erb +24 -0
- data/lib/octopolo/user_config.rb +112 -0
- data/lib/octopolo/version.rb +3 -0
- data/lib/octopolo/week.rb +130 -0
- data/octopolo.gemspec +31 -0
- data/spec/.DS_Store +0 -0
- data/spec/octopolo/cli_spec.rb +310 -0
- data/spec/octopolo/config_spec.rb +344 -0
- data/spec/octopolo/convenience_wrappers_spec.rb +80 -0
- data/spec/octopolo/dated_branch_creator_spec.rb +143 -0
- data/spec/octopolo/git_spec.rb +419 -0
- data/spec/octopolo/github/commit_spec.rb +59 -0
- data/spec/octopolo/github/pull_request_creator_spec.rb +174 -0
- data/spec/octopolo/github/pull_request_spec.rb +291 -0
- data/spec/octopolo/github/user_spec.rb +65 -0
- data/spec/octopolo/github_spec.rb +169 -0
- data/spec/octopolo/jira/stor_commenter_spec.rb +30 -0
- data/spec/octopolo/pivotal/story_commenter_spec.rb +34 -0
- data/spec/octopolo/pivotal_spec.rb +61 -0
- data/spec/octopolo/pull_request_merger_spec.rb +144 -0
- data/spec/octopolo/renderer_spec.rb +35 -0
- data/spec/octopolo/scripts/accept_pull_spec.rb +76 -0
- data/spec/octopolo/scripts/compare_release_spec.rb +115 -0
- data/spec/octopolo/scripts/deployable_spec.rb +52 -0
- data/spec/octopolo/scripts/github_auth_spec.rb +156 -0
- data/spec/octopolo/scripts/new_branch_spec.rb +41 -0
- data/spec/octopolo/scripts/new_deployable_spec.rb +18 -0
- data/spec/octopolo/scripts/new_staging_spec.rb +18 -0
- data/spec/octopolo/scripts/octopolo_setup_spec.rb +120 -0
- data/spec/octopolo/scripts/pivotal_auth_spec.rb +77 -0
- data/spec/octopolo/scripts/pull_request_spec.rb +217 -0
- data/spec/octopolo/scripts/signoff_spec.rb +139 -0
- data/spec/octopolo/scripts/stage_up_spec.rb +52 -0
- data/spec/octopolo/scripts/stale_branches_spec.rb +81 -0
- data/spec/octopolo/scripts/sync_branch_spec.rb +57 -0
- data/spec/octopolo/scripts/tag_release_spec.rb +108 -0
- data/spec/octopolo/user_config_spec.rb +167 -0
- data/spec/octopolo_spec.rb +7 -0
- data/spec/spec_helper.rb +29 -0
- data/spec/support/engine_yard.cache +0 -0
- data/spec/support/sample_octopolo.yml +2 -0
- data/spec/support/sample_user.yml +2 -0
- data/templates/lib.erb +23 -0
- data/templates/script.erb +7 -0
- data/templates/spec.erb +29 -0
- metadata +344 -0
@@ -0,0 +1,37 @@
|
|
1
|
+
require_relative "../scripts"
|
2
|
+
|
3
|
+
module Octopolo
|
4
|
+
module Scripts
|
5
|
+
class SyncBranch
|
6
|
+
include ConfigWrapper
|
7
|
+
include CLIWrapper
|
8
|
+
include GitWrapper
|
9
|
+
|
10
|
+
attr_accessor :branch
|
11
|
+
|
12
|
+
def self.execute(branch=nil)
|
13
|
+
new(branch).execute
|
14
|
+
end
|
15
|
+
|
16
|
+
def initialize(branch=nil)
|
17
|
+
@branch = branch || default_branch
|
18
|
+
end
|
19
|
+
|
20
|
+
# Public: Default value of branch if none given
|
21
|
+
def default_branch
|
22
|
+
config.deploy_branch
|
23
|
+
end
|
24
|
+
|
25
|
+
def execute
|
26
|
+
merge_branch
|
27
|
+
end
|
28
|
+
|
29
|
+
# Public: Merge the specified remote branch into your local
|
30
|
+
def merge_branch
|
31
|
+
git.merge branch
|
32
|
+
rescue Git::MergeFailed
|
33
|
+
cli.say "Merge failed. Please resolve these conflicts."
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require "date" # necessary to get the Date.today convenience method
|
2
|
+
require_relative "../scripts"
|
3
|
+
require_relative "../changelog"
|
4
|
+
|
5
|
+
module Octopolo
|
6
|
+
module Scripts
|
7
|
+
class TagRelease
|
8
|
+
include CLIWrapper
|
9
|
+
include ConfigWrapper
|
10
|
+
include GitWrapper
|
11
|
+
|
12
|
+
attr_accessor :suffix
|
13
|
+
attr_accessor :force
|
14
|
+
alias_method :force?, :force
|
15
|
+
|
16
|
+
TIMESTAMP_FORMAT = "%Y.%m.%d.%H.%M"
|
17
|
+
|
18
|
+
def self.execute(suffix=nil, force=false)
|
19
|
+
new(suffix, force).execute
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(suffix=nil, force=false)
|
23
|
+
@suffix = suffix
|
24
|
+
@force = force
|
25
|
+
end
|
26
|
+
|
27
|
+
def execute
|
28
|
+
if should_create_branch?
|
29
|
+
update_changelog
|
30
|
+
tag_release
|
31
|
+
else
|
32
|
+
raise Octopolo::WrongBranch.new("Must perform this script from the deploy branch (#{config.deploy_branch})")
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Public: Whether to create a new branch
|
37
|
+
#
|
38
|
+
# Returns a Boolean
|
39
|
+
def should_create_branch?
|
40
|
+
force? || (git.current_branch == config.deploy_branch)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Public: Generate a tag for the current release
|
44
|
+
def tag_release
|
45
|
+
git.new_tag tag_name
|
46
|
+
end
|
47
|
+
|
48
|
+
# Public: The name to apply to the new tag
|
49
|
+
def tag_name
|
50
|
+
@tag_name ||= %Q(#{Time.now.strftime(TIMESTAMP_FORMAT)}#{"_#{suffix}" if suffix})
|
51
|
+
end
|
52
|
+
|
53
|
+
def changelog
|
54
|
+
@changelog ||= Changelog.new
|
55
|
+
end
|
56
|
+
|
57
|
+
def update_changelog
|
58
|
+
changelog.open do |log|
|
59
|
+
log.puts "#### #{tag_name}"
|
60
|
+
end
|
61
|
+
git.perform("add #{changelog.filename}")
|
62
|
+
git.perform("commit -m 'Updating Changelog for #{tag_name}'")
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
WrongBranch = Class.new(StandardError)
|
69
|
+
end
|
70
|
+
|
@@ -0,0 +1,24 @@
|
|
1
|
+
<%= description %>
|
2
|
+
|
3
|
+
Deploy Plan
|
4
|
+
-----------
|
5
|
+
Does Infrastructure need to know anything special about this deploy? If so, keep this section and fill it in. **Otherwise, delete it.**
|
6
|
+
|
7
|
+
Rollback Plan
|
8
|
+
-------------
|
9
|
+
**If this pull request requires anything more complex (e.g., rolling back a migration), you MUST update this section. Otherwise, delete this note.**
|
10
|
+
|
11
|
+
To roll back this change, revert the merge with `git revert -m 1 MERGE_SHA` and perform another deploy.
|
12
|
+
|
13
|
+
URLs
|
14
|
+
----
|
15
|
+
<% pivotal_ids.each do |pivotal_id| -%>
|
16
|
+
* [pivotal tracker story <%= pivotal_id %>](https://www.pivotaltracker.com/story/show/<%= pivotal_id %>)
|
17
|
+
<% end -%>
|
18
|
+
<% jira_ids.each do |jira_id| -%>
|
19
|
+
* [Jira issue <%= jira_id %>](<%= jira_url %>/browse/<%= jira_id %>)
|
20
|
+
<% end -%>
|
21
|
+
|
22
|
+
QA Plan
|
23
|
+
-------
|
24
|
+
Provide a detailed QA plan, or other developers will retain the right to mock you mercilessly.
|
@@ -0,0 +1,112 @@
|
|
1
|
+
module Octopolo
|
2
|
+
class UserConfig
|
3
|
+
# config values
|
4
|
+
attr_accessor :github_user
|
5
|
+
attr_accessor :github_token
|
6
|
+
attr_accessor :full_name
|
7
|
+
attr_accessor :pivotal_token
|
8
|
+
attr_accessor :attributes # keep the whole hash
|
9
|
+
|
10
|
+
# Public: Initialize a new UserConfig instance
|
11
|
+
def initialize attributes={}
|
12
|
+
self.attributes = attributes
|
13
|
+
attributes.each do |key, value|
|
14
|
+
# e.g., foo: "bar" translates to self.foo = "bar"
|
15
|
+
setter = "#{key}="
|
16
|
+
send(setter, value) if respond_to? setter
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Public: Parse the user's config file
|
21
|
+
#
|
22
|
+
# Returns a UserConfig instance
|
23
|
+
def self.parse
|
24
|
+
new attributes_from_file
|
25
|
+
end
|
26
|
+
|
27
|
+
# Public: Set and store a new configuration value
|
28
|
+
def set key, value
|
29
|
+
# capture new value in instance
|
30
|
+
send("#{key}=", value)
|
31
|
+
attributes.merge!(key => value)
|
32
|
+
# and store it
|
33
|
+
File.write UserConfig.config_path, YAML.dump(attributes)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Public: The user's configuration values
|
37
|
+
#
|
38
|
+
# Returns a Hash
|
39
|
+
def self.attributes_from_file
|
40
|
+
YAML.load_file config_path
|
41
|
+
rescue Errno::ENOENT
|
42
|
+
# create the file if it doesn't exist
|
43
|
+
touch_config_file
|
44
|
+
{}
|
45
|
+
end
|
46
|
+
|
47
|
+
# Public: The path to the users's configuration file
|
48
|
+
#
|
49
|
+
# Returns a String containing the path
|
50
|
+
def self.config_path
|
51
|
+
File.join(config_parent, "config.yml")
|
52
|
+
end
|
53
|
+
|
54
|
+
# Public: The parent directory of the user's configuration file
|
55
|
+
#
|
56
|
+
# Returns a String containing the path
|
57
|
+
def self.config_parent
|
58
|
+
dir = File.expand_path("~/.octopolo")
|
59
|
+
dir = File.expand_path("~/.automation") unless Dir.exists?(dir)
|
60
|
+
dir
|
61
|
+
end
|
62
|
+
|
63
|
+
# Public: Create the user's configuration file
|
64
|
+
# NOTE this seems a mite gnarly, and its tests worse, but doesn't seem worth splitting out into "create_config_parent_directory" and "create_config_file" at this stage
|
65
|
+
def self.touch_config_file
|
66
|
+
unless Dir.exist? config_parent
|
67
|
+
Dir.mkdir config_parent
|
68
|
+
end
|
69
|
+
|
70
|
+
unless File.exist? config_path
|
71
|
+
File.write UserConfig.config_path, YAML.dump({})
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Public: The user's name
|
76
|
+
#
|
77
|
+
# Returns a String
|
78
|
+
def full_name
|
79
|
+
@full_name || ENV["USER"]
|
80
|
+
end
|
81
|
+
|
82
|
+
# Public: The GitHub username
|
83
|
+
#
|
84
|
+
# If none is stored, generate it for the user.
|
85
|
+
#
|
86
|
+
# Returns a String or raises MissingGitHubAuth
|
87
|
+
def github_user
|
88
|
+
@github_user || raise(MissingGitHubAuth)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Public: The GitHub token
|
92
|
+
#
|
93
|
+
# If none is stored, generate it for the user.
|
94
|
+
#
|
95
|
+
# Returns a String or raises MissingGitHubAuth
|
96
|
+
def github_token
|
97
|
+
@github_token || raise(MissingGitHubAuth)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Public: The Pivotal Tracker token
|
101
|
+
#
|
102
|
+
# If none is stored, prompt them to generate it.
|
103
|
+
#
|
104
|
+
# Returns a String or raises MissingPivotalAuth
|
105
|
+
def pivotal_token
|
106
|
+
@pivotal_token || raise(MissingPivotalAuth)
|
107
|
+
end
|
108
|
+
|
109
|
+
MissingGitHubAuth = Class.new(StandardError)
|
110
|
+
MissingPivotalAuth = Class.new(StandardError)
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
module Octopolo
|
2
|
+
class Week
|
3
|
+
attr_accessor :year
|
4
|
+
attr_accessor :number
|
5
|
+
|
6
|
+
DATE_STRING_FORMAT = "%Y-%m-%d"
|
7
|
+
|
8
|
+
# Public: Instantiate a new Week
|
9
|
+
#
|
10
|
+
# year - Year the week occurs in
|
11
|
+
# number - The number of the week in the given year
|
12
|
+
def initialize year, number
|
13
|
+
self.year = year
|
14
|
+
self.number = number
|
15
|
+
end
|
16
|
+
|
17
|
+
# Public: Parse the date from a date-ish object
|
18
|
+
#
|
19
|
+
# dateish - A Date, Time, or String representing a date or time
|
20
|
+
def self.parse dateish
|
21
|
+
# TODO i'm not 100% happy with this implementation, but it does what i want and the tests pass
|
22
|
+
if dateish.respond_to? :to_date
|
23
|
+
date = dateish.to_date
|
24
|
+
new date.year, date.cweek
|
25
|
+
else
|
26
|
+
date = Date.parse(dateish.to_s)
|
27
|
+
new date.year, date.cweek
|
28
|
+
end
|
29
|
+
rescue ArgumentError
|
30
|
+
raise InvalidType
|
31
|
+
end
|
32
|
+
|
33
|
+
# Public: Instantiate a Week for the current week
|
34
|
+
#
|
35
|
+
# Returns an instance of Week
|
36
|
+
def self.current
|
37
|
+
today = Date.today
|
38
|
+
Week.new today.cwyear, today.cweek
|
39
|
+
end
|
40
|
+
|
41
|
+
# Public: Instantiate a Week for the prior week
|
42
|
+
#
|
43
|
+
# Returns an instance of Week
|
44
|
+
def self.prior
|
45
|
+
current.previous
|
46
|
+
end
|
47
|
+
|
48
|
+
# Public: A list of the last N weeks, excluding the current week
|
49
|
+
#
|
50
|
+
# count - An integer listing the number of weeks to return
|
51
|
+
#
|
52
|
+
# Returns an Array of Week instances
|
53
|
+
def self.last count
|
54
|
+
# holy hell this is gnarly
|
55
|
+
#
|
56
|
+
# essentially, create an array with the current week in it, then loop
|
57
|
+
# through and tack on the previous week for whichever week is last; this
|
58
|
+
# way, we keep tacking on ever more previous weeks as many times as is
|
59
|
+
# asked for.
|
60
|
+
out = [prior]
|
61
|
+
(count - 1).times do
|
62
|
+
out << out.last.previous
|
63
|
+
end
|
64
|
+
out
|
65
|
+
end
|
66
|
+
|
67
|
+
# Public: Friendly string representation of the week
|
68
|
+
#
|
69
|
+
# Returns a String of the start date
|
70
|
+
def to_s
|
71
|
+
start_date.strftime(DATE_STRING_FORMAT)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Public: Programmer-friendly representation of the week
|
75
|
+
#
|
76
|
+
# Returns a String containing object details
|
77
|
+
def inspect
|
78
|
+
"#<#{self.class} #{to_s} (year=#{year} number=#{number})>"
|
79
|
+
end
|
80
|
+
|
81
|
+
# Public: Whether the week is the same as another week
|
82
|
+
#
|
83
|
+
# Returns a Boolean
|
84
|
+
def == other
|
85
|
+
year == other.year && number == other.number
|
86
|
+
rescue
|
87
|
+
false
|
88
|
+
end
|
89
|
+
|
90
|
+
# Public: The previous week
|
91
|
+
#
|
92
|
+
# Returns an instance of Week
|
93
|
+
def previous
|
94
|
+
if number == 1
|
95
|
+
# carry the 1
|
96
|
+
Week.new year - 1, 52
|
97
|
+
else
|
98
|
+
Week.new year, number - 1
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Public: The next week
|
103
|
+
#
|
104
|
+
# Returns an instance of Week
|
105
|
+
def next
|
106
|
+
if number == 52
|
107
|
+
# carry the 1
|
108
|
+
Week.new year + 1, 1
|
109
|
+
else
|
110
|
+
Week.new year, number + 1
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Public: Monday that starts the week
|
115
|
+
#
|
116
|
+
# Returns an instance of Date
|
117
|
+
def start_date
|
118
|
+
Date.commercial year, number
|
119
|
+
end
|
120
|
+
|
121
|
+
# Public: Sunday that ends the week
|
122
|
+
#
|
123
|
+
# Returns an instance of Date
|
124
|
+
def end_date
|
125
|
+
Date.commercial year, number, 7
|
126
|
+
end
|
127
|
+
|
128
|
+
InvalidType = Class.new(StandardError)
|
129
|
+
end
|
130
|
+
end
|
data/octopolo.gemspec
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/octopolo/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Patrick Byrne", "Luke Ludwig"]
|
6
|
+
gem.email = ["patrick.byrne@sportngin.com", "luke.ludwig@sportngin.com"]
|
7
|
+
gem.description = %q{A set of Github workflow scripts.}
|
8
|
+
gem.summary = %q{A set of Github workflow scripts.}
|
9
|
+
gem.homepage = ""
|
10
|
+
|
11
|
+
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
12
|
+
gem.files = `git ls-files`.split("\n")
|
13
|
+
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
14
|
+
gem.name = "octopolo"
|
15
|
+
gem.license = "MIT"
|
16
|
+
gem.require_paths = ["lib"]
|
17
|
+
gem.version = Octopolo::VERSION
|
18
|
+
|
19
|
+
gem.add_dependency 'rake'
|
20
|
+
gem.add_dependency 'gli', '~> 2.10'
|
21
|
+
gem.add_dependency 'hashie', '~> 1.2'
|
22
|
+
gem.add_dependency 'octokit', '~> 2.7.1'
|
23
|
+
gem.add_dependency 'highline', '~> 1.6'
|
24
|
+
gem.add_dependency 'pivotal-tracker', '~> 0.5'
|
25
|
+
gem.add_dependency 'jiralicious'
|
26
|
+
|
27
|
+
gem.add_development_dependency 'rspec', '~> 2.11.0'
|
28
|
+
gem.add_development_dependency "guard"
|
29
|
+
gem.add_development_dependency "guard-rspec"
|
30
|
+
gem.add_development_dependency 'octopolo-plugin-example'
|
31
|
+
end
|
data/spec/.DS_Store
ADDED
Binary file
|
@@ -0,0 +1,310 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
module Octopolo
|
4
|
+
describe CLI do
|
5
|
+
subject { CLI }
|
6
|
+
|
7
|
+
context ".perform(command)" do
|
8
|
+
let(:command) { "ls" }
|
9
|
+
let(:result) { "result" }
|
10
|
+
let(:error) { "error message" }
|
11
|
+
let(:exception_message) { "Error with something" }
|
12
|
+
|
13
|
+
it "passes the given command to the shell" do
|
14
|
+
subject.should_receive(:say).with(command)
|
15
|
+
Open3.should_receive(:capture3).with(command).and_return([result, nil])
|
16
|
+
subject.should_receive(:say).with(result)
|
17
|
+
subject.perform(command).should == result
|
18
|
+
end
|
19
|
+
|
20
|
+
it "uses Kernel#` if Open3 has no capture3 method (e.g., Ruby 1.8.7)" do
|
21
|
+
subject.should_receive(:say).with(command)
|
22
|
+
# simulating ruby 1.8.7 not having an Open3.capture3 method
|
23
|
+
Open3.should_receive(:respond_to?).with(:capture3).and_return(false)
|
24
|
+
subject.should_receive(:`).with(command).and_return(result)
|
25
|
+
subject.should_receive(:say).with(result)
|
26
|
+
subject.perform(command).should == result
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should handle errors gracefully" do
|
30
|
+
subject.should_receive(:say).with(command)
|
31
|
+
Open3.should_receive(:capture3).with(command).and_raise(exception_message)
|
32
|
+
subject.should_receive(:say).with("Unable to perform '#{command}': #{exception_message}")
|
33
|
+
subject.perform(command).should be_nil
|
34
|
+
end
|
35
|
+
|
36
|
+
it "should not speak the command if told not to" do
|
37
|
+
subject.should_receive(:say).with(command).never
|
38
|
+
subject.perform(command, false)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
context ".perform_quietly(command)" do
|
43
|
+
let(:command) { "ls" }
|
44
|
+
|
45
|
+
it "performs the command without displaying itself" do
|
46
|
+
subject.should_receive(:perform).with(command, false)
|
47
|
+
subject.perform_quietly(command)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context ".perform_and_exit(command)" do
|
52
|
+
let(:command) { "ls" }
|
53
|
+
|
54
|
+
it "should use the 'exec' command to replace the Ruby process with the command" do
|
55
|
+
subject.should_receive(:say).with(command)
|
56
|
+
subject.should_receive(:exec).with(command)
|
57
|
+
subject.perform_and_exit(command)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
context ".say(message)" do
|
62
|
+
let(:message) { "asdf" }
|
63
|
+
|
64
|
+
it "displays the given message" do
|
65
|
+
subject.should_receive(:puts).with(message)
|
66
|
+
subject.say message
|
67
|
+
end
|
68
|
+
|
69
|
+
it "does nothing if the message is nil" do
|
70
|
+
subject.should_receive(:puts).never
|
71
|
+
subject.say nil
|
72
|
+
end
|
73
|
+
|
74
|
+
it "does nothing if the message is an empty string" do
|
75
|
+
subject.should_receive(:puts).never
|
76
|
+
subject.say ""
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
context ".spacer_line" do
|
81
|
+
it "displays a blank space" do
|
82
|
+
subject.should_receive(:say).with(" ")
|
83
|
+
subject.spacer_line
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
context ".perform_in(dir, &block)" do
|
88
|
+
let(:dir) { "/tmp" }
|
89
|
+
let(:command) { "ls" }
|
90
|
+
|
91
|
+
it "changes the script to the given directory" do
|
92
|
+
subject.should_receive(:say).with("Performing in #{dir}:")
|
93
|
+
Dir.should_receive(:chdir).with(dir).and_yield
|
94
|
+
subject.should_receive(:perform).with(command)
|
95
|
+
|
96
|
+
subject.perform_in(dir) do
|
97
|
+
subject.perform(command)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
context ".ask(question, choices)" do
|
103
|
+
let(:question) { "What do you want to eat?" }
|
104
|
+
let(:choices) { ["sandwich", "carrots", "cake"] }
|
105
|
+
let(:one_choice) { [choices.first] }
|
106
|
+
let(:valid_string_answer) { choices[valid_numeric_answer - 1] }
|
107
|
+
let(:invalid_string_answer) { "pineapple" }
|
108
|
+
# answers start at 1, not at 0, so +1
|
109
|
+
let(:valid_numeric_answer) { rand(choices.size) + 1 }
|
110
|
+
let(:invalid_low_numeric_answer) { 0 }
|
111
|
+
let(:invalid_high_numeric_answer) { choices.size + 2 }
|
112
|
+
|
113
|
+
it "provides the given list of choices for the given question" do
|
114
|
+
subject.should_receive(:say).with(question)
|
115
|
+
subject.should_receive(:say).with("1) sandwich")
|
116
|
+
subject.should_receive(:say).with("2) carrots")
|
117
|
+
subject.should_receive(:say).with("3) cake")
|
118
|
+
subject.should_receive(:prompt).and_return(valid_string_answer) # only specifying return value to prevent infinite loop
|
119
|
+
|
120
|
+
subject.ask(question, choices)
|
121
|
+
end
|
122
|
+
|
123
|
+
it "skips printing the question and choices if told not to (useful to avoid cluttering spec output)" do
|
124
|
+
subject.should_receive(:say).with(question).never
|
125
|
+
subject.should_receive(:say).with("1) sandwich").never
|
126
|
+
subject.should_receive(:say).with("2) carrots").never
|
127
|
+
subject.should_receive(:say).with("3) cake").never
|
128
|
+
subject.should_receive(:prompt).and_return(valid_string_answer) # only specifying return value to prevent infinite loop
|
129
|
+
|
130
|
+
subject.ask(question, choices, true)
|
131
|
+
end
|
132
|
+
|
133
|
+
it "simply returns the value if given only one choice" do
|
134
|
+
subject.should_receive(:say).never
|
135
|
+
subject.should_receive(:prompt).never
|
136
|
+
|
137
|
+
subject.ask(question, one_choice).should == one_choice.first
|
138
|
+
end
|
139
|
+
|
140
|
+
context "when answering with the string value" do
|
141
|
+
it "returns the user's selection, if in the available choices" do
|
142
|
+
subject.should_receive(:prompt).and_return(valid_string_answer)
|
143
|
+
subject.ask(question, choices, true).should == valid_string_answer
|
144
|
+
end
|
145
|
+
|
146
|
+
it "asks again if given a string other than one of the choices" do
|
147
|
+
subject.should_receive(:prompt).and_return(invalid_string_answer)
|
148
|
+
subject.should_receive(:say).with("Not a valid choice.")
|
149
|
+
subject.should_receive(:prompt).and_return(valid_string_answer)
|
150
|
+
|
151
|
+
subject.ask(question, choices, true).should == valid_string_answer
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
context "when answering with the numeric value" do
|
156
|
+
it "returns the user's selection, if in the available choices" do
|
157
|
+
subject.should_receive(:prompt).and_return(valid_numeric_answer)
|
158
|
+
subject.ask(question, choices, true).should == valid_string_answer
|
159
|
+
end
|
160
|
+
|
161
|
+
it "asks again if given a answer 0 or less" do
|
162
|
+
subject.should_receive(:prompt).and_return(invalid_low_numeric_answer)
|
163
|
+
subject.should_receive(:say).with("Not a valid choice.")
|
164
|
+
subject.should_receive(:prompt).and_return(valid_numeric_answer)
|
165
|
+
|
166
|
+
subject.ask(question, choices, true).should == valid_string_answer
|
167
|
+
end
|
168
|
+
|
169
|
+
it "asks again if given a answer greater than the list of choices" do
|
170
|
+
subject.should_receive(:prompt).and_return(invalid_high_numeric_answer)
|
171
|
+
subject.should_receive(:say).with("Not a valid choice.")
|
172
|
+
subject.should_receive(:prompt).and_return(valid_numeric_answer)
|
173
|
+
|
174
|
+
subject.ask(question, choices, true).should == valid_string_answer
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
context ".ask_boolean question" do
|
180
|
+
let(:question) { "Are you truly happy?" }
|
181
|
+
|
182
|
+
it "asks the question and prompts for an answer" do
|
183
|
+
subject.should_receive(:prompt).with("#{question} (y/n)") { "y" }
|
184
|
+
subject.ask_boolean(question)
|
185
|
+
end
|
186
|
+
|
187
|
+
it "returns true for 'y'" do
|
188
|
+
subject.should_receive(:prompt) { "y" }
|
189
|
+
subject.ask_boolean(question).should be_true
|
190
|
+
end
|
191
|
+
|
192
|
+
it "returns true for 'yes'" do
|
193
|
+
subject.should_receive(:prompt) { "yes" }
|
194
|
+
subject.ask_boolean(question).should be_true
|
195
|
+
end
|
196
|
+
|
197
|
+
it "returns true for 'Y'" do
|
198
|
+
subject.should_receive(:prompt) { "Y" }
|
199
|
+
subject.ask_boolean(question).should be_true
|
200
|
+
end
|
201
|
+
|
202
|
+
it "returns false for 'n'" do
|
203
|
+
subject.should_receive(:prompt) { "n" }
|
204
|
+
subject.ask_boolean(question).should be_false
|
205
|
+
end
|
206
|
+
|
207
|
+
it "returns false for 'no'" do
|
208
|
+
subject.should_receive(:prompt) { "no" }
|
209
|
+
subject.ask_boolean(question).should be_false
|
210
|
+
end
|
211
|
+
|
212
|
+
it "returns false for 'N'" do
|
213
|
+
subject.should_receive(:prompt) { "N" }
|
214
|
+
subject.ask_boolean(question).should be_false
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
context ".prompt prompt_text" do
|
219
|
+
let(:input) { "asdf" }
|
220
|
+
let(:highline) { stub }
|
221
|
+
let(:prompt_text) { "Foo: " }
|
222
|
+
|
223
|
+
before do
|
224
|
+
subject.stub(highline: highline)
|
225
|
+
end
|
226
|
+
|
227
|
+
it "retrieves response from the user" do
|
228
|
+
highline.should_receive(:ask).with("> ").and_return(input)
|
229
|
+
|
230
|
+
subject.prompt.should == input
|
231
|
+
end
|
232
|
+
|
233
|
+
it "uses the given text as the prompt" do
|
234
|
+
highline.should_receive(:ask).with(prompt_text)
|
235
|
+
|
236
|
+
subject.prompt prompt_text
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
context ".prompt_secret prompt_text" do
|
241
|
+
let(:input) { "asdf" }
|
242
|
+
let(:prompt_text) { "Foo: " }
|
243
|
+
let(:highline) { stub }
|
244
|
+
let(:highline_config) { stub }
|
245
|
+
|
246
|
+
before do
|
247
|
+
subject.stub(highline: highline)
|
248
|
+
end
|
249
|
+
|
250
|
+
it "retrieves response from the user without displaying what they type" do
|
251
|
+
highline.should_receive(:ask).with(prompt_text).and_yield(highline_config).and_return(input)
|
252
|
+
highline_config.should_receive(:echo=).with(false)
|
253
|
+
highline_config.should_receive(:readline=).with(true)
|
254
|
+
subject.prompt_secret(prompt_text).should == input
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
context ".prompt_multiline prompt_text" do
|
259
|
+
let(:input) { %w(a s d f) }
|
260
|
+
let(:prompt_text) { "Steps:" }
|
261
|
+
let(:highline) { stub }
|
262
|
+
let(:highline_config) { stub }
|
263
|
+
|
264
|
+
before do
|
265
|
+
subject.stub(highline: highline)
|
266
|
+
end
|
267
|
+
|
268
|
+
it "retreives response from the user across multiple lines, returning an array of their input" do
|
269
|
+
highline.should_receive(:ask).with(prompt_text).and_yield(highline_config).and_return(input)
|
270
|
+
highline_config.should_receive(:gather=).with("")
|
271
|
+
highline_config.should_receive(:readline=).with(true)
|
272
|
+
subject.prompt_multiline(prompt_text).should == input
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
context ".highline" do
|
277
|
+
let(:result) { stub }
|
278
|
+
|
279
|
+
it "instantiates a HighLine object" do
|
280
|
+
HighLine.should_receive(:new) { result }
|
281
|
+
subject.highline.should == result
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
context ".open path" do
|
286
|
+
let(:path) { "http://example.com/" }
|
287
|
+
|
288
|
+
it "exits and opens the path with Mac OS X's `open` command" do
|
289
|
+
subject.should_receive(:perform_and_exit).with("open '#{path}'")
|
290
|
+
subject.open path
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
context ".copy_to_clipboard(input)" do
|
295
|
+
let(:input) { "something#{rand(1000)}" }
|
296
|
+
|
297
|
+
# FIXME figure out a better way to test this on non-Mac systems, which don't have pbcopy or pbpaste
|
298
|
+
#
|
299
|
+
# only run on the test on a mac and when not in tmux
|
300
|
+
if RUBY_PLATFORM.include?("darwin") and !ENV['TMUX']
|
301
|
+
it "puts the text on the system clipboard (for pasting)" do
|
302
|
+
subject.should_receive(:say).with("Putting '#{input}' on the clipboard.")
|
303
|
+
subject.copy_to_clipboard input
|
304
|
+
# and to test that it's on the clipboard
|
305
|
+
`pbpaste`.should == input
|
306
|
+
end
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|