ace-task 0.38.0 → 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 +4 -4
- data/CHANGELOG.md +23 -0
- data/handbook/workflow-instructions/task/draft.wf.md +18 -18
- data/lib/ace/task/atoms/task_frontmatter_defaults.rb +5 -2
- data/lib/ace/task/cli/commands/create.rb +20 -13
- data/lib/ace/task/cli/commands/issue_link.rb +38 -0
- data/lib/ace/task/cli/commands/issue_sync.rb +42 -0
- data/lib/ace/task/cli/commands/update.rb +2 -0
- data/lib/ace/task/cli.rb +9 -5
- data/lib/ace/task/molecules/issue_link.rb +75 -0
- data/lib/ace/task/molecules/issue_sync_adapter.rb +99 -0
- data/lib/ace/task/molecules/subtask_creator.rb +2 -2
- data/lib/ace/task/molecules/task_creator.rb +2 -2
- data/lib/ace/task/molecules/task_frontmatter_validator.rb +44 -18
- data/lib/ace/task/molecules/task_plan_prompt_builder.rb +3 -3
- data/lib/ace/task/molecules/task_reparenter.rb +66 -0
- data/lib/ace/task/molecules/task_resolver.rb +31 -3
- data/lib/ace/task/organisms/task_manager.rb +780 -107
- data/lib/ace/task/version.rb +1 -1
- data/lib/ace/task.rb +0 -1
- metadata +24 -8
- data/lib/ace/task/cli/commands/github_sync.rb +0 -73
- data/lib/ace/task/molecules/github_issue_sync_adapter.rb +0 -88
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f192e0d481809ade06d5be298d573c824a6a2b16147448911a6743bdea524e5d
|
|
4
|
+
data.tar.gz: a1a05a622f467e7e9cb42139166c3335aba57ff1fb0cce34711c41453b725387
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 566a4273824d53d2810a84d62202077de16411e6bd166c695ce454890c37e415fbc2b80c444c2768f46bedd03709633b87db5d2bf4e475edda85f89e87c3e948
|
|
7
|
+
data.tar.gz: 8e6266c0fc3c3bb400d840e35c3d3ff460dd9f24b0f1ae783ab14d414a5d29501ab24b7096615f2ecb3fb4bda32571031fee3fc3b8390c3bab70048a69eb0b6e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ 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
|
+
|
|
25
|
+
## [0.38.1] - 2026-09-29
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- The released gemspec now declares `ace-git-github ~> 0.2`, matching the source
|
|
29
|
+
constraint: the previously published 0.38.0 metadata still pinned `~> 0.1.1`,
|
|
30
|
+
which kept published installations resolving ace-git-github 0.1.2 and made
|
|
31
|
+
the released 0.2.0 unreachable through dependency resolution.
|
|
32
|
+
|
|
10
33
|
## [0.38.0] - 2026-09-28
|
|
11
34
|
|
|
12
35
|
### Added
|
|
@@ -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
|
|
39
|
-
|
|
40
|
-
|
|
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 `
|
|
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" --
|
|
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" --
|
|
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
|
|
170
|
-
* Put `--
|
|
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
|
-
|
|
173
|
-
* Run `ace-task
|
|
174
|
-
|
|
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
|
-
* [ ]
|
|
247
|
-
* [ ] Linked
|
|
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
|
|
354
|
-
* The parent/orchestrator or single flat task is created with `--
|
|
355
|
-
* The task frontmatter contains `
|
|
356
|
-
* `ace-task
|
|
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" --
|
|
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
|
-
|
|
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["
|
|
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" --
|
|
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 :
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 --
|
|
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("
|
|
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
|
-
|
|
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
|
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/
|
|
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
|
-
["
|
|
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
|
|
52
|
-
"ace-task
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
+
remote_issue: remote_issue
|
|
80
80
|
)
|
|
81
81
|
|
|
82
82
|
# Write spec file with file_slug
|