ace-git-worktree 0.19.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 +7 -0
- data/.ace-defaults/git/worktree.yml +250 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-git-worktree.yml +19 -0
- data/CHANGELOG.md +957 -0
- data/LICENSE +21 -0
- data/README.md +40 -0
- data/Rakefile +14 -0
- data/docs/demo/ace-git-worktree-getting-started.gif +0 -0
- data/docs/demo/ace-git-worktree-getting-started.tape.yml +28 -0
- data/docs/demo/fixtures/README.md +3 -0
- data/docs/demo/fixtures/sample.txt +1 -0
- data/docs/getting-started.md +114 -0
- data/docs/handbook.md +38 -0
- data/docs/usage.md +334 -0
- data/exe/ace-git-worktree +24 -0
- data/handbook/agents/worktree.ag.md +189 -0
- data/handbook/skills/as-git-worktree/SKILL.md +27 -0
- data/handbook/skills/as-git-worktree-create/SKILL.md +21 -0
- data/handbook/skills/as-git-worktree-manage/SKILL.md +20 -0
- data/handbook/workflow-instructions/git/worktree-create.wf.md +262 -0
- data/handbook/workflow-instructions/git/worktree-manage.wf.md +384 -0
- data/handbook/workflow-instructions/git/worktree.wf.md +224 -0
- data/lib/ace/git/worktree/atoms/git_command.rb +121 -0
- data/lib/ace/git/worktree/atoms/path_expander.rb +189 -0
- data/lib/ace/git/worktree/atoms/slug_generator.rb +235 -0
- data/lib/ace/git/worktree/atoms/task_id_extractor.rb +91 -0
- data/lib/ace/git/worktree/cli/commands/config.rb +50 -0
- data/lib/ace/git/worktree/cli/commands/create.rb +80 -0
- data/lib/ace/git/worktree/cli/commands/list.rb +76 -0
- data/lib/ace/git/worktree/cli/commands/prune.rb +43 -0
- data/lib/ace/git/worktree/cli/commands/remove.rb +48 -0
- data/lib/ace/git/worktree/cli/commands/shared_helpers.rb +66 -0
- data/lib/ace/git/worktree/cli/commands/switch.rb +44 -0
- data/lib/ace/git/worktree/cli.rb +103 -0
- data/lib/ace/git/worktree/commands/config_command.rb +351 -0
- data/lib/ace/git/worktree/commands/create_command.rb +961 -0
- data/lib/ace/git/worktree/commands/list_command.rb +247 -0
- data/lib/ace/git/worktree/commands/prune_command.rb +260 -0
- data/lib/ace/git/worktree/commands/remove_command.rb +522 -0
- data/lib/ace/git/worktree/commands/switch_command.rb +249 -0
- data/lib/ace/git/worktree/configuration.rb +167 -0
- data/lib/ace/git/worktree/models/worktree_config.rb +502 -0
- data/lib/ace/git/worktree/models/worktree_info.rb +303 -0
- data/lib/ace/git/worktree/models/worktree_metadata.rb +294 -0
- data/lib/ace/git/worktree/molecules/config_loader.rb +125 -0
- data/lib/ace/git/worktree/molecules/current_task_linker.rb +136 -0
- data/lib/ace/git/worktree/molecules/hook_executor.rb +361 -0
- data/lib/ace/git/worktree/molecules/parent_task_resolver.rb +186 -0
- data/lib/ace/git/worktree/molecules/pr_creator.rb +253 -0
- data/lib/ace/git/worktree/molecules/task_committer.rb +329 -0
- data/lib/ace/git/worktree/molecules/task_fetcher.rb +244 -0
- data/lib/ace/git/worktree/molecules/task_pusher.rb +183 -0
- data/lib/ace/git/worktree/molecules/task_status_updater.rb +447 -0
- data/lib/ace/git/worktree/molecules/worktree_creator.rb +832 -0
- data/lib/ace/git/worktree/molecules/worktree_lister.rb +337 -0
- data/lib/ace/git/worktree/molecules/worktree_remover.rb +416 -0
- data/lib/ace/git/worktree/organisms/task_worktree_orchestrator.rb +906 -0
- data/lib/ace/git/worktree/organisms/worktree_manager.rb +714 -0
- data/lib/ace/git/worktree/version.rb +9 -0
- data/lib/ace/git/worktree.rb +215 -0
- metadata +218 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/task_id_extractor"
|
|
4
|
+
require_relative "../atoms/git_command"
|
|
5
|
+
require_relative "task_fetcher"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Git
|
|
9
|
+
module Worktree
|
|
10
|
+
module Molecules
|
|
11
|
+
# Parent task resolver molecule
|
|
12
|
+
#
|
|
13
|
+
# Determines the target branch for a task's PR by resolving the parent task's
|
|
14
|
+
# worktree branch. This enables subtasks to target their orchestrator branch
|
|
15
|
+
# instead of defaulting to main.
|
|
16
|
+
#
|
|
17
|
+
# @example Resolve target branch for a subtask
|
|
18
|
+
# resolver = ParentTaskResolver.new
|
|
19
|
+
# target = resolver.resolve_target_branch(subtask_data)
|
|
20
|
+
# target # => "202-rename-support-gems" or "main"
|
|
21
|
+
class ParentTaskResolver
|
|
22
|
+
# Default fallback target branch when no parent is found
|
|
23
|
+
DEFAULT_TARGET = "main"
|
|
24
|
+
|
|
25
|
+
# Regex pattern for extracting task number from full task ID
|
|
26
|
+
# Pattern: v.0.9.0+task.202.01 -> captures "202.01"
|
|
27
|
+
TASK_ID_PATTERN = /task\.(\d+(?:\.\d+)?)\z/
|
|
28
|
+
|
|
29
|
+
# SHA pattern for detecting detached HEAD state
|
|
30
|
+
# Git returns 7-40 hex characters for SHAs (7 for short, 40 for full)
|
|
31
|
+
SHA_PATTERN = /\A[0-9a-f]{7,40}\z/i
|
|
32
|
+
|
|
33
|
+
# Initialize a new ParentTaskResolver
|
|
34
|
+
#
|
|
35
|
+
# @param project_root [String] Project root directory
|
|
36
|
+
# @param task_fetcher [TaskFetcher] Optional TaskFetcher instance (for testing)
|
|
37
|
+
def initialize(project_root: Dir.pwd, task_fetcher: nil)
|
|
38
|
+
@project_root = project_root
|
|
39
|
+
@task_fetcher = task_fetcher || TaskFetcher.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Resolve target branch for a task
|
|
43
|
+
#
|
|
44
|
+
# @param task_data [Hash] Task data hash from ace-task
|
|
45
|
+
# @return [String] Parent's worktree branch or DEFAULT_TARGET
|
|
46
|
+
#
|
|
47
|
+
# @example Subtask with parent
|
|
48
|
+
# resolve_target_branch(subtask_data) # => "202-orchestrator-branch"
|
|
49
|
+
#
|
|
50
|
+
# @example Orchestrator task (no parent)
|
|
51
|
+
# resolve_target_branch(orchestrator_data) # => "main"
|
|
52
|
+
def resolve_target_branch(task_data)
|
|
53
|
+
return DEFAULT_TARGET unless task_data
|
|
54
|
+
|
|
55
|
+
# Extract parent task ID from task ID
|
|
56
|
+
parent_id = extract_parent_id(task_data[:id])
|
|
57
|
+
unless parent_id
|
|
58
|
+
# Non-subtask: use current branch (e.g., feature branch) instead of defaulting to main
|
|
59
|
+
return current_branch_fallback || DEFAULT_TARGET
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Load parent task data
|
|
63
|
+
parent_task = load_parent_task(parent_id)
|
|
64
|
+
return DEFAULT_TARGET unless parent_task
|
|
65
|
+
|
|
66
|
+
# Extract parent's worktree branch
|
|
67
|
+
extract_parent_branch(parent_task)
|
|
68
|
+
rescue
|
|
69
|
+
# Silently fall back to default - debugging info available via --verbose flag on CLI
|
|
70
|
+
DEFAULT_TARGET
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Load parent task data
|
|
74
|
+
#
|
|
75
|
+
# @param parent_id [String] Parent task ID (e.g., "202")
|
|
76
|
+
# @return [Hash, nil] Parent task data or nil
|
|
77
|
+
def load_parent_task(parent_id)
|
|
78
|
+
return nil unless parent_id
|
|
79
|
+
|
|
80
|
+
@task_fetcher.fetch(parent_id)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Extract parent's worktree branch from task data
|
|
84
|
+
#
|
|
85
|
+
# Implements a 3-level fallback priority chain for determining the target branch:
|
|
86
|
+
#
|
|
87
|
+
# Priority 1: Parent worktree branch
|
|
88
|
+
# - If parent task has worktree metadata with a branch, use that branch
|
|
89
|
+
# - This enables subtasks to target their orchestrator's feature branch
|
|
90
|
+
#
|
|
91
|
+
# Priority 2: Current branch fallback
|
|
92
|
+
# - If parent has no worktree metadata, use the current branch
|
|
93
|
+
# - Skipped if in detached HEAD state (SHA detected)
|
|
94
|
+
# - Allows creating subtask worktrees from the parent's context
|
|
95
|
+
#
|
|
96
|
+
# Priority 3: DEFAULT_TARGET ("main")
|
|
97
|
+
# - Final fallback when no branch context is available
|
|
98
|
+
# - Ensures a valid target branch is always returned
|
|
99
|
+
#
|
|
100
|
+
# @param parent_data [Hash] Parent task data hash
|
|
101
|
+
# @return [String] Parent's worktree branch, current branch, or DEFAULT_TARGET
|
|
102
|
+
#
|
|
103
|
+
# @example Parent with worktree metadata
|
|
104
|
+
# extract_parent_branch({ worktree: { branch: "202-feature" } })
|
|
105
|
+
# # => "202-feature"
|
|
106
|
+
#
|
|
107
|
+
# @example Parent without worktree (uses current branch)
|
|
108
|
+
# # When current branch is "develop"
|
|
109
|
+
# extract_parent_branch({ id: "task.202" })
|
|
110
|
+
# # => "develop"
|
|
111
|
+
#
|
|
112
|
+
# @example Detached HEAD state (falls back to main)
|
|
113
|
+
# # When in detached HEAD state
|
|
114
|
+
# extract_parent_branch({ id: "task.202" })
|
|
115
|
+
# # => "main"
|
|
116
|
+
def extract_parent_branch(parent_data)
|
|
117
|
+
# Try parent worktree branch first
|
|
118
|
+
if parent_data
|
|
119
|
+
worktree_data = parent_data[:worktree] || parent_data["worktree"]
|
|
120
|
+
if worktree_data.is_a?(Hash)
|
|
121
|
+
branch = worktree_data[:branch] || worktree_data["branch"]
|
|
122
|
+
return branch if branch
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Fallback: current branch (if valid) or default
|
|
127
|
+
current_branch_fallback || DEFAULT_TARGET
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Extract parent task ID from task ID
|
|
133
|
+
#
|
|
134
|
+
# @param task_id [String] Full task ID (e.g., "v.0.9.0+task.202.01")
|
|
135
|
+
# @return [String, nil] Parent task ID (e.g., "202") or nil
|
|
136
|
+
#
|
|
137
|
+
# @example Subtask
|
|
138
|
+
# extract_parent_id("v.0.9.0+task.202.01") # => "202"
|
|
139
|
+
#
|
|
140
|
+
# @example Orchestrator task
|
|
141
|
+
# extract_parent_id("v.0.9.0+task.202") # => nil
|
|
142
|
+
def extract_parent_id(task_id)
|
|
143
|
+
return nil unless task_id
|
|
144
|
+
|
|
145
|
+
# Extract task number from full ID using TASK_ID_PATTERN
|
|
146
|
+
# Pattern: v.0.9.0+task.202.01 -> 202.01
|
|
147
|
+
match = task_id.match(TASK_ID_PATTERN)
|
|
148
|
+
return nil unless match
|
|
149
|
+
|
|
150
|
+
task_number = match[1]
|
|
151
|
+
|
|
152
|
+
# Check if this is a subtask (contains dot)
|
|
153
|
+
return nil unless task_number.include?(".")
|
|
154
|
+
|
|
155
|
+
# Extract parent number: 202.01 -> 202
|
|
156
|
+
task_number.split(".").first
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Get current branch as fallback for target branch
|
|
160
|
+
#
|
|
161
|
+
# Returns the current branch name if available and not a detached HEAD.
|
|
162
|
+
# Used when parent task has no worktree metadata.
|
|
163
|
+
#
|
|
164
|
+
# @return [String, nil] Current branch name or nil if detached/error
|
|
165
|
+
def current_branch_fallback
|
|
166
|
+
branch = Atoms::GitCommand.current_branch
|
|
167
|
+
return nil if branch.nil? || branch.empty?
|
|
168
|
+
return nil if branch == "HEAD"
|
|
169
|
+
|
|
170
|
+
# If branch looks like a SHA, verify it's not actually a branch name
|
|
171
|
+
# This handles edge case of hex-only branch names like "deadbeef"
|
|
172
|
+
if branch.match?(SHA_PATTERN)
|
|
173
|
+
# Check if refs/heads/<name> exists - if so, it's a real branch
|
|
174
|
+
return nil unless Atoms::GitCommand.ref_exists?("refs/heads/#{branch}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
branch
|
|
178
|
+
rescue => e
|
|
179
|
+
warn "[DEBUG] current_branch_fallback failed: #{e.message}" if ENV["DEBUG"]
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
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
|