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