checkoff 0.58.1 → 0.59.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../function_evaluator'
4
+
5
+ module Checkoff
6
+ module SelectorClasses
7
+ module Task
8
+ # Base class to evaluate a task selector function given fully evaluated arguments
9
+ class FunctionEvaluator < ::Checkoff::SelectorClasses::FunctionEvaluator
10
+ # @param selector [Array<(Symbol, Array)>,String]
11
+ # @param tasks [Checkoff::Tasks]
12
+ def initialize(selector:,
13
+ tasks:)
14
+ @selector = selector
15
+ @tasks = tasks
16
+ super()
17
+ end
18
+
19
+ private
20
+
21
+ # @param task [Asana::Resources::Task]
22
+ # @param field_name [Symbol]
23
+ #
24
+ # @sg-ignore
25
+ # @return [Date, nil]
26
+ def pull_date_field_by_name(task, field_name)
27
+ if field_name == :modified
28
+ return Time.parse(task.modified_at).to_date unless task.modified_at.nil?
29
+
30
+ return nil
31
+ end
32
+
33
+ if field_name == :due
34
+ return Time.parse(task.due_at).to_date unless task.due_at.nil?
35
+
36
+ return Date.parse(task.due_on) unless task.due_on.nil?
37
+
38
+ return nil
39
+ end
40
+
41
+ raise "Teach me how to handle field #{field_name}"
42
+ end
43
+
44
+ # @param task [Asana::Resources::Task]
45
+ # @param field_name [Symbol]
46
+ #
47
+ # @sg-ignore
48
+ # @return [Date, Time, nil]
49
+ def pull_date_or_time_field_by_name(task, field_name)
50
+ if field_name == :due
51
+ return Time.parse(task.due_at) unless task.due_at.nil?
52
+
53
+ return Date.parse(task.due_on) unless task.due_on.nil?
54
+
55
+ return nil
56
+ end
57
+
58
+ if field_name == :start
59
+ return Time.parse(task.start_at) unless task.start_at.nil?
60
+
61
+ return Date.parse(task.start_on) unless task.start_on.nil?
62
+
63
+ return nil
64
+ end
65
+
66
+ raise "Teach me how to handle field #{field_name}"
67
+ end
68
+
69
+ # @sg-ignore
70
+ # @param task [Asana::Resources::Task]
71
+ # @param custom_field_gid [String]
72
+ # @return [Hash]
73
+ def pull_custom_field_or_raise(task, custom_field_gid)
74
+ # @type [Array<Hash>]
75
+ custom_fields = task.custom_fields
76
+ if custom_fields.nil?
77
+ raise "Could not find custom_fields under task (was 'custom_fields' included in 'extra_fields'?)"
78
+ end
79
+
80
+ # @sg-ignore
81
+ # @type [Hash, nil]
82
+ matched_custom_field = custom_fields.find { |data| data.fetch('gid') == custom_field_gid }
83
+ if matched_custom_field.nil?
84
+ raise "Could not find custom field with gid #{custom_field_gid} " \
85
+ "in task #{task.gid} with custom fields #{custom_fields}"
86
+ end
87
+
88
+ matched_custom_field
89
+ end
90
+
91
+ # @return [Array<(Symbol, Array)>]
92
+ attr_reader :selector
93
+
94
+ # @sg-ignore
95
+ # @param custom_field [Hash]
96
+ # @return [Array<String>]
97
+ def pull_enum_values(custom_field)
98
+ resource_subtype = custom_field.fetch('resource_subtype')
99
+ case resource_subtype
100
+ when 'enum'
101
+ [custom_field.fetch('enum_value')]
102
+ when 'multi_enum'
103
+ custom_field.fetch('multi_enum_values')
104
+ else
105
+ raise "Teach me how to handle resource_subtype #{resource_subtype}"
106
+ end
107
+ end
108
+
109
+ # @param custom_field [Hash]
110
+ # @param enum_value [Object, nil]
111
+ # @return [Array<String>]
112
+ def find_gids(custom_field, enum_value)
113
+ if enum_value.nil?
114
+ []
115
+ else
116
+ raise "Unexpected enabled value on custom field: #{custom_field}" if enum_value.fetch('enabled') == false
117
+
118
+ [enum_value.fetch('gid')]
119
+ end
120
+ end
121
+
122
+ # @param task [Asana::Resources::Task]
123
+ # @param custom_field_gid [String]
124
+ # @return [Array<String>]
125
+ def pull_custom_field_values_gids(task, custom_field_gid)
126
+ custom_field = pull_custom_field_or_raise(task, custom_field_gid)
127
+ pull_enum_values(custom_field).flat_map do |enum_value|
128
+ find_gids(custom_field, enum_value)
129
+ end
130
+ end
131
+
132
+ # @sg-ignore
133
+ # @param task [Asana::Resources::Task]
134
+ # @param custom_field_name [String]
135
+ # @return [Hash, nil]
136
+ def pull_custom_field_by_name(task, custom_field_name)
137
+ custom_fields = task.custom_fields
138
+ if custom_fields.nil?
139
+ raise "custom fields not found on task - did you add 'custom_fields' in your extra_fields argument?"
140
+ end
141
+
142
+ # @sg-ignore
143
+ # @type [Hash, nil]
144
+ custom_fields.find { |field| field.fetch('name') == custom_field_name }
145
+ end
146
+
147
+ # @param task [Asana::Resources::Task]
148
+ # @param custom_field_name [String]
149
+ # @return [Hash]
150
+ def pull_custom_field_by_name_or_raise(task, custom_field_name)
151
+ custom_field = pull_custom_field_by_name(task, custom_field_name)
152
+ if custom_field.nil?
153
+ raise "Could not find custom field with name #{custom_field_name} " \
154
+ "in task #{task.gid} with custom fields #{task.custom_fields}"
155
+ end
156
+ custom_field
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'task/function_evaluator'
4
+
5
+ module Checkoff
6
+ module SelectorClasses
7
+ module Task
8
+ # :tag function
9
+ class TagPFunctionEvaluator < FunctionEvaluator
10
+ def matches?
11
+ fn?(selector, :tag)
12
+ end
13
+
14
+ # @param _index [Integer]
15
+ def evaluate_arg?(_index)
16
+ false
17
+ end
18
+
19
+ # @sg-ignore
20
+ # @param task [Asana::Resources::Task]
21
+ # @param tag_name [String]
22
+ # @return [Boolean]
23
+ def evaluate(task, tag_name)
24
+ task.tags.map(&:name).include? tag_name
25
+ end
26
+ end
27
+
28
+ # :due function
29
+ class DuePFunctionEvaluator < FunctionEvaluator
30
+ def matches?
31
+ fn?(selector, :due)
32
+ end
33
+
34
+ # @param task [Asana::Resources::Task]
35
+ # @param ignore_dependencies [Boolean]
36
+ # @return [Boolean]
37
+ def evaluate(task, ignore_dependencies: false)
38
+ @tasks.task_ready?(task, ignore_dependencies: ignore_dependencies)
39
+ end
40
+ end
41
+
42
+ # :unassigned function
43
+ class UnassignedPFunctionEvaluator < FunctionEvaluator
44
+ def matches?
45
+ fn?(selector, :unassigned)
46
+ end
47
+
48
+ # @param task [Asana::Resources::Task]
49
+ # @return [Boolean]
50
+ def evaluate(task)
51
+ task.assignee.nil?
52
+ end
53
+ end
54
+
55
+ # :due_date_set function
56
+ class DueDateSetPFunctionEvaluator < FunctionEvaluator
57
+ FUNCTION_NAME = :due_date_set
58
+
59
+ def matches?
60
+ fn?(selector, FUNCTION_NAME)
61
+ end
62
+
63
+ # @sg-ignore
64
+ # @param task [Asana::Resources::Task]
65
+ # @return [Boolean]
66
+ def evaluate(task)
67
+ !task.due_at.nil? || !task.due_on.nil?
68
+ end
69
+ end
70
+
71
+ # :due_between_n_days function
72
+ class DueBetweenRelativePFunctionEvaluator < FunctionEvaluator
73
+ FUNCTION_NAME = :due_between_relative
74
+
75
+ def matches?
76
+ fn?(selector, FUNCTION_NAME)
77
+ end
78
+
79
+ # @param _index [Integer]
80
+ def evaluate_arg?(_index)
81
+ false
82
+ end
83
+
84
+ # @param task [Asana::Resources::Task]
85
+ # @param beginning_num_days_from_now [Integer]
86
+ # @param end_num_days_from_now [Integer]
87
+ # @param ignore_dependencies [Boolean]
88
+ #
89
+ # @return [Boolean]
90
+ def evaluate(task, beginning_num_days_from_now, end_num_days_from_now, ignore_dependencies: false)
91
+ beginning_n_days_from_now_time = (Time.now + (beginning_num_days_from_now * 24 * 60 * 60))
92
+ end_n_days_from_now_time = (Time.now + (end_num_days_from_now * 24 * 60 * 60))
93
+
94
+ # @type [Date, Time, nil]
95
+ task_date_or_time = pull_date_or_time_field_by_name(task, :start) ||
96
+ pull_date_or_time_field_by_name(task, :due)
97
+
98
+ return false if task_date_or_time.nil?
99
+
100
+ # if time
101
+ in_range = if task_date_or_time.is_a?(Time)
102
+ task_date_or_time > beginning_n_days_from_now_time &&
103
+ task_date_or_time <= end_n_days_from_now_time
104
+ else
105
+ # if date
106
+ task_date_or_time > beginning_n_days_from_now_time.to_date &&
107
+ task_date_or_time <= end_n_days_from_now_time.to_date
108
+ end
109
+
110
+ return false unless in_range
111
+
112
+ return false if !ignore_dependencies && @tasks.incomplete_dependencies?(task)
113
+
114
+ true
115
+ end
116
+ end
117
+
118
+ # :field_less_than_n_days_ago
119
+ class FieldLessThanNDaysAgoPFunctionEvaluator < FunctionEvaluator
120
+ FUNCTION_NAME = :field_less_than_n_days_ago
121
+
122
+ def matches?
123
+ fn?(selector, FUNCTION_NAME)
124
+ end
125
+
126
+ def evaluate_arg?(_index)
127
+ false
128
+ end
129
+
130
+ # @param task [Asana::Resources::Task]
131
+ # @param field_name [Symbol]
132
+ # @param num_days [Integer]
133
+ #
134
+ # @return [Boolean]
135
+ def evaluate(task, field_name, num_days)
136
+ date = pull_date_field_by_name(task, field_name)
137
+
138
+ return false if date.nil?
139
+
140
+ # @sg-ignore
141
+ n_days_ago = Date.today - num_days
142
+ # @sg-ignore
143
+ date < n_days_ago
144
+ end
145
+ end
146
+
147
+ # :field_greater_than_or_equal_to_n_days_from_today
148
+ class FieldGreaterThanOrEqualToNDaysFromTodayPFunctionEvaluator < FunctionEvaluator
149
+ FUNCTION_NAME = :field_greater_than_or_equal_to_n_days_from_today
150
+
151
+ def matches?
152
+ fn?(selector, FUNCTION_NAME)
153
+ end
154
+
155
+ def evaluate_arg?(_index)
156
+ false
157
+ end
158
+
159
+ # @param task [Asana::Resources::Task]
160
+ # @param field_name [Symbol]
161
+ # @param num_days [Integer]
162
+ #
163
+ # @return [Boolean]
164
+ def evaluate(task, field_name, num_days)
165
+ date = pull_date_field_by_name(task, field_name)
166
+
167
+ return false if date.nil?
168
+
169
+ # @sg-ignore
170
+ n_days_from_today = Date.today + num_days
171
+ # @sg-ignore
172
+ date >= n_days_from_today
173
+ end
174
+ end
175
+
176
+ # :custom_field_less_than_n_days_from_now function
177
+ class CustomFieldLessThanNDaysFromNowFunctionEvaluator < FunctionEvaluator
178
+ FUNCTION_NAME = :custom_field_less_than_n_days_from_now
179
+
180
+ def matches?
181
+ fn?(selector, FUNCTION_NAME)
182
+ end
183
+
184
+ def evaluate_arg?(_index)
185
+ false
186
+ end
187
+
188
+ # @param task [Asana::Resources::Task]
189
+ # @param custom_field_name [String]
190
+ # @param num_days [Integer]
191
+ # @return [Boolean]
192
+ def evaluate(task, custom_field_name, num_days)
193
+ custom_field = pull_custom_field_by_name_or_raise(task, custom_field_name)
194
+
195
+ # @sg-ignore
196
+ # @type [String, nil]
197
+ time_str = custom_field.fetch('display_value')
198
+ return false if time_str.nil?
199
+
200
+ time = Time.parse(time_str)
201
+ n_days_from_now = (Time.now + (num_days * 24 * 60 * 60))
202
+ time < n_days_from_now
203
+ end
204
+ end
205
+
206
+ # :custom_field_greater_than_or_equal_to_n_days_from_now function
207
+ class CustomFieldGreaterThanOrEqualToNDaysFromNowFunctionEvaluator < FunctionEvaluator
208
+ FUNCTION_NAME = :custom_field_greater_than_or_equal_to_n_days_from_now
209
+
210
+ def matches?
211
+ fn?(selector, FUNCTION_NAME)
212
+ end
213
+
214
+ def evaluate_arg?(_index)
215
+ false
216
+ end
217
+
218
+ # @param task [Asana::Resources::Task]
219
+ # @param custom_field_name [String]
220
+ # @param num_days [Integer]
221
+ # @return [Boolean]
222
+ def evaluate(task, custom_field_name, num_days)
223
+ custom_field = pull_custom_field_by_name_or_raise(task, custom_field_name)
224
+
225
+ # @sg-ignore
226
+ # @type [String, nil]
227
+ time_str = custom_field.fetch('display_value')
228
+ return false if time_str.nil?
229
+
230
+ time = Time.parse(time_str)
231
+ n_days_from_now = (Time.now + (num_days * 24 * 60 * 60))
232
+ time >= n_days_from_now
233
+ end
234
+ end
235
+
236
+ # :last_story_created_less_than_n_days_ago function
237
+ class LastStoryCreatedLessThanNDaysAgoFunctionEvaluator < FunctionEvaluator
238
+ FUNCTION_NAME = :last_story_created_less_than_n_days_ago
239
+
240
+ def matches?
241
+ fn?(selector, FUNCTION_NAME)
242
+ end
243
+
244
+ def evaluate_arg?(_index)
245
+ false
246
+ end
247
+
248
+ # @param task [Asana::Resources::Task]
249
+ # @param num_days [Integer]
250
+ # @param excluding_resource_subtypes [Array<String>]
251
+ # @return [Boolean]
252
+ def evaluate(task, num_days, excluding_resource_subtypes)
253
+ # for whatever reason, .last on the enumerable does not impose ordering; .to_a does!
254
+
255
+ # @type [Array<Asana::Resources::Story>]
256
+ stories = task.stories(per_page: 100).to_a.reject do |story|
257
+ excluding_resource_subtypes.include? story.resource_subtype
258
+ end
259
+ return true if stories.empty? # no stories == infinitely old!
260
+
261
+ last_story = stories.last
262
+ last_story_created_at = Time.parse(last_story.created_at)
263
+ n_days_ago = Time.now - (num_days * 24 * 60 * 60)
264
+ last_story_created_at < n_days_ago
265
+ end
266
+ end
267
+
268
+ # :estimate_exceeds_duration
269
+ class EstimateExceedsDurationFunctionEvaluator < FunctionEvaluator
270
+ FUNCTION_NAME = :estimate_exceeds_duration
271
+
272
+ def matches?
273
+ fn?(selector, FUNCTION_NAME)
274
+ end
275
+
276
+ # @param task [Asana::Resources::Task]
277
+ # @return [Float]
278
+ def calculate_allocated_hours(task)
279
+ due_on = nil
280
+ start_on = nil
281
+ start_on = Date.parse(task.start_on) unless task.start_on.nil?
282
+ due_on = Date.parse(task.due_on) unless task.due_on.nil?
283
+ allocated_hours = 8.0
284
+ # @sg-ignore
285
+ allocated_hours = (due_on - start_on + 1).to_i * 8.0 if start_on && due_on
286
+ allocated_hours
287
+ end
288
+
289
+ # @param task [Asana::Resources::Task]
290
+ # @return [Boolean]
291
+ def evaluate(task)
292
+ custom_field = pull_custom_field_by_name_or_raise(task, 'Estimated time')
293
+
294
+ # @sg-ignore
295
+ # @type [Integer, nil]
296
+ estimate_minutes = custom_field.fetch('number_value')
297
+
298
+ # no estimate set
299
+ return false if estimate_minutes.nil?
300
+
301
+ estimate_hours = estimate_minutes / 60.0
302
+
303
+ allocated_hours = calculate_allocated_hours(task)
304
+
305
+ estimate_hours > allocated_hours
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkoff
4
+ # Base class to evaluate Asana resource selectors against an Asana resource
5
+ class SelectorEvaluator
6
+ # @param selector [Array]
7
+ # @return [Boolean, Object, nil]
8
+ def evaluate(selector)
9
+ return true if selector.empty?
10
+
11
+ function_evaluators.each do |evaluator_class|
12
+ # @type [SelectorClasses::FunctionEvaluator]
13
+ # @sg-ignore
14
+ evaluator = evaluator_class.new(selector: selector,
15
+ **initializer_kwargs)
16
+
17
+ next unless evaluator.matches?
18
+
19
+ return try_this_evaluator(selector, evaluator)
20
+ end
21
+
22
+ raise "Syntax issue trying to handle #{selector.inspect}"
23
+ end
24
+
25
+ private
26
+
27
+ # @return [Hash]
28
+ def initializer_kwargs
29
+ {}
30
+ end
31
+
32
+ # @return [Array<Class<FunctionEvaluator>>]
33
+ # @sg-ignore
34
+ def function_evaluators
35
+ raise 'Implement me!'
36
+ end
37
+
38
+ # @param selector [Array]
39
+ # @param evaluator [SelectorClasses::FunctionEvaluator]
40
+ # @return [Array]
41
+ def evaluate_args(selector, evaluator)
42
+ return [] unless selector.is_a?(Array)
43
+
44
+ selector[1..].map.with_index do |item, index|
45
+ if evaluator.evaluate_arg?(index)
46
+ evaluate(item)
47
+ else
48
+ item
49
+ end
50
+ end
51
+ end
52
+
53
+ # @param selector [Array]
54
+ # @param evaluator [SelectorClasses::FunctionEvaluator]
55
+ # @return [Boolean, Object, nil]
56
+ def try_this_evaluator(selector, evaluator)
57
+ # if selector is an array
58
+ evaluated_args = evaluate_args(selector, evaluator)
59
+
60
+ evaluator.evaluate(item, *evaluated_args)
61
+ end
62
+
63
+ # @return [Asana::Resources::Resource]
64
+ attr_reader :item
65
+ end
66
+ end