ace-git 0.19.3 → 0.22.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 +52 -0
- data/README.md +9 -0
- data/docs/getting-started.md +9 -0
- data/docs/usage.md +19 -1
- data/handbook/guides/version-control-system-git.g.md +2 -2
- data/handbook/skills/as-git-rebase/SKILL.md +10 -9
- data/handbook/skills/as-github-pr-create/SKILL.md +13 -1
- data/handbook/workflow-instructions/git/rebase.wf.md +79 -16
- data/handbook/workflow-instructions/github/pr/update.wf.md +1 -1
- data/lib/ace/git/models/diff_config.rb +2 -2
- data/lib/ace/git/molecules/gh_cli_executor.rb +77 -0
- data/lib/ace/git/molecules/github_issue_sync.rb +248 -0
- data/lib/ace/git/version.rb +1 -1
- data/lib/ace/git.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee4112e17d169c530b00f45e0b6cc8f75e9ca439efdbf8ea091c57dce4511fdb
|
|
4
|
+
data.tar.gz: c9701d7158d7b5c181be4a718482db44ebc31e5efc26497f0c1546095d2fd908
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c3653b988dc194c2b756c5bf3d6176c5ae3a326de9c811c868353616235bf2eec64578ee0d13721de5698d98f9a48907b5d100602d4a9da48b2f22bc7f30c792
|
|
7
|
+
data.tar.gz: 46a393b19445eb2ec0cfde8cd62fbf5803d4ce5c3012eafa3d10d0486eead6a8017f7e95b463968ac7fe0b9fbca2da4f8ba03c092ed15be1c794fb0c02cf502a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.22.0] - 2026-04-15
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- 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.
|
|
14
|
+
- Added `TS-GIT-002-public-surface-smoke` with help and range-routing coverage to validate documented public CLI entrypoints.
|
|
15
|
+
|
|
16
|
+
## [0.21.1] - 2026-04-13
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Completed the batch i05 migration follow-through for this package and aligned it with the restarted `fast` / `feat` / `e2e` verification model.
|
|
20
|
+
|
|
21
|
+
### Technical
|
|
22
|
+
- Included in the coordinated assignment-driven patch release for batch i05 package updates.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Expanded `as-github-pr-create` canonical skill metadata so public `create-pr` assign-step discovery is skill-owned rather than catalog-owned.
|
|
28
|
+
|
|
29
|
+
## [0.21.0] - 2026-04-12
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- 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.
|
|
33
|
+
- Release workflow now normalizes target branch context and supports automatic PR/task source detection.
|
|
34
|
+
## [0.20.2] - 2026-04-07
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- Added shared GitHub issue sync primitives (`GhCliExecutor`, `GithubIssueSync`) for broader package reuse.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- Prevented cross-task GitHub issue ownership conflicts during sync and derived sticky-comment update/delete IDs from `gh issue view` URLs.
|
|
41
|
+
|
|
42
|
+
## [0.20.1] - 2026-04-07
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
- 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.
|
|
46
|
+
|
|
47
|
+
## [0.20.0] - 2026-04-07
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
- 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.
|
|
51
|
+
- Removed stale tracking artifacts when the last linked task is detached by deleting the sticky tracking comment and removing the `ace:tracked` label.
|
|
52
|
+
- Switched tracked task links to `blob/HEAD` URLs so issue comments resolve against repositories whose default branch is not `main`.
|
|
53
|
+
- Enforced one-owner issue sync semantics by rejecting attempts to link a GitHub issue already owned by a different ACE task.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
- Added reusable GitHub issue sync primitives in `ace-git` (`GhCliExecutor` and `GithubIssueSync`) for shared ACE issue lifecycle integration.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
- Migrated `ace-review` GitHub CLI execution ownership to the shared `ace-git` executor boundary.
|
|
60
|
+
- Simplified linked-issue lifecycle handling by treating each tracked GitHub issue as owned by exactly one ACE task.
|
|
61
|
+
|
|
10
62
|
## [0.19.3] - 2026-03-31
|
|
11
63
|
|
|
12
64
|
### 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)
|
data/docs/getting-started.md
CHANGED
|
@@ -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-
|
|
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-
|
|
22
|
+
- **ace-*/**: Ruby gems for specific functionality (ace-git, ace-task, ace-docs, etc.)
|
|
23
23
|
- **docs/**: Project documentation and architecture decisions
|
|
24
|
-
- **.ace-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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:
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
82
|
+
rescue
|
|
83
|
+
exit 0
|
|
55
84
|
end
|
|
56
|
-
' "$task_file" 2>/dev/null
|
|
57
|
-
|
|
58
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
data/lib/ace/git/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.22.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-04-
|
|
10
|
+
date: 2026-04-20 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-config
|
|
@@ -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
|