ace-git-worktree 0.23.0 → 0.24.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 +47 -0
- data/docs/usage.md +62 -10
- data/lib/ace/git/worktree/commands/cleanup_command.rb +49 -25
- data/lib/ace/git/worktree/commands/create_command.rb +67 -154
- data/lib/ace/git/worktree/molecules/cleanup_pr_resolver.rb +141 -133
- data/lib/ace/git/worktree/molecules/cleanup_reporter.rb +49 -11
- data/lib/ace/git/worktree/molecules/pull_request_checkout_preparer.rb +100 -0
- data/lib/ace/git/worktree/molecules/pull_request_creator.rb +67 -0
- data/lib/ace/git/worktree/molecules/pull_request_evidence_resolver.rb +40 -0
- data/lib/ace/git/worktree/molecules/worktree_creator.rb +52 -20
- data/lib/ace/git/worktree/organisms/task_worktree_orchestrator.rb +43 -9
- data/lib/ace/git/worktree/organisms/worktree_manager.rb +55 -11
- data/lib/ace/git/worktree/version.rb +1 -1
- data/lib/ace/git/worktree.rb +3 -1
- metadata +5 -17
- data/lib/ace/git/worktree/molecules/pr_creator.rb +0 -253
|
@@ -103,20 +103,27 @@ module Ace
|
|
|
103
103
|
end
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
# Create a worktree for a Pull Request
|
|
106
|
+
# Create a worktree for a Pull Request from an already-verified
|
|
107
|
+
# source ref (see PullRequestCheckoutPreparer).
|
|
107
108
|
#
|
|
108
|
-
# @param pr_data [Hash] PR data
|
|
109
|
+
# @param pr_data [Hash] normalized PR data: :number, :title,
|
|
110
|
+
# :head_branch, :base_branch
|
|
109
111
|
# @param config [WorktreeConfig] Worktree configuration
|
|
110
112
|
# @param git_root [String, nil] Git repository root (auto-detected if nil)
|
|
113
|
+
# @param source_ref [String, nil] verified local ref (e.g. FETCH_HEAD)
|
|
114
|
+
# the PR head was proven against; required
|
|
115
|
+
# @param remote_tracking [String, nil] "remote/branch" tracking ref
|
|
116
|
+
# when the source is reachable via a configured local remote
|
|
111
117
|
# @return [Hash] Result with :success, :worktree_path, :branch, :error
|
|
112
118
|
#
|
|
113
119
|
# @example
|
|
114
120
|
# pr_data = { number: 26, title: "Add feature", head_branch: "feature/auth", base_branch: "main" }
|
|
115
|
-
# result = creator.create_for_pr(pr_data, config)
|
|
116
|
-
# # => { success: true, worktree_path: "
|
|
117
|
-
def create_for_pr(pr_data, config, git_root: nil)
|
|
121
|
+
# result = creator.create_for_pr(pr_data, config, source_ref: "FETCH_HEAD")
|
|
122
|
+
# # => { success: true, worktree_path: ".../ace-pr-26", branch: "pr-26-add-feature", tracking: nil }
|
|
123
|
+
def create_for_pr(pr_data, config, git_root: nil, source_ref: nil, remote_tracking: nil)
|
|
118
124
|
return error_result("PR data is required") unless pr_data
|
|
119
125
|
return error_result("Configuration is required") unless config
|
|
126
|
+
return error_result("Verified source ref is required for PR checkout") if source_ref.nil? || source_ref.empty?
|
|
120
127
|
|
|
121
128
|
begin
|
|
122
129
|
# Determine git repository root
|
|
@@ -125,7 +132,6 @@ module Ace
|
|
|
125
132
|
|
|
126
133
|
# Get PR-specific configuration (fallback to defaults)
|
|
127
134
|
pr_config = config.pr_config || {}
|
|
128
|
-
remote_name = pr_config[:remote_name] || "origin"
|
|
129
135
|
directory_format = pr_config[:directory_format] || "ace-pr-{number}"
|
|
130
136
|
branch_format = pr_config[:branch_format] || "pr-{number}-{slug}"
|
|
131
137
|
|
|
@@ -140,27 +146,28 @@ module Ace
|
|
|
140
146
|
validation = validate_worktree_path(worktree_path, git_root)
|
|
141
147
|
return error_result(validation[:error]) unless validation[:valid]
|
|
142
148
|
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
fetch_result = fetch_remote_branch(remote_name, head_branch, git_root)
|
|
146
|
-
return error_result(fetch_result[:error]) unless fetch_result[:success]
|
|
147
|
-
|
|
148
|
-
# Create worktree with remote tracking
|
|
149
|
-
result = create_worktree_with_tracking(
|
|
150
|
-
worktree_path,
|
|
151
|
-
local_branch_name,
|
|
152
|
-
"#{remote_name}/#{head_branch}",
|
|
153
|
-
git_root,
|
|
154
|
-
configure_push: config.configure_push_for_mismatch?
|
|
155
|
-
)
|
|
149
|
+
# Create the worktree from the proven source ref
|
|
150
|
+
result = create_worktree(worktree_path, local_branch_name, git_root, start_point: source_ref)
|
|
156
151
|
return result unless result[:success]
|
|
157
152
|
|
|
153
|
+
# Configure push behavior when tracking a differently-named branch
|
|
154
|
+
if remote_tracking && local_branch_name != extract_remote_branch_name(remote_tracking)
|
|
155
|
+
configure_push_for_worktree(worktree_path, local_branch_name, remote_tracking)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Bind the reported tracking ref as the branch's real upstream so
|
|
159
|
+
# `@{upstream}` resolves; advisory when the tracking ref is not a
|
|
160
|
+
# local remote-tracking ref (fork URL fetches).
|
|
161
|
+
upstream = remote_tracking ? set_and_verify_upstream(local_branch_name, remote_tracking) : nil
|
|
162
|
+
|
|
158
163
|
# Success - return worktree information
|
|
159
164
|
{
|
|
160
165
|
success: true,
|
|
161
166
|
worktree_path: worktree_path,
|
|
162
167
|
branch: local_branch_name,
|
|
163
|
-
tracking:
|
|
168
|
+
tracking: remote_tracking,
|
|
169
|
+
upstream: upstream,
|
|
170
|
+
warnings: remote_tracking && !upstream ? ["Could not set upstream to #{remote_tracking}"] : nil,
|
|
164
171
|
directory_name: directory_name,
|
|
165
172
|
pr_number: pr_data[:number],
|
|
166
173
|
pr_title: pr_data[:title],
|
|
@@ -764,6 +771,31 @@ module Ace
|
|
|
764
771
|
}
|
|
765
772
|
end
|
|
766
773
|
|
|
774
|
+
# Set and verify upstream for a PR worktree branch.
|
|
775
|
+
#
|
|
776
|
+
# @param local_branch [String] the worktree's local branch
|
|
777
|
+
# @param remote_tracking [String] e.g. "origin/feature"
|
|
778
|
+
# @return [String, nil] the verified upstream, or nil when it could
|
|
779
|
+
# not be established (advisory)
|
|
780
|
+
def set_and_verify_upstream(local_branch, remote_tracking)
|
|
781
|
+
require_relative "../atoms/git_command"
|
|
782
|
+
|
|
783
|
+
exists = Atoms::GitCommand.execute(
|
|
784
|
+
"show-ref", "--verify", "--quiet", "refs/remotes/#{remote_tracking}", timeout: 5
|
|
785
|
+
)
|
|
786
|
+
return nil unless exists[:success]
|
|
787
|
+
|
|
788
|
+
result = Atoms::GitCommand.execute(
|
|
789
|
+
"branch", "--set-upstream-to=#{remote_tracking}", local_branch, timeout: 10
|
|
790
|
+
)
|
|
791
|
+
return nil unless result[:success]
|
|
792
|
+
|
|
793
|
+
verify = Atoms::GitCommand.execute(
|
|
794
|
+
"rev-parse", "--abbrev-ref", "#{local_branch}@{upstream}", timeout: 5
|
|
795
|
+
)
|
|
796
|
+
verify[:success] && verify[:output].to_s.strip == remote_tracking ? remote_tracking : nil
|
|
797
|
+
end
|
|
798
|
+
|
|
767
799
|
# Format PR name using template
|
|
768
800
|
#
|
|
769
801
|
# @param template [String] Template string with {variables}
|
|
@@ -31,7 +31,6 @@ module Ace
|
|
|
31
31
|
@task_committer = Molecules::TaskCommitter.new(project_root: project_root)
|
|
32
32
|
@task_pusher = Molecules::TaskPusher.new
|
|
33
33
|
@worktree_creator = Molecules::WorktreeCreator.new
|
|
34
|
-
@pr_creator = Molecules::PrCreator.new
|
|
35
34
|
@parent_task_resolver = Molecules::ParentTaskResolver.new(project_root: project_root)
|
|
36
35
|
end
|
|
37
36
|
|
|
@@ -861,14 +860,14 @@ module Ace
|
|
|
861
860
|
result[:success] && result[:output]&.include?(branch)
|
|
862
861
|
end
|
|
863
862
|
|
|
864
|
-
# Create draft PR for task
|
|
863
|
+
# Create draft PR for task through the forge-neutral provider contract.
|
|
865
864
|
#
|
|
866
|
-
#
|
|
867
|
-
#
|
|
865
|
+
# The exact pushed head SHA is proven to the provider (expected_head);
|
|
866
|
+
# PR identity is never inferred from branch names alone.
|
|
868
867
|
#
|
|
869
868
|
# @param task_data [Hash] Task data hash from ace-task
|
|
870
869
|
# @param worktree_result [Hash] Worktree creation result with :branch, :start_point
|
|
871
|
-
# @param options [Hash] Options (may include :source
|
|
870
|
+
# @param options [Hash] Options (may include :source, :server, :default_server)
|
|
872
871
|
# @return [Hash] Result with :success, :pr_number, :pr_url, :existing, :error
|
|
873
872
|
def create_pr_for_task(task_data, worktree_result, options)
|
|
874
873
|
branch = worktree_result[:branch]
|
|
@@ -880,14 +879,49 @@ module Ace
|
|
|
880
879
|
# Resolve base branch - handle SHA vs branch name
|
|
881
880
|
base = resolve_pr_base(start_point, options)
|
|
882
881
|
|
|
883
|
-
|
|
884
|
-
|
|
882
|
+
push_remote = options[:push_remote] || @config.push_remote || "origin"
|
|
883
|
+
creator = Molecules::PullRequestCreator.new(
|
|
884
|
+
server_name: options[:server],
|
|
885
|
+
use_default: options[:default_server] == true,
|
|
886
|
+
remote_name: push_remote
|
|
887
|
+
)
|
|
888
|
+
creator.create_draft(
|
|
885
889
|
branch: branch,
|
|
886
890
|
base: base,
|
|
887
|
-
title: title
|
|
891
|
+
title: title,
|
|
892
|
+
expected_head: pushed_branch_sha(branch, worktree_result[:worktree_path]),
|
|
893
|
+
head_repository_url: remote_url(push_remote)
|
|
888
894
|
)
|
|
889
895
|
end
|
|
890
896
|
|
|
897
|
+
# Exact SHA of the pushed worktree branch, proven locally before any
|
|
898
|
+
# provider call.
|
|
899
|
+
#
|
|
900
|
+
# @param branch [String] worktree branch name
|
|
901
|
+
# @param worktree_path [String, nil] worktree directory to resolve in
|
|
902
|
+
# @return [String] branch head SHA
|
|
903
|
+
def pushed_branch_sha(branch, worktree_path)
|
|
904
|
+
result = if worktree_path && Dir.exist?(worktree_path)
|
|
905
|
+
Dir.chdir(worktree_path) do
|
|
906
|
+
Atoms::GitCommand.execute("rev-parse", "--verify", "#{branch}^{commit}", timeout: 10)
|
|
907
|
+
end
|
|
908
|
+
else
|
|
909
|
+
Atoms::GitCommand.execute("rev-parse", "--verify", "#{branch}^{commit}", timeout: 10)
|
|
910
|
+
end
|
|
911
|
+
return result[:output].to_s.strip if result[:success]
|
|
912
|
+
|
|
913
|
+
raise Ace::Git::Error, "Cannot prove pushed head SHA for branch '#{branch}': #{result[:error]}"
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
# Local URL of a configured git remote (no network).
|
|
917
|
+
#
|
|
918
|
+
# @param remote [String] remote name
|
|
919
|
+
# @return [String, nil] remote URL
|
|
920
|
+
def remote_url(remote)
|
|
921
|
+
result = Atoms::GitCommand.execute("remote", "get-url", remote, timeout: 10)
|
|
922
|
+
result[:success] ? result[:output].to_s.strip : nil
|
|
923
|
+
end
|
|
924
|
+
|
|
891
925
|
# Resolve PR base branch from start_point
|
|
892
926
|
#
|
|
893
927
|
# If start_point is a commit SHA (not a branch name), creates a branch
|
|
@@ -948,7 +982,7 @@ module Ace
|
|
|
948
982
|
# Add started_at timestamp to task file IN WORKTREE
|
|
949
983
|
#
|
|
950
984
|
# This creates an initial commit in the worktree branch, enabling PR creation
|
|
951
|
-
# (
|
|
985
|
+
# (Forges require at least one commit difference between branches for a PR).
|
|
952
986
|
#
|
|
953
987
|
# @param task_data [Hash] Task data hash from ace-task
|
|
954
988
|
# @param worktree_result [Hash] Worktree creation result with :worktree_path
|
|
@@ -121,25 +121,31 @@ module Ace
|
|
|
121
121
|
end
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
-
# Create a worktree for a Pull Request
|
|
124
|
+
# Create a worktree for a Pull Request from normalized evidence.
|
|
125
125
|
#
|
|
126
|
-
# @param
|
|
127
|
-
#
|
|
126
|
+
# @param evidence [Ace::Git::ProviderPullRequest] normalized PR
|
|
127
|
+
# evidence with exact source/base provenance
|
|
128
|
+
# @param checkout [Hash, nil] verified checkout from
|
|
129
|
+
# PullRequestCheckoutPreparer ({local_ref:, remote_tracking:});
|
|
130
|
+
# nil is valid only for dry runs
|
|
128
131
|
# @param options [Hash] Options for creation
|
|
129
132
|
# @return [Hash] Creation result
|
|
130
133
|
#
|
|
131
134
|
# @example
|
|
132
135
|
# manager = WorktreeManager.new
|
|
133
|
-
#
|
|
134
|
-
# result = manager.create_pr(
|
|
135
|
-
def create_pr(
|
|
136
|
-
return error_result("PR
|
|
137
|
-
|
|
136
|
+
# checkout = preparer.prepare(evidence)
|
|
137
|
+
# result = manager.create_pr(evidence, checkout)
|
|
138
|
+
def create_pr(evidence, checkout, options = {})
|
|
139
|
+
return error_result("PR evidence is required") if evidence.nil?
|
|
140
|
+
|
|
141
|
+
pr_number = evidence.number
|
|
142
|
+
pr_data = normalized_pr_data(evidence)
|
|
143
|
+
local_branch = preview_pr_branch(pr_data)
|
|
138
144
|
|
|
139
145
|
# Check if worktree already exists for this PR's branch
|
|
140
|
-
head_branch = pr_data[:head_branch]
|
|
141
146
|
existing = @worktree_lister.find_by_branch("pr-#{pr_number}") ||
|
|
142
|
-
@worktree_lister.find_by_branch(
|
|
147
|
+
@worktree_lister.find_by_branch(local_branch) ||
|
|
148
|
+
@worktree_lister.find_by_branch(evidence.head_ref)
|
|
143
149
|
|
|
144
150
|
if existing && !options[:force]
|
|
145
151
|
return error_result("Worktree already exists at: #{existing.path}")
|
|
@@ -150,11 +156,15 @@ module Ace
|
|
|
150
156
|
return dry_run_pr_creation(pr_number, pr_data, options)
|
|
151
157
|
end
|
|
152
158
|
|
|
159
|
+
return error_result("Verified checkout is required for PR worktree creation") unless checkout
|
|
160
|
+
|
|
153
161
|
# Create the worktree
|
|
154
162
|
result = @worktree_creator.create_for_pr(
|
|
155
163
|
pr_data,
|
|
156
164
|
@config,
|
|
157
|
-
git_root: @project_root
|
|
165
|
+
git_root: @project_root,
|
|
166
|
+
source_ref: checkout[:local_ref],
|
|
167
|
+
remote_tracking: checkout[:remote_tracking]
|
|
158
168
|
)
|
|
159
169
|
|
|
160
170
|
if result[:success]
|
|
@@ -554,6 +564,40 @@ module Ace
|
|
|
554
564
|
|
|
555
565
|
# Create error result
|
|
556
566
|
#
|
|
567
|
+
# Convert normalized PR evidence into the internal pr_data naming
|
|
568
|
+
# format used by configuration templates.
|
|
569
|
+
#
|
|
570
|
+
# @param evidence [Ace::Git::ProviderPullRequest]
|
|
571
|
+
# @return [Hash] {number:, title:, head_branch:, base_branch:}
|
|
572
|
+
def normalized_pr_data(evidence)
|
|
573
|
+
{
|
|
574
|
+
number: evidence.number,
|
|
575
|
+
title: evidence.title,
|
|
576
|
+
head_branch: evidence.head_ref,
|
|
577
|
+
base_branch: evidence.base_ref
|
|
578
|
+
}
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Preview the configured local branch name for a PR worktree.
|
|
582
|
+
#
|
|
583
|
+
# @param pr_data [Hash] normalized PR data
|
|
584
|
+
# @return [String] formatted branch name
|
|
585
|
+
def preview_pr_branch(pr_data)
|
|
586
|
+
pr_config = @config.pr_config || {}
|
|
587
|
+
branch_format = pr_config[:branch_format] || "pr-{number}-{slug}"
|
|
588
|
+
require_relative "../atoms/slug_generator"
|
|
589
|
+
|
|
590
|
+
result = branch_format.dup
|
|
591
|
+
result.gsub!("{number}", pr_data[:number].to_s)
|
|
592
|
+
if pr_data[:title]
|
|
593
|
+
slug = Atoms::SlugGenerator.from_title(pr_data[:title])
|
|
594
|
+
result.gsub!("{slug}", slug)
|
|
595
|
+
result.gsub!("{title_slug}", slug)
|
|
596
|
+
end
|
|
597
|
+
result.gsub!("{base_branch}", pr_data[:base_branch].to_s) if pr_data[:base_branch]
|
|
598
|
+
result
|
|
599
|
+
end
|
|
600
|
+
|
|
557
601
|
# Dry run PR worktree creation
|
|
558
602
|
#
|
|
559
603
|
# @param pr_number [Integer] PR number
|
data/lib/ace/git/worktree.rb
CHANGED
|
@@ -190,7 +190,9 @@ module Ace
|
|
|
190
190
|
|
|
191
191
|
require_relative "worktree/molecules/config_loader"
|
|
192
192
|
require_relative "worktree/molecules/task_fetcher"
|
|
193
|
-
require_relative "worktree/molecules/
|
|
193
|
+
require_relative "worktree/molecules/pull_request_creator"
|
|
194
|
+
require_relative "worktree/molecules/pull_request_evidence_resolver"
|
|
195
|
+
require_relative "worktree/molecules/pull_request_checkout_preparer"
|
|
194
196
|
require_relative "worktree/molecules/task_committer"
|
|
195
197
|
require_relative "worktree/molecules/task_pusher"
|
|
196
198
|
require_relative "worktree/molecules/task_status_updater"
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-git-worktree
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.24.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-cli
|
|
@@ -65,20 +65,6 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '0.24'
|
|
68
|
-
- !ruby/object:Gem::Dependency
|
|
69
|
-
name: ace-git-github
|
|
70
|
-
requirement: !ruby/object:Gem::Requirement
|
|
71
|
-
requirements:
|
|
72
|
-
- - "~>"
|
|
73
|
-
- !ruby/object:Gem::Version
|
|
74
|
-
version: 0.1.1
|
|
75
|
-
type: :runtime
|
|
76
|
-
prerelease: false
|
|
77
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
-
requirements:
|
|
79
|
-
- - "~>"
|
|
80
|
-
- !ruby/object:Gem::Version
|
|
81
|
-
version: 0.1.1
|
|
82
68
|
- !ruby/object:Gem::Dependency
|
|
83
69
|
name: ace-task
|
|
84
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -205,7 +191,9 @@ files:
|
|
|
205
191
|
- lib/ace/git/worktree/molecules/current_task_linker.rb
|
|
206
192
|
- lib/ace/git/worktree/molecules/hook_executor.rb
|
|
207
193
|
- lib/ace/git/worktree/molecules/parent_task_resolver.rb
|
|
208
|
-
- lib/ace/git/worktree/molecules/
|
|
194
|
+
- lib/ace/git/worktree/molecules/pull_request_checkout_preparer.rb
|
|
195
|
+
- lib/ace/git/worktree/molecules/pull_request_creator.rb
|
|
196
|
+
- lib/ace/git/worktree/molecules/pull_request_evidence_resolver.rb
|
|
209
197
|
- lib/ace/git/worktree/molecules/task_committer.rb
|
|
210
198
|
- lib/ace/git/worktree/molecules/task_fetcher.rb
|
|
211
199
|
- lib/ace/git/worktree/molecules/task_pusher.rb
|
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "open3"
|
|
5
|
-
|
|
6
|
-
module Ace
|
|
7
|
-
module Git
|
|
8
|
-
module Worktree
|
|
9
|
-
module Molecules
|
|
10
|
-
# PR creator molecule
|
|
11
|
-
#
|
|
12
|
-
# Creates draft pull requests on GitHub using the gh CLI.
|
|
13
|
-
# Provides a simple interface for creating PRs with graceful degradation
|
|
14
|
-
# when gh CLI is unavailable or not authenticated.
|
|
15
|
-
#
|
|
16
|
-
# @example Create a draft PR
|
|
17
|
-
# creator = PrCreator.new
|
|
18
|
-
# result = creator.create_draft(
|
|
19
|
-
# branch: "125-upstream-setup",
|
|
20
|
-
# base: "main",
|
|
21
|
-
# title: "125 - upstream-setup-and-pr-creation"
|
|
22
|
-
# )
|
|
23
|
-
# result[:pr_number] # => 456
|
|
24
|
-
# result[:pr_url] # => "https://github.com/owner/repo/pull/456"
|
|
25
|
-
#
|
|
26
|
-
# @example Handle unavailable gh CLI
|
|
27
|
-
# creator = PrCreator.new
|
|
28
|
-
# unless creator.gh_available?
|
|
29
|
-
# puts "gh CLI not available"
|
|
30
|
-
# end
|
|
31
|
-
class PrCreator
|
|
32
|
-
# Error raised when gh CLI is not available
|
|
33
|
-
class GhNotAvailableError < StandardError; end
|
|
34
|
-
|
|
35
|
-
# Error raised when gh CLI is not authenticated
|
|
36
|
-
class GhNotAuthenticatedError < StandardError; end
|
|
37
|
-
|
|
38
|
-
# Error raised on network/timeout issues
|
|
39
|
-
class NetworkError < StandardError; end
|
|
40
|
-
|
|
41
|
-
# Error raised when PR already exists
|
|
42
|
-
class PrAlreadyExistsError < StandardError; end
|
|
43
|
-
|
|
44
|
-
# Initialize a new PrCreator
|
|
45
|
-
#
|
|
46
|
-
# @param timeout [Integer] Timeout in seconds (default: 30)
|
|
47
|
-
def initialize(timeout: 30)
|
|
48
|
-
@timeout = timeout
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Create a draft pull request
|
|
52
|
-
#
|
|
53
|
-
# @param branch [String] Head branch for the PR
|
|
54
|
-
# @param base [String] Base branch to merge into
|
|
55
|
-
# @param title [String] PR title
|
|
56
|
-
# @param body [String, nil] PR body/description
|
|
57
|
-
# @return [Hash] Result hash with :success, :pr_number, :pr_url, :error
|
|
58
|
-
#
|
|
59
|
-
# @example
|
|
60
|
-
# result = creator.create_draft(branch: "feature", base: "main", title: "Add feature")
|
|
61
|
-
# result[:success] # => true
|
|
62
|
-
# result[:pr_number] # => 123
|
|
63
|
-
# result[:pr_url] # => "https://github.com/owner/repo/pull/123"
|
|
64
|
-
def create_draft(branch:, base:, title:, body: nil)
|
|
65
|
-
# Check gh availability and authentication
|
|
66
|
-
unless gh_available?
|
|
67
|
-
return error_result("gh CLI is not installed")
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
unless gh_authenticated?
|
|
71
|
-
return error_result("gh CLI is not authenticated. Run: gh auth login")
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# Check if PR already exists for this branch
|
|
75
|
-
existing_pr = find_existing_pr(branch: branch)
|
|
76
|
-
if existing_pr
|
|
77
|
-
return {
|
|
78
|
-
success: true,
|
|
79
|
-
pr_number: existing_pr[:number],
|
|
80
|
-
pr_url: existing_pr[:url],
|
|
81
|
-
existing: true,
|
|
82
|
-
message: "PR already exists for branch"
|
|
83
|
-
}
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# Build gh command
|
|
87
|
-
cmd = ["gh", "pr", "create", "--draft", "--head", branch, "--base", base, "--title", title]
|
|
88
|
-
cmd += ["--body", body || title]
|
|
89
|
-
|
|
90
|
-
# Execute command
|
|
91
|
-
stdout, stderr, status = execute_with_timeout(cmd, @timeout)
|
|
92
|
-
|
|
93
|
-
if status.success?
|
|
94
|
-
# Parse PR URL from output
|
|
95
|
-
pr_url = stdout.strip
|
|
96
|
-
pr_number = extract_pr_number(pr_url)
|
|
97
|
-
|
|
98
|
-
{
|
|
99
|
-
success: true,
|
|
100
|
-
pr_number: pr_number,
|
|
101
|
-
pr_url: pr_url,
|
|
102
|
-
existing: false,
|
|
103
|
-
error: nil
|
|
104
|
-
}
|
|
105
|
-
else
|
|
106
|
-
handle_creation_error(stderr)
|
|
107
|
-
end
|
|
108
|
-
rescue => e
|
|
109
|
-
error_result("Unexpected error: #{e.message}")
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Check if gh CLI is available (cached)
|
|
113
|
-
#
|
|
114
|
-
# @return [Boolean] true if gh is installed and accessible
|
|
115
|
-
def gh_available?
|
|
116
|
-
return @gh_available unless @gh_available.nil?
|
|
117
|
-
|
|
118
|
-
@gh_available = system("which gh > /dev/null 2>&1")
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Check if gh CLI is authenticated (cached)
|
|
122
|
-
#
|
|
123
|
-
# @return [Boolean] true if gh is authenticated
|
|
124
|
-
def gh_authenticated?
|
|
125
|
-
return @gh_authenticated unless @gh_authenticated.nil?
|
|
126
|
-
|
|
127
|
-
_, _stderr, status = Open3.capture3("gh", "auth", "status")
|
|
128
|
-
@gh_authenticated = status.success?
|
|
129
|
-
rescue
|
|
130
|
-
@gh_authenticated = false
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# Find an existing PR for a branch
|
|
134
|
-
#
|
|
135
|
-
# @param branch [String] Branch name to search for
|
|
136
|
-
# @return [Hash, nil] PR info hash or nil if not found
|
|
137
|
-
#
|
|
138
|
-
# @example
|
|
139
|
-
# pr = creator.find_existing_pr(branch: "feature-branch")
|
|
140
|
-
# pr[:number] # => 123
|
|
141
|
-
# pr[:url] # => "https://github.com/owner/repo/pull/123"
|
|
142
|
-
def find_existing_pr(branch:)
|
|
143
|
-
return nil unless gh_available?
|
|
144
|
-
|
|
145
|
-
# Search for open PRs with this head branch
|
|
146
|
-
cmd = [
|
|
147
|
-
"gh", "pr", "list",
|
|
148
|
-
"--head", branch,
|
|
149
|
-
"--state", "open",
|
|
150
|
-
"--json", "number,url",
|
|
151
|
-
"--limit", "1"
|
|
152
|
-
]
|
|
153
|
-
|
|
154
|
-
stdout, _stderr, status = execute_with_timeout(cmd, @timeout)
|
|
155
|
-
return nil unless status.success?
|
|
156
|
-
|
|
157
|
-
prs = JSON.parse(stdout)
|
|
158
|
-
return nil if prs.empty?
|
|
159
|
-
|
|
160
|
-
pr = prs.first
|
|
161
|
-
{
|
|
162
|
-
number: pr["number"],
|
|
163
|
-
url: pr["url"]
|
|
164
|
-
}
|
|
165
|
-
rescue JSON::ParserError
|
|
166
|
-
nil
|
|
167
|
-
rescue
|
|
168
|
-
nil
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Get helpful error message when gh CLI is unavailable
|
|
172
|
-
#
|
|
173
|
-
# @return [String] User-friendly error message with installation guidance
|
|
174
|
-
def gh_not_available_message
|
|
175
|
-
<<~MESSAGE
|
|
176
|
-
gh CLI is required for PR creation but is not installed.
|
|
177
|
-
|
|
178
|
-
Install gh CLI:
|
|
179
|
-
- macOS: brew install gh
|
|
180
|
-
- Linux: See https://github.com/cli/cli#installation
|
|
181
|
-
- Windows: See https://github.com/cli/cli#installation
|
|
182
|
-
|
|
183
|
-
After installation, authenticate with: gh auth login
|
|
184
|
-
MESSAGE
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
private
|
|
188
|
-
|
|
189
|
-
# Execute command with timeout
|
|
190
|
-
#
|
|
191
|
-
# @param cmd [Array<String>] Command and arguments
|
|
192
|
-
# @param timeout [Integer] Timeout in seconds
|
|
193
|
-
# @return [Array<String, String, Process::Status>] stdout, stderr, status
|
|
194
|
-
def execute_with_timeout(cmd, timeout)
|
|
195
|
-
require "timeout"
|
|
196
|
-
|
|
197
|
-
Timeout.timeout(timeout) do
|
|
198
|
-
Open3.capture3(*cmd)
|
|
199
|
-
end
|
|
200
|
-
rescue Timeout::Error
|
|
201
|
-
raise NetworkError, "Request timed out after #{timeout} seconds"
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
# Extract PR number from URL
|
|
205
|
-
#
|
|
206
|
-
# @param url [String] PR URL
|
|
207
|
-
# @return [Integer, nil] PR number or nil if not parseable
|
|
208
|
-
def extract_pr_number(url)
|
|
209
|
-
return nil unless url
|
|
210
|
-
|
|
211
|
-
match = url.match(%r{/pull/(\d+)})
|
|
212
|
-
match ? match[1].to_i : nil
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
# Handle PR creation error
|
|
216
|
-
#
|
|
217
|
-
# @param stderr [String] Error output from gh
|
|
218
|
-
# @return [Hash] Error result hash
|
|
219
|
-
def handle_creation_error(stderr)
|
|
220
|
-
error_msg = stderr.downcase
|
|
221
|
-
|
|
222
|
-
if error_msg.include?("already exists") || error_msg.include?("pull request already exists")
|
|
223
|
-
error_result("A PR already exists for this branch")
|
|
224
|
-
elsif error_msg.include?("authentication") || error_msg.include?("not logged in")
|
|
225
|
-
error_result("GitHub CLI not authenticated. Run: gh auth login")
|
|
226
|
-
elsif error_msg.include?("network") || error_msg.include?("connection")
|
|
227
|
-
error_result("Network error. Check your connection and try again.")
|
|
228
|
-
elsif error_msg.include?("repository not found") || error_msg.include?("not a git repository")
|
|
229
|
-
error_result("Not in a git repository or repository not found on GitHub")
|
|
230
|
-
elsif error_msg.include?("branch") && error_msg.include?("not found")
|
|
231
|
-
error_result("Branch not found on remote. Push the branch first.")
|
|
232
|
-
else
|
|
233
|
-
error_result("GitHub CLI error: #{stderr.strip}")
|
|
234
|
-
end
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
# Create an error result hash
|
|
238
|
-
#
|
|
239
|
-
# @param message [String] Error message
|
|
240
|
-
# @return [Hash] Error result
|
|
241
|
-
def error_result(message)
|
|
242
|
-
{
|
|
243
|
-
success: false,
|
|
244
|
-
pr_number: nil,
|
|
245
|
-
pr_url: nil,
|
|
246
|
-
error: message
|
|
247
|
-
}
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
|
-
end
|