ace-git 0.19.3 → 0.22.2

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: 6de57ed11849e98fe1955d76111319dc711368343272b6b2d80dd58bc6f65bef
4
- data.tar.gz: d05cb9bacf11ef83814bf2f763c3e3904ac527a2a8c6aa2164be413861cbe208
3
+ metadata.gz: 9ec30e7c3fd82d4582834dc1623823c01ccfef74987aa95a11002fc50889321b
4
+ data.tar.gz: 5a7526010c653057b5b88a3af813fd18a617630636b5c0323c42abc03c79cd3e
5
5
  SHA512:
6
- metadata.gz: eb44c158096edc47a8d49b30fceb7f0cad852ff655f630ac6a585fa962ef4b81d3d749639778ef29bb98148f74be599dfcd4e1f698b63de4e9bce7932c944347
7
- data.tar.gz: aff4d087154edc8ecc6dc8f90d9ee2db4a0f00933860a0b8331d7624b0d5f71d9276d33830b6cda07f1ce3021dd502bdb14f7db697396055cc4bbeef0e5a0bca
6
+ metadata.gz: 7a49f45af6761e288db661713c63cd874fc7bfa7c3a4b93de1f13b0be448e7473ee5ab5144e3201dfc52647baa748aa740ff4d4211ce792805d5d129396c9040
7
+ data.tar.gz: b3642c81411d64e0ca6dae9bf794d6c99f81b8b437ad2534d85479e5a221880ef68dd6879f49e7667c82d70e2ee4faeb556db54afb9f607a608cf62f8bdd14bc
data/CHANGELOG.md CHANGED
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.22.2] - 2026-08-12
11
+
12
+ ### Technical
13
+ - Raised the `ace-support-core` dependency floor to `~> 0.31` after the principles-first bootstrap guidance release.
14
+ ## [0.22.1] - 2026-08-12
15
+
16
+ ### Technical
17
+ - Aligned gemspec dependency floors with current ACE package minor release lines and safe external minor dependency bumps (no major version jumps).
18
+
19
+ ## [0.22.0] - 2026-04-15
20
+
21
+ ### Changed
22
+ - Reworked `TS-GIT-001` E2E contracts to use scenario-owned setup, removed runner bootstrap/fallback recipes, and tightened PR verification to direct `ace-git pr` outcomes.
23
+ - Added `TS-GIT-002-public-surface-smoke` with help and range-routing coverage to validate documented public CLI entrypoints.
24
+
25
+ ## [0.21.1] - 2026-04-13
26
+
27
+ ### Changed
28
+ - Completed the batch i05 migration follow-through for this package and aligned it with the restarted `fast` / `feat` / `e2e` verification model.
29
+
30
+ ### Technical
31
+ - Included in the coordinated assignment-driven patch release for batch i05 package updates.
32
+
33
+
34
+
35
+ ### Changed
36
+ - Expanded `as-github-pr-create` canonical skill metadata so public `create-pr` assign-step discovery is skill-owned rather than catalog-owned.
37
+
38
+ ## [0.21.0] - 2026-04-12
39
+
40
+ ### Changed
41
+ - Migrated package tests to the `fast` / `feat` / `e2e` model, moved deterministic coverage into `test/fast` and `test/feat`, and aligned TS-GIT-001 scenario artifacts with the current E2E runner/verifier contract.
42
+ - Release workflow now normalizes target branch context and supports automatic PR/task source detection.
43
+ ## [0.20.2] - 2026-04-07
44
+
45
+ ### Added
46
+ - Added shared GitHub issue sync primitives (`GhCliExecutor`, `GithubIssueSync`) for broader package reuse.
47
+
48
+ ### Fixed
49
+ - Prevented cross-task GitHub issue ownership conflicts during sync and derived sticky-comment update/delete IDs from `gh issue view` URLs.
50
+
51
+ ## [0.20.1] - 2026-04-07
52
+
53
+ ### Fixed
54
+ - Corrected sticky-comment mutation to derive the REST comment ID from GitHub issue comment URLs when `gh issue view` returns GraphQL node IDs, so linked issues continue closing cleanly on task archive/update.
55
+
56
+ ## [0.20.0] - 2026-04-07
57
+
58
+ ### Fixed
59
+ - Reconciled removed/retargeted task issue links by honoring explicit current issue sets during sync so unlinked issues are cleaned without re-adding stale tracked lines or applying lifecycle actions.
60
+ - Removed stale tracking artifacts when the last linked task is detached by deleting the sticky tracking comment and removing the `ace:tracked` label.
61
+ - Switched tracked task links to `blob/HEAD` URLs so issue comments resolve against repositories whose default branch is not `main`.
62
+ - Enforced one-owner issue sync semantics by rejecting attempts to link a GitHub issue already owned by a different ACE task.
63
+
64
+ ### Added
65
+ - Added reusable GitHub issue sync primitives in `ace-git` (`GhCliExecutor` and `GithubIssueSync`) for shared ACE issue lifecycle integration.
66
+
67
+ ### Changed
68
+ - Migrated `ace-review` GitHub CLI execution ownership to the shared `ace-git` executor boundary.
69
+ - Simplified linked-issue lifecycle handling by treating each tracked GitHub issue as owned by exactly one ACE task.
70
+
10
71
  ## [0.19.3] - 2026-03-31
11
72
 
12
73
  ### Fixed
data/README.md CHANGED
@@ -36,5 +36,14 @@
36
36
 
37
37
  **Coordinate with commit and worktree tools** - pair with [ace-git-commit](../ace-git-commit) for scoped commit authoring, [ace-git-worktree](../ace-git-worktree) for task-oriented worktree management, and [ace-bundle](../ace-bundle) for loading workflow instructions.
38
38
 
39
+ ## Testing Contract
40
+
41
+ `ace-git` follows the restarted package test model:
42
+
43
+ - `ace-test ace-git` for deterministic fast-loop coverage (`test/fast/`)
44
+ - `ace-test ace-git feat` for deterministic feature/contract coverage (`test/feat/`)
45
+ - `ace-test-e2e ace-git` for retained workflow scenarios (`test/e2e/`)
46
+ - `ace-test ace-git all` for full package verification
47
+
39
48
  ---
40
49
  [Getting Started](docs/getting-started.md) | [Usage Guide](docs/usage.md) | [Handbook - Skills, Agents, Templates](docs/handbook.md) | Part of [ACE](https://github.com/cs3b/ace)
@@ -79,6 +79,15 @@ Use this for preferences, not for redefining the workflow logic.
79
79
  | Show PR metadata | `ace-git pr` |
80
80
  | Show package version | `ace-git version` |
81
81
 
82
+ ## Run Package Tests
83
+
84
+ Use the restarted package test model:
85
+
86
+ - `ace-test ace-git` for deterministic fast-loop tests
87
+ - `ace-test ace-git feat` for deterministic feature/contract tests
88
+ - `ace-test-e2e ace-git` for retained workflow scenarios
89
+ - `ace-test ace-git all` for complete package verification
90
+
82
91
  ## Next Steps
83
92
 
84
93
  - Load `wfi://github/pr/create` to open a structured PR workflow
data/docs/usage.md CHANGED
@@ -236,7 +236,7 @@ diff:
236
236
  | `grouped_stats.layers` | Array | `["lib","test","handbook"]` | Preferred layer grouping order |
237
237
  | `grouped_stats.collapse_above` | Integer | `5` | Collapse markdown groups above this file count |
238
238
  | `grouped_stats.show_full_tree` | String | `collapsible` | Tree rendering strategy hint |
239
- | `grouped_stats.dotfile_groups` | Array | `[".ace-taskflow",".ace"]` | Dot-directories to prioritize in grouping |
239
+ | `grouped_stats.dotfile_groups` | Array | `[".ace-task",".ace"]` | Dot-directories to prioritize in grouping |
240
240
 
241
241
  **Default exclude_patterns** (from `.ace-defaults/git/config.yml`):
242
242
  - Lock files: `**/*.lock`, `package-lock.json`, `yarn.lock`, `Gemfile.lock`
@@ -251,9 +251,27 @@ Note: `test/**/*` and `spec/**/*` are NOT excluded by default - test changes are
251
251
  - `0` - Success
252
252
  - `1` - Error (configuration error, git error, etc.)
253
253
 
254
+ ## Testing Contract
255
+
256
+ `ace-git` uses the `fast` / `feat` / `e2e` model:
257
+
258
+ - `ace-test ace-git` - default deterministic suite (`test/fast/`)
259
+ - `ace-test ace-git feat` - deterministic feature/contract suite (`test/feat/`)
260
+ - `ace-test-e2e ace-git` - scenario workflow suite (`test/e2e/`)
261
+ - `ace-test ace-git all` - full package suite
262
+
254
263
  ## Related Tools
255
264
 
256
265
  - **ace-git-commit** - Smart git commit generation
257
266
  - **ace-git-worktree** - Git worktree management
258
267
  - **ace-bundle** - Load ACE workflow instructions directly
259
268
  - **ace-nav** - Discover workflow and template protocol paths
269
+
270
+ ## Reusable GitHub Issue Primitives
271
+
272
+ `ace-git` also exposes reusable library primitives for issue synchronization flows used by other ACE packages:
273
+
274
+ - `Ace::Git::Molecules::GhCliExecutor` - shared `gh` command execution with timeout and auth/install error handling
275
+ - `Ace::Git::Molecules::GithubIssueSync.sync_task(...)` - sticky comment upsert, `ace:tracked` label management, and close/reopen lifecycle actions
276
+
277
+ These are intended for package integrations such as `ace-task` and are not invoked directly through `ace-git` CLI commands.
@@ -19,9 +19,9 @@ This guide provides comprehensive documentation for Git-specific version control
19
19
 
20
20
  This project uses a **mono-repo architecture** with all packages at the repository root:
21
21
 
22
- - **ace-*/**: Ruby gems for specific functionality (ace-git, ace-taskflow, ace-docs, etc.)
22
+ - **ace-*/**: Ruby gems for specific functionality (ace-git, ace-task, ace-docs, etc.)
23
23
  - **docs/**: Project documentation and architecture decisions
24
- - **.ace-taskflow/**: Task management and release coordination
24
+ - **.ace-task/**: Task management and release coordination
25
25
  - **_legacy/**: Archived content from previous multi-repo structure
26
26
 
27
27
  #### Common Git Commands
@@ -25,15 +25,16 @@ integration:
25
25
  assign:
26
26
  source: wfi://git/rebase
27
27
  steps:
28
- - name: rebase-with-main
29
- description: Rebase current branch onto origin/main while preserving changelog intent
30
- intent:
31
- phrases:
32
- - "rebase with main"
33
- - "rebase with origin main"
34
- - "rebase onto main"
35
- - "sync with main"
36
- tags: [git, history, rebase]
28
+ - name: rebase-with-target
29
+ description: Rebase current branch onto the target branch
30
+ intent:
31
+ phrases:
32
+ - "rebase with target branch"
33
+ - "rebase with main"
34
+ - "rebase with origin main"
35
+ - "rebase onto main"
36
+ - "sync with main"
37
+ tags: [git, history, rebase]
37
38
  skill:
38
39
  kind: workflow
39
40
  execution:
@@ -25,7 +25,6 @@ integration:
25
25
  context: fork
26
26
  model: haiku
27
27
  assign:
28
- source: wfi://github/pr/create
29
28
  steps:
30
29
  - name: create-pr
31
30
  description: Create a pull request for the implemented changes
@@ -35,6 +34,19 @@ assign:
35
34
  - "create a pr"
36
35
  - "open pr"
37
36
  - "open pull request"
37
+ prerequisites:
38
+ - name: work-on-task
39
+ strength: required
40
+ reason: "Must have code changes to create a PR"
41
+ produces: [pull-request]
42
+ consumes: [code-changes, commits]
43
+ context:
44
+ default: null
45
+ reason: "PR creation needs access to git state in main context"
46
+ when_to_skip:
47
+ - "PR already exists for this branch"
48
+ - "Changes don't warrant a PR (e.g., direct push allowed)"
49
+ effort: light
38
50
  tags: [git, pr, publishing]
39
51
  skill:
40
52
  kind: workflow
@@ -5,6 +5,17 @@ purpose: Safe rebase workflow with state capture, explicit conflict triage, and
5
5
  ace-docs:
6
6
  last-updated: 2026-03-12
7
7
  last-checked: 2026-03-21
8
+ bundle:
9
+ embed_document_source: true
10
+ params:
11
+ output: cache
12
+ timeout: 30
13
+ max_size: 81920
14
+ sections:
15
+ current_repository_status:
16
+ title: Current Repository Status
17
+ commands:
18
+ - ace-git status --format json
8
19
  ---
9
20
 
10
21
  # Rebase Workflow
@@ -35,28 +46,80 @@ Rebase feature branches against a target branch with automatic state capture for
35
46
  # $target_branch: Branch to rebase against (default: auto-detect or origin/main)
36
47
  # $no_verify: Skip post-rebase verification (default: false)
37
48
 
38
- # Helper: Auto-detect target branch from task spec or default to origin/main
39
- get_target_branch() {
49
+ # Helper: Normalize a branch to an explicit remote ref
50
+ normalize_target_ref() {
51
+ local branch
52
+ branch=$(printf '%s' "$1" | tr -d '[:space:]')
53
+
54
+ case "$branch" in
55
+ origin/*) echo "$branch" ;;
56
+ refs/heads/*) echo "origin/${branch#refs/heads/}" ;;
57
+ refs/remotes/origin/*) echo "${branch#refs/remotes/}" ;;
58
+ refs/remotes/*/*) echo "${branch#refs/remotes/}" ;;
59
+ "") echo "origin/main" ;;
60
+ *) echo "origin/$branch" ;;
61
+ esac
62
+ }
63
+
64
+ # Helper: Auto-detect target branch from task spec
65
+ get_task_target_branch() {
40
66
  local task_file
41
67
  task_file=$(ls _current/*.s.md 2>/dev/null | head -1)
42
68
  if [ -n "$task_file" ] && [ -f "$task_file" ] && [ -r "$task_file" ]; then
43
69
  ruby -ryaml -e '
44
- c = File.read(ARGV[0])
45
- if c.start_with?("---")
46
- yaml_content = c.split("---", 3)[1]
47
- if yaml_content
48
- data = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
49
- puts data.dig("worktree", "target_branch") || "origin/main"
50
- else
51
- puts "origin/main"
70
+ begin
71
+ c = File.read(ARGV[0])
72
+ if c.start_with?("---")
73
+ yaml_content = c.split("---", 3)[1]
74
+ if yaml_content
75
+ data = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
76
+ worktree = data["worktree"] || data[:worktree]
77
+ next unless worktree.is_a?(Hash)
78
+ target = worktree["target_branch"] || worktree[:target_branch]
79
+ puts target if target
80
+ end
52
81
  end
53
- else
54
- puts "origin/main"
82
+ rescue
83
+ exit 0
55
84
  end
56
- ' "$task_file" 2>/dev/null || echo "origin/main"
57
- else
58
- echo "origin/main"
85
+ ' "$task_file" 2>/dev/null
86
+ fi
87
+ }
88
+
89
+ # Helper: Auto-detect target branch from git context (`ace-git status`)
90
+ get_status_target_branch() {
91
+ ace-git status --format json 2>/dev/null | ruby -rjson -e '
92
+ begin
93
+ data = JSON.parse(STDIN.read)
94
+ pr_metadata = data["pr_metadata"] || data[:pr_metadata]
95
+ if pr_metadata.is_a?(Hash)
96
+ base = pr_metadata["baseRefName"] || pr_metadata[:baseRefName]
97
+ base ||= pr_metadata["base"] || pr_metadata[:base]
98
+ puts base if base
99
+ end
100
+ rescue
101
+ exit 0
102
+ end
103
+ ' 2>/dev/null
104
+ }
105
+
106
+ # Helper: Auto-detect target branch from task spec/context or default to origin/main
107
+ get_target_branch() {
108
+ local target_branch
109
+
110
+ target_branch="$(get_task_target_branch)"
111
+ if [ -n "$target_branch" ]; then
112
+ normalize_target_ref "$target_branch"
113
+ return
59
114
  fi
115
+
116
+ target_branch="$(get_status_target_branch)"
117
+ if [ -n "$target_branch" ]; then
118
+ normalize_target_ref "$target_branch"
119
+ return
120
+ fi
121
+
122
+ echo "origin/main"
60
123
  }
61
124
 
62
125
  # Helper: Recover session variables (for multi-shell workflows)
@@ -408,4 +471,4 @@ Target: {branch} | Commits: {count}
408
471
  Verification: {MATCH|MISMATCH} | Tests: {Pass|Fail}
409
472
  Reason: {no-conflicts|localized-conflict|repeated-conflicts|large-conflict-set|user-request}
410
473
  Status: {Complete|Needs attention}
411
- ```
474
+ ```
@@ -31,7 +31,7 @@ If no PR exists for current branch, ask the user for the target PR number.
31
31
 
32
32
  ```bash
33
33
  gh pr view $pr_number --json baseRefName,headRefName,title
34
- ace-taskflow status
34
+ ace-task status
35
35
  ```
36
36
 
37
37
  If taskflow context indicates a subtask and the PR targets `main`, retarget to the parent task branch before continuing.
@@ -39,7 +39,7 @@ module Ace
39
39
  grouped_stats_layers: %w[lib test handbook],
40
40
  grouped_stats_collapse_above: 5,
41
41
  grouped_stats_show_full_tree: "collapsible",
42
- grouped_stats_dotfile_groups: %w[.ace-taskflow .ace]
42
+ grouped_stats_dotfile_groups: %w[.ace-task .ace]
43
43
  )
44
44
  @exclude_patterns = Array(exclude_patterns)
45
45
  @exclude_whitespace = exclude_whitespace
@@ -152,7 +152,7 @@ module Ace
152
152
  grouped_stats_layers: grouped_stats["layers"] || grouped_stats[:layers] || %w[lib test handbook],
153
153
  grouped_stats_collapse_above: grouped_stats.fetch("collapse_above", grouped_stats.fetch(:collapse_above, 5)),
154
154
  grouped_stats_show_full_tree: grouped_stats["show_full_tree"] || grouped_stats[:show_full_tree] || "collapsible",
155
- grouped_stats_dotfile_groups: grouped_stats["dotfile_groups"] || grouped_stats[:dotfile_groups] || %w[.ace-taskflow .ace]
155
+ grouped_stats_dotfile_groups: grouped_stats["dotfile_groups"] || grouped_stats[:dotfile_groups] || %w[.ace-task .ace]
156
156
  )
157
157
  end
158
158
 
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module Ace
7
+ module Git
8
+ module Molecules
9
+ # Safely execute gh CLI commands with timeout and structured output.
10
+ class GhCliExecutor
11
+ DEFAULT_GH_TIMEOUT = 30
12
+ DEFAULT_SIMPLE_TIMEOUT = 10
13
+
14
+ def self.execute(subcommand, args = [], timeout: nil)
15
+ check_installed
16
+ timeout_seconds = timeout || Ace::Git.network_timeout || DEFAULT_GH_TIMEOUT
17
+ command = ["gh", subcommand] + args
18
+ run_command(command, timeout_seconds)
19
+ rescue Timeout::Error
20
+ raise Ace::Git::TimeoutError, "gh command timed out after #{timeout_seconds} seconds"
21
+ end
22
+
23
+ def self.check_installed
24
+ result = execute_simple("--version")
25
+ raise Ace::Git::GhNotInstalledError unless result[:success]
26
+
27
+ true
28
+ end
29
+
30
+ def self.check_authenticated
31
+ result = execute_simple("auth", ["status"])
32
+ raise Ace::Git::GhAuthenticationError unless result[:success]
33
+
34
+ {
35
+ authenticated: true,
36
+ username: extract_username(result[:stderr])
37
+ }
38
+ end
39
+
40
+ def self.execute_simple(command, args = [], timeout_seconds = nil)
41
+ timeout_seconds ||= Ace::Git.network_timeout || DEFAULT_SIMPLE_TIMEOUT
42
+ cmd = ["gh", command] + args
43
+ run_command(cmd, timeout_seconds)
44
+ rescue Timeout::Error
45
+ {
46
+ success: false,
47
+ stdout: "",
48
+ stderr: "Command timed out",
49
+ exit_code: 1
50
+ }
51
+ end
52
+
53
+ def self.extract_username(output)
54
+ match = output.match(/Logged in to .+ as (\S+)/)
55
+ match ? match[1] : nil
56
+ end
57
+
58
+ def self.run_command(command, timeout_seconds)
59
+ stdout_str, stderr_str, status = Timeout.timeout(timeout_seconds) do
60
+ Open3.capture3(*command)
61
+ end
62
+
63
+ {
64
+ success: status.success?,
65
+ stdout: stdout_str,
66
+ stderr: stderr_str,
67
+ exit_code: status.exitstatus
68
+ }
69
+ rescue Errno::ENOENT
70
+ raise Ace::Git::GhNotInstalledError
71
+ end
72
+
73
+ private_class_method :execute_simple, :extract_username, :run_command
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Ace
7
+ module Git
8
+ module Molecules
9
+ # Synchronize ACE task linkage metadata to GitHub issues.
10
+ class GithubIssueSync
11
+ class OwnershipConflict < StandardError; end
12
+
13
+ STICKY_MARKER = "<!-- ace-task:tracked -->"
14
+ TRACKED_LABEL = "ace:tracked"
15
+ TERMINAL_STATUSES = %w[done completed shipped closed cancelled skipped archived].freeze
16
+
17
+ def self.sync_task(task_id:, task_title:, task_status:, task_path:, issue_ids:, reason:, previous: nil,
18
+ current_issue_ids: nil)
19
+ current_ids = if current_issue_ids.nil?
20
+ Array(issue_ids).map(&:to_i).uniq
21
+ else
22
+ Array(current_issue_ids).map(&:to_i).uniq
23
+ end
24
+
25
+ issue_ids.each do |issue_id|
26
+ sync_issue(
27
+ issue_id: issue_id.to_i,
28
+ task_id: task_id,
29
+ task_title: task_title,
30
+ task_status: task_status,
31
+ task_path: task_path,
32
+ reason: reason,
33
+ previous: previous,
34
+ currently_linked: current_ids.include?(issue_id.to_i)
35
+ )
36
+ end
37
+
38
+ {success: true, synced: issue_ids.length, issues: issue_ids}
39
+ end
40
+
41
+ def self.sync_issue(issue_id:, task_id:, task_title:, task_status:, task_path:, reason:, previous:,
42
+ currently_linked:)
43
+ issue = fetch_issue(issue_id)
44
+ sticky = find_sticky_comment(issue["comments"] || [])
45
+
46
+ lines = tracked_lines(sticky&.dig("body"))
47
+ validate_link!(issue_id: issue_id, task_id: task_id, previous_task_id: previous&.[](:id), lines: lines) if currently_linked
48
+ lines = remove_previous_line(lines, previous)
49
+ lines = upsert_line(lines, task_id: task_id, task_title: task_title, task_path: task_path) if currently_linked
50
+ if lines.empty?
51
+ cleanup_tracking_artifacts(issue_id: issue_id, sticky: sticky, labels: issue["labels"] || [])
52
+ return {success: true, issue: issue_id, reason: reason}
53
+ end
54
+
55
+ body = render_body(lines)
56
+
57
+ if sticky
58
+ update_comment(issue_id: issue_id, comment: sticky, body: body)
59
+ else
60
+ create_comment(issue_id: issue_id, body: body)
61
+ end
62
+
63
+ ensure_label(issue_id, issue["labels"] || [])
64
+ if currently_linked
65
+ sync_lifecycle(
66
+ issue_id: issue_id,
67
+ issue_state: issue["state"],
68
+ task_status: task_status
69
+ )
70
+ end
71
+
72
+ {success: true, issue: issue_id, reason: reason}
73
+ end
74
+
75
+ def self.cleanup_tracking_artifacts(issue_id:, sticky:, labels:)
76
+ delete_comment(issue_id: issue_id, comment: sticky) if sticky
77
+ remove_label(issue_id, labels)
78
+ end
79
+
80
+ def self.fetch_issue(issue_id)
81
+ result = GhCliExecutor.execute("issue", ["view", issue_id.to_s, "--json", "state,comments,labels"])
82
+ raise "Failed to fetch issue #{issue_id}: #{result[:stderr]}" unless result[:success]
83
+
84
+ JSON.parse(result[:stdout])
85
+ rescue JSON::ParserError => e
86
+ raise "Failed to parse issue #{issue_id} response: #{e.message}"
87
+ end
88
+
89
+ def self.find_sticky_comment(comments)
90
+ comments.find { |comment| comment["body"].to_s.include?(STICKY_MARKER) }
91
+ end
92
+
93
+ def self.tracked_lines(body)
94
+ body.to_s.lines.map(&:rstrip).select { |line| line.start_with?("Tracked in ace-task: ") }
95
+ end
96
+
97
+ def self.remove_previous_line(lines, previous)
98
+ return lines unless previous.is_a?(Hash)
99
+
100
+ previous_id = previous[:id] || previous["id"]
101
+ return lines unless previous_id
102
+
103
+ lines.reject { |line| extract_task_id(line) == previous_id.to_s }
104
+ end
105
+
106
+ def self.upsert_line(lines, task_id:, task_title:, task_path:)
107
+ filtered = lines.reject { |line| extract_task_id(line) == task_id.to_s }
108
+ filtered << tracked_line(task_id: task_id, task_title: task_title, task_path: task_path)
109
+ filtered.uniq.sort
110
+ end
111
+
112
+ def self.tracked_line(task_id:, task_title:, task_path:)
113
+ "Tracked in ace-task: [#{task_id}](#{task_url(task_path)})"
114
+ end
115
+
116
+ def self.extract_task_id(line)
117
+ match = line.match(/\[([^\]]+)\]\(/)
118
+ match ? match[1] : nil
119
+ end
120
+
121
+ def self.render_body(lines)
122
+ ([STICKY_MARKER] + lines).join("\n")
123
+ end
124
+
125
+ def self.create_comment(issue_id:, body:)
126
+ GhCliExecutor.execute("issue", ["comment", issue_id.to_s, "--body", body]).tap do |result|
127
+ raise "Failed to create sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
128
+ end
129
+ end
130
+
131
+ def self.update_comment(issue_id:, comment:, body:)
132
+ comment_id = comment_api_id(comment)
133
+ GhCliExecutor.execute("api", [
134
+ "repos/{owner}/{repo}/issues/comments/#{comment_id}",
135
+ "--method", "PATCH",
136
+ "--field", "body=#{body}"
137
+ ]).tap do |result|
138
+ raise "Failed to update sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
139
+ end
140
+ end
141
+
142
+ def self.delete_comment(issue_id:, comment:)
143
+ comment_id = comment_api_id(comment)
144
+ GhCliExecutor.execute("api", [
145
+ "repos/{owner}/{repo}/issues/comments/#{comment_id}",
146
+ "--method", "DELETE"
147
+ ]).tap do |result|
148
+ raise "Failed to delete sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
149
+ end
150
+ end
151
+
152
+ def self.comment_api_id(comment)
153
+ database_id = comment["databaseId"] || comment[:databaseId]
154
+ return database_id.to_s if database_id.to_s.match?(/\A\d+\z/)
155
+
156
+ url = comment["url"] || comment[:url]
157
+ return Regexp.last_match(1) if url.to_s.match(/issuecomment-(\d+)/)
158
+
159
+ (comment["id"] || comment[:id]).to_s
160
+ end
161
+
162
+ def self.ensure_label(issue_id, labels)
163
+ return if labels.any? { |label| label["name"] == TRACKED_LABEL }
164
+
165
+ result = GhCliExecutor.execute("issue", ["edit", issue_id.to_s, "--add-label", TRACKED_LABEL])
166
+ raise "Failed to add #{TRACKED_LABEL} label to issue #{issue_id}: #{result[:stderr]}" unless result[:success]
167
+ end
168
+
169
+ def self.remove_label(issue_id, labels)
170
+ return unless labels.any? { |label| label["name"] == TRACKED_LABEL }
171
+
172
+ result = GhCliExecutor.execute("issue", ["edit", issue_id.to_s, "--remove-label", TRACKED_LABEL])
173
+ raise "Failed to remove #{TRACKED_LABEL} label from issue #{issue_id}: #{result[:stderr]}" unless result[:success]
174
+ end
175
+
176
+ def self.validate_link!(issue_id:, task_id:, previous_task_id: nil, lines: nil)
177
+ issue = fetch_issue(issue_id)
178
+ sticky = find_sticky_comment(issue["comments"] || [])
179
+ lines ||= tracked_lines(sticky&.dig("body"))
180
+ owners = lines.map { |line| extract_task_id(line) }.compact.uniq
181
+ return true if owners.empty?
182
+ return true if owners == [task_id.to_s]
183
+ return true if previous_task_id && owners == [previous_task_id.to_s]
184
+
185
+ raise OwnershipConflict, "GitHub issue ##{issue_id} is already owned by task #{owners.first}"
186
+ end
187
+
188
+ def self.sync_lifecycle(issue_id:, issue_state:, task_status:)
189
+ terminal = TERMINAL_STATUSES.include?(task_status.to_s.downcase)
190
+ issue_open = issue_state.to_s.upcase == "OPEN"
191
+
192
+ if terminal && issue_open
193
+ close_result = GhCliExecutor.execute("issue", ["close", issue_id.to_s])
194
+ raise "Failed to close issue #{issue_id}: #{close_result[:stderr]}" unless close_result[:success]
195
+ elsif !terminal && !issue_open
196
+ reopen_result = GhCliExecutor.execute("issue", ["reopen", issue_id.to_s])
197
+ raise "Failed to reopen issue #{issue_id}: #{reopen_result[:stderr]}" unless reopen_result[:success]
198
+ end
199
+ end
200
+
201
+ def self.task_url(task_path)
202
+ return task_path.to_s if task_path.to_s.empty?
203
+
204
+ repo_root = repo_root_path
205
+ relative = if repo_root && task_path.start_with?(repo_root + "/")
206
+ task_path.delete_prefix(repo_root + "/")
207
+ else
208
+ task_path.to_s
209
+ end
210
+
211
+ repo_slug = github_repo_slug
212
+ return relative unless repo_slug
213
+
214
+ "https://github.com/#{repo_slug}/blob/HEAD/#{relative}"
215
+ end
216
+
217
+ def self.repo_root_path
218
+ stdout, _stderr, status = Open3.capture3("git", "rev-parse", "--show-toplevel")
219
+ return nil unless status.success?
220
+
221
+ stdout.strip
222
+ rescue
223
+ nil
224
+ end
225
+
226
+ def self.github_repo_slug
227
+ stdout, _stderr, status = Open3.capture3("git", "remote", "get-url", "origin")
228
+ return nil unless status.success?
229
+
230
+ url = stdout.strip
231
+ return Regexp.last_match(1) if url.match(%r{github\.com[:/](.+?)(?:\.git)?$})
232
+
233
+ nil
234
+ rescue
235
+ nil
236
+ end
237
+
238
+ private_class_method :sync_issue, :fetch_issue, :find_sticky_comment, :tracked_lines, :remove_previous_line,
239
+ :upsert_line, :tracked_line, :extract_task_id, :render_body, :create_comment, :update_comment, :delete_comment,
240
+ :comment_api_id, :ensure_label, :remove_label, :cleanup_tracking_artifacts, :sync_lifecycle, :task_url, :repo_root_path,
241
+ :github_repo_slug
242
+ end
243
+
244
+ # Backward-compatible alias for early integration experiments.
245
+ IssueSync = GithubIssueSync
246
+ end
247
+ end
248
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Git
5
- VERSION = '0.19.3'
5
+ VERSION = "0.22.2"
6
6
  end
7
7
  end
data/lib/ace/git.rb CHANGED
@@ -221,6 +221,8 @@ require_relative "git/molecules/branch_reader"
221
221
  require_relative "git/molecules/pr_metadata_fetcher"
222
222
  require_relative "git/molecules/recent_commits_fetcher"
223
223
  require_relative "git/molecules/git_status_fetcher"
224
+ require_relative "git/molecules/gh_cli_executor"
225
+ require_relative "git/molecules/github_issue_sync"
224
226
 
225
227
  require_relative "git/organisms/diff_orchestrator"
226
228
  require_relative "git/organisms/repo_status_loader"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-git
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.3
4
+ version: 0.22.2
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-08-12 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-config
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.9'
18
+ version: '0.17'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.9'
25
+ version: '0.17'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: ace-support-core
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.29'
32
+ version: '0.31'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0.29'
39
+ version: '0.31'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: ace-support-cli
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: '0.13'
60
+ version: '0.14'
61
61
  type: :development
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '0.13'
67
+ version: '0.14'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: minitest
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -168,7 +168,9 @@ files:
168
168
  - lib/ace/git/molecules/config_loader.rb
169
169
  - lib/ace/git/molecules/diff_filter.rb
170
170
  - lib/ace/git/molecules/diff_generator.rb
171
+ - lib/ace/git/molecules/gh_cli_executor.rb
171
172
  - lib/ace/git/molecules/git_status_fetcher.rb
173
+ - lib/ace/git/molecules/github_issue_sync.rb
172
174
  - lib/ace/git/molecules/pr_metadata_fetcher.rb
173
175
  - lib/ace/git/molecules/recent_commits_fetcher.rb
174
176
  - lib/ace/git/organisms/diff_orchestrator.rb