checkoff 0.97.0 → 0.99.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.
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