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