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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 865ef273c3954eefcfd0742e3a2dc3b2e9a02aa2e93aef1f13888f02e128aea7
4
- data.tar.gz: 0f6167a9930838246beafea1454ce56a1b68938c96c6be658308e3724130801d
3
+ metadata.gz: baa6f879ef34125b1099214ec6387d091ebbca80116f51c7ea4ea37fc9fc08a9
4
+ data.tar.gz: b7f37f202295ba924168e9b3e2f639c221bf93c63a8d2f6112a2fc243cdb8888
5
5
  SHA512:
6
- metadata.gz: 0043d7965efda107ac27aa490b6e7e329b7dbc4a792c919fb3bca22e11aabcf406203cf8b8578d662c8aba582d045f197de3ad7a42dba45af24dafb008a3a001
7
- data.tar.gz: c3c2f64ae9a1a463113bb49f6f453104d358161e9c94b35261327ea3368e787a795d7460f605769cf8f938d5b14569a8290cdb14617f5c309676fd34d5980839
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
- @repo_target ||= Ace::Git::Atoms::ServerUrl.normalize(server.url)
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: check["state"].to_s.downcase.to_sym,
128
- conclusion: BUCKET_MAP.fetch(bucket, bucket.empty? ? nil : bucket.to_sym),
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: run["status"].to_s.downcase.to_sym,
347
- conclusion: run["conclusion"]&.downcase&.to_sym,
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
- next unless entry.is_a?(Hash) && entry["body"].is_a?(String) && entry["body"].include?(marker)
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
- entry.dig("user", "login").is_a?(String)
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: entry.dig("user", "login"), body: entry["body"],
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"] || head, resolved: resolved, thread_id: thread_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
- unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry.dig("user", "login").is_a?(String) &&
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: entry.dig("user", "login"), body: entry["body"],
770
- state: entry["state"], url: entry["html_url"], head_sha: entry["commit_id"] || head
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.host}#{":#{uri.port}" if uri.port && uri.port != uri.default_port}"
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"].to_s.upcase == "OPEN" ? :open : :closed,
1160
+ state: normalize_state(data["state"]),
997
1161
  author: normalize_author(data["author"]),
998
1162
  url: data["url"],
999
1163
  labels: nil
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Github
6
- VERSION = "0.3.0"
6
+ VERSION = "0.4.0"
7
7
  end
8
8
  end
9
9
  end
@@ -6,7 +6,6 @@ require_relative "github/version"
6
6
  require_relative "github/cli_executor"
7
7
  require_relative "github/pr_identifier"
8
8
  require_relative "github/pr_fetcher"
9
- require_relative "github/issue_sync"
10
9
  require_relative "github/provider"
11
10
 
12
11
  module Ace
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.3.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-02 00:00:00.000000000 Z
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