octofacts-updater 0.5.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,136 @@
1
+ # This class represents a fact fixture, which is a set of facts along with a node name.
2
+ # Facts are OctofactsUpdater::Fact objects, and internally are stored as a hash table
3
+ # with the key being the fact name and the value being the OctofactsUpdater::Fact object.
4
+
5
+ require "yaml"
6
+
7
+ module OctofactsUpdater
8
+ class Fixture
9
+ attr_reader :facts, :hostname
10
+
11
+ # Make a fact fixture for the specified host name by consulting data sources
12
+ # specified in the configuration.
13
+ #
14
+ # hostname - A String with the FQDN of the host.
15
+ # config - A Hash with configuration data.
16
+ #
17
+ # Returns the OctofactsUpdater::Fixture object.
18
+ def self.make(hostname, config)
19
+ fact_hash = facts_from_configured_datasource(hostname, config)
20
+
21
+ if config.key?("enc")
22
+ enc_data = OctofactsUpdater::Service::ENC.run_enc(hostname, config)
23
+ if enc_data.key?("parameters")
24
+ fact_hash.merge! enc_data["parameters"]
25
+ end
26
+ end
27
+
28
+ obj = new(hostname, config, fact_hash)
29
+ obj.execute_plugins!
30
+ end
31
+
32
+ # Get fact hash from the first configured and working data source.
33
+ #
34
+ # hostname - A String with the FQDN of the host.
35
+ # config - A Hash with configuration data.
36
+ #
37
+ # Returns a Hash with the facts for the specified node; raises exception if this was not possible.
38
+ def self.facts_from_configured_datasource(hostname, config)
39
+ last_exception = nil
40
+ data_sources = %w(LocalFile PuppetDB SSH)
41
+ data_sources.each do |ds|
42
+ next if config.fetch(:options, {})[:datasource] && config[:options][:datasource] != ds.downcase.to_sym
43
+ next unless config.key?(ds.downcase)
44
+ clazz = Kernel.const_get("OctofactsUpdater::Service::#{ds}")
45
+ begin
46
+ result = clazz.send(:facts, hostname, config)
47
+ return result["values"] if result["values"].is_a?(Hash)
48
+ return result
49
+ rescue => e
50
+ last_exception = e
51
+ end
52
+ end
53
+
54
+ raise last_exception if last_exception
55
+ raise ArgumentError, "No fact data sources were configured"
56
+ end
57
+
58
+ # Load a fact fixture from a file. This helps create an index without the more expensive operation
59
+ # of actually looking up the facts from the data source.
60
+ #
61
+ # hostname - A String with the FQDN of the host.
62
+ # filename - A String with the filename of the existing host.
63
+ #
64
+ # Returns the OctofactsUpdater::Fixture object.
65
+ def self.load_file(hostname, filename)
66
+ unless File.file?(filename)
67
+ raise Errno::ENOENT, "Could not load facts from #{filename} because it does not exist"
68
+ end
69
+
70
+ data = YAML.safe_load(File.read(filename))
71
+ new(hostname, {}, data)
72
+ end
73
+
74
+ # Constructor.
75
+ #
76
+ # hostname - A String with the FQDN of the host.
77
+ # config - A Hash with configuration data.
78
+ # fact_hash - A Hash with the facts (key = fact name, value = fact value).
79
+ def initialize(hostname, config, fact_hash = {})
80
+ @hostname = hostname
81
+ @config = config
82
+ @facts = Hash[fact_hash.collect { |k, v| [k, OctofactsUpdater::Fact.new(k, v)] }]
83
+ end
84
+
85
+ # Execute plugins to clean up facts as per configuration. This modifies the value of the facts
86
+ # stored in this object. Any facts with a value of nil are removed.
87
+ #
88
+ # Returns a copy of this object.
89
+ def execute_plugins!
90
+ return self unless @config["facts"].is_a?(Hash)
91
+
92
+ @config["facts"].each do |fact_tag, args|
93
+ fact_names(fact_tag, args).each do |fact_name|
94
+ @facts[fact_name] ||= OctofactsUpdater::Fact.new(fact_name, nil)
95
+ plugin_name = args.fetch("plugin", "noop")
96
+ OctofactsUpdater::Plugin.execute(plugin_name, @facts[fact_name], args, @facts)
97
+ @facts.delete(fact_name) if @facts[fact_name].value.nil?
98
+ end
99
+ end
100
+
101
+ self
102
+ end
103
+
104
+ # Get fact names associated with a particular data structure. Implements:
105
+ # - Default behavior, where YAML key = fact name
106
+ # - Regexp behavior, where YAML "regexp" key is used to match against all facts
107
+ # - Override behavior, where YAML "fact" key overrides whatever is in the tag
108
+ #
109
+ # fact_tag - A String with the YAML key
110
+ # args - A Hash with the arguments
111
+ #
112
+ # Returns an Array of Strings with all fact names matched.
113
+ def fact_names(fact_tag, args = {})
114
+ return [args["fact"]] if args.key?("fact")
115
+ return [fact_tag] unless args.key?("regexp")
116
+ rexp = Regexp.new(args["regexp"])
117
+ @facts.keys.select { |k| rexp.match(k) }
118
+ end
119
+
120
+ # Write this fixture to a file.
121
+ #
122
+ # filename - A String with the filename to write.
123
+ def write_file(filename)
124
+ File.open(filename, "w") { |f| f.write(to_yaml) }
125
+ end
126
+
127
+ # YAML representation of the fact fixture.
128
+ #
129
+ # Returns a String containing the YAML representation of the fact fixture.
130
+ def to_yaml
131
+ sorted_facts = @facts.sort.to_h
132
+ facts_hash_with_expanded_values = Hash[sorted_facts.collect { |k, v| [k, v.value] }]
133
+ YAML.dump(facts_hash_with_expanded_values)
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,70 @@
1
+ # This class provides the base methods for fact manipulation plugins.
2
+
3
+ require "digest"
4
+
5
+ module OctofactsUpdater
6
+ class Plugin
7
+ # Register a plugin.
8
+ #
9
+ # plugin_name - A Symbol which is the name of the plugin.
10
+ # block - A block of code that constitutes the plugin. See sample plugins for expected format.
11
+ def self.register(plugin_name, &block)
12
+ @plugins ||= {}
13
+ if @plugins.key?(plugin_name.to_sym)
14
+ raise ArgumentError, "A plugin named #{plugin_name} is already registered."
15
+ end
16
+ @plugins[plugin_name.to_sym] = block
17
+ end
18
+
19
+ # Execute a plugin
20
+ #
21
+ # plugin_name - A Symbol which is the name of the plugin.
22
+ # fact - An OctofactsUpdater::Fact object
23
+ # args - An optional Hash of additional configuration arguments
24
+ # all_facts - A Hash of all of the facts
25
+ #
26
+ # Returns nothing, but may adjust the "fact"
27
+ def self.execute(plugin_name, fact, args = {}, all_facts = {})
28
+ unless @plugins.key?(plugin_name.to_sym)
29
+ raise NoMethodError, "A plugin named #{plugin_name} could not be found."
30
+ end
31
+
32
+ begin
33
+ @plugins[plugin_name.to_sym].call(fact, args, all_facts)
34
+ rescue => e
35
+ warn "#{e.class} occurred executing #{plugin_name} on #{fact.name} with value #{fact.value.inspect}"
36
+ raise e
37
+ end
38
+ end
39
+
40
+ # Clear out a plugin definition. (Useful for testing.)
41
+ #
42
+ # plugin_name - The name of the plugin to clear.
43
+ def self.clear!(plugin_name)
44
+ @plugins ||= {}
45
+ @plugins.delete(plugin_name.to_sym)
46
+ end
47
+
48
+ # Get the plugins hash.
49
+ def self.plugins
50
+ @plugins
51
+ end
52
+
53
+ # ---------------------------
54
+ # Below this point are shared methods intended to be called by plugins.
55
+ # ---------------------------
56
+
57
+ # Randomize a long string. This method accepts a string (consisting of, for example, a SSH key)
58
+ # and returns a string of the same length, but with randomized characters.
59
+ #
60
+ # string_in - A String with the original fact value.
61
+ #
62
+ # Returns a String with the same length as string_in.
63
+ def self.randomize_long_string(string_in)
64
+ seed = Digest::MD5.hexdigest(string_in).to_i(36)
65
+ prng = Random.new(seed)
66
+ chars = [("a".."z"), ("A".."Z"), ("0".."9")].flat_map(&:to_a)
67
+ (1..(string_in.length)).map { chars[prng.rand(chars.length)] }.join
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,38 @@
1
+ # This file is part of the octofacts updater fact manipulation plugins. This plugin provides
2
+ # methods to update facts that are IP addresses in order to anonymize or randomize them.
3
+
4
+ require "ipaddr"
5
+
6
+ # ipv4_anonymize. This method modifies an IP (version 4) address and
7
+ # sets it to a randomized (yet consistent) address in the given
8
+ # network.
9
+ #
10
+ # Supported parameters in args:
11
+ # - subnet: (Required) The network prefix in CIDR notation
12
+ OctofactsUpdater::Plugin.register(:ipv4_anonymize) do |fact, args = {}, facts|
13
+ raise ArgumentError, "ipv4_anonymize requires a subnet" if args["subnet"].nil?
14
+
15
+ subnet_range = IPAddr.new(args["subnet"], Socket::AF_INET).to_range
16
+ # Convert the original IP to an integer representation that we can use as seed
17
+ seed = IPAddr.new(fact.value(args["structure"]), Socket::AF_INET).to_i
18
+ srand seed
19
+ random_ip = IPAddr.new(rand(subnet_range.first.to_i..subnet_range.last.to_i), Socket::AF_INET)
20
+ fact.set_value(random_ip.to_s, args["structure"])
21
+ end
22
+
23
+ # ipv6_anonymize. This method modifies an IP (version 6) address and
24
+ # sets it to a randomized (yet consistent) address in the given
25
+ # network.
26
+ #
27
+ # Supported parameters in args:
28
+ # - subnet: (Required) The network prefix in CIDR notation
29
+ OctofactsUpdater::Plugin.register(:ipv6_anonymize) do |fact, args = {}, facts|
30
+ raise ArgumentError, "ipv6_anonymize requires a subnet" if args["subnet"].nil?
31
+
32
+ subnet_range = IPAddr.new(args["subnet"], Socket::AF_INET6).to_range
33
+ # Convert the hostname to an integer representation that we can use as seed
34
+ seed = IPAddr.new(fact.value(args["structure"]), Socket::AF_INET6).to_i
35
+ srand seed
36
+ random_ip = IPAddr.new(rand(subnet_range.first.to_i..subnet_range.last.to_i), Socket::AF_INET6)
37
+ fact.set_value(random_ip.to_s, args["structure"])
38
+ end
@@ -0,0 +1,23 @@
1
+ # This file is part of the octofacts updater fact manipulation plugins. This plugin provides
2
+ # methods to update facts that are SSH keys, since we do not desire to commit SSH keys from
3
+ # actual hosts into the source code repository.
4
+
5
+ # sshfp. This method randomizes the secret key for sshfp formatted keys. Each key is replaced
6
+ # by a randomized (yet consistent) string the same length as the input key.
7
+ # The input looks like this:
8
+ # sshfp_ecdsa: |-
9
+ # SSHFP 3 1 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
10
+ # SSHFP 3 2 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
11
+ OctofactsUpdater::Plugin.register(:sshfp_randomize) do |fact, args = {}|
12
+ blk = Proc.new do |val|
13
+ lines = val.split("\n").map(&:strip)
14
+ result = lines.map do |line|
15
+ unless line =~ /\ASSHFP (\d+) (\d+) (\w+)/
16
+ raise "Unparseable pattern: #{line}"
17
+ end
18
+ "SSHFP #{Regexp.last_match(1)} #{Regexp.last_match(2)} #{OctofactsUpdater::Plugin.randomize_long_string(Regexp.last_match(3))}"
19
+ end
20
+ result.join("\n")
21
+ end
22
+ fact.set_value(blk, args["structure"])
23
+ end
@@ -0,0 +1,53 @@
1
+ # This file is part of the octofacts updater fact manipulation plugins. This plugin provides
2
+ # methods to do static operations on facts -- delete, add, or set to a known value.
3
+
4
+ # Delete. This method deletes the fact or the identified portion. Setting the value to nil
5
+ # causes the tooling to remove any such portions of the value.
6
+ #
7
+ # Supported parameters in args:
8
+ # - structure: A String or Array of a structure within a structured fact
9
+ OctofactsUpdater::Plugin.register(:delete) do |fact, args = {}, _all_facts = {}|
10
+ fact.set_value(nil, args["structure"])
11
+ end
12
+
13
+ # Set. This method sets the fact or the identified portion to a static value.
14
+ #
15
+ # Supported parameters in args:
16
+ # - structure: A String or Array of a structure within a structured fact
17
+ # - value: The new value to set the fact to
18
+ OctofactsUpdater::Plugin.register(:set) do |fact, args = {}, _all_facts = {}|
19
+ fact.set_value(args["value"], args["structure"])
20
+ end
21
+
22
+ # Remove matching objects from a delimited string. Requires that the delimiter
23
+ # and regular expression be set. This is useful, for example, to transform a
24
+ # string like `foo,bar,baz,fizz` into `foo,fizz` (by removing /^ba/).
25
+ #
26
+ # Supported parameters in args:
27
+ # - delimiter: (Required) Character that is the delimiter.
28
+ # - regexp: (Required) String used to construct a regular expression of items to remove
29
+ OctofactsUpdater::Plugin.register(:remove_from_delimited_string) do |fact, args = {}, _all_facts = {}|
30
+ unless fact.value.nil?
31
+ unless args["delimiter"]
32
+ raise ArgumentError, "remove_from_delimited_string requires a delimiter, got #{args.inspect}"
33
+ end
34
+ unless args["regexp"]
35
+ raise ArgumentError, "remove_from_delimited_string requires a regexp, got #{args.inspect}"
36
+ end
37
+ parts = fact.value.split(args["delimiter"])
38
+ regexp = Regexp.new(args["regexp"])
39
+ parts.delete_if { |part| regexp.match(part) }
40
+ fact.set_value(parts.join(args["delimiter"]))
41
+ end
42
+ end
43
+
44
+ # No-op. Do nothing at all.
45
+ OctofactsUpdater::Plugin.register(:noop) do |_fact, _args = {}, _all_facts = {}|
46
+ #
47
+ end
48
+
49
+ # Randomize long string. This is just a wrapper around OctofactsUpdater::Plugin.randomize_long_string
50
+ OctofactsUpdater::Plugin.register(:randomize_long_string) do |fact, args = {}, _all_facts = {}|
51
+ blk = Proc.new { |val| OctofactsUpdater::Plugin.randomize_long_string(val) }
52
+ fact.set_value(blk, args["structure"])
53
+ end
@@ -0,0 +1,35 @@
1
+ # This contains handy utility methods that might be used in any of the other classes.
2
+
3
+ require "yaml"
4
+
5
+ module OctofactsUpdater
6
+ module Service
7
+ class Base
8
+ # Parse a YAML fact file from PuppetServer. This removes the header (e.g. "--- !ruby/object:Puppet::Node::Facts")
9
+ # so that it's not necessary to bring in all of Puppet.
10
+ #
11
+ # yaml_string - A String with YAML to parse.
12
+ #
13
+ # Returns a Hash with the facts.
14
+ def self.parse_yaml(yaml_string)
15
+ # Convert first "---" after any comments and blank lines.
16
+ yaml_array = yaml_string.to_s.split("\n")
17
+ yaml_array.each_with_index do |line, index|
18
+ next if line =~ /\A\s*#/
19
+ next if line.strip == ""
20
+ if line.start_with?("---")
21
+ yaml_array[index] = "---"
22
+ end
23
+ break
24
+ end
25
+
26
+ # Parse the YAML file
27
+ result = YAML.safe_load(yaml_array.join("\n"))
28
+
29
+ # Pull out "values" if this is in a name-values format. Otherwise just return the hash.
30
+ return result["values"] if result["values"].is_a?(Hash)
31
+ result
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,41 @@
1
+ # This class contains methods to interact with an external node classifier.
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require "yaml"
6
+
7
+ module OctofactsUpdater
8
+ module Service
9
+ class ENC
10
+ # Execute the external node classifier script. This expects the value of "path" to be
11
+ # set in the configuration.
12
+ #
13
+ # hostname - A String with the FQDN of the host.
14
+ # config - A Hash with configuration data.
15
+ #
16
+ # Returns a Hash consisting of the parsed output of the ENC.
17
+ def self.run_enc(hostname, config)
18
+ unless config["enc"].is_a?(Hash)
19
+ raise ArgumentError, "The ENC configuration must be defined"
20
+ end
21
+
22
+ unless config["enc"]["path"].is_a?(String)
23
+ raise ArgumentError, "The ENC path must be defined"
24
+ end
25
+
26
+ unless File.file?(config["enc"]["path"])
27
+ raise Errno::ENOENT, "The ENC script could not be found at #{config['enc']['path'].inspect}"
28
+ end
29
+
30
+ command = [config["enc"]["path"], hostname].map { |x| Shellwords.escape(x) }.join(" ")
31
+ stdout, stderr, exitstatus = Open3.capture3(command)
32
+ unless exitstatus.exitstatus == 0
33
+ output = { "stdout" => stdout, "stderr" => stderr, "exitstatus" => exitstatus.exitstatus }
34
+ raise "Error executing #{command.inspect}: #{output.to_yaml}"
35
+ end
36
+
37
+ YAML.load(stdout)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,230 @@
1
+ # This class contains methods to interact with the GitHub API.
2
+ # frozen_string_literal: true
3
+
4
+ require "octokit"
5
+ require "pathname"
6
+
7
+ module OctofactsUpdater
8
+ module Service
9
+ class GitHub
10
+ attr_reader :options
11
+
12
+ # Callable external method: Push all changes to the indicated paths to GitHub.
13
+ #
14
+ # root - A String with the root directory, to which paths are relative.
15
+ # paths - An Array of Strings, which are relative to the repository root.
16
+ # options - A Hash with configuration options.
17
+ #
18
+ # Returns true if there were any changes made, false otherwise.
19
+ def self.run(root, paths, options = {})
20
+ root ||= options.fetch("github", {})["base_directory"]
21
+ unless root && File.directory?(root)
22
+ raise ArgumentError, "Base directory must be specified"
23
+ end
24
+ github = new(options)
25
+ project_root = Pathname.new(root)
26
+ paths.each do |path|
27
+ absolute_path = Pathname.new(path)
28
+ stripped_path = absolute_path.relative_path_from(project_root)
29
+ github.commit_data(stripped_path.to_s, File.read(path))
30
+ end
31
+ github.finalize_commit
32
+ end
33
+
34
+ # Constructor.
35
+ #
36
+ # options - Hash with options
37
+ def initialize(options = {})
38
+ @options = options
39
+ @verbose = github_options.fetch("verbose", false)
40
+ @changes = []
41
+ end
42
+
43
+ # Commit a file to a location in the repository with the provided message. This will return true if there was
44
+ # an actual change, and false otherwise. This method does not actually do the commit, but rather it batches up
45
+ # all of the changes which must be realized later.
46
+ #
47
+ # path - A String with the path at which to commit the file
48
+ # new_content - A String with the new contents
49
+ #
50
+ # Returns true (and updates @changes) if there was actually a change, false otherwise.
51
+ def commit_data(path, new_content)
52
+ ensure_branch_exists
53
+
54
+ old_content = nil
55
+ begin
56
+ contents = octokit.contents(repository, path: path, ref: branch)
57
+ old_content = Base64.decode64(contents.content)
58
+ rescue Octokit::NotFound
59
+ verbose("No old content found in #{repository.inspect} at #{path.inspect} in #{branch.inspect}")
60
+ # Fine, we will add below.
61
+ end
62
+
63
+ if new_content == old_content
64
+ verbose("Content of #{path} matches, no commit needed")
65
+ return false
66
+ else
67
+ verbose("Content of #{path} does not match. A commit is needed.")
68
+ verbose(Diffy::Diff.new(old_content, new_content))
69
+ end
70
+
71
+ @changes << Hash(
72
+ path: path,
73
+ mode: "100644",
74
+ type: "blob",
75
+ sha: octokit.create_blob(repository, new_content)
76
+ )
77
+
78
+ verbose("Batched update of #{path}")
79
+ true
80
+ end
81
+
82
+ # Finalize the GitHub commit by actually pushing any of the changes. This will not do anything if there
83
+ # are not any changes batched via the `commit_data` method.
84
+ #
85
+ # message - A String with a commit message, defaults to the overall configured commit message.
86
+ def finalize_commit(message = commit_message)
87
+ return unless @changes.any?
88
+
89
+ ensure_branch_exists
90
+ branch_ref = octokit.branch(repository, branch)
91
+ commit = octokit.git_commit(repository, branch_ref[:commit][:sha])
92
+ tree = commit["tree"]
93
+ new_tree = octokit.create_tree(repository, @changes, base_tree: tree["sha"])
94
+ new_commit = octokit.create_commit(repository, message, new_tree["sha"], commit["sha"])
95
+ octokit.update_ref(repository, "heads/#{branch}", new_commit["sha"])
96
+ verbose("Committed #{@changes.size} change(s) to GitHub")
97
+ find_or_create_pull_request
98
+ end
99
+
100
+ # Delete a file from the repository. Because of the way the GitHub API works, this will generate an
101
+ # immediate commit and push. It will NOT be batched for later application.
102
+ #
103
+ # path - A String with the path at which to commit the file
104
+ # message - A String with a commit message, defaults to the overall configured commit message.
105
+ #
106
+ # Returns true if the file existed before and was deleted. Returns false if the file didn't exist anyway.
107
+ def delete_file(path, message = commit_message)
108
+ ensure_branch_exists
109
+ contents = octokit.contents(repository, path: path, ref: branch)
110
+ blob_sha = contents.sha
111
+ octokit.delete_contents(repository, path, message, blob_sha, branch: branch)
112
+ verbose("Deleted #{path}")
113
+ find_or_create_pull_request
114
+ true
115
+ rescue Octokit::NotFound
116
+ verbose("Deleted #{path} (already gone)")
117
+ false
118
+ end
119
+
120
+ private
121
+
122
+ # Private: Build an octokit object from the provided options.
123
+ #
124
+ # Returns an octokit object.
125
+ def octokit
126
+ @octokit ||= begin
127
+ token = options.fetch("github", {})["token"] || ENV["OCTOKIT_TOKEN"]
128
+ if token
129
+ Octokit::Client.new(access_token: token)
130
+ else
131
+ raise ArgumentError, "Access token must be provided in config file or OCTOKIT_TOKEN environment variable."
132
+ end
133
+ end
134
+ end
135
+
136
+ # Private: Get the default branch from the repository. Unless default_branch is specified in the options, then use
137
+ # that instead.
138
+ #
139
+ # Returns a String with the name of the default branch.
140
+ def default_branch
141
+ github_options["default_branch"] || octokit.repo(repository)[:default_branch]
142
+ end
143
+
144
+ # Private: Ensure branch exists. This will use octokit to create the branch on GitHub if the branch
145
+ # does not already exist.
146
+ def ensure_branch_exists
147
+ @ensure_branch_exists ||= begin
148
+ created = false
149
+ begin
150
+ if octokit.branch(repository, branch)
151
+ verbose("Branch #{branch} already exists in #{repository}.")
152
+ created = true
153
+ end
154
+ rescue Octokit::NotFound
155
+ # Fine, we'll create it
156
+ end
157
+
158
+ unless created
159
+ base_sha = octokit.branch(repository, default_branch)[:commit][:sha]
160
+ octokit.create_ref(repository, "heads/#{branch}", base_sha)
161
+ verbose("Created branch #{branch} based on #{default_branch} #{base_sha}.")
162
+ end
163
+
164
+ true
165
+ end
166
+ end
167
+
168
+ # Private: Find an existing pull request for the branch, and commit a new pull request if
169
+ # there was not an existing one open.
170
+ #
171
+ # Returns the pull request object that was created.
172
+ def find_or_create_pull_request
173
+ @find_or_create_pull_request ||= begin
174
+ prs = octokit.pull_requests(repository, head: "github:#{branch}", state: "open")
175
+ if prs && !prs.empty?
176
+ verbose("Found existing PR #{prs.first.html_url}")
177
+ prs.first
178
+ else
179
+ new_pr = octokit.create_pull_request(
180
+ repository,
181
+ default_branch,
182
+ branch,
183
+ pr_subject,
184
+ pr_body
185
+ )
186
+ verbose("Created a new PR #{new_pr.html_url}")
187
+ new_pr
188
+ end
189
+ end
190
+ end
191
+
192
+ # Simple methods not covered by unit tests explicitly.
193
+ # :nocov:
194
+
195
+ # Log a verbose message.
196
+ #
197
+ # message - A String with the message to print.
198
+ def verbose(message)
199
+ return unless @verbose
200
+ puts "*** #{Time.now}: #{message}"
201
+ end
202
+
203
+ def github_options
204
+ return {} unless options.is_a?(Hash)
205
+ options.fetch("github", {})
206
+ end
207
+
208
+ def repository
209
+ github_options.fetch("repository")
210
+ end
211
+
212
+ def branch
213
+ github_options.fetch("branch")
214
+ end
215
+
216
+ def commit_message
217
+ github_options.fetch("commit_message")
218
+ end
219
+
220
+ def pr_subject
221
+ github_options.fetch("pr_subject")
222
+ end
223
+
224
+ def pr_body
225
+ github_options.fetch("pr_body")
226
+ end
227
+ # :nocov:
228
+ end
229
+ end
230
+ end