ace-task 0.38.1 → 0.39.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: b5816e661bad83e251f7163f2c31be9c0395492ab92ef00d2f8d0ddc63c02291
4
- data.tar.gz: 8c69d8964833c9f008de95ea38dde3efa36095baa9814a60f8ebf8a8da86956e
3
+ metadata.gz: f192e0d481809ade06d5be298d573c824a6a2b16147448911a6743bdea524e5d
4
+ data.tar.gz: a1a05a622f467e7e9cb42139166c3335aba57ff1fb0cce34711c41453b725387
5
5
  SHA512:
6
- metadata.gz: 4183d716b57fdde53188afb4a3da9d63e62398ea9223e9ffc954c9c7bd6061cd23e6005208cf4e58313176c2b33feb0831155d3490c6bd9f9859feda29c17b6a
7
- data.tar.gz: a5dbbd8e5795dac65bad64b8c79cf515f6b5f3daf736a2e0baea7f9195a5e2a042797689a048fa913eb05532d437ab30dec6fa8c0f137c0ce037cf64a06ec9cb
6
+ metadata.gz: 566a4273824d53d2810a84d62202077de16411e6bd166c695ce454890c37e415fbc2b80c444c2768f46bedd03709633b87db5d2bf4e475edda85f89e87c3e948
7
+ data.tar.gz: 8e6266c0fc3c3bb400d840e35c3d3ff460dd9f24b0f1ae783ab14d414a5d29501ab24b7096615f2ecb3fb4bda32571031fee3fc3b8390c3bab70048a69eb0b6e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.39.0] - 2026-10-02
11
+
12
+ ### Added
13
+ - Named-forge issue links and best-effort issue synchronization for task creation, updates, and pending replay.
14
+
15
+ ### Changed
16
+ - Task issue commands and workflow guidance now use forge-neutral `--issue` and `--default-server` options.
17
+ - Raised minimum dependencies to the versions providing the issue tracking contract: ace-git ~> 0.26, ace-git-github ~> 0.3, ace-git-forgejo ~> 0.4.
18
+
19
+ ### Fixed
20
+ - Issue identity locks serialize by canonical repository identity (provider + normalized web repository + issue number) and are held across task relocations, with parent-before-child ordering that closes the archive deadlock window.
21
+ - Definitive-looking provider failures after a tracking comment committed (label/state steps, post-write ownership conflicts) retain the task, link, and pending identity as the cleanup record instead of deleting them; only pre-mutation ownership rejection rolls back.
22
+ - Clear intent is persisted before comment reconciliation (with the create guard remembered), pending clears complete after authoritative create absence, and a pending clear's outgoing ID survives reparenting.
23
+ - Generic task updates reject all issue-sync control fields, including `issue_sync_reconcile_create`.
24
+
10
25
  ## [0.38.1] - 2026-09-29
11
26
 
12
27
  ### Fixed
@@ -35,11 +35,11 @@ Create high-level behavioral specifications that define WHAT the system should d
35
35
  * User stories or experience descriptions
36
36
  * Interface specifications or contracts
37
37
  * If files are referenced, read their contents
38
- * If the input is a GitHub issue URL, extract the issue number and treat it
39
- as task lifecycle metadata. Use that number with `ace-task create
40
- --github-issue <number>` when creating the parent task or single flat task.
38
+ * If the input is a configured forge issue URL, retain its exact repository
39
+ identity. Use it with `ace-task create --issue <url>` when creating the
40
+ parent task or single flat task.
41
41
  The issue URL may still appear in the task body as source context, but the
42
- lifecycle link belongs in task frontmatter as `github_issue`.
42
+ lifecycle link belongs in task frontmatter as a complete `remote_issue` mapping.
43
43
  * **Intent mapping from enhanced ideas**: When the source idea contains the
44
44
  3-Question Delegation Brief sections (`What I Hope to Accomplish`,
45
45
  `What "Complete" Looks Like`, `Success Criteria`), use them as the
@@ -134,14 +134,14 @@ Create high-level behavioral specifications that define WHAT the system should d
134
134
  **Pattern A -- Single flat task (default):**
135
135
  ```bash
136
136
  ace-task create "Task Title" --status draft --estimate "TBD"
137
- ace-task create "Task Title" --status draft --estimate "TBD" --github-issue 276
137
+ ace-task create "Task Title" --status draft --estimate "TBD" --issue 276 --default-server
138
138
  ```
139
139
 
140
140
  **Pattern B -- Orchestrator with subtasks:**
141
141
  ```bash
142
142
  # 1. Create the parent task first
143
143
  ace-task create "Parent Title" --status draft --estimate "TBD"
144
- ace-task create "Parent Title" --status draft --estimate "TBD" --github-issue 276
144
+ ace-task create "Parent Title" --status draft --estimate "TBD" --issue 276 --default-server
145
145
  # Returns: v.X.Y+task.NNN
146
146
 
147
147
  # 2. Add each subtask with --child-of (auto-converts parent to orchestrator)
@@ -166,12 +166,12 @@ Create high-level behavioral specifications that define WHAT the system should d
166
166
  * For subtasks, list required shared context files explicitly in EACH subtask's bundle (no implicit inheritance)
167
167
  * Include behavioral specification template
168
168
  * Focus on behavioral content, leave implementation for replan step
169
- * When drafting from a GitHub issue:
170
- * Put `--github-issue <number>` only on the parent/orchestrator or single
169
+ * When drafting from a forge issue:
170
+ * Put `--issue <url>` only on the parent/orchestrator or single
171
171
  flat task create command unless separate subtasks each own distinct
172
- GitHub issues.
173
- * Run `ace-task github-sync <parent-or-task-ref>` after creation so the
174
- GitHub issue receives the ACE task tracking update.
172
+ issues.
173
+ * Run `ace-task issue-sync <parent-or-task-ref>` after creation so the
174
+ exact linked issue receives the ACE task tracking update.
175
175
 
176
176
  ### Review-First Rule for Uncertain Drafts
177
177
 
@@ -243,8 +243,8 @@ When requirements are uncertain, do not create a special spike task. Draft the r
243
243
  * **Validation Checklist:**
244
244
  * [ ] All behavioral requirements captured as drafts
245
245
  * [ ] Task files have status: draft
246
- * [ ] GitHub issue inputs are linked through `github_issue` frontmatter on the parent/single task
247
- * [ ] Linked GitHub issue inputs have been synced with `ace-task github-sync <ref>`
246
+ * [ ] Forge issue inputs are linked through complete `remote_issue` frontmatter on the parent/single task
247
+ * [ ] Linked issue inputs have been synced with `ace-task issue-sync <ref>`
248
248
  * [ ] Behavioral specifications are complete
249
249
  * [ ] Interface contracts are defined
250
250
  * [ ] Success criteria are measurable
@@ -350,10 +350,10 @@ All code implementation happens during `ace-bundle wfi://task/work` (status: in-
350
350
  * All tasks have status: draft
351
351
  * No implementation details mixed with behavioral requirements
352
352
  * Clear handoff to review-task for readiness validation and promotion to pending
353
- * **REQUIRED when drafting from a GitHub issue URL:**
354
- * The parent/orchestrator or single flat task is created with `--github-issue <number>`
355
- * The task frontmatter contains `github_issue: <number>`
356
- * `ace-task github-sync <ref>` has updated the linked issue
353
+ * **REQUIRED when drafting from a forge issue URL:**
354
+ * The parent/orchestrator or single flat task is created with `--issue <url>`
355
+ * The task frontmatter contains a complete `remote_issue` mapping
356
+ * `ace-task issue-sync <ref>` has updated the linked issue
357
357
  * **REQUIRED when drafting from ideas:**
358
358
  * All source idea files marked as done via `ace-idea move <id> --to archive`
359
359
  * Task references updated to new idea file locations
@@ -383,7 +383,7 @@ All code implementation happens during `ace-bundle wfi://task/work` (status: in-
383
383
  Create the parent or single task with the linked issue number:
384
384
 
385
385
  ```bash
386
- ace-task create "Task Title" --status draft --estimate "TBD" --github-issue 276
386
+ ace-task create "Task Title" --status draft --estimate "TBD" --issue 276 --default-server
387
387
  ```
388
388
 
389
389
  ---
@@ -27,7 +27,7 @@ module Ace
27
27
  created_at: nil,
28
28
  parent: nil,
29
29
  estimate: nil,
30
- github_issue: nil
30
+ remote_issue: nil
31
31
  )
32
32
  fm = {
33
33
  "id" => id,
@@ -39,7 +39,10 @@ module Ace
39
39
  fm["dependencies"] = dependencies || []
40
40
  fm["tags"] = tags || []
41
41
  fm["parent"] = parent if parent
42
- fm["github_issue"] = github_issue if github_issue
42
+ fm["remote_issue"] = remote_issue if remote_issue
43
+ # Linked tasks start pending-sync in the same write that establishes
44
+ # the link; a crash before the first sync still replays via --pending.
45
+ fm["issue_sync_pending"] = true if remote_issue
43
46
  fm
44
47
  end
45
48
 
@@ -21,7 +21,7 @@ module Ace
21
21
  '--title "Fix login bug" # Create task with title flag',
22
22
  '"Fix auth" --priority high --tags auth,security # With priority and tags',
23
23
  '"Setup DB" --child-of q7w # Create as subtask',
24
- '"Track issue" --github-issue 276 # Link GitHub issue',
24
+ '"Track issue" --issue 276 --server forgejo-lab # Link one exact issue',
25
25
  '"Quick task" --in maybe # Create in _maybe/ folder',
26
26
  '"Draft spec" --status draft --estimate TBD # Create as draft with estimate',
27
27
  '"Preview only" --dry-run # Show what would be created'
@@ -34,7 +34,9 @@ module Ace
34
34
  option :tags, type: :string, aliases: %w[-T], desc: "Tags (comma-separated)"
35
35
  option :status, type: :string, aliases: %w[-s], desc: "Initial status (draft, pending, blocked, ...)"
36
36
  option :estimate, type: :string, aliases: %w[-e], desc: "Effort estimate (e.g. TBD, 2h, 1d)"
37
- option :"github-issue", type: :array, desc: "Linked GitHub issue number"
37
+ option :issue, type: :array, desc: "Issue number or URL to link"
38
+ option :server, type: :string, desc: "Named forge server"
39
+ option :"default-server", type: :boolean, desc: "Use the configured default forge server"
38
40
  option :"child-of", type: :string, desc: "Parent task reference (creates subtask)"
39
41
  option :in, type: :string, aliases: %w[-i], desc: "Target folder (e.g. next, maybe)"
40
42
  option :"dry-run", type: :boolean, aliases: %w[-n], desc: "Preview without writing"
@@ -53,7 +55,13 @@ module Ace
53
55
  tags = tags_str ? tags_str.split(",").map(&:strip).reject(&:empty?) : []
54
56
  status = options[:status]
55
57
  estimate = options[:estimate]
56
- github_issue = parse_github_issue(options[:"github-issue"])
58
+ issue = parse_issue(options[:issue])
59
+ if !issue && (options[:server] || options[:"default-server"])
60
+ raise Ace::Support::Cli::Error, "Server selection requires --issue"
61
+ end
62
+ remote_issue = issue && Ace::Task::Molecules::IssueLink.from_input(
63
+ issue, server_name: options[:server], use_default: options[:"default-server"]
64
+ )
57
65
  child_of = options[:"child-of"]
58
66
  in_folder = options[:in]
59
67
 
@@ -70,7 +78,7 @@ module Ace
70
78
  puts " Status: #{status}" if status
71
79
  puts " Priority: #{priority}" if priority
72
80
  puts " Estimate: #{estimate}" if estimate
73
- puts " GitHub: #{github_issue}" if github_issue
81
+ puts " Issue: #{remote_issue['url']}" if remote_issue
74
82
  puts " Tags: #{tags.join(", ")}" if tags.any?
75
83
  puts " Parent: #{child_of}" if child_of
76
84
  puts " Folder: #{in_folder}" if in_folder
@@ -88,7 +96,7 @@ module Ace
88
96
  priority: priority,
89
97
  tags: tags,
90
98
  estimate: estimate,
91
- github_issue: github_issue
99
+ remote_issue: remote_issue
92
100
  )
93
101
  else
94
102
  manager.create(
@@ -97,7 +105,7 @@ module Ace
97
105
  priority: priority,
98
106
  tags: tags,
99
107
  estimate: estimate,
100
- github_issue: github_issue
108
+ remote_issue: remote_issue
101
109
  )
102
110
  end
103
111
  rescue Ace::Task::Organisms::TaskManager::CreateRetriesExhaustedError => e
@@ -126,6 +134,8 @@ module Ace
126
134
  intention: "create task #{task.id}"
127
135
  )
128
136
  end
137
+ rescue Ace::Git::Error, ArgumentError => e
138
+ raise Ace::Support::Cli::Error, e.message
129
139
  end
130
140
 
131
141
  private
@@ -148,18 +158,15 @@ module Ace
148
158
  !value.nil? && !value.to_s.strip.empty?
149
159
  end
150
160
 
151
- def parse_github_issue(raw_values)
161
+ def parse_issue(raw_values)
152
162
  values = Array(raw_values).flatten.compact
153
163
  return nil if values.empty?
154
- raise Ace::Support::Cli::Error.new("Only one --github-issue may be provided") if values.length > 1
164
+ raise Ace::Support::Cli::Error.new("Only one --issue may be provided") if values.length > 1
155
165
 
156
166
  candidate = values.first.to_s.strip
157
- raise Ace::Support::Cli::Error.new("Invalid GitHub issue '#{values.first}': expected numeric ID") unless candidate.match?(/\A\d+\z/)
158
-
159
- id = candidate.to_i
160
- raise Ace::Support::Cli::Error.new("Invalid GitHub issue '#{values.first}': expected positive numeric ID") if id <= 0
167
+ raise Ace::Support::Cli::Error.new("Issue identifier cannot be empty") if candidate.empty?
161
168
 
162
- id
169
+ candidate
163
170
  end
164
171
  end
165
172
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+
5
+ module Ace
6
+ module Task
7
+ module CLI
8
+ module Commands
9
+ class IssueLink < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "Link a task to one validated issue or clear its owned tracking artifacts"
13
+ argument :ref, required: true, desc: "Task reference"
14
+ option :issue, type: :string, desc: "Issue number or URL"
15
+ option :clear, type: :boolean, desc: "Clear the existing issue link"
16
+ option :server, type: :string, desc: "Named forge server"
17
+ option :"default-server", type: :boolean, desc: "Use configured default forge server"
18
+
19
+ def call(ref:, **options)
20
+ if options[:clear] && (options[:issue] || options[:server] || options[:"default-server"])
21
+ raise Ace::Support::Cli::Error, "--clear cannot combine with issue or server selection"
22
+ end
23
+ result = Ace::Task::Organisms::TaskManager.new.issue_link(
24
+ ref, issue: options[:issue], clear: options[:clear],
25
+ server_name: options[:server], use_default: options[:"default-server"]
26
+ )
27
+ raise Ace::Support::Cli::Error, "Task #{ref.inspect} not found" unless result
28
+
29
+ puts(options[:clear] ? "Cleared issue link for #{result.id}" :
30
+ "Linked #{result.id} to #{result.metadata.fetch('remote_issue').fetch('url')}")
31
+ rescue Ace::Git::Error, ArgumentError => e
32
+ raise Ace::Support::Cli::Error, e.message
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+
5
+ module Ace
6
+ module Task
7
+ module CLI
8
+ module Commands
9
+ class IssueSync < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "Synchronize the exact stored issue link for one task or a selected task set"
13
+ argument :ref, required: false, desc: "Task reference"
14
+ option :all, type: :boolean, desc: "Sync all linked tasks"
15
+ option :pending, type: :boolean, desc: "Replay deferred issue synchronization"
16
+
17
+ def call(ref: nil, **options)
18
+ if options[:all] && options[:pending] || ref && (options[:all] || options[:pending])
19
+ raise Ace::Support::Cli::Error, "Choose REF, --all, or --pending"
20
+ end
21
+ unless ref || options[:all] || options[:pending]
22
+ raise Ace::Support::Cli::Error, "Provide REF, --all, or --pending"
23
+ end
24
+ result = Ace::Task::Organisms::TaskManager.new.issue_sync(
25
+ ref: ref, all: options[:all], pending: options[:pending]
26
+ )
27
+ raise Ace::Support::Cli::Error, "Task #{ref.inspect} not found" unless result
28
+
29
+ Array(result[:failures]).each do |failure|
30
+ warn "Issue sync failed for #{failure[:task_id]} (#{failure[:remote_issues].inspect}): #{failure[:error]}"
31
+ end
32
+ summary = "Issue sync: synced #{result[:synced]}, failed #{result[:failed]}, " \
33
+ "pending #{result[:pending]}, skipped #{result[:skipped]}"
34
+ raise Ace::Support::Cli::Error, summary if result[:failed].positive? || result[:pending].positive?
35
+
36
+ puts summary
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -114,6 +114,8 @@ module Ace
114
114
  intention: intention
115
115
  )
116
116
  end
117
+ rescue ArgumentError => e
118
+ raise Ace::Support::Cli::Error, e.message
117
119
  end
118
120
 
119
121
  private
data/lib/ace/task/cli.rb CHANGED
@@ -10,7 +10,8 @@ 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
+ require_relative "cli/commands/issue_sync"
14
+ require_relative "cli/commands/issue_link"
14
15
 
15
16
  module Ace
16
17
  module Task
@@ -28,7 +29,8 @@ module Ace
28
29
  ["doctor", "Run health checks on tasks"],
29
30
  ["status", "Show task status overview"],
30
31
  ["plan", "Resolve or generate implementation plan"],
31
- ["github-sync", "Sync linked GitHub issues for task(s)"]
32
+ ["issue-sync", "Sync linked issues for task(s)"],
33
+ ["issue-link", "Link or clear one exact remote issue"]
32
34
  ].freeze
33
35
 
34
36
  HELP_EXAMPLES = [
@@ -48,8 +50,9 @@ module Ace
48
50
  "ace-task plan q7w",
49
51
  "ace-task plan q7w --refresh",
50
52
  "ace-task plan q7w --content",
51
- "ace-task github-sync q7w",
52
- "ace-task github-sync --all"
53
+ "ace-task issue-sync q7w",
54
+ "ace-task issue-sync --pending",
55
+ "ace-task issue-link q7w --issue 42 --server forgejo-lab"
53
56
  ].freeze
54
57
 
55
58
  register "create", CLI::Commands::Create
@@ -59,7 +62,8 @@ module Ace
59
62
  register "doctor", CLI::Commands::Doctor
60
63
  register "status", CLI::Commands::Status
61
64
  register "plan", CLI::Commands::Plan
62
- register "github-sync", CLI::Commands::GithubSync
65
+ register "issue-sync", CLI::Commands::IssueSync
66
+ register "issue-link", CLI::Commands::IssueLink
63
67
 
64
68
  version_cmd = Ace::Support::Cli::VersionCommand.build(
65
69
  gem_name: "ace-task",
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "ace/git"
5
+
6
+ module Ace
7
+ module Task
8
+ module Molecules
9
+ # Resolves and validates the one persisted remote issue identity.
10
+ module IssueLink
11
+ FIELDS = %w[server_name provider repository_url number url].freeze
12
+
13
+ module_function
14
+
15
+ def from_input(input, server_name: nil, use_default: false)
16
+ value = input.to_s.strip
17
+ raise ArgumentError, "Issue identifier is empty" if value.empty?
18
+
19
+ selected = server_name || use_default ?
20
+ Ace::Git::ServerRegistry.resolve_for(server_name: server_name, use_default: use_default) : nil
21
+ number, candidates = if value.match?(/\A[1-9]\d*\z/)
22
+ [value.to_i, nil]
23
+ elsif (match = value.match(%r{\A(https?://[^\s]+?)/issues/([1-9]\d*)\z}))
24
+ [match[2].to_i, Ace::Git::ServerRegistry.matching_servers(match[1])]
25
+ elsif (match = value.match(/\A([^\s#]+\/[^\s#]+)#([1-9]\d*)\z/))
26
+ [match[2].to_i, Ace::Git::ServerRegistry.servers_for_owner_repo(match[1])]
27
+ else
28
+ raise ArgumentError, "Invalid issue identifier #{input.inspect}; use a positive number or issue URL"
29
+ end
30
+
31
+ if candidates
32
+ raise Ace::Git::AmbiguousRemoteError, "Issue repository matches no configured server" if candidates.empty?
33
+ # A URL input must resolve uniquely on its own; explicit selection
34
+ # filters but never disambiguates multiple matches.
35
+ raise Ace::Git::AmbiguousRemoteError,
36
+ "Issue repository matches multiple configured servers" if candidates.length != 1
37
+ if selected && candidates.first.name != selected.name
38
+ raise Ace::Git::ProviderIdentityMismatchError,
39
+ "Issue identifier does not match selected server #{selected.name}"
40
+ end
41
+ selected ||= candidates.first
42
+ else
43
+ selected ||= Ace::Git::ServerRegistry.resolve_for
44
+ end
45
+
46
+ {
47
+ "server_name" => selected.name,
48
+ "provider" => selected.provider.to_s,
49
+ "repository_url" => selected.url,
50
+ "number" => number,
51
+ "url" => "#{Ace::Git::Atoms::ServerUrl.web_base(selected.url)}/issues/#{number}"
52
+ }
53
+ end
54
+
55
+ def validate!(mapping)
56
+ unless mapping.is_a?(Hash) && mapping.keys.map(&:to_s).sort == FIELDS.sort
57
+ raise ArgumentError, "remote_issue must contain exactly #{FIELDS.join(', ')}"
58
+ end
59
+ identity = mapping.transform_keys(&:to_s)
60
+ number = identity["number"]
61
+ unless number.is_a?(Integer) && number.positive?
62
+ raise ArgumentError, "remote_issue.number must be a positive integer"
63
+ end
64
+ server = Ace::Git::ServerRegistry.resolve(identity["server_name"])
65
+ expected = from_input(number.to_s, server_name: server.name)
66
+ unless identity == expected
67
+ raise Ace::Git::ProviderIdentityMismatchError,
68
+ "Stored remote_issue identity for #{server.name} no longer matches its configured repository/provider"
69
+ end
70
+ server
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "issue_link"
4
+ require "pathname"
5
+
6
+ module Ace
7
+ module Task
8
+ module Molecules
9
+ class IssueSyncAdapter
10
+ def initialize(provider_factory: Ace::Git::Providers)
11
+ @provider_factory = provider_factory
12
+ end
13
+
14
+ def validate_link!(identity:, task_id: nil, previous_task_id: nil)
15
+ server = IssueLink.validate!(identity)
16
+ tracking(server).validate_link!(number: identity.fetch("number"), task_id: task_id,
17
+ previous_task_id: previous_task_id)
18
+ end
19
+
20
+ def sync_task(task:, previous_task_id: nil, before_create: nil)
21
+ identity = task.metadata.fetch("remote_issue")
22
+ server = IssueLink.validate!(identity)
23
+ tracking(server).sync(
24
+ number: identity.fetch("number"), task_id: task.id,
25
+ previous_task_id: previous_task_id,
26
+ task_link: task_link(identity, task), task_status: task.status,
27
+ create_pending: task.metadata["issue_sync_operation"] == "reconcile-create" ||
28
+ task.metadata["issue_sync_reconcile_create"] == true,
29
+ before_create: before_create
30
+ )
31
+ end
32
+
33
+ # Clear-time reconciliation: establish comment ownership without any
34
+ # lifecycle side effect (clear never changes issue state).
35
+ def reconcile_comment(task:)
36
+ identity = task.metadata.fetch("remote_issue")
37
+ server = IssueLink.validate!(identity)
38
+ tracking(server).sync(
39
+ number: identity.fetch("number"), task_id: task.id,
40
+ previous_task_id: task.metadata["issue_sync_previous_id"],
41
+ task_link: task_link(identity, task), task_status: task.status,
42
+ create_pending: task.metadata["issue_sync_operation"] == "reconcile-create" ||
43
+ task.metadata["issue_sync_reconcile_create"] == true,
44
+ comment_only: true
45
+ )
46
+ end
47
+
48
+ def clear_task(task:, previous_task_id: nil)
49
+ identity = task.metadata.fetch("remote_issue")
50
+ server = IssueLink.validate!(identity)
51
+ tracking(server).clear(number: identity.fetch("number"), task_id: task.id,
52
+ previous_task_id: previous_task_id)
53
+ end
54
+
55
+ private
56
+
57
+ def tracking(server)
58
+ Ace::Git::Organisms::IssueTracking.new(provider: @provider_factory.for(server))
59
+ end
60
+
61
+ # Repository-relative so the forge blob link is stable no matter which
62
+ # working directory ace-task was invoked from.
63
+ def safe_task_path(task)
64
+ path = Pathname.new(task.file_path || task.path).expand_path
65
+ root = Pathname.new(git_repo_root(path))
66
+ relative = path.relative_path_from(root).to_s
67
+ relative.start_with?("../") ? path.basename.to_s : relative
68
+ rescue ArgumentError, SystemCallError
69
+ path.basename.to_s
70
+ end
71
+
72
+ def git_repo_root(path)
73
+ candidate = path.dirname
74
+ loop do
75
+ return candidate.to_s if File.exist?(File.join(candidate, ".git"))
76
+
77
+ parent = candidate.parent
78
+ raise SystemCallError, "no git repository" if parent == candidate
79
+
80
+ candidate = parent
81
+ end
82
+ end
83
+
84
+ # A repository-relative path is not a resolvable link target inside a
85
+ # forge comment; build a web URL from the linked repository using the
86
+ # provider's file-browse route (GitHub: /blob/, Forgejo: /src/branch/).
87
+ def task_link(identity, task)
88
+ base = Ace::Git::Atoms::ServerUrl.web_base(identity.fetch("repository_url"))
89
+ path = safe_task_path(task)
90
+ if identity.fetch("provider") == "github"
91
+ "#{base}/blob/HEAD/#{path}"
92
+ else
93
+ "#{base}/src/branch/HEAD/#{path}"
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -40,7 +40,7 @@ module Ace
40
40
  tags: [],
41
41
  time: Time.now.utc,
42
42
  estimate: nil,
43
- github_issue: nil
43
+ remote_issue: nil
44
44
  )
45
45
  raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
46
46
 
@@ -70,7 +70,7 @@ module Ace
70
70
  created_at: time,
71
71
  parent: parent_task.id,
72
72
  estimate: estimate,
73
- github_issue: github_issue
73
+ remote_issue: remote_issue
74
74
  )
75
75
 
76
76
  # Write spec file
@@ -40,7 +40,7 @@ module Ace
40
40
  time: Time.now.utc,
41
41
  use_llm_slug: false,
42
42
  estimate: nil,
43
- github_issue: nil
43
+ remote_issue: nil
44
44
  )
45
45
  raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
46
46
 
@@ -76,7 +76,7 @@ module Ace
76
76
  dependencies: dependencies,
77
77
  created_at: time,
78
78
  estimate: estimate,
79
- github_issue: github_issue
79
+ remote_issue: remote_issue
80
80
  )
81
81
 
82
82
  # Write spec file with file_slug
@@ -110,7 +110,7 @@ module Ace
110
110
  issues << {type: :warning, message: "Title exceeds #{Atoms::TaskValidationRules::MAX_TITLE_LENGTH} characters (#{title.length} chars)", location: file_path}
111
111
  end
112
112
 
113
- validate_github_fields(frontmatter, file_path, issues)
113
+ validate_issue_fields(frontmatter, file_path, issues)
114
114
  end
115
115
 
116
116
  def validate_recommended_fields(frontmatter, file_path, issues)
@@ -134,24 +134,50 @@ module Ace
134
134
  end
135
135
  end
136
136
 
137
- def validate_github_fields(frontmatter, file_path, issues)
138
- linked = frontmatter["github_issue"]
139
- unless linked.nil? || (linked.is_a?(Integer) && linked.positive?)
140
- issues << {
141
- type: :error,
142
- message: "Invalid GitHub issue ID '#{linked}' in github_issue (expected positive integer)",
143
- location: file_path
144
- }
145
- end
146
-
147
- pending = frontmatter["github_sync_pending"]
148
- return if pending.nil? || pending == true || pending == false
137
+ def validate_issue_fields(frontmatter, file_path, issues)
138
+ %w[github_issue github_sync_pending].each do |key|
139
+ next unless frontmatter.key?(key)
149
140
 
150
- issues << {
151
- type: :error,
152
- message: "Invalid github_sync_pending value (expected boolean)",
153
- location: file_path
154
- }
141
+ issues << {type: :error, message: "Obsolete #{key}; use remote_issue/issue_sync_pending",
142
+ location: file_path}
143
+ end
144
+ if frontmatter.key?("remote_issue")
145
+ linked = frontmatter["remote_issue"]
146
+ valid = linked.is_a?(Hash) && linked.keys.all? { |key| key.is_a?(String) } &&
147
+ linked.keys.sort ==
148
+ %w[number provider repository_url server_name url] &&
149
+ linked["number"].is_a?(Integer) && linked["number"].positive? &&
150
+ %w[server_name provider repository_url url].all? { |key| linked[key].is_a?(String) && !linked[key].empty? } &&
151
+ linked["url"] == "#{Ace::Git::Atoms::ServerUrl.web_base(linked["repository_url"])}/issues/#{linked["number"]}"
152
+ unless valid
153
+ issues << {type: :error, message: "Invalid complete remote_issue identity", location: file_path}
154
+ end
155
+ end
156
+ pending = frontmatter["issue_sync_pending"]
157
+ unless pending.nil? || pending == true || pending == false
158
+ issues << {type: :error, message: "Invalid issue_sync_pending value (expected boolean)",
159
+ location: file_path}
160
+ end
161
+ if pending == true && !frontmatter.key?("remote_issue")
162
+ issues << {type: :error, message: "issue_sync_pending requires a complete remote_issue identity",
163
+ location: file_path}
164
+ end
165
+ operation = frontmatter["issue_sync_operation"]
166
+ valid_operation = operation.nil? ||
167
+ (%w[clear reconcile-create].include?(operation) && pending == true && frontmatter["remote_issue"].is_a?(Hash))
168
+ unless valid_operation
169
+ issues << {type: :error, message: "Invalid issue_sync_operation", location: file_path}
170
+ end
171
+ previous_id = frontmatter["issue_sync_previous_id"]
172
+ unless previous_id.nil? || (previous_id.is_a?(String) && !previous_id.empty?)
173
+ issues << {type: :error, message: "Invalid issue_sync_previous_id value (expected task ID string)",
174
+ location: file_path}
175
+ end
176
+ reconcile_flag = frontmatter["issue_sync_reconcile_create"]
177
+ unless reconcile_flag.nil? || reconcile_flag == true
178
+ issues << {type: :error, message: "Invalid issue_sync_reconcile_create value (expected boolean)",
179
+ location: file_path}
180
+ end
155
181
  end
156
182
  end
157
183
  end