issue_scheduler 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module IssueScheduler
6
+ # Manage the configuration for the YCA EOL Dashboard program
7
+ # @api public
8
+ class Config
9
+ # Create a new Config object from a given YAML config
10
+ # @example
11
+ # config_yaml = <<~CONFIG
12
+ # { username: user, password: pass, site: https://jira.mydomain.com, auth_type: basic }
13
+ # CONFIG
14
+ # config = IssueScheduler::Config.new(config_yaml)
15
+ #
16
+ # @param config_hash [Hash] the config object as a hash
17
+ #
18
+ def initialize(config_hash)
19
+ @config = DEFAULT_VALUES.merge(config_hash)
20
+ assert_no_unexpected_keys(config)
21
+ assert_all_values_are_non_nil(config)
22
+ end
23
+
24
+ # The username to use when authenticating with Jira
25
+ #
26
+ # @example
27
+ # config_yaml = <<~CONFIG
28
+ # { username: user, password: pass, site: https://jira.mydomain.com, auth_type: basic }
29
+ # CONFIG
30
+ # config = IssueScheduler::Config.new(config_yaml)
31
+ # config.username #=> 'user'
32
+ #
33
+ # @return [String] the username
34
+ #
35
+ def username
36
+ config[:username]
37
+ end
38
+
39
+ # The password to use when authenticating with Jira
40
+ #
41
+ # @example
42
+ # config_yaml = <<~CONFIG
43
+ # { username: user, password: pass, site: https://jira.mydomain.com, auth_type: basic }
44
+ # CONFIG
45
+ # config = IssueScheduler::Config.new(config_yaml)
46
+ # config.password #=> 'pass'
47
+ #
48
+ # @return [String] the password
49
+ #
50
+ def password
51
+ config[:password]
52
+ end
53
+
54
+ # The URL of the Jira server
55
+ #
56
+ # @example
57
+ # config_yaml = <<~CONFIG
58
+ # { username: user, password: pass, site: https://jira.mydomain.com, auth_type: basic }
59
+ # CONFIG
60
+ # config = IssueScheduler::Config.new(config_yaml)
61
+ # config.site #=> 'https://jira.mydomain.com'
62
+ #
63
+ # @return [String] the Jira URL
64
+ #
65
+ def site
66
+ config[:site]
67
+ end
68
+
69
+ # The context path to append to the Jira server URL
70
+ #
71
+ # @example
72
+ # config_yaml = <<~CONFIG
73
+ # { username: user, password: pass, site: https://jira.mydomain.com, context_path: /jira, auth_type: basic }
74
+ # CONFIG
75
+ # config = IssueScheduler::Config.new(config_yaml)
76
+ # config.context_path #=> '/jira'
77
+ #
78
+ # @return [String] the context path
79
+ #
80
+ def context_path
81
+ config[:context_path]
82
+ end
83
+
84
+ # The authentication type to use when authenticating with Jira
85
+ #
86
+ # @example
87
+ # config_yaml = <<~CONFIG
88
+ # { username: user, password: pass, site: https://jira.mydomain.com, auth_type: basic }
89
+ # CONFIG
90
+ # config = IssueScheduler::Config.new(config_yaml)
91
+ # config.auth_type #=> 'basic'
92
+ #
93
+ # @return ['basic', 'oauth'] the authentication type
94
+ #
95
+ def auth_type
96
+ config[:auth_type]
97
+ end
98
+
99
+ # The glob pattern to use to find issue files
100
+ #
101
+ # @example
102
+ # config_yaml = <<~CONFIG
103
+ # {
104
+ # username: user, password: pass, site: https://jira.mydomain.com, auth_type: basic,
105
+ # issue_templates: 'config/**/*.yaml'
106
+ # }
107
+ # CONFIG
108
+ # config = IssueScheduler::Config.new(config_yaml)
109
+ # config.issue_templates #=> 'config/**/*.yaml'
110
+ #
111
+ # @return [String] the glob pattern
112
+ #
113
+ # @see https://ruby-doc.org/core-3.1.2/Dir.html#method-c-glob Dir.glob
114
+ #
115
+ def issue_templates
116
+ config[:issue_templates]
117
+ end
118
+
119
+ # Load the issue templates from the configured glob pattern(s) in issue_templates
120
+ #
121
+ # @example
122
+ # config_yaml = <<~CONFIG
123
+ # {
124
+ # username: user, password: pass, site: https://jira.mydomain.com, auth_type: basic,
125
+ # issue_templates: 'config/**/*.yaml'
126
+ # }
127
+ # CONFIG
128
+ # config = IssueScheduler::Config.new(config_yaml)
129
+ # config.load_issue_templates
130
+ # IssueScheduler::IssueTemplate.size #=> number of issue templates loaded from config/**/*.yaml
131
+ #
132
+ # @return [void] as a side effect the issue templates are loaded
133
+ #
134
+ def load_issue_templates
135
+ Dir[*Array(issue_templates)].each do |file|
136
+ template_hash = { name: file, **IssueScheduler.load_yaml(file) }
137
+ template = IssueScheduler::IssueTemplate.new(template_hash)
138
+ if template.valid?
139
+ template.save
140
+ else
141
+ warn "Skipping invalid issue template #{file}: #{template.errors.full_messages.join(', ')}"
142
+ end
143
+ end
144
+ end
145
+
146
+ # The config in the form of a Hash
147
+ #
148
+ # @example
149
+ # config = IssueScheduler::Config.new(<<~YAML)
150
+ # username: jcouball
151
+ # password: my_password
152
+ # site: https://jira.example.com
153
+ # context_path: ''
154
+ # auth_type: basic
155
+ # issue_templates: '~/scheduled_issues/**/*.yaml'
156
+ # YAML
157
+ # config.to_h #=> { 'username' => 'jcouball', 'password' => 'my_password', ... }
158
+ #
159
+ # @return [Hash] the config
160
+ #
161
+ def to_h
162
+ config
163
+ end
164
+
165
+ # Creates an options hash suitable to pass to Jira::Client.new
166
+ #
167
+ # @example
168
+ # config = IssueScheduler::Config.new(<<~YAML)
169
+ # username: jcouball
170
+ # password: my_password
171
+ # site: https://jira.example.com
172
+ # context_path: ''
173
+ # auth_type: basic
174
+ # issue_templates: '~/scheduled_issues/**/*.yaml'
175
+ # YAML
176
+ # config.to_jira_options #=> {
177
+ # username: 'jcouball',
178
+ # password: 'my_password',
179
+ # site: 'https://jira.example.com',
180
+ # context_path: '',
181
+ # auth_type: 'basic'
182
+ # }
183
+ #
184
+ # @return [Hash] the options hash
185
+ #
186
+ def to_jira_options
187
+ config.slice(:username, :password, :site, :context_path, :auth_type)
188
+ end
189
+
190
+ private
191
+
192
+ # Raise a RuntimeError if the config contains unexpected keys
193
+ # @raise [RuntimeError] if the config is invalid
194
+ # @return [Void]
195
+ # @api private
196
+ def assert_no_unexpected_keys(config_hash)
197
+ unexpected_keys = config_hash.keys - DEFAULT_VALUES.keys
198
+ raise "Unexpected configuration keys : #{unexpected_keys}" unless
199
+ unexpected_keys.empty?
200
+ end
201
+
202
+ # Raise a RuntimeError if the config contains a nil value
203
+ # @raise [RuntimeError] if the config is invalid
204
+ # @return [Void]
205
+ # @api private
206
+ def assert_all_values_are_non_nil(config_hash)
207
+ keys_of_missing_values = config_hash.select { |_k, v| v.nil? }.keys
208
+ raise "Missing configuration values: #{keys_of_missing_values}" unless
209
+ keys_of_missing_values.empty?
210
+ end
211
+
212
+ # The config hash from config_yaml
213
+ # @return [Hash] the config hash
214
+ # @api private
215
+ attr_reader :config
216
+
217
+ # Defines the allows keys and default values for each key
218
+ #
219
+ # A nil value indicates that the key does not have a default value and must
220
+ # be specified in the config_yaml.
221
+ #
222
+ # @return [Hash] the allows keys and their default values
223
+ # @api private
224
+ DEFAULT_VALUES = {
225
+ username: nil,
226
+ password: nil,
227
+ site: nil,
228
+ context_path: '',
229
+ auth_type: 'basic',
230
+ issue_templates: '~/.issue_scheduler/issue_templates/**/*.yaml'
231
+ }.freeze
232
+ end
233
+ end
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+ require 'active_model_persistence'
5
+
6
+ require 'pp'
7
+ require 'fugit'
8
+
9
+ module IssueScheduler
10
+ # A template for an issue
11
+ #
12
+ # @example Create a new issue template specifying all attributes
13
+ # attributes = {
14
+ # name: 'weekly_status_report.yaml',
15
+ # cron: '0 7 * * 6', # every Friday at 7:00 AM
16
+ # project: 'MYJIRA',
17
+ # component: 'Management',
18
+ # type: 'Story',
19
+ # summary: 'Weekly status report',
20
+ # description: "Update the weekly status report\n\nhttp://mydomain.com/status",
21
+ # due_date: '2022-05-06'
22
+ # }
23
+ #
24
+ # template = IssueScheduler::IssueTemplate.new(attributes)
25
+ #
26
+ # @example Create a new issue template specifying only required attributes
27
+ # attributes = {
28
+ # name: 'weekly_status_report.yaml',
29
+ # cron: '0 7 * * 6', # every Friday at 7:00 AM
30
+ # project: 'MYJIRA',
31
+ # summary: 'Weekly status report',
32
+ # }
33
+ #
34
+ # template = IssueScheduler::IssueTemplate.new(attributes)
35
+ #
36
+ # @api public
37
+ class IssueTemplate
38
+ include ActiveModelPersistence::Persistence
39
+ include ActiveModel::Validations::Callbacks
40
+
41
+ # @!attribute [rw] name
42
+ # The template name
43
+ #
44
+ # * name must not be present
45
+ # * name must be unique across all IssueTemplate objects
46
+ #
47
+ # @example
48
+ # template.name #=> "weekly_status_report"
49
+ #
50
+ # @return [String] the template name
51
+ #
52
+ # @api public
53
+
54
+ attribute :name, :string
55
+ validates :name, presence: true
56
+
57
+ # @!attribute [rw] cron
58
+ # A cron string that specifies when the issue should be created
59
+ #
60
+ # * cron must be present
61
+ # * cron must be a valid cron string
62
+ #
63
+ # @example
64
+ # # 7 AM Monday - Friday
65
+ # template.cron #=> "0 7 * * 1,2,3,4,5"
66
+ #
67
+ # @return [String] the cron string
68
+ #
69
+ # @see https://github.com/floraison/fugit The fugit gem
70
+ #
71
+ # @api public
72
+
73
+ attribute :cron, :string
74
+ validates :cron, presence: true
75
+ validates_each :cron do |record, attr, value|
76
+ record.errors.add(attr, 'is not a valid cron string') unless Fugit.parse_cron(value)
77
+ end
78
+
79
+ # @!attribute [rw] project
80
+ # The JIRA project name
81
+ #
82
+ # * project must be present
83
+ # * project is upcased
84
+ #
85
+ # @example
86
+ # template.project #=> "MYPROJECT"
87
+ #
88
+ # @return [String] the project name
89
+ #
90
+ # @api public
91
+
92
+ attribute :project, :string
93
+ validates :project, presence: true
94
+
95
+ def project=(project)
96
+ super(project.is_a?(String) ? project.upcase : project)
97
+ end
98
+
99
+ # @!attribute [rw] component
100
+ # The JIRA component name
101
+ #
102
+ # * component is optional and defaults to nil
103
+ # * component may be nil but not an empty string
104
+ #
105
+ # The component name is optional and will be set to nil if not given.
106
+ #
107
+ # @example
108
+ # template.component #=> "MYCOMPONENT"
109
+ #
110
+ # @return [String] the component name
111
+ #
112
+ # @api public
113
+
114
+ attribute :component, :string, default: nil
115
+ validates :component, presence: true, allow_nil: true
116
+
117
+ # @!attribute [rw] summary
118
+ # The JIRA issue summary
119
+ #
120
+ # * summary must be present
121
+ #
122
+ # @example
123
+ # template.summary #=> "Take out the trash"
124
+ #
125
+ # @return [String] the JIRA issue summary
126
+ #
127
+ # @api public
128
+
129
+ attribute :summary, :string
130
+ validates :summary, presence: true
131
+
132
+ # @!attribute [rw] description
133
+ # The JIRA issue description
134
+ #
135
+ # * description is optional and defaults to nil
136
+ # * description may be nil but not an empty string
137
+ #
138
+ # @example
139
+ # template.description #=> "Take out the trash in:\n- kitchen\n- bathroom\n- bedroom"
140
+ #
141
+ # @return [String] the JIRA issue description
142
+ #
143
+ # @api public
144
+
145
+ attribute :description, :string, default: nil
146
+ validates :description, presence: true, allow_nil: true
147
+
148
+ # @!attribute [rw] type
149
+ # The name of the JIRA issue type
150
+ #
151
+ # * type is optional and defaults to nil
152
+ # * type may be nil but not an empty string
153
+ #
154
+ # @example
155
+ # template.type #=> "Story"
156
+ #
157
+ # @return [String] the JIRA issue type
158
+ #
159
+ # @api public
160
+
161
+ attribute :type, :string, default: nil
162
+ validates :type, presence: true, allow_nil: true
163
+
164
+ # @!attribute [rw] due_date
165
+ # The JIRA issue due date
166
+ #
167
+ # * due_date is optional and defaults to nil
168
+ # * If non-nil, due_date must be a Date object or parsable by Date.parse
169
+ #
170
+ # @example
171
+ # template.due_date #=> #<Date: 2022-05-03 ((2459703j,0s,0n),+0s,2299161j)>
172
+ #
173
+ # @return [Date, nil] the JIRA issue description
174
+ #
175
+ # @api public
176
+
177
+ attribute :due_date, :date
178
+ validates :due_date, presence: true, allow_nil: true
179
+
180
+ validates_each :due_date do |record, attr, value|
181
+ before_type_cast = record.due_date_before_type_cast
182
+ record.errors.add(attr, "'#{before_type_cast}' is not a valid date") if value.nil? && !before_type_cast.nil?
183
+ end
184
+
185
+ def due_date=(date)
186
+ @due_date_before_type_cast = date
187
+ super(date)
188
+ end
189
+
190
+ # Sets the primary key to `:name`
191
+ # @return [Symbol] the attribute name of the primary key
192
+ # @api private
193
+ def self.primary_key
194
+ :name
195
+ end
196
+
197
+ # The due_date supplied by the user since ActiveModel sets it to nil of invalid
198
+ # @return [Date, String, nil] the due_date before type cast
199
+ # @api private
200
+ attr_reader :due_date_before_type_cast
201
+
202
+ # private
203
+
204
+ # Create a new issue template from the given template_yaml
205
+ #
206
+ # @example
207
+ # template_yaml = <<~YAML
208
+ # template_name: Take out the trash
209
+ # cron: '0 7 * * 1,2,3,4,5 America/Los_Angeles' # Mon-Fri, 7 AM UTC
210
+ # project: 'MYPROJECT'
211
+ # component: 'Internal'
212
+ # summary: 'Take out the trash'
213
+ # description: |
214
+ # Take out the trash in the following rooms:
215
+ # - kitchen
216
+ # - bathroom
217
+ # - bedroom
218
+ # type: 'Story'
219
+ # due_date: '2022-05-03'
220
+ # YAML
221
+ #
222
+ # template = IssueScheduler::IssueTemplate.new(template_yaml)
223
+ # template.recurrance_rule #=> "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR"
224
+ #
225
+ # @param template_yaml [String] the YAML string to parse
226
+ #
227
+
228
+ # Define getter methods for allowed keys
229
+ # ATTRIBUTES.each do |key, _value|
230
+ # define_method(key) do
231
+ # template[__method__]
232
+ # end
233
+ # end
234
+
235
+ # private
236
+
237
+ # The template Hash read from YAML
238
+ # @return [Hash]
239
+ # @api private
240
+ # attr_reader :template
241
+
242
+ # Reads the config file returning a hash
243
+ # @raise [RuntimeError] if the config file can not be read or is not valid
244
+ # @return [Hash<String, String] the config hash read from config_yaml
245
+ # @api private
246
+ # def parse_template(template_yaml)
247
+ # YAML.safe_load(template_yaml, permitted_classes: [Symbol, Date], aliases: true, symbolize_names: true)
248
+ # rescue Psych::SyntaxError => e
249
+ # raise "Error parsing YAML template: #{e.message}"
250
+ # end
251
+
252
+ # Set default values for keys that are not given in the template
253
+ #
254
+ # @param template_from_yaml [Hash] the template from YAML
255
+ # @return [Hash] the template with default values
256
+ # @api private
257
+ # def template_with_default_values(template_from_yaml)
258
+ # template = {}
259
+ # ATTRIBUTES.each { |k, v| template[k] = v[:default] if v.key?(:default) }
260
+ # template.merge!(template_from_yaml)
261
+ # end
262
+
263
+ # Run the conversion functions for values that are given in the template
264
+ #
265
+ # @return [Void] the template is modified in place
266
+ # @api private
267
+ # def run_value_conversions!
268
+ # template.each do |key, value|
269
+ # if (conversion = ATTRIBUTES.dig(key, :conversion))
270
+ # template[key] = conversion.call(value)
271
+ # end
272
+ # end
273
+ # end
274
+
275
+ # Raise a RuntimeError if any validation method returns false
276
+ #
277
+ # @raise [RuntimeError] if the config is invalid
278
+ # @return [Void]
279
+ # @api private
280
+ # def assert_all_values_are_valid
281
+ # template.each do |key, value|
282
+ # if (validation = ATTRIBUTES.dig(key, :validation)) && !validation.call(value)
283
+ # raise "Invalid value for #{key}: #{value.pretty_inspect}"
284
+ # end
285
+ # end
286
+ # end
287
+
288
+ # Raise a RuntimeError if the config is not a Hash
289
+ # @raise [RuntimeError] if the config is invalid
290
+ # @return [Void]
291
+ # @api private
292
+ # def assert_template_is_an_object(template_from_yaml)
293
+ # raise "YAML issue template is not an object, contained '#{template_from_yaml}'" unless
294
+ # template_from_yaml.is_a?(Hash)
295
+ # end
296
+
297
+ # Raise a RuntimeError if the config contains unexpected keys
298
+ # @raise [RuntimeError] if the config is invalid
299
+ # @return [Void]
300
+ # @api private
301
+ # def assert_no_unexpected_keys
302
+ # unexpected_keys = template.keys - ATTRIBUTES.keys
303
+ # raise "Unexpected issue template keys: #{unexpected_keys}" unless
304
+ # unexpected_keys.empty?
305
+ # end
306
+
307
+ # Raise a RuntimeError if the config contains a nil value
308
+ # @raise [RuntimeError] if the config is invalid
309
+ # @return [Void]
310
+ # @api private
311
+ # def assert_all_required_keys_are_given
312
+ # required_attributes = ATTRIBUTES.select { |_, v| v[:required] }.keys
313
+ # missing_keys = required_attributes - template.keys
314
+ # raise "Missing issue template keys: #{missing_keys}" unless
315
+ # missing_keys.empty?
316
+ # end
317
+ end
318
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pp'
4
+ require 'sidekiq'
5
+ require 'sidekiq-cron'
6
+ require 'issue_scheduler'
7
+ require 'jira-ruby'
8
+
9
+ issue_config = IssueScheduler::Config.new(IssueScheduler.load_yaml('config/config.yaml'))
10
+ issue_config.load_issue_templates
11
+
12
+ Sidekiq.configure_client do |config|
13
+ config.redis = { db: 1 }
14
+ end
15
+
16
+ Sidekiq.configure_server do |config|
17
+ config.redis = { db: 1 }
18
+
19
+ config.on(:startup) do
20
+ Sidekiq::Cron::Job.destroy_all!
21
+
22
+ IssueScheduler::IssueTemplate.all.each do |issue_template|
23
+ job_properties = {
24
+ 'name' => issue_template.name,
25
+ 'description' => issue_template.summary,
26
+ 'cron' => issue_template.cron,
27
+ 'class' => 'IssueWorker',
28
+ 'args' => issue_template.name,
29
+ 'date_as_argument' => true
30
+ }
31
+
32
+ job = Sidekiq::Cron::Job.new(job_properties)
33
+ raise "Error creating job: #{job.errors.pretty_inspect}" unless job.valid?
34
+
35
+ job.save
36
+ end
37
+ end
38
+ end
39
+
40
+ # My worker class that creates issues
41
+ # @api private
42
+ class IssueWorker
43
+ include Sidekiq::Worker
44
+
45
+ # Create an issue with the given template name
46
+ # @param template_name [String] the name of the template to use
47
+ # @param time [Time] the time to use for the issue
48
+ # @return [void]
49
+ # @api private
50
+ def perform(template_name, time)
51
+ puts "**** IssueWorker#perform called with #{template_name.pretty_inspect.chomp}, #{time.pretty_inspect.chomp}"
52
+
53
+ template = load_issue_template(template_name)
54
+ return unless template
55
+
56
+ create_issue(template)
57
+
58
+ puts '**** IssueWorker#perform finished'
59
+ end
60
+
61
+ private
62
+
63
+ # Create a Jira issue based on the following template
64
+ #
65
+ # @param template [IssueScheduler::IssueTemplate] the template to base the issue from
66
+ # @return [void]
67
+ # @api private
68
+ def create_issue(template)
69
+ puts ">>>> Creating issue with template: #{template.name}"
70
+
71
+ issue = jira_client.Issue.build
72
+
73
+ fields = { 'project' => { 'key' => template.project }, 'summary' => template.summary }
74
+
75
+ # fields['component'] = { 'key' => template.component } if template.component
76
+ fields['issuetype'] = { 'name' => template.type } if template.type
77
+ # fields['description'] = template.description if template.description
78
+ # fields['duedate'] = template.due_date.strftime('%Y-%m-%d') if template.due_date
79
+
80
+ issue.save({ 'fields' => fields })
81
+ issue.fetch
82
+
83
+ puts ">>>> Created issue #{issue.key}"
84
+ end
85
+
86
+ # Create and memoize a Jira Client
87
+ #
88
+ # @return [JIRA::Client] the Jira client to be used to create issues
89
+ # @api private
90
+ def jira_client
91
+ @jira_client ||= JIRA::Client.new(issue_config.to_jira_options)
92
+ end
93
+
94
+ # Create and memoize a IssueScheduler::Config
95
+ #
96
+ # @return [IssueScheduler::Config] the config to be used to create issues
97
+ # @api private
98
+ def issue_config
99
+ @issue_config ||= IssueScheduler::Config.new(IssueScheduler.load_yaml('config/config.yaml'))
100
+ end
101
+
102
+ # Load the IssueScheduler::IssueTemplate with the given name
103
+ #
104
+ # @return [IssueScheuler::IssueTemplate] the template with the given name
105
+ # @api private
106
+ def load_issue_template(template_name)
107
+ issue_config.load_issue_templates
108
+ IssueScheduler::IssueTemplate.find(template_name)
109
+ end
110
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IssueScheduler
4
+ # The version of the Issue Scheduler gem
5
+ VERSION = '0.1.1'
6
+ end