checkoff 0.97.0 → 0.99.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b1e38f1b4d152c8c28b2ebb2c5a946abe7e2d95ce9650bdf708ecf177afd424
4
- data.tar.gz: 55449cde16e497f2332af45b7a8301c926e227c0732c7f0dc4cb9b837ecbf888
3
+ metadata.gz: f0aa9c88e96e1094d284c59a9c7146ff72f393f2e16442c8e320b3253ab0221d
4
+ data.tar.gz: 7f6021289d7fb0e331cb6b993b10307287c365d9a00d5fbe789e5b6c13972dad
5
5
  SHA512:
6
- metadata.gz: 2d8995a72761c9cba456c3c5da9e1cf57d6693018005ccfec14e34db8f9ea82c226628b4794f09fd28495c5b273a06ad1088f6bb5fe01d505d05487c8a754d66
7
- data.tar.gz: f295d9173c6e605f250b39a4572cc0148d1a720f10ca0bae3a5157504c5f36003e2f08012e75877fc0dc60f298e8d3916acd41ac03a6f7beb4e45fafd5725c28
6
+ metadata.gz: ab4476c149bf918e5d138f71a5d1c5df78192f3fa3fa13e8ef0e8a2d7c7198b34268a78ab14e6009237d3cf0113316f3ff91222d22893606c5ecc1ab31830d55
7
+ data.tar.gz: 4f804bcfc9548b7106fcd80c5764da4250bf024e9dc7f1c2b95460078b89de3a238ba2b28f6d44188cd3fee074a0d348925ef780b751534a0f801f08b74fab24
data/Gemfile.lock CHANGED
@@ -12,7 +12,7 @@ GIT
12
12
  PATH
13
13
  remote: .
14
14
  specs:
15
- checkoff (0.97.0)
15
+ checkoff (0.99.0)
16
16
  activesupport
17
17
  asana (> 0.10.0)
18
18
  cache_method
@@ -42,6 +42,8 @@
42
42
  # module Resources
43
43
  # # https://developers.asana.com/reference/gettask
44
44
  # class Task
45
+ # # @return [String]
46
+ # def resource_subtype; end
45
47
  # # @return [String,nil]
46
48
  # def due_at; end
47
49
  # # @return [String,nil]
@@ -136,6 +138,12 @@
136
138
  # # @param project_gid [String]
137
139
  # # @return [Enumerable<Asana::Resources::Section>]
138
140
  # def get_sections_for_project(project_gid:, options: {}); end
141
+ # # Returns the complete record for a single section.
142
+ # #
143
+ # # @param [String] id - The section to get.
144
+ # # @param options [Hash] - the request I/O options.
145
+ # # @return [Asana::Resources::Section]
146
+ # def find_by_id(id, options: {}); end
139
147
  # end
140
148
  # class Project
141
149
  # # Returns the compact project records for all projects in the workspace.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../function_evaluator'
4
+ require 'checkoff/internal/task_timing'
4
5
 
5
6
  module Checkoff
6
7
  module SelectorClasses
@@ -9,10 +10,14 @@ module Checkoff
9
10
  class FunctionEvaluator < ::Checkoff::SelectorClasses::FunctionEvaluator
10
11
  # @param selector [Array<(Symbol, Array)>,String]
11
12
  # @param tasks [Checkoff::Tasks]
13
+ # @param timelines [Checkoff::Timelines]
12
14
  def initialize(selector:,
13
- tasks:)
15
+ tasks:,
16
+ timelines:)
14
17
  @selector = selector
15
18
  @tasks = tasks
19
+ @timelines = timelines
20
+ @task_timing = ::Checkoff::Internal::TaskTiming.new
16
21
  super()
17
22
  end
18
23
 
@@ -41,31 +46,6 @@ module Checkoff
41
46
  raise "Teach me how to handle field #{field_name}"
42
47
  end
43
48
 
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
49
  # @sg-ignore
70
50
  # @param task [Asana::Resources::Task]
71
51
  # @param custom_field_gid [String]
@@ -138,8 +138,8 @@ module Checkoff
138
138
  end_n_days_from_now_time = (Time.now + (end_num_days_from_now * 24 * 60 * 60))
139
139
 
140
140
  # @type [Date, Time, nil]
141
- task_date_or_time = pull_date_or_time_field_by_name(task, :start) ||
142
- pull_date_or_time_field_by_name(task, :due)
141
+ task_date_or_time = @task_timing.date_or_time_field_by_name(task, :start) ||
142
+ @task_timing.date_or_time_field_by_name(task, :due)
143
143
 
144
144
  return false if task_date_or_time.nil?
145
145
 
@@ -353,6 +353,26 @@ module Checkoff
353
353
  estimate_hours > allocated_hours
354
354
  end
355
355
  end
356
+
357
+ # :dependent_on_previous_section_last_milestone
358
+ class DependentOnPreviousSectionLastMilestoneFunctionEvaluator < FunctionEvaluator
359
+ FUNCTION_NAME = :dependent_on_previous_section_last_milestone
360
+
361
+ def matches?
362
+ fn?(selector, FUNCTION_NAME)
363
+ end
364
+
365
+ # @param task [Asana::Resources::Task]
366
+ # @param project_name [String]
367
+ # @param limit_to_portfolio_gid [String, nil] If specified,
368
+ # only projects in this portfolio will be evaluated.
369
+ #
370
+ # @return [Boolean]
371
+ def evaluate(task, limit_to_portfolio_gid: nil)
372
+ @timelines.task_dependent_on_previous_section_last_milestone?(task,
373
+ limit_to_portfolio_gid: limit_to_portfolio_gid)
374
+ end
375
+ end
356
376
  end
357
377
  end
358
378
  end
@@ -9,10 +9,13 @@ module Checkoff
9
9
  class TaskSelectorEvaluator < SelectorEvaluator
10
10
  # @param task [Asana::Resources::Task]
11
11
  # @param tasks [Checkoff::Tasks]
12
+ # @param timelines [Checkoff::Timelines]
12
13
  def initialize(task:,
13
- tasks: Checkoff::Tasks.new)
14
+ tasks: Checkoff::Tasks.new,
15
+ timelines: Checkoff::Timelines.new)
14
16
  @item = task
15
17
  @tasks = tasks
18
+ @timelines = timelines
16
19
  super()
17
20
  end
18
21
 
@@ -35,12 +38,14 @@ module Checkoff
35
38
 
36
39
  # @return [Hash]
37
40
  def initializer_kwargs
38
- { tasks: tasks }
41
+ { tasks: tasks, timelines: timelines }
39
42
  end
40
43
 
41
44
  # @return [Asana::Resources::Task]
42
45
  attr_reader :item
43
46
  # @return [Checkoff::Tasks]
44
47
  attr_reader :tasks
48
+ # @return [Checkoff::Timelines]
49
+ attr_reader :timelines
45
50
  end
46
51
  end
@@ -5,27 +5,62 @@ module Checkoff
5
5
  # Utility methods for working with task dates and times
6
6
  class TaskTiming
7
7
  # @param time_class [Class<Time>]
8
- def initialize(time_class: Time)
8
+ # @param date_class [Class<Date>]
9
+ def initialize(time_class: Time, date_class: Date)
9
10
  @time_class = time_class
11
+ @date_class = date_class
10
12
  end
11
13
 
12
14
  # @param task [Asana::Resources::Task]
13
15
  # @return [Time, nil]
14
16
  def start_time(task)
15
- return @time_class.parse(task.start_at) if task.start_at
16
- return @time_class.parse(task.start_on) if task.start_on
17
-
18
- nil
17
+ date_or_time_field_by_name(task, :start)&.to_time
19
18
  end
20
19
 
21
20
  # @param task [Asana::Resources::Task]
22
21
  # @return [Time, nil]
23
22
  def due_time(task)
24
- return @time_class.parse(task.due_at) if task.due_at
25
- return @time_class.parse(task.due_on) if task.due_on
23
+ date_or_time_field_by_name(task, :due)&.to_time
24
+ end
25
+
26
+ # @param task [Asana::Resources::Task]
27
+ # @param field_name [Symbol]
28
+ #
29
+ # @sg-ignore
30
+ # @return [Date, Time, nil]
31
+ def start_date_or_time(task)
32
+ return @time_class.parse(task.start_at) unless task.start_at.nil?
33
+
34
+ return @date_class.parse(task.start_on) unless task.start_on.nil?
26
35
 
27
36
  nil
28
37
  end
38
+
39
+ # @param task [Asana::Resources::Task]
40
+ # @param field_name [Symbol]
41
+ #
42
+ # @sg-ignore
43
+ # @return [Date, Time, nil]
44
+ def due_date_or_time(task)
45
+ return @time_class.parse(task.due_at) unless task.due_at.nil?
46
+
47
+ return @date_class.parse(task.due_on) unless task.due_on.nil?
48
+
49
+ nil
50
+ end
51
+
52
+ # @param task [Asana::Resources::Task]
53
+ # @param field_name [Symbol]
54
+ #
55
+ # @sg-ignore
56
+ # @return [Date, Time, nil]
57
+ def date_or_time_field_by_name(task, field_name)
58
+ return due_date_or_time(task) if field_name == :due
59
+
60
+ return start_date_or_time(task) if field_name == :start
61
+
62
+ raise "Teach me how to handle field #{field_name}"
63
+ end
29
64
  end
30
65
  end
31
66
  end
@@ -56,13 +56,23 @@ module Checkoff
56
56
  #
57
57
  # @return [Enumerable<Asana::Resources::Section>]
58
58
  def sections_or_raise(workspace_name, project_name, extra_fields: [])
59
- fields = %w[name] + extra_fields
60
59
  project = project_or_raise(workspace_name, project_name)
61
- client.sections.get_sections_for_project(project_gid: project.gid,
62
- options: { fields: fields })
60
+ sections_by_project_gid(project.gid, extra_fields: extra_fields)
63
61
  end
64
62
  cache_method :sections_or_raise, SHORT_CACHE_TIME
65
63
 
64
+ # Returns a list of Asana API section objects for a given project GID
65
+ # @param project_gid [String]
66
+ # @param extra_fields [Array<String>]
67
+ #
68
+ # @return [Enumerable<Asana::Resources::Section>]
69
+ def sections_by_project_gid(project_gid, extra_fields: [])
70
+ fields = %w[name] + extra_fields
71
+ client.sections.get_sections_for_project(project_gid: project_gid,
72
+ options: { fields: fields })
73
+ end
74
+ cache_method :sections_by_project_gid, SHORT_CACHE_TIME
75
+
66
76
  # Given a workspace name and project name, then provide a Hash of
67
77
  # tasks with section name -> task list of the uncompleted tasks
68
78
  # @param workspace_name [String]
@@ -85,6 +95,20 @@ module Checkoff
85
95
  end
86
96
  end
87
97
 
98
+ # @param section_gid [String]
99
+ # @param only_uncompleted [Boolean]
100
+ # @param extra_fields [Array<String>]
101
+ #
102
+ # @return [Enumerable<Asana::Resources::Task>]
103
+ def tasks_by_section_gid(section_gid,
104
+ only_uncompleted: true,
105
+ extra_fields: [])
106
+ options = projects.task_options
107
+ options[:options][:fields] += extra_fields
108
+ options[:completed_since] = '9999-12-01' if only_uncompleted
109
+ client.tasks.get_tasks(section: section_gid, **options)
110
+ end
111
+
88
112
  # XXX: Rename to section_tasks
89
113
  #
90
114
  # Pulls task objects from a specified section
@@ -147,8 +171,45 @@ module Checkoff
147
171
  name
148
172
  end
149
173
 
174
+ # @param section [Asana::Resources::Section]
175
+ #
176
+ # @return [Asana::Resources::Section, nil]
177
+ def previous_section(section)
178
+ sections = sections_by_project_gid(section.project.fetch('gid'))
179
+
180
+ # @type [Array<Asana::Resources::Section>]
181
+ sections = sections.to_a
182
+
183
+ index = sections.find_index { |s| s.gid == section.gid }
184
+ return nil if index.nil? || index.zero?
185
+
186
+ sections[index - 1]
187
+ end
188
+ cache_method :previous_section, SHORT_CACHE_TIME
189
+
190
+ # @param gid [String]
191
+ #
192
+ # @return [Asana::Resources::Section]
193
+ def section_by_gid(gid)
194
+ options = {}
195
+ Asana::Resources::Section.new(parse(client.get("/sections/#{gid}", options: options)).first,
196
+ client: client)
197
+ end
198
+
150
199
  private
151
200
 
201
+ # https://github.com/Asana/ruby-asana/blob/master/lib/asana/resource_includes/response_helper.rb#L7
202
+ # @param response [Faraday::Response]
203
+ #
204
+ # @return [Array<Hash, Hash>]
205
+ def parse(response)
206
+ data = response.body.fetch('data') do
207
+ raise("Unexpected response body: #{response.body}")
208
+ end
209
+ extra = response.body.except('data')
210
+ [data, extra]
211
+ end
212
+
152
213
  # @return [Asana::Client]
153
214
  attr_reader :client
154
215
 
@@ -188,6 +249,7 @@ module Checkoff
188
249
  # @param project_gid [String]
189
250
  # @return [void]
190
251
  def file_task_by_section(by_section, task, project_gid)
252
+ # @sg-ignore
191
253
  # @type [Array<Hash>]
192
254
  membership = task.memberships.find { |m| m['project']['gid'] == project_gid }
193
255
  raise "Could not find task in project_gid #{project_gid}: #{task}" if membership.nil?
@@ -23,12 +23,16 @@ module Checkoff
23
23
  # @param [Hash] config
24
24
  # @param [Asana::Client] client
25
25
  # @param [Checkoff::Tasks] tasks
26
+ # @param [Checkoff::Timelines] timelines
26
27
  def initialize(config: Checkoff::Internal::ConfigLoader.load(:asana),
27
28
  client: Checkoff::Clients.new(config: config).client,
28
29
  tasks: Checkoff::Tasks.new(config: config,
29
- client: client))
30
+ client: client),
31
+ timelines: Checkoff::Timelines.new(config: config,
32
+ client: client))
30
33
  @config = config
31
34
  @tasks = tasks
35
+ @timelines = timelines
32
36
  end
33
37
 
34
38
  # @param [Asana::Resources::Task] task
@@ -36,7 +40,7 @@ module Checkoff
36
40
  # task details. Examples: [:tag, 'foo'] [:not, [:tag, 'foo']] [:tag, 'foo']
37
41
  # @return [Boolean]
38
42
  def filter_via_task_selector(task, task_selector)
39
- evaluator = TaskSelectorEvaluator.new(task: task, tasks: tasks)
43
+ evaluator = TaskSelectorEvaluator.new(task: task, tasks: tasks, timelines: timelines)
40
44
  evaluator.evaluate(task_selector)
41
45
  end
42
46
 
@@ -45,6 +49,9 @@ module Checkoff
45
49
  # @return [Checkoff::Tasks]
46
50
  attr_reader :tasks
47
51
 
52
+ # @return [Checkoff::Timelines]
53
+ attr_reader :timelines
54
+
48
55
  # bundle exec ./task_selectors.rb
49
56
  # :nocov:
50
57
  class << self
@@ -27,6 +27,7 @@ module Checkoff
27
27
  # @param workspaces [Checkoff::Workspaces]
28
28
  # @param sections [Checkoff::Sections]
29
29
  # @param time_class [Class<Time>]
30
+ # @param date_class [Class<Date>]
30
31
  # @param asana_task [Class<Asana::Resources::Task>]
31
32
  def initialize(config: Checkoff::Internal::ConfigLoader.load(:asana),
32
33
  client: Checkoff::Clients.new(config: config).client,
@@ -35,10 +36,12 @@ module Checkoff
35
36
  sections: Checkoff::Sections.new(config: config,
36
37
  client: client),
37
38
  time_class: Time,
39
+ date_class: Date,
38
40
  asana_task: Asana::Resources::Task)
39
41
  @config = config
40
42
  @sections = sections
41
43
  @time_class = time_class
44
+ @date_class = date_class
42
45
  @asana_task = asana_task
43
46
  @client = client
44
47
  @workspaces = workspaces
@@ -167,7 +170,7 @@ module Checkoff
167
170
 
168
171
  # @return [Checkoff::Internal::TaskTiming]
169
172
  def task_timing
170
- @task_timing ||= Checkoff::Internal::TaskTiming.new(time_class: @time_class)
173
+ @task_timing ||= Checkoff::Internal::TaskTiming.new(time_class: @time_class, date_class: @date_class)
171
174
  end
172
175
 
173
176
  # @return [Checkoff::Internal::TaskHashes]
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require 'forwardable'
6
+ require 'cache_method'
7
+ require_relative 'internal/config_loader'
8
+ require_relative 'workspaces'
9
+ require_relative 'clients'
10
+
11
+ # https://developers.asana.com/reference/timelines
12
+
13
+ module Checkoff
14
+ # Manages timelines of dependent tasks with dates and milestones
15
+ class Timelines
16
+ # @!parse
17
+ # extend CacheMethod::ClassMethods
18
+
19
+ MINUTE = 60
20
+ HOUR = MINUTE * 60
21
+ DAY = 24 * HOUR
22
+ REALLY_LONG_CACHE_TIME = HOUR * 1
23
+ LONG_CACHE_TIME = MINUTE * 15
24
+ SHORT_CACHE_TIME = MINUTE
25
+
26
+ # @param config [Hash]
27
+ # @param workspaces [Checkoff::Workspaces]
28
+ # @param sections [Checkoff::Sections]
29
+ # @param tasks [Checkoff::Tasks]
30
+ # @param clients [Checkoff::Clients]
31
+ # @param client [Asana::Client]
32
+ def initialize(config: Checkoff::Internal::ConfigLoader.load(:asana),
33
+ workspaces: Checkoff::Workspaces.new(config: config),
34
+ sections: Checkoff::Sections.new(config: config),
35
+ tasks: Checkoff::Tasks.new(config: config),
36
+ clients: Checkoff::Clients.new(config: config),
37
+ client: clients.client)
38
+ @workspaces = workspaces
39
+ @sections = sections
40
+ @tasks = tasks
41
+ @client = client
42
+ end
43
+
44
+ # @param task [Asana::Resources::Task]
45
+ # @param limit_to_portfolio_gid [String, nil]
46
+ # @param project_name [String]
47
+ def task_dependent_on_previous_section_last_milestone?(task, limit_to_portfolio_gid: nil)
48
+ task_data = @tasks.task_to_h(task)
49
+ # @sg-ignore
50
+ # @type [Array<Hash{String => Hash{String => String}}>]
51
+ memberships_data = task_data.fetch('memberships')
52
+ memberships_data.all? do |membership_data|
53
+ # @type [Hash{String => String}]
54
+ section_data = membership_data.fetch('section')
55
+ section_gid = section_data.fetch('gid')
56
+ section = @sections.section_by_gid(section_gid)
57
+ task_data_dependent_on_previous_section_last_milestone?(task_data, section)
58
+ end
59
+ end
60
+
61
+ # @param section_gid [String]
62
+ #
63
+ # @return [Asana::Resources::Task,nil]
64
+ def last_milestone_in_section(section_gid)
65
+ # @type [Array<Asana::Resources::Task>]
66
+ task_list = @sections.tasks_by_section_gid(section_gid).to_a
67
+ last_task = task_list.last
68
+ last_task&.resource_subtype == 'milestone' ? last_task : nil
69
+ end
70
+
71
+ private
72
+
73
+ # @param task_data [Hash]
74
+ # @param section [Asana::Resources::Section]
75
+ #
76
+ # @return [Boolean]
77
+ def task_data_dependent_on_previous_section_last_milestone?(task_data, section)
78
+ # @sg-ignore
79
+ # @type [Array<Hash{String => String}>]
80
+ dependencies = task_data.fetch('dependencies')
81
+ return false if dependencies.empty?
82
+
83
+ previous_section = @sections.previous_section(section)
84
+ return false if previous_section.nil?
85
+
86
+ previous_section_last_milestone = last_milestone_in_section(previous_section.gid)
87
+ return false if previous_section_last_milestone.nil?
88
+
89
+ dependencies.any? { |dependency| dependency.fetch('gid') == previous_section_last_milestone.gid }
90
+ end
91
+
92
+ # @return [Checkoff::Workspaces]
93
+ attr_reader :workspaces
94
+
95
+ # @return [Asana::Client]
96
+ attr_reader :client
97
+
98
+ # bundle exec ./timelines.rb
99
+ # :nocov:
100
+ class << self
101
+ # @return [void]
102
+ def run
103
+ # @sg-ignore
104
+ # @type [String]
105
+ # workspace_name = ARGV[0] || raise('Please pass workspace name as first argument')
106
+ # @sg-ignore
107
+ # @type [String]
108
+ # timeline_name = ARGV[1] || raise('Please pass timeline name as second argument')
109
+ # timelines = Checkoff::Timelines.new
110
+ # timeline = timelines.timeline_or_raise(workspace_name, timeline_name)
111
+ # puts "Results: #{timeline}"
112
+ end
113
+ end
114
+ # :nocov:
115
+ end
116
+ end
117
+
118
+ # :nocov:
119
+ abs_program_name = File.expand_path($PROGRAM_NAME)
120
+ Checkoff::Timelines.run if abs_program_name == File.expand_path(__FILE__)
121
+ # :nocov:
@@ -3,5 +3,5 @@
3
3
  # Command-line and gem client for Asana (unofficial)
4
4
  module Checkoff
5
5
  # Version of library
6
- VERSION = '0.97.0'
6
+ VERSION = '0.99.0'
7
7
  end
data/lib/checkoff.rb CHANGED
@@ -8,6 +8,7 @@ require 'checkoff/projects'
8
8
  require 'checkoff/sections'
9
9
  require 'checkoff/subtasks'
10
10
  require 'checkoff/timing'
11
+ require 'checkoff/timelines'
11
12
  require 'checkoff/tasks'
12
13
  require 'checkoff/custom_fields'
13
14
  require 'checkoff/tags'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: checkoff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.97.0
4
+ version: 0.99.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vince Broz
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-10-25 00:00:00.000000000 Z
11
+ date: 2023-10-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -166,6 +166,7 @@ files:
166
166
  - lib/checkoff/task_searches.rb
167
167
  - lib/checkoff/task_selectors.rb
168
168
  - lib/checkoff/tasks.rb
169
+ - lib/checkoff/timelines.rb
169
170
  - lib/checkoff/timing.rb
170
171
  - lib/checkoff/version.rb
171
172
  - lib/checkoff/workspaces.rb