ace-git-github 0.3.0 → 0.4.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 +8 -1
- data/lib/ace/git/github/provider.rb +184 -20
- data/lib/ace/git/github/version.rb +1 -1
- data/lib/ace/git/github.rb +0 -1
- metadata +2 -3
- data/lib/ace/git/github/issue_sync.rb +0 -252
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baa6f879ef34125b1099214ec6387d091ebbca80116f51c7ea4ea37fc9fc08a9
|
|
4
|
+
data.tar.gz: b7f37f202295ba924168e9b3e2f639c221bf93c63a8d2f6112a2fc243cdb8888
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f49e0c270b7235b7db93ecbeebef80a78ca5a9791959fae5f13b3d6e06c29576583c8f6a270654ffb7815f0703e5be5aaf41cae1c2e123dc80167d4fcf4e1ce
|
|
7
|
+
data.tar.gz: 770eec189bf46c9502bc405fbf0e5b1258c154e3a49a1467df4479f71c2118d29a2910c9830e2d78c5fbeb81e3239fd1c824aefa78f4b8cad8a9e99a7fca3513
|
data/CHANGELOG.md
CHANGED
|
@@ -7,13 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- GitHub issue tracking through the shared provider contract, including repository-bound lookup and mutation.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Issue labels are looked up and created on demand before attachment (concurrent creations tolerated), and the label listing no longer sends the gh-incompatible `--slurp` + `--jq` combination.
|
|
17
|
+
|
|
10
18
|
## [0.3.0] - 2026-10-02
|
|
11
19
|
|
|
12
20
|
### Added
|
|
13
21
|
- Repository-bound GitHub PR comment and review retrieval, comment create/update, and thread resolution through the shared provider contract.
|
|
14
22
|
- Correlation-based comment reconciliation after uncertain posts and explicit malformed or unsupported result classification.
|
|
15
23
|
|
|
16
|
-
|
|
17
24
|
## [0.2.0] - 2026-09-28
|
|
18
25
|
|
|
19
26
|
### Added
|
|
@@ -76,7 +76,12 @@ module Ace
|
|
|
76
76
|
# to every data/mutation command so a `--server` selection can never
|
|
77
77
|
# be silently retargeted to the checkout's repository.
|
|
78
78
|
def repo_target
|
|
79
|
-
|
|
79
|
+
# Issue operations talk to the HTTPS web endpoint derived from the
|
|
80
|
+
# configured URL; a plain normalize would carry an SSH-style
|
|
81
|
+
# host:port into gh --hostname/--repo.
|
|
82
|
+
@repo_target ||= Ace::Git::Atoms::ServerUrl.normalize(
|
|
83
|
+
Ace::Git::Atoms::ServerUrl.web_base(server.url)
|
|
84
|
+
)
|
|
80
85
|
end
|
|
81
86
|
|
|
82
87
|
# @return [ProviderPullRequest, nil] evidence for the branch's PR
|
|
@@ -116,16 +121,107 @@ module Ace
|
|
|
116
121
|
normalize_issue(output)
|
|
117
122
|
end
|
|
118
123
|
|
|
124
|
+
def issue_tracking(number:)
|
|
125
|
+
data = gh_json(["issue", "view", number.to_s, "--json", "number,title,state,author,url,labels"])
|
|
126
|
+
evidence = normalize_issue(data)
|
|
127
|
+
unless evidence.number.to_i == number.to_i && url_matches_server?(evidence.url, number)
|
|
128
|
+
raise Ace::Git::ProviderIdentityMismatchError, "Issue ##{number} is not in #{server.url}"
|
|
129
|
+
end
|
|
130
|
+
uri = URI.parse(Ace::Git::Atoms::ServerUrl.web_base(server.url))
|
|
131
|
+
owner_repo = uri.path.sub(%r{\A/}, "").chomp("/").sub(/\.git\z/, "")
|
|
132
|
+
pages = gh_json(["api", "repos/#{owner_repo}/issues/#{number}/comments", "--hostname", forge_hostname(uri),
|
|
133
|
+
"--paginate", "--slurp"], bind_repo: false)
|
|
134
|
+
unless pages.is_a?(Array) && pages.all? { |page| page.is_a?(Array) }
|
|
135
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub comment pagination for issue ##{number}"
|
|
136
|
+
end
|
|
137
|
+
{
|
|
138
|
+
issue: evidence,
|
|
139
|
+
comments: pages.flatten.map do |comment|
|
|
140
|
+
id = comment["id"]
|
|
141
|
+
raise Ace::Git::ProviderMalformedOutputError, "Issue comment has no API id" unless id.to_s.match?(/\A\d+\z/)
|
|
142
|
+
|
|
143
|
+
{id: id.to_i, body: comment["body"].to_s}
|
|
144
|
+
end,
|
|
145
|
+
labels: Array(data["labels"]).map { |label| label["name"].to_s }
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def create_issue_comment(number:, body:)
|
|
150
|
+
issue_api("POST", "issues/#{number}/comments", fields: ["body=#{body}"])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def update_issue_comment(number:, comment_id:, body:)
|
|
154
|
+
assert_issue_comment_owned!(number, comment_id)
|
|
155
|
+
issue_api("PATCH", "issues/comments/#{Integer(comment_id)}", fields: ["body=#{body}"])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def delete_issue_comment(number:, comment_id:)
|
|
159
|
+
assert_issue_comment_owned!(number, comment_id)
|
|
160
|
+
issue_api("DELETE", "issues/comments/#{Integer(comment_id)}")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def add_issue_label(number:, label:)
|
|
164
|
+
ensure_repo_label(label)
|
|
165
|
+
issue_api("POST", "issues/#{number}/labels", fields: ["labels[]=#{label}"])
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def remove_issue_label(number:, label:)
|
|
169
|
+
issue_api("DELETE", "issues/#{number}/labels/#{URI.encode_www_form_component(label)}")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def set_issue_state(number:, state:)
|
|
173
|
+
raise ArgumentError, "Invalid issue state #{state.inspect}" unless %i[open closed].include?(state)
|
|
174
|
+
|
|
175
|
+
issue_api("PATCH", "issues/#{number}", fields: ["state=#{state}"])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Fresh repositories lack the tracking label: look it up and create
|
|
179
|
+
# it on demand BEFORE attaching, so linking cannot commit a tracking
|
|
180
|
+
# comment it could never label (mirrors the Forgejo provider). A
|
|
181
|
+
# concurrent creator surfaces as a failed create whose re-list
|
|
182
|
+
# confirms the ensured state; any other lookup/create failure still
|
|
183
|
+
# surfaces through the attach call.
|
|
184
|
+
def ensure_repo_label(label)
|
|
185
|
+
uri = URI.parse(Ace::Git::Atoms::ServerUrl.web_base(server.url))
|
|
186
|
+
owner_repo = uri.path.sub(%r{\A/}, "").chomp("/").sub(/\.git\z/, "")
|
|
187
|
+
hostname = forge_hostname(uri)
|
|
188
|
+
return if label_exists?(owner_repo, hostname, label)
|
|
189
|
+
|
|
190
|
+
CliExecutor.execute("api", ["repos/#{owner_repo}/labels", "--hostname", hostname,
|
|
191
|
+
"--method", "POST", "--raw-field", "name=#{label}", "--raw-field", "color=0e8a16"],
|
|
192
|
+
timeout: timeout, runner: runner)
|
|
193
|
+
label_exists?(owner_repo, hostname, label)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def label_exists?(owner_repo, hostname, label)
|
|
197
|
+
# gh api rejects --slurp combined with --jq; per-page jq output is
|
|
198
|
+
# one name per line, which the line parse below handles.
|
|
199
|
+
result = CliExecutor.execute("api", ["repos/#{owner_repo}/labels?per_page=100", "--hostname", hostname,
|
|
200
|
+
"--paginate", "--jq", ".[].name"], timeout: timeout, runner: runner)
|
|
201
|
+
return false unless result[:success]
|
|
202
|
+
|
|
203
|
+
result[:stdout].to_s.lines.map(&:strip).include?(label)
|
|
204
|
+
rescue Ace::Git::Error
|
|
205
|
+
false
|
|
206
|
+
end
|
|
207
|
+
|
|
119
208
|
# @return [Array<ProviderCheck>] normalized check evidence for a ref
|
|
120
209
|
def checks(ref:)
|
|
121
210
|
output = gh_json(["pr", "checks", ref.to_s, "--json", "name,state,bucket"])
|
|
122
211
|
output.map do |check|
|
|
212
|
+
state = check["state"].to_s.downcase
|
|
213
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
214
|
+
"Malformed GitHub check entry: missing state" if state.empty?
|
|
123
215
|
bucket = check["bucket"].to_s.downcase
|
|
216
|
+
unless bucket.empty? || BUCKET_MAP.key?(bucket)
|
|
217
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
218
|
+
"Malformed GitHub check entry: unrecognized bucket #{bucket}"
|
|
219
|
+
end
|
|
124
220
|
Ace::Git::ProviderCheck.new(
|
|
125
221
|
server_name: server.name,
|
|
126
222
|
name: check["name"].to_s,
|
|
127
|
-
state:
|
|
128
|
-
conclusion: BUCKET_MAP.fetch(bucket,
|
|
223
|
+
state: state.to_sym,
|
|
224
|
+
conclusion: BUCKET_MAP.fetch(bucket, nil),
|
|
129
225
|
url: nil
|
|
130
226
|
)
|
|
131
227
|
end
|
|
@@ -341,10 +437,18 @@ end
|
|
|
341
437
|
raise Ace::Git::ProviderMalformedOutputError, "Incomplete GitHub PR check evidence"
|
|
342
438
|
end
|
|
343
439
|
checks = runs.map do |run|
|
|
440
|
+
status = run["status"].to_s.downcase
|
|
441
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
442
|
+
"Malformed GitHub check run entry: missing status" if status.empty?
|
|
443
|
+
conclusion = run["conclusion"]
|
|
444
|
+
unless conclusion.nil? || conclusion.is_a?(String)
|
|
445
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
446
|
+
"Malformed GitHub check run entry: conclusion must be a string or null"
|
|
447
|
+
end
|
|
344
448
|
Ace::Git::ProviderCheck.new(
|
|
345
449
|
server_name: server.name, name: run["name"],
|
|
346
|
-
state:
|
|
347
|
-
conclusion:
|
|
450
|
+
state: status.to_sym,
|
|
451
|
+
conclusion: conclusion&.downcase&.to_sym,
|
|
348
452
|
url: run["html_url"]
|
|
349
453
|
)
|
|
350
454
|
end
|
|
@@ -443,7 +547,7 @@ end
|
|
|
443
547
|
end
|
|
444
548
|
begin
|
|
445
549
|
matches = matching_review_comments(pr, marker, expected_head)
|
|
446
|
-
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
550
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
|
|
447
551
|
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
448
552
|
"PR comment sent but reconciliation read failed for session #{correlation}: #{e.message}; reconcile before repeating"
|
|
449
553
|
end
|
|
@@ -494,7 +598,7 @@ end
|
|
|
494
598
|
# valid GET item endpoints) and validate its PR identity.
|
|
495
599
|
begin
|
|
496
600
|
updated = gh_api(update_route)
|
|
497
|
-
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
601
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
|
|
498
602
|
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
499
603
|
"Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
|
|
500
604
|
end
|
|
@@ -530,7 +634,7 @@ end
|
|
|
530
634
|
}
|
|
531
635
|
GRAPHQL
|
|
532
636
|
thread = gh_graphql(query, id: thread_id).dig("data", "node")
|
|
533
|
-
verify_review_thread!(thread, pr, expected_head)
|
|
637
|
+
verify_review_thread!(thread, pr, expected_head, thread_id: thread_id)
|
|
534
638
|
if thread["isResolved"] == true
|
|
535
639
|
verify_post_mutation_head!(pr, expected_head, "thread #{thread_id}")
|
|
536
640
|
return true
|
|
@@ -556,7 +660,7 @@ end
|
|
|
556
660
|
"head #{expected_head}, thread #{thread_id}: #{e.message}"
|
|
557
661
|
end
|
|
558
662
|
begin
|
|
559
|
-
verify_review_thread!(resolved, pr, expected_head)
|
|
663
|
+
verify_review_thread!(resolved, pr, expected_head, thread_id: thread_id)
|
|
560
664
|
rescue Ace::Git::ProviderIdentityMismatchError, Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderExpectedHeadConflictError => e
|
|
561
665
|
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
562
666
|
"Thread resolution sent but confirmation unreadable for #{server.name}/#{pr.number}, thread #{thread_id}: #{e.message}; reconcile before repeating"
|
|
@@ -662,13 +766,17 @@ end
|
|
|
662
766
|
"Cannot post review comment to PR ##{pr.number} in #{pr.state} state"
|
|
663
767
|
end
|
|
664
768
|
|
|
665
|
-
def verify_review_thread!(thread, pr, expected_head)
|
|
769
|
+
def verify_review_thread!(thread, pr, expected_head, thread_id: nil)
|
|
666
770
|
unless thread.is_a?(Hash) && thread["id"] && thread["pullRequest"].is_a?(Hash) &&
|
|
667
771
|
thread.dig("pullRequest", "number") == pr.number &&
|
|
668
772
|
Ace::Git::Atoms::ServerUrl.match?(thread.dig("pullRequest", "repository", "url"), server.url)
|
|
669
773
|
raise Ace::Git::ProviderIdentityMismatchError,
|
|
670
774
|
"GitHub review thread does not belong to selected PR ##{pr.number}"
|
|
671
775
|
end
|
|
776
|
+
if thread_id && thread["id"] != thread_id
|
|
777
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
778
|
+
"GitHub returned thread #{thread["id"]} instead of #{thread_id}"
|
|
779
|
+
end
|
|
672
780
|
unless thread.dig("pullRequest", "headRefOid") == expected_head
|
|
673
781
|
raise Ace::Git::ProviderExpectedHeadConflictError,
|
|
674
782
|
"GitHub review thread PR head changed before resolution"
|
|
@@ -699,7 +807,7 @@ end
|
|
|
699
807
|
def verify_post_mutation_head!(pr, expected_head, correlation)
|
|
700
808
|
current = begin
|
|
701
809
|
pull_request(number: pr.number)
|
|
702
|
-
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
810
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
|
|
703
811
|
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
704
812
|
"Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
|
|
705
813
|
end
|
|
@@ -739,35 +847,43 @@ end
|
|
|
739
847
|
|
|
740
848
|
def matching_review_comments(pr, marker, head)
|
|
741
849
|
gh_api_pages("issues/#{pr.number}/comments").filter_map do |entry|
|
|
742
|
-
|
|
850
|
+
# Every row must be a valid comment; a row without a body
|
|
851
|
+
# means malformed collection and must not hide our session post.
|
|
852
|
+
unless entry.is_a?(Hash) && entry["body"].is_a?(String)
|
|
853
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR comment collection"
|
|
854
|
+
end
|
|
855
|
+
next unless entry["body"].include?(marker)
|
|
743
856
|
|
|
744
857
|
review_comment(entry, pr, head)
|
|
745
858
|
end
|
|
746
859
|
end
|
|
747
860
|
|
|
748
861
|
def review_comment(entry, pr, head, threads = {})
|
|
862
|
+
user = entry.is_a?(Hash) ? entry["user"] : nil
|
|
749
863
|
unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry["body"].is_a?(String) &&
|
|
750
|
-
|
|
864
|
+
user.is_a?(Hash) && user["login"].is_a?(String)
|
|
751
865
|
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR comment evidence"
|
|
752
866
|
end
|
|
753
867
|
thread_id, resolved = threads[entry["id"]] || [nil, nil]
|
|
754
868
|
Ace::Git::ProviderReviewComment.new(
|
|
755
869
|
server_name: server.name, repository_url: server.url, pr_number: pr.number,
|
|
756
|
-
id: entry["id"], author:
|
|
870
|
+
id: entry["id"], author: user["login"], body: entry["body"],
|
|
757
871
|
url: entry["html_url"], path: entry["path"], line: entry["line"],
|
|
758
|
-
head_sha: entry["commit_id"]
|
|
872
|
+
head_sha: entry["commit_id"], resolved: resolved, thread_id: thread_id
|
|
759
873
|
)
|
|
760
874
|
end
|
|
761
875
|
|
|
762
876
|
def review_entry(entry, pr, head)
|
|
763
|
-
|
|
877
|
+
user = entry.is_a?(Hash) ? entry["user"] : nil
|
|
878
|
+
unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? &&
|
|
879
|
+
user.is_a?(Hash) && user["login"].is_a?(String) &&
|
|
764
880
|
entry["state"].is_a?(String) && !entry["state"].empty?
|
|
765
881
|
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR review evidence"
|
|
766
882
|
end
|
|
767
883
|
Ace::Git::ProviderReview.new(
|
|
768
884
|
server_name: server.name, repository_url: server.url, pr_number: pr.number,
|
|
769
|
-
id: entry["id"], author:
|
|
770
|
-
state: entry["state"], url: entry["html_url"], head_sha: entry["commit_id"]
|
|
885
|
+
id: entry["id"], author: user["login"], body: entry["body"],
|
|
886
|
+
state: entry["state"], url: entry["html_url"], head_sha: entry["commit_id"]
|
|
771
887
|
)
|
|
772
888
|
end
|
|
773
889
|
|
|
@@ -862,6 +978,26 @@ end
|
|
|
862
978
|
out
|
|
863
979
|
end
|
|
864
980
|
|
|
981
|
+
def assert_issue_comment_owned!(number, comment_id)
|
|
982
|
+
return if issue_tracking(number: number)[:comments].any? { |comment| comment[:id].to_i == comment_id.to_i }
|
|
983
|
+
|
|
984
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
985
|
+
"Comment #{comment_id} is not on selected issue ##{number} in #{server.url}"
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
def issue_api(method, suffix, fields: [])
|
|
989
|
+
uri = URI.parse(Ace::Git::Atoms::ServerUrl.web_base(server.url))
|
|
990
|
+
owner_repo = uri.path.sub(%r{\A/}, "").chomp("/").sub(/\.git\z/, "")
|
|
991
|
+
args = ["repos/#{owner_repo}/#{suffix}", "--hostname", forge_hostname(uri), "--method", method]
|
|
992
|
+
fields.each { |field| args += ["--raw-field", field] }
|
|
993
|
+
result = CliExecutor.execute("api", args, timeout: timeout, runner: runner)
|
|
994
|
+
classify_failure(result[:stderr], context: "issue #{method} #{suffix}") unless result[:success]
|
|
995
|
+
result
|
|
996
|
+
rescue Ace::Git::ProviderUnreachableError => e
|
|
997
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
998
|
+
"Unknown outcome for issue mutation on #{server.url} (#{suffix}): #{e.message}"
|
|
999
|
+
end
|
|
1000
|
+
|
|
865
1001
|
STATE_ORDER = {open: 0, merged: 1, closed: 2}.freeze
|
|
866
1002
|
|
|
867
1003
|
# Send one mutating `gh` command. When `ambiguous` is true (create),
|
|
@@ -974,10 +1110,38 @@ end
|
|
|
974
1110
|
def server_host_root
|
|
975
1111
|
@server_host_root ||= begin
|
|
976
1112
|
uri = URI.parse(server.url.to_s)
|
|
977
|
-
"#{uri.scheme || "https"}://#{uri
|
|
1113
|
+
"#{uri.scheme || "https"}://#{forge_hostname(uri)}"
|
|
1114
|
+
end
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
# gh --hostname accepts "host:port"; a bare uri.host would silently
|
|
1118
|
+
# retarget a port-configured server to the default authority.
|
|
1119
|
+
# Issue evidence must match the configured server URL including its
|
|
1120
|
+
# scheme; ServerUrl.match? intentionally ignores schemes for git
|
|
1121
|
+
# remotes, but a web issue URL must not downgrade the authority.
|
|
1122
|
+
def url_matches_server?(evidence_url, number)
|
|
1123
|
+
expected = "#{Ace::Git::Atoms::ServerUrl.web_base(server.url)}/issues/#{number.to_i}"
|
|
1124
|
+
return false unless evidence_url.to_s.chomp("/").casecmp?(expected)
|
|
1125
|
+
|
|
1126
|
+
configured = URI.parse(Ace::Git::Atoms::ServerUrl.web_base(server.url)).scheme
|
|
1127
|
+
supplied = URI.parse(evidence_url.to_s).scheme
|
|
1128
|
+
configured.nil? || supplied.nil? || configured.downcase == supplied.downcase
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
# Unrecognized or missing state is malformed evidence: mapping it to
|
|
1132
|
+
# :closed would let sync close an issue without valid state proof.
|
|
1133
|
+
def normalize_state(value)
|
|
1134
|
+
case value.to_s.upcase
|
|
1135
|
+
when "OPEN" then :open
|
|
1136
|
+
when "CLOSED" then :closed
|
|
1137
|
+
else raise Ace::Git::ProviderMalformedOutputError, "Unknown issue state #{value.inspect}"
|
|
978
1138
|
end
|
|
979
1139
|
end
|
|
980
1140
|
|
|
1141
|
+
def forge_hostname(uri)
|
|
1142
|
+
uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
|
|
1143
|
+
end
|
|
1144
|
+
|
|
981
1145
|
def merged_at_of(data)
|
|
982
1146
|
data["mergedAt"]
|
|
983
1147
|
end
|
|
@@ -993,7 +1157,7 @@ end
|
|
|
993
1157
|
server_name: server.name,
|
|
994
1158
|
number: data["number"],
|
|
995
1159
|
title: data["title"],
|
|
996
|
-
state: data["state"]
|
|
1160
|
+
state: normalize_state(data["state"]),
|
|
997
1161
|
author: normalize_author(data["author"]),
|
|
998
1162
|
url: data["url"],
|
|
999
1163
|
labels: nil
|
data/lib/ace/git/github.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-git-github
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-03 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-git
|
|
@@ -38,7 +38,6 @@ files:
|
|
|
38
38
|
- Rakefile
|
|
39
39
|
- lib/ace/git/github.rb
|
|
40
40
|
- lib/ace/git/github/cli_executor.rb
|
|
41
|
-
- lib/ace/git/github/issue_sync.rb
|
|
42
41
|
- lib/ace/git/github/pr_fetcher.rb
|
|
43
42
|
- lib/ace/git/github/pr_identifier.rb
|
|
44
43
|
- lib/ace/git/github/provider.rb
|
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "open3"
|
|
5
|
-
|
|
6
|
-
module Ace
|
|
7
|
-
module Git
|
|
8
|
-
module Github
|
|
9
|
-
# Synchronize ACE task linkage metadata to GitHub issues.
|
|
10
|
-
class IssueSync
|
|
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
|
-
# True when GitHub sync can run: the gh CLI is installed and authenticated.
|
|
18
|
-
# GitHub sync is best-effort; callers skip it silently when this is false.
|
|
19
|
-
def self.available?
|
|
20
|
-
CliExecutor.authenticated?
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def self.sync_task(task_id:, task_title:, task_status:, task_path:, issue_ids:, reason:, previous: nil,
|
|
24
|
-
current_issue_ids: nil)
|
|
25
|
-
current_ids = if current_issue_ids.nil?
|
|
26
|
-
Array(issue_ids).map(&:to_i).uniq
|
|
27
|
-
else
|
|
28
|
-
Array(current_issue_ids).map(&:to_i).uniq
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
issue_ids.each do |issue_id|
|
|
32
|
-
sync_issue(
|
|
33
|
-
issue_id: issue_id.to_i,
|
|
34
|
-
task_id: task_id,
|
|
35
|
-
task_title: task_title,
|
|
36
|
-
task_status: task_status,
|
|
37
|
-
task_path: task_path,
|
|
38
|
-
reason: reason,
|
|
39
|
-
previous: previous,
|
|
40
|
-
currently_linked: current_ids.include?(issue_id.to_i)
|
|
41
|
-
)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
{success: true, synced: issue_ids.length, issues: issue_ids}
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def self.sync_issue(issue_id:, task_id:, task_title:, task_status:, task_path:, reason:, previous:,
|
|
48
|
-
currently_linked:)
|
|
49
|
-
issue = fetch_issue(issue_id)
|
|
50
|
-
sticky = find_sticky_comment(issue["comments"] || [])
|
|
51
|
-
|
|
52
|
-
lines = tracked_lines(sticky&.dig("body"))
|
|
53
|
-
validate_link!(issue_id: issue_id, task_id: task_id, previous_task_id: previous&.[](:id), lines: lines) if currently_linked
|
|
54
|
-
lines = remove_previous_line(lines, previous)
|
|
55
|
-
lines = upsert_line(lines, task_id: task_id, task_title: task_title, task_path: task_path) if currently_linked
|
|
56
|
-
if lines.empty?
|
|
57
|
-
cleanup_tracking_artifacts(issue_id: issue_id, sticky: sticky, labels: issue["labels"] || [])
|
|
58
|
-
return {success: true, issue: issue_id, reason: reason}
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
body = render_body(lines)
|
|
62
|
-
|
|
63
|
-
if sticky
|
|
64
|
-
update_comment(issue_id: issue_id, comment: sticky, body: body)
|
|
65
|
-
else
|
|
66
|
-
create_comment(issue_id: issue_id, body: body)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
ensure_label(issue_id, issue["labels"] || [])
|
|
70
|
-
if currently_linked
|
|
71
|
-
sync_lifecycle(
|
|
72
|
-
issue_id: issue_id,
|
|
73
|
-
issue_state: issue["state"],
|
|
74
|
-
task_status: task_status
|
|
75
|
-
)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
{success: true, issue: issue_id, reason: reason}
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def self.cleanup_tracking_artifacts(issue_id:, sticky:, labels:)
|
|
82
|
-
delete_comment(issue_id: issue_id, comment: sticky) if sticky
|
|
83
|
-
remove_label(issue_id, labels)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def self.fetch_issue(issue_id)
|
|
87
|
-
result = CliExecutor.execute("issue", ["view", issue_id.to_s, "--json", "state,comments,labels"])
|
|
88
|
-
raise "Failed to fetch issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
89
|
-
|
|
90
|
-
JSON.parse(result[:stdout])
|
|
91
|
-
rescue JSON::ParserError => e
|
|
92
|
-
raise "Failed to parse issue #{issue_id} response: #{e.message}"
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def self.find_sticky_comment(comments)
|
|
96
|
-
comments.find { |comment| comment["body"].to_s.include?(STICKY_MARKER) }
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def self.tracked_lines(body)
|
|
100
|
-
body.to_s.lines.map(&:rstrip).select { |line| line.start_with?("Tracked in ace-task: ") }
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def self.remove_previous_line(lines, previous)
|
|
104
|
-
return lines unless previous.is_a?(Hash)
|
|
105
|
-
|
|
106
|
-
previous_id = previous[:id] || previous["id"]
|
|
107
|
-
return lines unless previous_id
|
|
108
|
-
|
|
109
|
-
lines.reject { |line| extract_task_id(line) == previous_id.to_s }
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def self.upsert_line(lines, task_id:, task_title:, task_path:)
|
|
113
|
-
filtered = lines.reject { |line| extract_task_id(line) == task_id.to_s }
|
|
114
|
-
filtered << tracked_line(task_id: task_id, task_title: task_title, task_path: task_path)
|
|
115
|
-
filtered.uniq.sort
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def self.tracked_line(task_id:, task_title:, task_path:)
|
|
119
|
-
"Tracked in ace-task: [#{task_id}](#{task_url(task_path)})"
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def self.extract_task_id(line)
|
|
123
|
-
match = line.match(/\[([^\]]+)\]\(/)
|
|
124
|
-
match ? match[1] : nil
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def self.render_body(lines)
|
|
128
|
-
([STICKY_MARKER] + lines).join("\n")
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def self.create_comment(issue_id:, body:)
|
|
132
|
-
CliExecutor.execute("issue", ["comment", issue_id.to_s, "--body", body]).tap do |result|
|
|
133
|
-
raise "Failed to create sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def self.update_comment(issue_id:, comment:, body:)
|
|
138
|
-
comment_id = comment_api_id(comment)
|
|
139
|
-
CliExecutor.execute("api", [
|
|
140
|
-
"repos/{owner}/{repo}/issues/comments/#{comment_id}",
|
|
141
|
-
"--method", "PATCH",
|
|
142
|
-
"--field", "body=#{body}"
|
|
143
|
-
]).tap do |result|
|
|
144
|
-
raise "Failed to update sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def self.delete_comment(issue_id:, comment:)
|
|
149
|
-
comment_id = comment_api_id(comment)
|
|
150
|
-
CliExecutor.execute("api", [
|
|
151
|
-
"repos/{owner}/{repo}/issues/comments/#{comment_id}",
|
|
152
|
-
"--method", "DELETE"
|
|
153
|
-
]).tap do |result|
|
|
154
|
-
raise "Failed to delete sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def self.comment_api_id(comment)
|
|
159
|
-
database_id = comment["databaseId"] || comment[:databaseId]
|
|
160
|
-
return database_id.to_s if database_id.to_s.match?(/\A\d+\z/)
|
|
161
|
-
|
|
162
|
-
url = comment["url"] || comment[:url]
|
|
163
|
-
return Regexp.last_match(1) if url.to_s.match(/issuecomment-(\d+)/)
|
|
164
|
-
|
|
165
|
-
(comment["id"] || comment[:id]).to_s
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def self.ensure_label(issue_id, labels)
|
|
169
|
-
return if labels.any? { |label| label["name"] == TRACKED_LABEL }
|
|
170
|
-
|
|
171
|
-
result = CliExecutor.execute("issue", ["edit", issue_id.to_s, "--add-label", TRACKED_LABEL])
|
|
172
|
-
raise "Failed to add #{TRACKED_LABEL} label to issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def self.remove_label(issue_id, labels)
|
|
176
|
-
return unless labels.any? { |label| label["name"] == TRACKED_LABEL }
|
|
177
|
-
|
|
178
|
-
result = CliExecutor.execute("issue", ["edit", issue_id.to_s, "--remove-label", TRACKED_LABEL])
|
|
179
|
-
raise "Failed to remove #{TRACKED_LABEL} label from issue #{issue_id}: #{result[:stderr]}" unless result[:success]
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
def self.validate_link!(issue_id:, task_id:, previous_task_id: nil, lines: nil)
|
|
183
|
-
issue = fetch_issue(issue_id)
|
|
184
|
-
sticky = find_sticky_comment(issue["comments"] || [])
|
|
185
|
-
lines ||= tracked_lines(sticky&.dig("body"))
|
|
186
|
-
owners = lines.map { |line| extract_task_id(line) }.compact.uniq
|
|
187
|
-
return true if owners.empty?
|
|
188
|
-
return true if owners == [task_id.to_s]
|
|
189
|
-
return true if previous_task_id && owners == [previous_task_id.to_s]
|
|
190
|
-
|
|
191
|
-
raise OwnershipConflict, "GitHub issue ##{issue_id} is already owned by task #{owners.first}"
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def self.sync_lifecycle(issue_id:, issue_state:, task_status:)
|
|
195
|
-
terminal = TERMINAL_STATUSES.include?(task_status.to_s.downcase)
|
|
196
|
-
issue_open = issue_state.to_s.upcase == "OPEN"
|
|
197
|
-
|
|
198
|
-
if terminal && issue_open
|
|
199
|
-
close_result = CliExecutor.execute("issue", ["close", issue_id.to_s])
|
|
200
|
-
raise "Failed to close issue #{issue_id}: #{close_result[:stderr]}" unless close_result[:success]
|
|
201
|
-
elsif !terminal && !issue_open
|
|
202
|
-
reopen_result = CliExecutor.execute("issue", ["reopen", issue_id.to_s])
|
|
203
|
-
raise "Failed to reopen issue #{issue_id}: #{reopen_result[:stderr]}" unless reopen_result[:success]
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def self.task_url(task_path)
|
|
208
|
-
return task_path.to_s if task_path.to_s.empty?
|
|
209
|
-
|
|
210
|
-
repo_root = repo_root_path
|
|
211
|
-
relative = if repo_root && task_path.start_with?(repo_root + "/")
|
|
212
|
-
task_path.delete_prefix(repo_root + "/")
|
|
213
|
-
else
|
|
214
|
-
task_path.to_s
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
repo_slug = github_repo_slug
|
|
218
|
-
return relative unless repo_slug
|
|
219
|
-
|
|
220
|
-
"https://github.com/#{repo_slug}/blob/HEAD/#{relative}"
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def self.repo_root_path
|
|
224
|
-
stdout, _stderr, status = Open3.capture3("git", "rev-parse", "--show-toplevel")
|
|
225
|
-
return nil unless status.success?
|
|
226
|
-
|
|
227
|
-
stdout.strip
|
|
228
|
-
rescue
|
|
229
|
-
nil
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def self.github_repo_slug
|
|
233
|
-
stdout, _stderr, status = Open3.capture3("git", "remote", "get-url", "origin")
|
|
234
|
-
return nil unless status.success?
|
|
235
|
-
|
|
236
|
-
url = stdout.strip
|
|
237
|
-
return Regexp.last_match(1) if url.match(%r{github\.com[:/](.+?)(?:\.git)?$})
|
|
238
|
-
|
|
239
|
-
nil
|
|
240
|
-
rescue
|
|
241
|
-
nil
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
private_class_method :sync_issue, :fetch_issue, :find_sticky_comment, :tracked_lines, :remove_previous_line,
|
|
245
|
-
:upsert_line, :tracked_line, :extract_task_id, :render_body, :create_comment, :update_comment, :delete_comment,
|
|
246
|
-
:comment_api_id, :ensure_label, :remove_label, :cleanup_tracking_artifacts, :sync_lifecycle, :task_url, :repo_root_path,
|
|
247
|
-
:github_repo_slug
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
end
|
|
251
|
-
end
|
|
252
|
-
end
|