titan-cli 0.1.0__py3-none-any.whl
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.
- titan_cli/__init__.py +3 -0
- titan_cli/__main__.py +4 -0
- titan_cli/ai/__init__.py +0 -0
- titan_cli/ai/agents/__init__.py +15 -0
- titan_cli/ai/agents/base.py +152 -0
- titan_cli/ai/client.py +170 -0
- titan_cli/ai/constants.py +56 -0
- titan_cli/ai/exceptions.py +48 -0
- titan_cli/ai/models.py +34 -0
- titan_cli/ai/oauth_helper.py +120 -0
- titan_cli/ai/providers/__init__.py +9 -0
- titan_cli/ai/providers/anthropic.py +117 -0
- titan_cli/ai/providers/base.py +75 -0
- titan_cli/ai/providers/gemini.py +278 -0
- titan_cli/cli.py +59 -0
- titan_cli/clients/__init__.py +1 -0
- titan_cli/clients/gcloud_client.py +52 -0
- titan_cli/core/__init__.py +3 -0
- titan_cli/core/config.py +274 -0
- titan_cli/core/discovery.py +51 -0
- titan_cli/core/errors.py +81 -0
- titan_cli/core/models.py +52 -0
- titan_cli/core/plugins/available.py +36 -0
- titan_cli/core/plugins/models.py +67 -0
- titan_cli/core/plugins/plugin_base.py +108 -0
- titan_cli/core/plugins/plugin_registry.py +163 -0
- titan_cli/core/secrets.py +141 -0
- titan_cli/core/workflows/__init__.py +22 -0
- titan_cli/core/workflows/models.py +88 -0
- titan_cli/core/workflows/project_step_source.py +86 -0
- titan_cli/core/workflows/workflow_exceptions.py +17 -0
- titan_cli/core/workflows/workflow_filter_service.py +137 -0
- titan_cli/core/workflows/workflow_registry.py +419 -0
- titan_cli/core/workflows/workflow_sources.py +307 -0
- titan_cli/engine/__init__.py +39 -0
- titan_cli/engine/builder.py +159 -0
- titan_cli/engine/context.py +82 -0
- titan_cli/engine/mock_context.py +176 -0
- titan_cli/engine/results.py +91 -0
- titan_cli/engine/steps/ai_assistant_step.py +185 -0
- titan_cli/engine/steps/command_step.py +93 -0
- titan_cli/engine/utils/__init__.py +3 -0
- titan_cli/engine/utils/venv.py +31 -0
- titan_cli/engine/workflow_executor.py +187 -0
- titan_cli/external_cli/__init__.py +0 -0
- titan_cli/external_cli/configs.py +17 -0
- titan_cli/external_cli/launcher.py +65 -0
- titan_cli/messages.py +121 -0
- titan_cli/ui/tui/__init__.py +205 -0
- titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
- titan_cli/ui/tui/app.py +113 -0
- titan_cli/ui/tui/icons.py +70 -0
- titan_cli/ui/tui/screens/__init__.py +24 -0
- titan_cli/ui/tui/screens/ai_config.py +498 -0
- titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
- titan_cli/ui/tui/screens/base.py +110 -0
- titan_cli/ui/tui/screens/cli_launcher.py +151 -0
- titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
- titan_cli/ui/tui/screens/main_menu.py +162 -0
- titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
- titan_cli/ui/tui/screens/plugin_management.py +377 -0
- titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
- titan_cli/ui/tui/screens/workflow_execution.py +592 -0
- titan_cli/ui/tui/screens/workflows.py +249 -0
- titan_cli/ui/tui/textual_components.py +537 -0
- titan_cli/ui/tui/textual_workflow_executor.py +405 -0
- titan_cli/ui/tui/theme.py +102 -0
- titan_cli/ui/tui/widgets/__init__.py +40 -0
- titan_cli/ui/tui/widgets/button.py +108 -0
- titan_cli/ui/tui/widgets/header.py +116 -0
- titan_cli/ui/tui/widgets/panel.py +81 -0
- titan_cli/ui/tui/widgets/status_bar.py +115 -0
- titan_cli/ui/tui/widgets/table.py +77 -0
- titan_cli/ui/tui/widgets/text.py +177 -0
- titan_cli/utils/__init__.py +0 -0
- titan_cli/utils/autoupdate.py +155 -0
- titan_cli-0.1.0.dist-info/METADATA +149 -0
- titan_cli-0.1.0.dist-info/RECORD +146 -0
- titan_cli-0.1.0.dist-info/WHEEL +4 -0
- titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
- titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- titan_plugin_git/__init__.py +1 -0
- titan_plugin_git/clients/__init__.py +8 -0
- titan_plugin_git/clients/git_client.py +772 -0
- titan_plugin_git/exceptions.py +40 -0
- titan_plugin_git/messages.py +112 -0
- titan_plugin_git/models.py +39 -0
- titan_plugin_git/plugin.py +118 -0
- titan_plugin_git/steps/__init__.py +1 -0
- titan_plugin_git/steps/ai_commit_message_step.py +171 -0
- titan_plugin_git/steps/branch_steps.py +104 -0
- titan_plugin_git/steps/commit_step.py +80 -0
- titan_plugin_git/steps/push_step.py +63 -0
- titan_plugin_git/steps/status_step.py +59 -0
- titan_plugin_git/workflows/__previews__/__init__.py +1 -0
- titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
- titan_plugin_git/workflows/commit-ai.yaml +28 -0
- titan_plugin_github/__init__.py +11 -0
- titan_plugin_github/agents/__init__.py +6 -0
- titan_plugin_github/agents/config_loader.py +130 -0
- titan_plugin_github/agents/issue_generator.py +353 -0
- titan_plugin_github/agents/pr_agent.py +528 -0
- titan_plugin_github/clients/__init__.py +8 -0
- titan_plugin_github/clients/github_client.py +1105 -0
- titan_plugin_github/config/__init__.py +0 -0
- titan_plugin_github/config/pr_agent.toml +85 -0
- titan_plugin_github/exceptions.py +28 -0
- titan_plugin_github/messages.py +88 -0
- titan_plugin_github/models.py +330 -0
- titan_plugin_github/plugin.py +131 -0
- titan_plugin_github/steps/__init__.py +12 -0
- titan_plugin_github/steps/ai_pr_step.py +172 -0
- titan_plugin_github/steps/create_pr_step.py +86 -0
- titan_plugin_github/steps/github_prompt_steps.py +171 -0
- titan_plugin_github/steps/issue_steps.py +143 -0
- titan_plugin_github/steps/preview_step.py +40 -0
- titan_plugin_github/utils.py +82 -0
- titan_plugin_github/workflows/__previews__/__init__.py +1 -0
- titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
- titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
- titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
- titan_plugin_jira/__init__.py +8 -0
- titan_plugin_jira/agents/__init__.py +6 -0
- titan_plugin_jira/agents/config_loader.py +154 -0
- titan_plugin_jira/agents/jira_agent.py +553 -0
- titan_plugin_jira/agents/prompts.py +364 -0
- titan_plugin_jira/agents/response_parser.py +435 -0
- titan_plugin_jira/agents/token_tracker.py +223 -0
- titan_plugin_jira/agents/validators.py +246 -0
- titan_plugin_jira/clients/jira_client.py +745 -0
- titan_plugin_jira/config/jira_agent.toml +92 -0
- titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
- titan_plugin_jira/exceptions.py +37 -0
- titan_plugin_jira/formatters/__init__.py +6 -0
- titan_plugin_jira/formatters/markdown_formatter.py +245 -0
- titan_plugin_jira/messages.py +115 -0
- titan_plugin_jira/models.py +89 -0
- titan_plugin_jira/plugin.py +264 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
- titan_plugin_jira/steps/get_issue_step.py +82 -0
- titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
- titan_plugin_jira/steps/search_saved_query_step.py +238 -0
- titan_plugin_jira/utils/__init__.py +13 -0
- titan_plugin_jira/utils/issue_sorter.py +140 -0
- titan_plugin_jira/utils/saved_queries.py +150 -0
- titan_plugin_jira/workflows/analyze-jira-issues.yaml +34 -0
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
# plugins/titan-plugin-github/titan_plugin_github/clients/github_client.py
|
|
2
|
+
"""
|
|
3
|
+
GitHub Client
|
|
4
|
+
|
|
5
|
+
Python client for GitHub operations using gh CLI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import List, Optional, Dict, Any
|
|
11
|
+
|
|
12
|
+
from titan_cli.core.secrets import SecretManager
|
|
13
|
+
from titan_cli.core.plugins.models import GitHubPluginConfig
|
|
14
|
+
from titan_plugin_git.clients.git_client import GitClient
|
|
15
|
+
|
|
16
|
+
from ..models import (
|
|
17
|
+
PullRequest,
|
|
18
|
+
Review,
|
|
19
|
+
PRSearchResult,
|
|
20
|
+
PRMergeResult,
|
|
21
|
+
PRComment as GitHubPRComment,
|
|
22
|
+
Issue,
|
|
23
|
+
)
|
|
24
|
+
from ..exceptions import (
|
|
25
|
+
GitHubError,
|
|
26
|
+
GitHubAuthenticationError,
|
|
27
|
+
PRNotFoundError,
|
|
28
|
+
GitHubAPIError,
|
|
29
|
+
)
|
|
30
|
+
from ..messages import msg
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GitHubClient:
|
|
34
|
+
"""
|
|
35
|
+
GitHub client using gh CLI
|
|
36
|
+
|
|
37
|
+
This client wraps gh CLI commands and provides a Pythonic interface
|
|
38
|
+
for GitHub operations.
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
>>> config = GitHubPluginConfig()
|
|
42
|
+
>>> client = GitHubClient(config)
|
|
43
|
+
>>> pr = client.get_pull_request(123)
|
|
44
|
+
>>> print(pr.title)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
config: GitHubPluginConfig,
|
|
50
|
+
secrets: SecretManager,
|
|
51
|
+
git_client: GitClient,
|
|
52
|
+
repo_owner: str,
|
|
53
|
+
repo_name: str
|
|
54
|
+
):
|
|
55
|
+
"""
|
|
56
|
+
Initialize GitHub client
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
config: GitHub configuration
|
|
60
|
+
secrets: SecretManager instance
|
|
61
|
+
git_client: Initialized GitClient instance
|
|
62
|
+
repo_owner: GitHub repository owner.
|
|
63
|
+
repo_name: GitHub repository name.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
GitHubAuthenticationError: If gh CLI is not authenticated
|
|
67
|
+
"""
|
|
68
|
+
self.config = config
|
|
69
|
+
self.secrets = secrets
|
|
70
|
+
self.git_client = git_client
|
|
71
|
+
self.repo_owner = repo_owner
|
|
72
|
+
self.repo_name = repo_name
|
|
73
|
+
self._check_auth()
|
|
74
|
+
|
|
75
|
+
def _check_auth(self) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Check if gh CLI is authenticated
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
GitHubAuthenticationError: If not authenticated
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
subprocess.run(["gh", "auth", "status"], capture_output=True, check=True)
|
|
84
|
+
except subprocess.CalledProcessError:
|
|
85
|
+
raise GitHubAuthenticationError(msg.GitHub.NOT_AUTHENTICATED)
|
|
86
|
+
|
|
87
|
+
def _run_gh_command(
|
|
88
|
+
self, args: List[str], stdin_input: Optional[str] = None
|
|
89
|
+
) -> str:
|
|
90
|
+
"""
|
|
91
|
+
Run gh CLI command and return stdout
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
args: Command arguments (without 'gh' prefix)
|
|
95
|
+
stdin_input: Optional input to pass via stdin (for multiline text)
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Command stdout as string
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
GitHubAPIError: If command fails
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
result = subprocess.run(
|
|
105
|
+
["gh"] + args,
|
|
106
|
+
input=stdin_input,
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
check=True,
|
|
110
|
+
)
|
|
111
|
+
return result.stdout.strip()
|
|
112
|
+
except subprocess.CalledProcessError as e:
|
|
113
|
+
error_msg = e.stderr.strip() if e.stderr else str(e)
|
|
114
|
+
raise GitHubAPIError(msg.GitHub.API_ERROR.format(error_msg=error_msg))
|
|
115
|
+
except FileNotFoundError:
|
|
116
|
+
raise GitHubError(msg.GitHub.CLI_NOT_FOUND)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
raise GitHubError(msg.GitHub.UNEXPECTED_ERROR.format(error=e))
|
|
119
|
+
|
|
120
|
+
def _get_repo_arg(self) -> List[str]:
|
|
121
|
+
"""Get --repo argument for gh commands"""
|
|
122
|
+
if self.repo_owner and self.repo_name:
|
|
123
|
+
return ["--repo", f"{self.repo_owner}/{self.repo_name}"]
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
def _get_repo_string(self) -> str:
|
|
127
|
+
"""Get repo string in format 'owner/repo'"""
|
|
128
|
+
return f"{self.repo_owner}/{self.repo_name}"
|
|
129
|
+
|
|
130
|
+
def get_pull_request(self, pr_number: int) -> PullRequest:
|
|
131
|
+
"""
|
|
132
|
+
Get pull request by number
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
pr_number: PR number
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
PullRequest instance
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
PRNotFoundError: If PR doesn't exist
|
|
142
|
+
GitHubAPIError: If API call fails
|
|
143
|
+
|
|
144
|
+
Examples:
|
|
145
|
+
>>> pr = client.get_pull_request(123)
|
|
146
|
+
>>> print(pr.title, pr.state)
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
# Get PR with all relevant fields
|
|
150
|
+
fields = [
|
|
151
|
+
"number",
|
|
152
|
+
"title",
|
|
153
|
+
"body",
|
|
154
|
+
"state",
|
|
155
|
+
"author",
|
|
156
|
+
"baseRefName",
|
|
157
|
+
"headRefName",
|
|
158
|
+
"additions",
|
|
159
|
+
"deletions",
|
|
160
|
+
"changedFiles",
|
|
161
|
+
"mergeable",
|
|
162
|
+
"isDraft",
|
|
163
|
+
"createdAt",
|
|
164
|
+
"updatedAt",
|
|
165
|
+
"mergedAt",
|
|
166
|
+
"reviews",
|
|
167
|
+
"labels",
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
args = [
|
|
171
|
+
"pr",
|
|
172
|
+
"view",
|
|
173
|
+
str(pr_number),
|
|
174
|
+
"--json",
|
|
175
|
+
",".join(fields),
|
|
176
|
+
] + self._get_repo_arg()
|
|
177
|
+
|
|
178
|
+
output = self._run_gh_command(args)
|
|
179
|
+
data = json.loads(output)
|
|
180
|
+
|
|
181
|
+
return PullRequest.from_dict(data)
|
|
182
|
+
|
|
183
|
+
except json.JSONDecodeError as e:
|
|
184
|
+
raise GitHubAPIError(
|
|
185
|
+
msg.GitHub.API_ERROR.format(
|
|
186
|
+
error_msg=f"Failed to parse PR data: {e}"
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
except GitHubAPIError as e:
|
|
190
|
+
if "not found" in str(e).lower():
|
|
191
|
+
raise PRNotFoundError(
|
|
192
|
+
msg.GitHub.PR_NOT_FOUND.format(pr_number=pr_number)
|
|
193
|
+
)
|
|
194
|
+
raise
|
|
195
|
+
|
|
196
|
+
def get_default_branch(self) -> str:
|
|
197
|
+
"""
|
|
198
|
+
Get the default branch (base branch) for the repository
|
|
199
|
+
|
|
200
|
+
Checks in order:
|
|
201
|
+
1. Project config (.titan/config.toml -> github.default_branch)
|
|
202
|
+
2. GitHub repository default branch (via API)
|
|
203
|
+
3. Fallback to "develop"
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Default branch name (e.g., "main", "develop", "master")
|
|
207
|
+
|
|
208
|
+
Examples:
|
|
209
|
+
>>> # If config has github.default_branch = "develop"
|
|
210
|
+
>>> client = GitHubClient(config)
|
|
211
|
+
>>> branch = client.get_default_branch()
|
|
212
|
+
>>> print(branch) # "develop" (from config)
|
|
213
|
+
|
|
214
|
+
>>> # If no config, consults GitHub API
|
|
215
|
+
>>> client = GitHubClient(config)
|
|
216
|
+
>>> branch = client.get_default_branch()
|
|
217
|
+
>>> print(branch) # "main" (from GitHub API)
|
|
218
|
+
"""
|
|
219
|
+
# Try to get from project config first
|
|
220
|
+
if self.config.default_branch:
|
|
221
|
+
return self.config.default_branch
|
|
222
|
+
|
|
223
|
+
# Fallback to GitHub API
|
|
224
|
+
try:
|
|
225
|
+
# Get repository info including default branch
|
|
226
|
+
args = ["repo", "view", "--json", "defaultBranchRef"] + self._get_repo_arg()
|
|
227
|
+
|
|
228
|
+
output = self._run_gh_command(args)
|
|
229
|
+
data = json.loads(output)
|
|
230
|
+
|
|
231
|
+
# Extract default branch name
|
|
232
|
+
default_branch_ref = data.get("defaultBranchRef", {})
|
|
233
|
+
branch_name = default_branch_ref.get("name")
|
|
234
|
+
|
|
235
|
+
if branch_name:
|
|
236
|
+
return branch_name
|
|
237
|
+
|
|
238
|
+
except Exception:
|
|
239
|
+
# Log this, but don't re-raise immediately, try final fallback
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
# Final fallback: use git plugin's main_branch
|
|
243
|
+
return self.git_client.main_branch
|
|
244
|
+
|
|
245
|
+
def list_pending_review_prs(
|
|
246
|
+
self, max_results: int = 50, include_team_reviews: bool = False
|
|
247
|
+
) -> PRSearchResult:
|
|
248
|
+
"""
|
|
249
|
+
List PRs pending your review in the current repository
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
max_results: Maximum number of results
|
|
253
|
+
include_team_reviews: If True, includes PRs where only your team is requested
|
|
254
|
+
If False, only PRs where YOU are individually requested
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
PRSearchResult with pending PRs
|
|
258
|
+
|
|
259
|
+
Examples:
|
|
260
|
+
>>> # Only PRs where you're individually assigned
|
|
261
|
+
>>> result = client.list_pending_review_prs()
|
|
262
|
+
>>> # PRs where you OR your team are assigned
|
|
263
|
+
>>> result = client.list_pending_review_prs(include_team_reviews=True)
|
|
264
|
+
"""
|
|
265
|
+
try:
|
|
266
|
+
# Use 'gh pr list' instead of 'gh search prs' because:
|
|
267
|
+
# - 'gh search prs' ignores --repo flag and searches across all repos
|
|
268
|
+
# - 'gh pr list' respects current repo context
|
|
269
|
+
|
|
270
|
+
# Get all PRs with review-requested: @me
|
|
271
|
+
args = [
|
|
272
|
+
"pr",
|
|
273
|
+
"list",
|
|
274
|
+
"--search",
|
|
275
|
+
"review-requested: @me",
|
|
276
|
+
"--state",
|
|
277
|
+
"open",
|
|
278
|
+
"--limit",
|
|
279
|
+
str(max_results),
|
|
280
|
+
"--json",
|
|
281
|
+
"number,title,author,updatedAt,labels,isDraft,reviewRequests",
|
|
282
|
+
] + self._get_repo_arg()
|
|
283
|
+
|
|
284
|
+
output = self._run_gh_command(args)
|
|
285
|
+
all_prs = json.loads(output)
|
|
286
|
+
|
|
287
|
+
if include_team_reviews:
|
|
288
|
+
# Return all PRs (you individually OR your team)
|
|
289
|
+
return PRSearchResult.from_list(all_prs)
|
|
290
|
+
else:
|
|
291
|
+
# Filter to only PRs where current user is explicitly in reviewRequests
|
|
292
|
+
# Get current user to filter review requests
|
|
293
|
+
user_output = self._run_gh_command(["api", "user", "--jq", ".login"])
|
|
294
|
+
current_user = user_output.strip()
|
|
295
|
+
|
|
296
|
+
filtered_prs = []
|
|
297
|
+
for pr in all_prs:
|
|
298
|
+
review_requests = pr.get("reviewRequests", [])
|
|
299
|
+
# Check if current user is in the review requests
|
|
300
|
+
if any(
|
|
301
|
+
req and req.get("login") == current_user
|
|
302
|
+
for req in review_requests
|
|
303
|
+
):
|
|
304
|
+
filtered_prs.append(pr)
|
|
305
|
+
|
|
306
|
+
return PRSearchResult.from_list(filtered_prs)
|
|
307
|
+
|
|
308
|
+
except json.JSONDecodeError as e:
|
|
309
|
+
raise GitHubAPIError(
|
|
310
|
+
msg.GitHub.API_ERROR.format(
|
|
311
|
+
error_msg=f"Failed to parse search results: {e}"
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def list_my_prs(self, state: str = "open", max_results: int = 50) -> PRSearchResult:
|
|
316
|
+
"""
|
|
317
|
+
List your PRs
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
state: PR state (open, closed, merged, all)
|
|
321
|
+
max_results: Maximum number of results
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
PRSearchResult with your PRs
|
|
325
|
+
|
|
326
|
+
Examples:
|
|
327
|
+
>>> result = client.list_my_prs(state="open")
|
|
328
|
+
>>> print(f"You have {result.total} open PRs")
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
args = [
|
|
332
|
+
"pr",
|
|
333
|
+
"list",
|
|
334
|
+
"--author",
|
|
335
|
+
" @me",
|
|
336
|
+
"--state",
|
|
337
|
+
state,
|
|
338
|
+
"--limit",
|
|
339
|
+
str(max_results),
|
|
340
|
+
"--json",
|
|
341
|
+
"number,title,author,updatedAt,labels,isDraft,state",
|
|
342
|
+
] + self._get_repo_arg()
|
|
343
|
+
|
|
344
|
+
output = self._run_gh_command(args)
|
|
345
|
+
data = json.loads(output)
|
|
346
|
+
|
|
347
|
+
return PRSearchResult.from_list(data)
|
|
348
|
+
|
|
349
|
+
except json.JSONDecodeError as e:
|
|
350
|
+
raise GitHubAPIError(
|
|
351
|
+
msg.GitHub.API_ERROR.format(
|
|
352
|
+
error_msg=f"Failed to parse PR list: {e}"
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def list_all_prs(
|
|
357
|
+
self, state: str = "open", max_results: int = 50
|
|
358
|
+
) -> PRSearchResult:
|
|
359
|
+
"""
|
|
360
|
+
List all PRs in the repository
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
state: PR state (open, closed, merged, all)
|
|
364
|
+
max_results: Maximum number of results
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
PRSearchResult with all PRs
|
|
368
|
+
|
|
369
|
+
Examples:
|
|
370
|
+
>>> result = client.list_all_prs(state="open")
|
|
371
|
+
>>> print(f"Repository has {result.total} open PRs")
|
|
372
|
+
"""
|
|
373
|
+
try:
|
|
374
|
+
args = [
|
|
375
|
+
"pr",
|
|
376
|
+
"list",
|
|
377
|
+
"--state",
|
|
378
|
+
state,
|
|
379
|
+
"--limit",
|
|
380
|
+
str(max_results),
|
|
381
|
+
"--json",
|
|
382
|
+
"number,title,author,updatedAt,labels,isDraft,state,reviewRequests",
|
|
383
|
+
] + self._get_repo_arg()
|
|
384
|
+
|
|
385
|
+
output = self._run_gh_command(args)
|
|
386
|
+
data = json.loads(output)
|
|
387
|
+
|
|
388
|
+
return PRSearchResult.from_list(data)
|
|
389
|
+
|
|
390
|
+
except json.JSONDecodeError as e:
|
|
391
|
+
raise GitHubAPIError(
|
|
392
|
+
msg.GitHub.API_ERROR.format(
|
|
393
|
+
error_msg=f"Failed to parse PR list: {e}"
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def get_pr_diff(self, pr_number: int, file_path: Optional[str] = None) -> str:
|
|
398
|
+
"""
|
|
399
|
+
Get diff for a PR
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
pr_number: PR number
|
|
403
|
+
file_path: Optional specific file to get diff for
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Diff as string
|
|
407
|
+
|
|
408
|
+
Raises:
|
|
409
|
+
PRNotFoundError: If PR doesn't exist
|
|
410
|
+
|
|
411
|
+
Examples:
|
|
412
|
+
>>> diff = client.get_pr_diff(123)
|
|
413
|
+
>>> print(diff)
|
|
414
|
+
"""
|
|
415
|
+
try:
|
|
416
|
+
args = ["pr", "diff", str(pr_number)] + self._get_repo_arg()
|
|
417
|
+
|
|
418
|
+
if file_path:
|
|
419
|
+
args.append("--")
|
|
420
|
+
args.append(file_path)
|
|
421
|
+
|
|
422
|
+
return self._run_gh_command(args)
|
|
423
|
+
|
|
424
|
+
except GitHubAPIError as e:
|
|
425
|
+
if "not found" in str(e).lower():
|
|
426
|
+
raise PRNotFoundError(
|
|
427
|
+
msg.GitHub.PR_NOT_FOUND.format(pr_number=pr_number)
|
|
428
|
+
)
|
|
429
|
+
raise
|
|
430
|
+
|
|
431
|
+
def get_pr_files(self, pr_number: int) -> List[str]:
|
|
432
|
+
"""
|
|
433
|
+
Get list of changed files in PR
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
pr_number: PR number
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
List of file paths
|
|
440
|
+
|
|
441
|
+
Examples:
|
|
442
|
+
>>> files = client.get_pr_files(123)
|
|
443
|
+
>>> print(f"Changed {len(files)} files")
|
|
444
|
+
"""
|
|
445
|
+
try:
|
|
446
|
+
args = [
|
|
447
|
+
"pr",
|
|
448
|
+
"view",
|
|
449
|
+
str(pr_number),
|
|
450
|
+
"--json",
|
|
451
|
+
"files",
|
|
452
|
+
] + self._get_repo_arg()
|
|
453
|
+
|
|
454
|
+
output = self._run_gh_command(args)
|
|
455
|
+
data = json.loads(output)
|
|
456
|
+
|
|
457
|
+
return [f["path"] for f in data.get("files", [])]
|
|
458
|
+
|
|
459
|
+
except json.JSONDecodeError as e:
|
|
460
|
+
raise GitHubAPIError(
|
|
461
|
+
msg.GitHub.API_ERROR.format(
|
|
462
|
+
error_msg=f"Failed to parse files: {e}"
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def checkout_pr(self, pr_number: int) -> str:
|
|
467
|
+
"""
|
|
468
|
+
Checkout a PR locally
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
pr_number: PR number
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
Branch name that was checked out
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
PRNotFoundError: If PR doesn't exist
|
|
478
|
+
|
|
479
|
+
Examples:
|
|
480
|
+
>>> branch = client.checkout_pr(123)
|
|
481
|
+
>>> print(f"Checked out {branch}")
|
|
482
|
+
"""
|
|
483
|
+
try:
|
|
484
|
+
# Get PR branch name first
|
|
485
|
+
pr = self.get_pull_request(pr_number)
|
|
486
|
+
|
|
487
|
+
# Checkout the PR using gh CLI
|
|
488
|
+
args = ["pr", "checkout", str(pr_number)] + self._get_repo_arg()
|
|
489
|
+
self._run_gh_command(args)
|
|
490
|
+
|
|
491
|
+
return pr.head_ref
|
|
492
|
+
|
|
493
|
+
except GitHubAPIError as e:
|
|
494
|
+
if "not found" in str(e).lower():
|
|
495
|
+
raise PRNotFoundError(
|
|
496
|
+
msg.GitHub.PR_NOT_FOUND.format(pr_number=pr_number)
|
|
497
|
+
)
|
|
498
|
+
raise
|
|
499
|
+
|
|
500
|
+
def add_comment(self, pr_number: int, body: str) -> None:
|
|
501
|
+
"""
|
|
502
|
+
Add comment to a PR
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
pr_number: PR number
|
|
506
|
+
body: Comment text
|
|
507
|
+
|
|
508
|
+
Raises:
|
|
509
|
+
PRNotFoundError: If PR doesn't exist
|
|
510
|
+
|
|
511
|
+
Examples:
|
|
512
|
+
>>> client.add_comment(123, "LGTM!")
|
|
513
|
+
"""
|
|
514
|
+
try:
|
|
515
|
+
args = [
|
|
516
|
+
"pr",
|
|
517
|
+
"comment",
|
|
518
|
+
str(pr_number),
|
|
519
|
+
"--body",
|
|
520
|
+
body,
|
|
521
|
+
] + self._get_repo_arg()
|
|
522
|
+
|
|
523
|
+
self._run_gh_command(args)
|
|
524
|
+
|
|
525
|
+
except GitHubAPIError as e:
|
|
526
|
+
if "not found" in str(e).lower():
|
|
527
|
+
raise PRNotFoundError(
|
|
528
|
+
msg.GitHub.PR_NOT_FOUND.format(pr_number=pr_number)
|
|
529
|
+
)
|
|
530
|
+
raise
|
|
531
|
+
|
|
532
|
+
def get_pr_commit_sha(self, pr_number: int) -> str:
|
|
533
|
+
"""
|
|
534
|
+
Get the latest commit SHA for a PR
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
pr_number: PR number
|
|
538
|
+
|
|
539
|
+
Returns:
|
|
540
|
+
Latest commit SHA
|
|
541
|
+
|
|
542
|
+
Examples:
|
|
543
|
+
>>> sha = client.get_pr_commit_sha(123)
|
|
544
|
+
"""
|
|
545
|
+
try:
|
|
546
|
+
args = [
|
|
547
|
+
"pr",
|
|
548
|
+
"view",
|
|
549
|
+
str(pr_number),
|
|
550
|
+
"--json",
|
|
551
|
+
"commits",
|
|
552
|
+
] + self._get_repo_arg()
|
|
553
|
+
|
|
554
|
+
output = self._run_gh_command(args)
|
|
555
|
+
data = json.loads(output)
|
|
556
|
+
commits = data.get("commits", [])
|
|
557
|
+
|
|
558
|
+
if not commits:
|
|
559
|
+
raise GitHubAPIError(
|
|
560
|
+
msg.GitHub.API_ERROR.format(
|
|
561
|
+
error_msg=f"No commits found for PR #{pr_number}"
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
return commits[-1]["oid"]
|
|
566
|
+
|
|
567
|
+
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
|
568
|
+
raise GitHubAPIError(
|
|
569
|
+
msg.GitHub.API_ERROR.format(
|
|
570
|
+
error_msg=f"Failed to get commit SHA: {e}"
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
def get_pr_reviews(self, pr_number: int) -> List["Review"]:
|
|
575
|
+
"""
|
|
576
|
+
Get all reviews for a PR
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
pr_number: PR number
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
List of Review objects
|
|
583
|
+
|
|
584
|
+
Examples:
|
|
585
|
+
>>> reviews = client.get_pr_reviews(123)
|
|
586
|
+
>>> approved = sum(1 for r in reviews if r.state == "APPROVED")
|
|
587
|
+
"""
|
|
588
|
+
try:
|
|
589
|
+
repo = self._get_repo_string()
|
|
590
|
+
result = self._run_gh_command(
|
|
591
|
+
["api", f"/repos/{repo}/pulls/{pr_number}/reviews", "--jq", "."]
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
reviews_data = json.loads(result)
|
|
595
|
+
return [Review.from_dict(r) for r in reviews_data]
|
|
596
|
+
|
|
597
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
598
|
+
raise GitHubAPIError(
|
|
599
|
+
msg.GitHub.API_ERROR.format(
|
|
600
|
+
error_msg=f"Failed to get PR reviews: {e}"
|
|
601
|
+
)
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
def create_draft_review(self, pr_number: int, payload: Dict[str, Any]) -> int:
|
|
605
|
+
"""
|
|
606
|
+
Create a draft review on a PR
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
pr_number: PR number
|
|
610
|
+
payload: Review payload with commit_id, body, event, comments
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
Review ID
|
|
614
|
+
|
|
615
|
+
Examples:
|
|
616
|
+
>>> payload = {
|
|
617
|
+
... "commit_id": "abc123",
|
|
618
|
+
... "body": "",
|
|
619
|
+
... "event": "PENDING",
|
|
620
|
+
... "comments": [{"path": "file.kt", "line": 10, "body": "Nice"}]
|
|
621
|
+
... }
|
|
622
|
+
>>> review_id = client.create_draft_review(123, payload)
|
|
623
|
+
"""
|
|
624
|
+
try:
|
|
625
|
+
repo = self._get_repo_string()
|
|
626
|
+
args = [
|
|
627
|
+
"api",
|
|
628
|
+
f"/repos/{repo}/pulls/{pr_number}/reviews",
|
|
629
|
+
"--method",
|
|
630
|
+
"POST",
|
|
631
|
+
"--input",
|
|
632
|
+
"-",
|
|
633
|
+
]
|
|
634
|
+
|
|
635
|
+
# Run gh command with JSON payload via stdin
|
|
636
|
+
import subprocess
|
|
637
|
+
|
|
638
|
+
result = subprocess.run(
|
|
639
|
+
["gh"] + args,
|
|
640
|
+
input=json.dumps(payload),
|
|
641
|
+
capture_output=True,
|
|
642
|
+
text=True,
|
|
643
|
+
check=True,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
response = json.loads(result.stdout)
|
|
647
|
+
return response["id"]
|
|
648
|
+
|
|
649
|
+
except (json.JSONDecodeError, KeyError, subprocess.CalledProcessError) as e:
|
|
650
|
+
raise GitHubAPIError(
|
|
651
|
+
msg.GitHub.API_ERROR.format(
|
|
652
|
+
error_msg=f"Failed to create draft review: {e}"
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
def submit_review(
|
|
657
|
+
self, pr_number: int, review_id: int, event: str, body: str = ""
|
|
658
|
+
) -> None:
|
|
659
|
+
"""
|
|
660
|
+
Submit a review
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
pr_number: PR number
|
|
664
|
+
review_id: Review ID
|
|
665
|
+
event: Review event (APPROVE, REQUEST_CHANGES, COMMENT)
|
|
666
|
+
body: Optional review body text
|
|
667
|
+
|
|
668
|
+
Examples:
|
|
669
|
+
>>> client.submit_review(123, 456, "APPROVE", "")
|
|
670
|
+
"""
|
|
671
|
+
try:
|
|
672
|
+
repo = self._get_repo_string()
|
|
673
|
+
args = [
|
|
674
|
+
"api",
|
|
675
|
+
f"/repos/{repo}/pulls/{pr_number}/reviews/{review_id}/events",
|
|
676
|
+
"--method",
|
|
677
|
+
"POST",
|
|
678
|
+
"-f",
|
|
679
|
+
f"event={event}",
|
|
680
|
+
]
|
|
681
|
+
|
|
682
|
+
if body:
|
|
683
|
+
args.extend(["-f", f"body={body}"])
|
|
684
|
+
|
|
685
|
+
self._run_gh_command(args)
|
|
686
|
+
|
|
687
|
+
except GitHubAPIError as e:
|
|
688
|
+
raise GitHubAPIError(
|
|
689
|
+
msg.GitHub.API_ERROR.format(
|
|
690
|
+
error_msg=f"Failed to submit review: {e}"
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
def delete_review(self, pr_number: int, review_id: int) -> None:
|
|
695
|
+
"""
|
|
696
|
+
Delete a draft review
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
pr_number: PR number
|
|
700
|
+
review_id: Review ID
|
|
701
|
+
|
|
702
|
+
Examples:
|
|
703
|
+
>>> client.delete_review(123, 456)
|
|
704
|
+
"""
|
|
705
|
+
try:
|
|
706
|
+
repo = self._get_repo_string()
|
|
707
|
+
args = [
|
|
708
|
+
"api",
|
|
709
|
+
f"/repos/{repo}/pulls/{pr_number}/reviews/{review_id}",
|
|
710
|
+
"--method",
|
|
711
|
+
"DELETE",
|
|
712
|
+
]
|
|
713
|
+
|
|
714
|
+
self._run_gh_command(args)
|
|
715
|
+
|
|
716
|
+
except GitHubAPIError as e:
|
|
717
|
+
raise GitHubAPIError(
|
|
718
|
+
msg.GitHub.API_ERROR.format(
|
|
719
|
+
error_msg=f"Failed to delete review: {e}"
|
|
720
|
+
)
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
def merge_pr(
|
|
724
|
+
self,
|
|
725
|
+
pr_number: int,
|
|
726
|
+
merge_method: str = "squash",
|
|
727
|
+
commit_title: Optional[str] = None,
|
|
728
|
+
commit_message: Optional[str] = None,
|
|
729
|
+
) -> PRMergeResult:
|
|
730
|
+
"""
|
|
731
|
+
Merge a pull request
|
|
732
|
+
|
|
733
|
+
Args:
|
|
734
|
+
pr_number: PR number
|
|
735
|
+
merge_method: Merge method (squash, merge, rebase)
|
|
736
|
+
commit_title: Optional commit title
|
|
737
|
+
commit_message: Optional commit message
|
|
738
|
+
|
|
739
|
+
Returns:
|
|
740
|
+
PRMergeResult with merge status and SHA
|
|
741
|
+
|
|
742
|
+
Examples:
|
|
743
|
+
>>> result = client.merge_pr(123, merge_method="squash")
|
|
744
|
+
>>> if result.merged:
|
|
745
|
+
... print(f"Merged: {result.sha}")
|
|
746
|
+
"""
|
|
747
|
+
try:
|
|
748
|
+
# Validate merge method
|
|
749
|
+
valid_methods = ["squash", "merge", "rebase"]
|
|
750
|
+
if merge_method not in valid_methods:
|
|
751
|
+
return PRMergeResult(
|
|
752
|
+
merged=False,
|
|
753
|
+
message=msg.GitHub.INVALID_MERGE_METHOD.format(
|
|
754
|
+
method=merge_method, valid_methods=", ".join(valid_methods)
|
|
755
|
+
),
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# Build command
|
|
759
|
+
args = ["pr", "merge", str(pr_number), f"--{merge_method}"]
|
|
760
|
+
|
|
761
|
+
if commit_title:
|
|
762
|
+
args.extend(["--subject", commit_title])
|
|
763
|
+
|
|
764
|
+
if commit_message:
|
|
765
|
+
args.extend(["--body", commit_message])
|
|
766
|
+
|
|
767
|
+
# Execute merge
|
|
768
|
+
result = self._run_gh_command(args)
|
|
769
|
+
|
|
770
|
+
# Parse result to get SHA
|
|
771
|
+
# gh pr merge returns: "✓ Merged pull request #123 (SHA)"
|
|
772
|
+
# Extract SHA from output
|
|
773
|
+
sha = None
|
|
774
|
+
if result:
|
|
775
|
+
import re
|
|
776
|
+
|
|
777
|
+
sha_match = re.search(r"\(([a-f0-9]{40})\)", result)
|
|
778
|
+
if sha_match:
|
|
779
|
+
sha = sha_match.group(1)
|
|
780
|
+
else:
|
|
781
|
+
# Try short SHA (7 chars)
|
|
782
|
+
sha_match = re.search(r"\(([a-f0-9]{7,})\)", result)
|
|
783
|
+
if sha_match:
|
|
784
|
+
sha = sha_match.group(1)
|
|
785
|
+
|
|
786
|
+
return PRMergeResult(merged=True, sha=sha, message="Successfully merged")
|
|
787
|
+
|
|
788
|
+
except GitHubAPIError as e:
|
|
789
|
+
return PRMergeResult(merged=False, message=str(e))
|
|
790
|
+
except Exception as e:
|
|
791
|
+
return PRMergeResult(
|
|
792
|
+
merged=False, message=msg.GitHub.UNEXPECTED_ERROR.format(error=e)
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
def get_pr_comments(self, pr_number: int) -> List[GitHubPRComment]:
|
|
796
|
+
"""
|
|
797
|
+
Get all comments for a PR (review comments + issue comments)
|
|
798
|
+
|
|
799
|
+
Args:
|
|
800
|
+
pr_number: PR number
|
|
801
|
+
|
|
802
|
+
Returns:
|
|
803
|
+
List of PRComment objects
|
|
804
|
+
|
|
805
|
+
Examples:
|
|
806
|
+
>>> comments = client.get_pr_comments(123)
|
|
807
|
+
>>> for c in comments:
|
|
808
|
+
... print(f"{c.user.login}: {c.body}")
|
|
809
|
+
"""
|
|
810
|
+
try:
|
|
811
|
+
repo = self._get_repo_string()
|
|
812
|
+
|
|
813
|
+
# Get review comments (inline in files)
|
|
814
|
+
args = ["api", f"/repos/{repo}/pulls/{pr_number}/comments", "--paginate"]
|
|
815
|
+
output = self._run_gh_command(args)
|
|
816
|
+
review_comments_data = json.loads(output) if output else []
|
|
817
|
+
|
|
818
|
+
# Get issue comments (general comments)
|
|
819
|
+
args = ["api", f"/repos/{repo}/issues/{pr_number}/comments", "--paginate"]
|
|
820
|
+
output = self._run_gh_command(args)
|
|
821
|
+
issue_comments_data = json.loads(output) if output else []
|
|
822
|
+
|
|
823
|
+
# Convert to PRComment objects
|
|
824
|
+
comments = []
|
|
825
|
+
|
|
826
|
+
for data in review_comments_data:
|
|
827
|
+
comments.append(GitHubPRComment.from_dict(data, is_review=True))
|
|
828
|
+
|
|
829
|
+
for data in issue_comments_data:
|
|
830
|
+
comments.append(GitHubPRComment.from_dict(data, is_review=False))
|
|
831
|
+
|
|
832
|
+
return comments
|
|
833
|
+
|
|
834
|
+
except (json.JSONDecodeError, GitHubAPIError) as e:
|
|
835
|
+
raise GitHubAPIError(
|
|
836
|
+
msg.GitHub.API_ERROR.format(
|
|
837
|
+
error_msg=f"Failed to get PR comments: {e}"
|
|
838
|
+
)
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
def get_pending_comments(
|
|
842
|
+
self, pr_number: int, author: Optional[str] = None
|
|
843
|
+
) -> List[GitHubPRComment]:
|
|
844
|
+
"""
|
|
845
|
+
Get comments pending response from PR author
|
|
846
|
+
|
|
847
|
+
Filters out comments already responded to by the author.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
pr_number: PR number
|
|
851
|
+
author: PR author username (if None, uses current user)
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
List of PRComment objects that don't have author's response
|
|
855
|
+
|
|
856
|
+
Examples:
|
|
857
|
+
>>> pending = client.get_pending_comments(123)
|
|
858
|
+
>>> print(f"{len(pending)} comments pending")
|
|
859
|
+
"""
|
|
860
|
+
if author is None:
|
|
861
|
+
author = self.get_current_user()
|
|
862
|
+
|
|
863
|
+
all_comments = self.get_pr_comments(pr_number)
|
|
864
|
+
|
|
865
|
+
# Build set of comment IDs that have author's response
|
|
866
|
+
responded_ids = set()
|
|
867
|
+
for comment in all_comments:
|
|
868
|
+
if comment.in_reply_to_id and comment.user.login == author:
|
|
869
|
+
responded_ids.add(comment.in_reply_to_id)
|
|
870
|
+
|
|
871
|
+
# Filter to main comments (not replies) without author's response
|
|
872
|
+
pending = []
|
|
873
|
+
for comment in all_comments:
|
|
874
|
+
is_main_comment = comment.in_reply_to_id is None
|
|
875
|
+
not_from_author = comment.user.login != author
|
|
876
|
+
not_responded = comment.id not in responded_ids
|
|
877
|
+
|
|
878
|
+
if is_main_comment and not_from_author and not_responded:
|
|
879
|
+
pending.append(comment)
|
|
880
|
+
|
|
881
|
+
return pending
|
|
882
|
+
|
|
883
|
+
def reply_to_comment(self, pr_number: int, comment_id: int, body: str) -> None:
|
|
884
|
+
"""
|
|
885
|
+
Reply to a PR comment
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
pr_number: PR number
|
|
889
|
+
comment_id: Comment ID to reply to
|
|
890
|
+
body: Reply text
|
|
891
|
+
|
|
892
|
+
Examples:
|
|
893
|
+
>>> client.reply_to_comment(123, 456789, "Fixed in abc123")
|
|
894
|
+
"""
|
|
895
|
+
try:
|
|
896
|
+
repo = self._get_repo_string()
|
|
897
|
+
# Use -F body= @docs/guides/creating-visual-components.md to read body from stdin
|
|
898
|
+
# This properly handles multiline text, special characters, and code blocks
|
|
899
|
+
args = [
|
|
900
|
+
"api",
|
|
901
|
+
"-X",
|
|
902
|
+
"POST",
|
|
903
|
+
f"/repos/{repo}/pulls/{pr_number}/comments/{comment_id}/replies",
|
|
904
|
+
"-F",
|
|
905
|
+
"body= @-",
|
|
906
|
+
]
|
|
907
|
+
|
|
908
|
+
self._run_gh_command(args, stdin_input=body)
|
|
909
|
+
|
|
910
|
+
except GitHubAPIError as e:
|
|
911
|
+
raise GitHubAPIError(
|
|
912
|
+
msg.GitHub.API_ERROR.format(
|
|
913
|
+
error_msg=f"Failed to reply to comment: {e}"
|
|
914
|
+
)
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
def add_issue_comment(self, pr_number: int, body: str) -> None:
|
|
918
|
+
"""
|
|
919
|
+
Add a general comment to PR (issue comment)
|
|
920
|
+
|
|
921
|
+
Args:
|
|
922
|
+
pr_number: PR number
|
|
923
|
+
body: Comment text
|
|
924
|
+
|
|
925
|
+
Examples:
|
|
926
|
+
>>> client.add_issue_comment(123, "Thanks for the review!")
|
|
927
|
+
"""
|
|
928
|
+
try:
|
|
929
|
+
repo = self._get_repo_string()
|
|
930
|
+
# Use -F body= @docs/guides/creating-visual-components.md to read body from stdin
|
|
931
|
+
# This properly handles multiline text, special characters, and code blocks
|
|
932
|
+
args = [
|
|
933
|
+
"api",
|
|
934
|
+
"-X",
|
|
935
|
+
"POST",
|
|
936
|
+
f"/repos/{repo}/issues/{pr_number}/comments",
|
|
937
|
+
"-F",
|
|
938
|
+
"body= @-",
|
|
939
|
+
]
|
|
940
|
+
|
|
941
|
+
self._run_gh_command(args, stdin_input=body)
|
|
942
|
+
|
|
943
|
+
except GitHubAPIError as e:
|
|
944
|
+
raise GitHubAPIError(
|
|
945
|
+
msg.GitHub.API_ERROR.format(
|
|
946
|
+
error_msg=f"Failed to add comment: {e}"
|
|
947
|
+
)
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
def get_current_user(self) -> str:
|
|
951
|
+
"""
|
|
952
|
+
Get the currently authenticated GitHub username.
|
|
953
|
+
|
|
954
|
+
Returns:
|
|
955
|
+
GitHub username
|
|
956
|
+
|
|
957
|
+
Raises:
|
|
958
|
+
GitHubAPIError: If unable to get current user
|
|
959
|
+
"""
|
|
960
|
+
try:
|
|
961
|
+
output = self._run_gh_command(["api", "user", "-q", ".login"])
|
|
962
|
+
return output.strip()
|
|
963
|
+
except GitHubAPIError as e:
|
|
964
|
+
raise GitHubAPIError(f"Failed to get current GitHub user: {e}")
|
|
965
|
+
|
|
966
|
+
def create_pull_request(
|
|
967
|
+
self, title: str, body: str, base: str, head: str, draft: bool = False,
|
|
968
|
+
assignees: Optional[List[str]] = None
|
|
969
|
+
) -> Dict[str, Any]:
|
|
970
|
+
"""
|
|
971
|
+
Create a pull request
|
|
972
|
+
|
|
973
|
+
Args:
|
|
974
|
+
title: PR title
|
|
975
|
+
body: PR description/body
|
|
976
|
+
base: Base branch (e.g., "develop", "main")
|
|
977
|
+
head: Head branch (feature branch)
|
|
978
|
+
draft: Whether to create as draft PR
|
|
979
|
+
assignees: List of GitHub usernames to assign to the PR
|
|
980
|
+
|
|
981
|
+
Returns:
|
|
982
|
+
Dict with PR information including:
|
|
983
|
+
- number: PR number
|
|
984
|
+
- url: PR URL
|
|
985
|
+
- state: PR state
|
|
986
|
+
|
|
987
|
+
Raises:
|
|
988
|
+
GitHubAPIError: If PR creation fails
|
|
989
|
+
|
|
990
|
+
Examples:
|
|
991
|
+
>>> pr = client.create_pull_request(
|
|
992
|
+
... title="feat: Add new feature",
|
|
993
|
+
... body="Description of changes",
|
|
994
|
+
... base="develop",
|
|
995
|
+
... head="feat/new-feature",
|
|
996
|
+
... assignees=["username"]
|
|
997
|
+
... )
|
|
998
|
+
>>> print(f"Created PR #{pr['number']}: {pr['url']}")
|
|
999
|
+
"""
|
|
1000
|
+
try:
|
|
1001
|
+
args = [
|
|
1002
|
+
"pr",
|
|
1003
|
+
"create",
|
|
1004
|
+
"--base",
|
|
1005
|
+
base,
|
|
1006
|
+
"--head",
|
|
1007
|
+
head,
|
|
1008
|
+
"--title",
|
|
1009
|
+
title,
|
|
1010
|
+
"--body",
|
|
1011
|
+
body,
|
|
1012
|
+
]
|
|
1013
|
+
|
|
1014
|
+
if draft:
|
|
1015
|
+
args.append("--draft")
|
|
1016
|
+
|
|
1017
|
+
# Add assignees if provided
|
|
1018
|
+
if assignees:
|
|
1019
|
+
for assignee in assignees:
|
|
1020
|
+
args.extend(["--assignee", assignee])
|
|
1021
|
+
|
|
1022
|
+
args.extend(self._get_repo_arg())
|
|
1023
|
+
|
|
1024
|
+
# Run command and get PR URL
|
|
1025
|
+
output = self._run_gh_command(args)
|
|
1026
|
+
pr_url = output.strip()
|
|
1027
|
+
|
|
1028
|
+
# Extract PR number from URL
|
|
1029
|
+
# URL format: https://github.com/owner/repo/pull/123
|
|
1030
|
+
pr_number = int(pr_url.split("/")[-1])
|
|
1031
|
+
|
|
1032
|
+
return {
|
|
1033
|
+
"number": pr_number,
|
|
1034
|
+
"url": pr_url,
|
|
1035
|
+
"state": "draft" if draft else "open",
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
except ValueError:
|
|
1039
|
+
raise GitHubAPIError(
|
|
1040
|
+
msg.GitHub.FAILED_TO_PARSE_PR_NUMBER.format(url=pr_url)
|
|
1041
|
+
)
|
|
1042
|
+
except GitHubAPIError as e:
|
|
1043
|
+
raise GitHubAPIError(msg.GitHub.PR_CREATION_FAILED.format(error=e))
|
|
1044
|
+
|
|
1045
|
+
def create_issue(
|
|
1046
|
+
self,
|
|
1047
|
+
title: str,
|
|
1048
|
+
body: str,
|
|
1049
|
+
assignees: Optional[List[str]] = None,
|
|
1050
|
+
labels: Optional[List[str]] = None,
|
|
1051
|
+
) -> Issue:
|
|
1052
|
+
"""
|
|
1053
|
+
Create a new GitHub issue.
|
|
1054
|
+
"""
|
|
1055
|
+
try:
|
|
1056
|
+
args = ["issue", "create", "--title", title, "--body", body]
|
|
1057
|
+
|
|
1058
|
+
if assignees:
|
|
1059
|
+
for assignee in assignees:
|
|
1060
|
+
args.extend(["--assignee", assignee])
|
|
1061
|
+
if labels:
|
|
1062
|
+
for label in labels:
|
|
1063
|
+
args.extend(["--label", label])
|
|
1064
|
+
|
|
1065
|
+
args.extend(self._get_repo_arg())
|
|
1066
|
+
output = self._run_gh_command(args)
|
|
1067
|
+
issue_url = output.strip()
|
|
1068
|
+
try:
|
|
1069
|
+
issue_number = int(issue_url.strip().split("/")[-1])
|
|
1070
|
+
except (ValueError, IndexError) as e:
|
|
1071
|
+
raise GitHubAPIError(f"Failed to parse issue number from URL '{issue_url}': {e}")
|
|
1072
|
+
|
|
1073
|
+
# Fetch the issue to return the full object
|
|
1074
|
+
issue_args = [
|
|
1075
|
+
"issue",
|
|
1076
|
+
"view",
|
|
1077
|
+
str(issue_number),
|
|
1078
|
+
"--json",
|
|
1079
|
+
"number,title,body,state,author,labels,createdAt,updatedAt",
|
|
1080
|
+
] + self._get_repo_arg()
|
|
1081
|
+
issue_output = self._run_gh_command(issue_args)
|
|
1082
|
+
issue_data = json.loads(issue_output)
|
|
1083
|
+
return Issue.from_dict(issue_data)
|
|
1084
|
+
|
|
1085
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
1086
|
+
raise GitHubAPIError(f"Failed to parse issue data: {e}")
|
|
1087
|
+
except GitHubAPIError as e:
|
|
1088
|
+
raise GitHubAPIError(f"Failed to create issue: {e}")
|
|
1089
|
+
|
|
1090
|
+
def list_labels(self) -> List[str]:
|
|
1091
|
+
"""
|
|
1092
|
+
List all labels in the repository.
|
|
1093
|
+
|
|
1094
|
+
Returns:
|
|
1095
|
+
List of label names.
|
|
1096
|
+
"""
|
|
1097
|
+
try:
|
|
1098
|
+
args = ["label", "list", "--json", "name"] + self._get_repo_arg()
|
|
1099
|
+
output = self._run_gh_command(args)
|
|
1100
|
+
labels_data = json.loads(output)
|
|
1101
|
+
return [label["name"] for label in labels_data]
|
|
1102
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
1103
|
+
raise GitHubAPIError(f"Failed to parse label data: {e}")
|
|
1104
|
+
except GitHubAPIError as e:
|
|
1105
|
+
raise GitHubAPIError(f"Failed to list labels: {e}")
|