checkoff 0.58.1 → 0.59.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,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