ace-task 0.31.9 → 0.36.7

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.
@@ -7,6 +7,7 @@ require_relative "../molecules/task_loader"
7
7
  require_relative "../molecules/task_creator"
8
8
  require_relative "../molecules/subtask_creator"
9
9
  require_relative "../molecules/task_reparenter"
10
+ require_relative "../molecules/github_issue_sync_adapter"
10
11
  require_relative "../atoms/task_validation_rules"
11
12
 
12
13
  module Ace
@@ -15,6 +16,9 @@ module Ace
15
16
  # Orchestrates all task CRUD operations.
16
17
  # Entry point for task management with config-driven root directory.
17
18
  class TaskManager
19
+ CREATE_RETRY_LIMIT = 3
20
+ class CreateRetriesExhaustedError < StandardError; end
21
+
18
22
  attr_reader :last_list_total, :last_folder_counts
19
23
 
20
24
  # @param root_dir [String, nil] Override root directory for tasks
@@ -35,11 +39,41 @@ module Ace
35
39
  # @param dependencies [Array<String>] Dependency task IDs
36
40
  # @param use_llm_slug [Boolean] Whether to attempt LLM slug generation
37
41
  # @return [Models::Task] Created task
38
- def create(title, status: nil, priority: nil, tags: [], dependencies: [], use_llm_slug: false, estimate: nil)
42
+ def create(
43
+ title,
44
+ status: nil,
45
+ priority: nil,
46
+ tags: [],
47
+ dependencies: [],
48
+ use_llm_slug: false,
49
+ estimate: nil,
50
+ github_issue: nil
51
+ )
39
52
  ensure_root_dir
53
+ ensure_github_issue_linkable!(github_issue)
40
54
  creator = Molecules::TaskCreator.new(root_dir: @root_dir, config: @config)
41
- creator.create(title, status: status, priority: priority, tags: tags,
42
- dependencies: dependencies, use_llm_slug: use_llm_slug, estimate: estimate)
55
+ attempts = 0
56
+
57
+ begin
58
+ attempts += 1
59
+ created_task = creator.create(
60
+ title,
61
+ status: status,
62
+ priority: priority,
63
+ tags: tags,
64
+ dependencies: dependencies,
65
+ use_llm_slug: use_llm_slug,
66
+ time: Time.now.utc + ((attempts - 1) * 2),
67
+ estimate: estimate,
68
+ github_issue: github_issue
69
+ )
70
+ sync_linked_issues_for(created_task, reason: "create")
71
+ created_task
72
+ rescue Molecules::TaskCreator::IdCollisionError
73
+ retry if attempts < CREATE_RETRY_LIMIT
74
+ raise CreateRetriesExhaustedError,
75
+ "Failed to create task: unable to generate a unique ID after #{CREATE_RETRY_LIMIT} attempts"
76
+ end
43
77
  end
44
78
 
45
79
  # Show (load) a single task by reference, including subtasks.
@@ -109,6 +143,8 @@ module Ace
109
143
 
110
144
  # Apply field updates if any
111
145
  has_field_updates = [set, add, remove].any? { |h| h && !h.empty? }
146
+ desired_issue = extract_desired_github_issue(task, set: set, remove: remove)
147
+ ensure_github_issue_linkable!(desired_issue, previous_task: task) if desired_issue
112
148
  if has_field_updates
113
149
  Ace::Support::Items::Molecules::FieldUpdater.update(
114
150
  task.file_path, set: set, add: add, remove: remove
@@ -146,7 +182,9 @@ module Ace
146
182
  resolve_fn = ->(r) { show(r) }
147
183
  # Reload task from current path before reparenting (may have been field-updated)
148
184
  task_for_reparent = loader.load(current_path, id: task.id, special_folder: current_special)
149
- return reparenter.reparent(task_for_reparent, target: move_as_child_of, resolve_ref: resolve_fn)
185
+ reparented = reparenter.reparent(task_for_reparent, target: move_as_child_of, resolve_ref: resolve_fn)
186
+ sync_linked_issues_for(reparented, reason: "reparent", previous_task: task)
187
+ return reparented
150
188
  end
151
189
 
152
190
  # Auto-archive hook: if a subtask status was set to terminal,
@@ -156,7 +194,11 @@ module Ace
156
194
  end
157
195
 
158
196
  # Reload and return updated task
159
- loader.load(current_path, id: current_id, special_folder: current_special)
197
+ updated_task = loader.load(current_path, id: current_id, special_folder: current_special)
198
+ if sync_needed_after_update?(task, updated_task, set: set, add: add, remove: remove, move_to: move_to)
199
+ sync_linked_issues_for(updated_task, reason: "update", previous_task: task)
200
+ end
201
+ updated_task
160
202
  end
161
203
 
162
204
  # Create a subtask within a parent task.
@@ -166,12 +208,45 @@ module Ace
166
208
  # @param priority [String, nil] Priority level
167
209
  # @param tags [Array<String>] Tags
168
210
  # @return [Models::Task, nil] Created subtask or nil if parent not found
169
- def create_subtask(parent_ref, title, status: nil, priority: nil, tags: [], estimate: nil)
211
+ def create_subtask(parent_ref, title, status: nil, priority: nil, tags: [], estimate: nil, github_issue: nil)
170
212
  parent = show(parent_ref)
171
213
  return nil unless parent
172
214
 
215
+ ensure_github_issue_linkable!(github_issue)
173
216
  subtask_creator = Molecules::SubtaskCreator.new(config: @config)
174
- subtask_creator.create(parent, title, status: status, priority: priority, tags: tags, estimate: estimate)
217
+ created_subtask = subtask_creator.create(
218
+ parent,
219
+ title,
220
+ status: status,
221
+ priority: priority,
222
+ tags: tags,
223
+ estimate: estimate,
224
+ github_issue: github_issue
225
+ )
226
+ sync_linked_issues_for(created_subtask, reason: "create")
227
+ created_subtask
228
+ end
229
+
230
+ def github_sync(ref: nil, all: false)
231
+ raise ArgumentError, "Provide --all or a task reference" if !all && (ref.nil? || ref.strip.empty?)
232
+
233
+ if all
234
+ tasks = list(in_folder: "all")
235
+ linked_tasks = tasks.select { |t| linked_issue_id(t) }
236
+ results = linked_tasks.map { |task| sync_linked_issues_for(task, reason: "manual-sync") }
237
+ return summarize_manual_sync_results(results, skipped: tasks.length - linked_tasks.length)
238
+ end
239
+
240
+ task = show(ref)
241
+ return nil unless task
242
+
243
+ unless linked_issue_id(task)
244
+ return {synced: 0, failed: 0, skipped: 1, task_id: task.id, failures: []}
245
+ end
246
+
247
+ result = sync_linked_issues_for(task, reason: "manual-sync")
248
+ summary = summarize_manual_sync_results([result], skipped: 0)
249
+ summary.merge(task_id: task.id)
175
250
  end
176
251
 
177
252
  # Get the root directory.
@@ -261,10 +336,10 @@ module Ace
261
336
  parent_dir, recursive: true
262
337
  )
263
338
 
264
- # Auto-move the parent folder to archive
265
- parent_stub = Struct.new(:path).new(parent_dir)
266
- mover = Ace::Support::Items::Molecules::FolderMover.new(@root_dir)
267
- mover.move(parent_stub, to: "archive")
339
+ parent = load_parent_from_directory(parent_dir, loader)
340
+ return unless parent
341
+
342
+ archive_parent_via_update(parent)
268
343
  end
269
344
 
270
345
  def parse_archive_date(task)
@@ -312,27 +387,46 @@ module Ace
312
387
  }
313
388
  end
314
389
 
315
- unless Atoms::TaskValidationRules.terminal_status?(parent.status.to_s.downcase)
316
- Ace::Support::Items::Molecules::FieldUpdater.update(
317
- parent.file_path, set: {"status" => "done"}
318
- )
319
- parent = loader.load(parent.path, id: parent.id, special_folder: parent.special_folder)
320
- end
321
-
322
- mover = Ace::Support::Items::Molecules::FolderMover.new(@root_dir)
323
- new_parent_path = mover.move(parent, to: "archive", date: parse_archive_date(parent))
324
- new_special_folder = Ace::Support::Items::Atoms::SpecialFolderDetector.detect_in_path(
325
- new_parent_path, root: @root_dir
326
- )
390
+ archived_parent = archive_parent_via_update(parent)
391
+ return {
392
+ path: task.path,
393
+ special_folder: task.special_folder,
394
+ id: task.id
395
+ } unless archived_parent
327
396
 
328
397
  @last_update_note = "Archived parent task #{parent.id} because all subtasks are terminal."
329
398
  {
330
- path: File.join(new_parent_path, File.basename(task.path)),
331
- special_folder: new_special_folder,
399
+ path: File.join(archived_parent.path, File.basename(task.path)),
400
+ special_folder: archived_parent.special_folder,
332
401
  id: task.id
333
402
  }
334
403
  end
335
404
 
405
+ def archive_parent_via_update(parent)
406
+ previous_note = @last_update_note
407
+ archived_parent = update(
408
+ parent.id,
409
+ set: {"status" => "done"},
410
+ move_to: "archive"
411
+ )
412
+
413
+ if archived_parent && @last_update_note == previous_note
414
+ @last_update_note = "Archived parent task #{parent.id} because all subtasks are terminal."
415
+ end
416
+
417
+ archived_parent
418
+ end
419
+
420
+ def load_parent_from_directory(parent_dir, loader)
421
+ parent_id = File.basename(parent_dir).split("-", 2).first
422
+ return nil unless parent_id
423
+
424
+ parent_special = Ace::Support::Items::Atoms::SpecialFolderDetector.detect_in_path(
425
+ parent_dir, root: @root_dir
426
+ )
427
+ loader.load(parent_dir, id: parent_id, special_folder: parent_special)
428
+ end
429
+
336
430
  # Value accessor for FilterApplier
337
431
  def task_value_accessor(item, key)
338
432
  case key
@@ -347,6 +441,80 @@ module Ace
347
441
  item.metadata[key] || item.metadata[key.to_sym] if item.respond_to?(:metadata) && item.metadata
348
442
  end
349
443
  end
444
+
445
+ def sync_needed_after_update?(before_task, after_task, set:, add:, remove:, move_to:)
446
+ return false unless after_task
447
+ return true if move_to
448
+ return true if before_task.path != after_task.path
449
+ return true if linked_issue_id(before_task) != linked_issue_id(after_task)
450
+
451
+ touched_keys = [set, add, remove].compact.flat_map(&:keys).map(&:to_s)
452
+ touched_keys.any? do |key|
453
+ key == "title" || key == "status" || key == "github_issue"
454
+ end
455
+ end
456
+
457
+ def linked_issue_id(task)
458
+ return nil unless task&.metadata
459
+
460
+ issue_id = task.metadata["github_issue"]
461
+ return nil unless issue_id.to_i.positive?
462
+
463
+ issue_id.to_i
464
+ end
465
+
466
+ def extract_desired_github_issue(task, set:, remove:)
467
+ return nil if set&.key?("github_issue") && !set["github_issue"].to_i.positive?
468
+ return set["github_issue"].to_i if set&.key?("github_issue") && set["github_issue"].to_i.positive?
469
+ return nil if Array(remove&.keys).map(&:to_s).include?("github_issue")
470
+
471
+ linked_issue_id(task)
472
+ end
473
+
474
+ def ensure_github_issue_linkable!(github_issue, previous_task: nil)
475
+ return unless github_issue
476
+
477
+ Molecules::GithubIssueSyncAdapter.new.validate_link!(issue_id: github_issue, previous_task: previous_task)
478
+ end
479
+
480
+ def sync_linked_issues_for(task, reason:, previous_task: nil)
481
+ issue_ids = [linked_issue_id(task), linked_issue_id(previous_task)].compact.uniq
482
+ return sync_result_for(task: task, issues: issue_ids, success: true, reason: reason) if issue_ids.empty?
483
+
484
+ adapter = Molecules::GithubIssueSyncAdapter.new
485
+ adapter.sync_task(task: task, reason: reason, previous_task: previous_task)
486
+ sync_result_for(task: task, issues: issue_ids, success: true, reason: reason)
487
+ rescue StandardError => e
488
+ @last_update_note = "GitHub sync warning for task #{task&.id}: #{e.message}"
489
+ sync_result_for(task: task, issues: issue_ids, success: false, reason: reason, error: e.message)
490
+ end
491
+
492
+ def sync_result_for(task:, issues:, success:, reason:, error: nil)
493
+ {
494
+ task_id: task&.id,
495
+ issue_ids: issues,
496
+ success: success,
497
+ reason: reason,
498
+ error: error
499
+ }
500
+ end
501
+
502
+ def summarize_manual_sync_results(results, skipped:)
503
+ failures = results.reject { |result| result[:success] }.map do |result|
504
+ {
505
+ task_id: result[:task_id],
506
+ issue_ids: result[:issue_ids],
507
+ error: result[:error]
508
+ }
509
+ end
510
+
511
+ {
512
+ synced: results.length - failures.length,
513
+ failed: failures.length,
514
+ skipped: skipped,
515
+ failures: failures
516
+ }
517
+ end
350
518
  end
351
519
  end
352
520
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Task
5
- VERSION = '0.31.9'
5
+ VERSION = '0.36.7'
6
6
  end
7
7
  end
data/lib/ace/task.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "task/version"
5
5
  # Dependencies
6
6
  require "ace/support/items"
7
7
  require "ace/b36ts"
8
+ require "ace/git"
8
9
 
9
10
  # Atoms
10
11
  require_relative "task/atoms/task_id_formatter"
@@ -23,6 +24,7 @@ require_relative "task/molecules/task_creator"
23
24
  require_relative "task/molecules/subtask_creator"
24
25
  require_relative "task/molecules/task_display_formatter"
25
26
  require_relative "task/molecules/path_utils"
27
+ require_relative "task/molecules/github_issue_sync_adapter"
26
28
  require_relative "task/molecules/task_plan_cache"
27
29
  require_relative "task/molecules/task_plan_generator"
28
30
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-task
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.9
4
+ version: 0.36.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-01 00:00:00.000000000 Z
10
+ date: 2026-04-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-core
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0.13'
82
+ - !ruby/object:Gem::Dependency
83
+ name: ace-git
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.19'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.19'
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: ace-support-cli
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -144,12 +158,14 @@ files:
144
158
  - lib/ace/task/cli.rb
145
159
  - lib/ace/task/cli/commands/create.rb
146
160
  - lib/ace/task/cli/commands/doctor.rb
161
+ - lib/ace/task/cli/commands/github_sync.rb
147
162
  - lib/ace/task/cli/commands/list.rb
148
163
  - lib/ace/task/cli/commands/plan.rb
149
164
  - lib/ace/task/cli/commands/show.rb
150
165
  - lib/ace/task/cli/commands/status.rb
151
166
  - lib/ace/task/cli/commands/update.rb
152
167
  - lib/ace/task/models/task.rb
168
+ - lib/ace/task/molecules/github_issue_sync_adapter.rb
153
169
  - lib/ace/task/molecules/path_utils.rb
154
170
  - lib/ace/task/molecules/subtask_creator.rb
155
171
  - lib/ace/task/molecules/task_config_loader.rb