issue_scheduler 0.1.1

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