ace-task 0.31.9 → 0.36.1

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.
data/lib/ace/task/cli.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "cli/commands/update"
10
10
  require_relative "cli/commands/doctor"
11
11
  require_relative "cli/commands/status"
12
12
  require_relative "cli/commands/plan"
13
+ require_relative "cli/commands/github_sync"
13
14
 
14
15
  module Ace
15
16
  module Task
@@ -26,7 +27,8 @@ module Ace
26
27
  ["update", "Update task metadata (fields, move, reparent)"],
27
28
  ["doctor", "Run health checks on tasks"],
28
29
  ["status", "Show task status overview"],
29
- ["plan", "Resolve or generate implementation plan"]
30
+ ["plan", "Resolve or generate implementation plan"],
31
+ ["github-sync", "Sync linked GitHub issues for task(s)"]
30
32
  ].freeze
31
33
 
32
34
  HELP_EXAMPLES = [
@@ -45,7 +47,9 @@ module Ace
45
47
  "ace-task status --up-next-limit 5",
46
48
  "ace-task plan q7w",
47
49
  "ace-task plan q7w --refresh",
48
- "ace-task plan q7w --content"
50
+ "ace-task plan q7w --content",
51
+ "ace-task github-sync q7w",
52
+ "ace-task github-sync --all"
49
53
  ].freeze
50
54
 
51
55
  register "create", CLI::Commands::Create
@@ -55,6 +59,7 @@ module Ace
55
59
  register "doctor", CLI::Commands::Doctor
56
60
  register "status", CLI::Commands::Status
57
61
  register "plan", CLI::Commands::Plan
62
+ register "github-sync", CLI::Commands::GithubSync
58
63
 
59
64
  version_cmd = Ace::Support::Cli::VersionCommand.build(
60
65
  gem_name: "ace-task",
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Task
5
+ module Molecules
6
+ # Bridges ace-task lifecycle events to reusable ace-git issue sync primitives.
7
+ class GithubIssueSyncAdapter
8
+ UNAVAILABLE_MESSAGE = "GitHub issue sync primitives are unavailable. " \
9
+ "Complete dependency task 8r4.t.ilo.2 or update ace-git."
10
+
11
+ CANDIDATE_INTEGRATIONS = [
12
+ ["Ace::Git::Molecules::GithubIssueSync", :validate_link!],
13
+ ["Ace::Git::Molecules::IssueSync", :validate_link!],
14
+ ["Ace::Git::Molecules::GithubIssueSync", :sync_task],
15
+ ["Ace::Git::Molecules::IssueSync", :sync_task]
16
+ ].freeze
17
+
18
+ def validate_link!(issue_id:, previous_task: nil)
19
+ return unless issue_id.to_i.positive?
20
+
21
+ receiver, method_name = resolve_integration(:validate_link!)
22
+ raise UNAVAILABLE_MESSAGE unless receiver && method_name
23
+
24
+ receiver.public_send(
25
+ method_name,
26
+ issue_id: issue_id.to_i,
27
+ task_id: previous_task&.id,
28
+ previous_task_id: previous_task&.id
29
+ )
30
+ end
31
+
32
+ def sync_task(task:, reason:, previous_task: nil)
33
+ current_issue_ids = [task.metadata["github_issue"]].compact.map(&:to_i).uniq
34
+ previous_issue_ids = [previous_task&.metadata&.[]("github_issue")].compact.map(&:to_i).uniq
35
+ issue_ids = (current_issue_ids + previous_issue_ids).uniq
36
+ return {synced: 0, issues: []} if issue_ids.empty?
37
+
38
+ receiver, method_name = resolve_integration(:sync_task)
39
+ raise UNAVAILABLE_MESSAGE unless receiver && method_name
40
+
41
+ payload = {
42
+ task_id: task.id,
43
+ task_title: task.title,
44
+ task_status: task.status,
45
+ task_path: task.file_path || task.path,
46
+ issue_ids: issue_ids,
47
+ current_issue_ids: current_issue_ids,
48
+ reason: reason,
49
+ previous: previous_task ? {
50
+ id: previous_task.id,
51
+ title: previous_task.title,
52
+ status: previous_task.status,
53
+ path: previous_task.file_path || previous_task.path
54
+ } : nil
55
+ }
56
+ receiver.public_send(method_name, **payload)
57
+ {synced: issue_ids.length, issues: issue_ids}
58
+ end
59
+
60
+ private
61
+
62
+ def resolve_integration(expected_method = nil)
63
+ CANDIDATE_INTEGRATIONS.each do |constant_name, method_name|
64
+ next if expected_method && method_name != expected_method
65
+
66
+ klass = constant_name.split("::").reject(&:empty?).inject(Object) { |ctx, name| ctx.const_get(name) }
67
+ return [klass, method_name] if klass.respond_to?(method_name)
68
+ rescue NameError
69
+ next
70
+ end
71
+
72
+ [nil, nil]
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -32,7 +32,16 @@ module Ace
32
32
  # @param time [Time] Creation time (default: now)
33
33
  # @return [Models::Task] Created subtask
34
34
  # @raise [RangeError] If parent already has 36 subtasks
35
- def create(parent_task, title, status: nil, priority: nil, tags: [], time: Time.now.utc, estimate: nil)
35
+ def create(
36
+ parent_task,
37
+ title,
38
+ status: nil,
39
+ priority: nil,
40
+ tags: [],
41
+ time: Time.now.utc,
42
+ estimate: nil,
43
+ github_issue: nil
44
+ )
36
45
  raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
37
46
 
38
47
  # Find next available subtask character
@@ -60,7 +69,8 @@ module Ace
60
69
  tags: tags,
61
70
  created_at: time,
62
71
  parent: parent_task.id,
63
- estimate: estimate
72
+ estimate: estimate,
73
+ github_issue: github_issue
64
74
  )
65
75
 
66
76
  # Write spec file
@@ -13,6 +13,8 @@ module Ace
13
13
  # Generates folder structure and spec file with full frontmatter.
14
14
  # Optionally uses LLM for slug generation with deterministic fallback.
15
15
  class TaskCreator
16
+ class IdCollisionError < StandardError; end
17
+
16
18
  # @param root_dir [String] Root directory for tasks
17
19
  # @param config [Hash] Configuration hash
18
20
  def initialize(root_dir:, config: {})
@@ -29,52 +31,90 @@ module Ace
29
31
  # @param time [Time] Creation time (default: now)
30
32
  # @param use_llm_slug [Boolean] Whether to attempt LLM slug generation
31
33
  # @return [Models::Task] Created task
32
- def create(title, status: nil, priority: nil, tags: [], dependencies: [], time: Time.now.utc, use_llm_slug: false, estimate: nil)
34
+ def create(
35
+ title,
36
+ status: nil,
37
+ priority: nil,
38
+ tags: [],
39
+ dependencies: [],
40
+ time: Time.now.utc,
41
+ use_llm_slug: false,
42
+ estimate: nil,
43
+ github_issue: nil
44
+ )
33
45
  raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
34
46
 
35
47
  # Generate task ID
36
48
  item_id = Atoms::TaskIdFormatter.generate(time)
37
49
  formatted_id = item_id.formatted_id
38
50
 
39
- # Generate slugs
40
- slugs = if use_llm_slug
41
- generate_llm_slugs(title) || generate_slugs(title)
42
- else
43
- generate_slugs(title)
51
+ with_id_reservation(formatted_id) do
52
+ raise IdCollisionError, "Task ID collision detected for #{formatted_id}" if task_id_exists?(formatted_id)
53
+
54
+ # Generate slugs
55
+ slugs = if use_llm_slug
56
+ generate_llm_slugs(title) || generate_slugs(title)
57
+ else
58
+ generate_slugs(title)
59
+ end
60
+ folder_slug = slugs[:folder]
61
+ file_slug = slugs[:file]
62
+
63
+ # Create folder with folder_slug
64
+ folder_name = Atoms::TaskIdFormatter.folder_name(formatted_id, folder_slug)
65
+ task_dir = File.join(@root_dir, folder_name)
66
+ FileUtils.mkdir_p(task_dir)
67
+
68
+ begin
69
+ # Build frontmatter with all fields
70
+ effective_status = status || @config.dig("task", "default_status") || "pending"
71
+ frontmatter = Atoms::TaskFrontmatterDefaults.build(
72
+ id: formatted_id,
73
+ status: effective_status,
74
+ priority: priority,
75
+ tags: tags,
76
+ dependencies: dependencies,
77
+ created_at: time,
78
+ estimate: estimate,
79
+ github_issue: github_issue
80
+ )
81
+
82
+ # Write spec file with file_slug
83
+ spec_filename = Atoms::TaskIdFormatter.spec_filename(formatted_id, file_slug)
84
+ spec_file = File.join(task_dir, spec_filename)
85
+ content = build_spec_content(frontmatter: frontmatter, title: title)
86
+ File.write(spec_file, content)
87
+
88
+ # Load and return the created task
89
+ loader = TaskLoader.new
90
+ loader.load(task_dir, id: formatted_id)
91
+ rescue StandardError
92
+ FileUtils.rm_rf(task_dir) if Dir.exist?(task_dir)
93
+ raise
94
+ end
44
95
  end
45
- folder_slug = slugs[:folder]
46
- file_slug = slugs[:file]
47
-
48
- # Create folder with folder_slug
49
- folder_name = Atoms::TaskIdFormatter.folder_name(formatted_id, folder_slug)
50
- task_dir = File.join(@root_dir, folder_name)
51
- FileUtils.mkdir_p(task_dir)
52
-
53
- # Build frontmatter with all fields
54
- effective_status = status || @config.dig("task", "default_status") || "pending"
55
- frontmatter = Atoms::TaskFrontmatterDefaults.build(
56
- id: formatted_id,
57
- status: effective_status,
58
- priority: priority,
59
- tags: tags,
60
- dependencies: dependencies,
61
- created_at: time,
62
- estimate: estimate
63
- )
64
-
65
- # Write spec file with file_slug
66
- spec_filename = Atoms::TaskIdFormatter.spec_filename(formatted_id, file_slug)
67
- spec_file = File.join(task_dir, spec_filename)
68
- content = build_spec_content(frontmatter: frontmatter, title: title)
69
- File.write(spec_file, content)
70
-
71
- # Load and return the created task
72
- loader = TaskLoader.new
73
- loader.load(task_dir, id: formatted_id)
74
96
  end
75
97
 
76
98
  private
77
99
 
100
+ def task_id_exists?(formatted_id)
101
+ Dir.glob(File.join(@root_dir, "**", "#{formatted_id}-*")).any? do |path|
102
+ File.directory?(path)
103
+ end
104
+ end
105
+
106
+ def with_id_reservation(formatted_id)
107
+ reservation_path = File.join(@root_dir, ".ace-task-id-lock-#{formatted_id}")
108
+ acquired = false
109
+ Dir.mkdir(reservation_path)
110
+ acquired = true
111
+ yield
112
+ rescue Errno::EEXIST
113
+ raise IdCollisionError, "Task ID collision detected for #{formatted_id}"
114
+ ensure
115
+ FileUtils.rm_rf(reservation_path) if acquired && reservation_path && Dir.exist?(reservation_path)
116
+ end
117
+
78
118
  def generate_folder_slug(title)
79
119
  sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title)
80
120
  words = sanitized.split("-")
@@ -109,6 +109,8 @@ module Ace
109
109
  if title.length > Atoms::TaskValidationRules::MAX_TITLE_LENGTH
110
110
  issues << {type: :warning, message: "Title exceeds #{Atoms::TaskValidationRules::MAX_TITLE_LENGTH} characters (#{title.length} chars)", location: file_path}
111
111
  end
112
+
113
+ validate_github_fields(frontmatter, file_path, issues)
112
114
  end
113
115
 
114
116
  def validate_recommended_fields(frontmatter, file_path, issues)
@@ -131,6 +133,19 @@ module Ace
131
133
  issues << issue.merge(location: file_path)
132
134
  end
133
135
  end
136
+
137
+ def validate_github_fields(frontmatter, file_path, issues)
138
+ linked = frontmatter["github_issue"]
139
+ return if linked.nil?
140
+
141
+ unless linked.is_a?(Integer) && linked.positive?
142
+ issues << {
143
+ type: :error,
144
+ message: "Invalid GitHub issue ID '#{linked}' in github_issue (expected positive integer)",
145
+ location: file_path
146
+ }
147
+ end
148
+ end
134
149
  end
135
150
  end
136
151
  end
@@ -35,7 +35,9 @@ module Ace
35
35
  )
36
36
  rescue Ace::Support::Cli::Error
37
37
  raise
38
- rescue RuntimeError, IOError, Errno::ENOENT, Errno::ECONNREFUSED, Timeout::Error => e
38
+ rescue StandardError => e
39
+ raise unless recoverable_query_error?(e)
40
+
39
41
  raise Ace::Support::Cli::Error.new(
40
42
  "Plan generation failed: #{e.message}. Retry with --refresh or choose a working --model."
41
43
  )
@@ -135,6 +137,16 @@ module Ace
135
137
  require "ace/llm"
136
138
  Ace::LLM::QueryInterface
137
139
  end
140
+
141
+ def recoverable_query_error?(error)
142
+ return true if error.class.name == "Ace::LLM::ProviderError"
143
+
144
+ error.is_a?(RuntimeError) ||
145
+ error.is_a?(IOError) ||
146
+ error.is_a?(Errno::ENOENT) ||
147
+ error.is_a?(Errno::ECONNREFUSED) ||
148
+ error.is_a?(Timeout::Error)
149
+ end
138
150
  end
139
151
  end
140
152
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "../molecules/task_scanner"
4
4
  require_relative "../molecules/task_frontmatter_validator"
5
5
  require_relative "../molecules/task_structure_validator"
6
+ require_relative "../molecules/task_doctor_fixer"
6
7
  require_relative "../atoms/task_validation_rules"
7
8
 
8
9
  module Ace
@@ -108,13 +109,20 @@ module Ace
108
109
  scanner = Molecules::TaskScanner.new(@root_path)
109
110
  return unless scanner.root_exists?
110
111
 
111
- scan_results = scanner.scan
112
+ scan_results = frontmatter_scan_results(scanner)
112
113
  @stats[:tasks_scanned] = scan_results.size
114
+ id_locations = Hash.new { |hash, key| hash[key] = [] }
113
115
 
114
116
  scan_results.each do |scan_result|
115
117
  spec_file = scan_result.file_path
116
118
  next unless spec_file && File.exist?(spec_file)
117
119
 
120
+ content = File.read(spec_file)
121
+ frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
122
+ if frontmatter.is_a?(Hash) && frontmatter["id"] && !frontmatter["id"].to_s.strip.empty?
123
+ id_locations[frontmatter["id"]] << spec_file
124
+ end
125
+
118
126
  issues = Molecules::TaskFrontmatterValidator.validate(
119
127
  spec_file,
120
128
  special_folder: scan_result.special_folder
@@ -127,6 +135,8 @@ module Ace
127
135
  add_issue(issue[:type], issue[:message], issue[:location])
128
136
  end
129
137
  end
138
+
139
+ add_duplicate_id_issues(id_locations)
130
140
  end
131
141
 
132
142
  def run_scope_check
@@ -175,6 +185,26 @@ module Ace
175
185
  count
176
186
  end
177
187
 
188
+ def frontmatter_scan_results(scanner)
189
+ top_level_results = scanner.scan
190
+ subtask_results = top_level_results.flat_map { |scan_result|
191
+ scanner.scan_subtasks(scan_result.dir_path, parent_id: scan_result.id)
192
+ }
193
+ top_level_results + subtask_results
194
+ end
195
+
196
+ def add_duplicate_id_issues(id_locations)
197
+ id_locations.each do |id, locations|
198
+ next unless locations.size > 1
199
+
200
+ add_issue(
201
+ :error,
202
+ "Duplicate task ID '#{id}' found in: #{locations.sort.join(', ')}",
203
+ locations.first
204
+ )
205
+ end
206
+ end
207
+
178
208
  def add_issue(type, message, location = nil)
179
209
  issue = {type: type, message: message}
180
210
  issue[:location] = location if location
@@ -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.
@@ -347,6 +422,80 @@ module Ace
347
422
  item.metadata[key] || item.metadata[key.to_sym] if item.respond_to?(:metadata) && item.metadata
348
423
  end
349
424
  end
425
+
426
+ def sync_needed_after_update?(before_task, after_task, set:, add:, remove:, move_to:)
427
+ return false unless after_task
428
+ return true if move_to
429
+ return true if before_task.path != after_task.path
430
+ return true if linked_issue_id(before_task) != linked_issue_id(after_task)
431
+
432
+ touched_keys = [set, add, remove].compact.flat_map(&:keys).map(&:to_s)
433
+ touched_keys.any? do |key|
434
+ key == "title" || key == "status" || key == "github_issue"
435
+ end
436
+ end
437
+
438
+ def linked_issue_id(task)
439
+ return nil unless task&.metadata
440
+
441
+ issue_id = task.metadata["github_issue"]
442
+ return nil unless issue_id.to_i.positive?
443
+
444
+ issue_id.to_i
445
+ end
446
+
447
+ def extract_desired_github_issue(task, set:, remove:)
448
+ return nil if set&.key?("github_issue") && !set["github_issue"].to_i.positive?
449
+ return set["github_issue"].to_i if set&.key?("github_issue") && set["github_issue"].to_i.positive?
450
+ return nil if Array(remove&.keys).map(&:to_s).include?("github_issue")
451
+
452
+ linked_issue_id(task)
453
+ end
454
+
455
+ def ensure_github_issue_linkable!(github_issue, previous_task: nil)
456
+ return unless github_issue
457
+
458
+ Molecules::GithubIssueSyncAdapter.new.validate_link!(issue_id: github_issue, previous_task: previous_task)
459
+ end
460
+
461
+ def sync_linked_issues_for(task, reason:, previous_task: nil)
462
+ issue_ids = [linked_issue_id(task), linked_issue_id(previous_task)].compact.uniq
463
+ return sync_result_for(task: task, issues: issue_ids, success: true, reason: reason) if issue_ids.empty?
464
+
465
+ adapter = Molecules::GithubIssueSyncAdapter.new
466
+ adapter.sync_task(task: task, reason: reason, previous_task: previous_task)
467
+ sync_result_for(task: task, issues: issue_ids, success: true, reason: reason)
468
+ rescue StandardError => e
469
+ @last_update_note = "GitHub sync warning for task #{task&.id}: #{e.message}"
470
+ sync_result_for(task: task, issues: issue_ids, success: false, reason: reason, error: e.message)
471
+ end
472
+
473
+ def sync_result_for(task:, issues:, success:, reason:, error: nil)
474
+ {
475
+ task_id: task&.id,
476
+ issue_ids: issues,
477
+ success: success,
478
+ reason: reason,
479
+ error: error
480
+ }
481
+ end
482
+
483
+ def summarize_manual_sync_results(results, skipped:)
484
+ failures = results.reject { |result| result[:success] }.map do |result|
485
+ {
486
+ task_id: result[:task_id],
487
+ issue_ids: result[:issue_ids],
488
+ error: result[:error]
489
+ }
490
+ end
491
+
492
+ {
493
+ synced: results.length - failures.length,
494
+ failed: failures.length,
495
+ skipped: skipped,
496
+ failures: failures
497
+ }
498
+ end
350
499
  end
351
500
  end
352
501
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Task
5
- VERSION = '0.31.9'
5
+ VERSION = '0.36.1'
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