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.
Files changed (146) hide show
  1. titan_cli/__init__.py +3 -0
  2. titan_cli/__main__.py +4 -0
  3. titan_cli/ai/__init__.py +0 -0
  4. titan_cli/ai/agents/__init__.py +15 -0
  5. titan_cli/ai/agents/base.py +152 -0
  6. titan_cli/ai/client.py +170 -0
  7. titan_cli/ai/constants.py +56 -0
  8. titan_cli/ai/exceptions.py +48 -0
  9. titan_cli/ai/models.py +34 -0
  10. titan_cli/ai/oauth_helper.py +120 -0
  11. titan_cli/ai/providers/__init__.py +9 -0
  12. titan_cli/ai/providers/anthropic.py +117 -0
  13. titan_cli/ai/providers/base.py +75 -0
  14. titan_cli/ai/providers/gemini.py +278 -0
  15. titan_cli/cli.py +59 -0
  16. titan_cli/clients/__init__.py +1 -0
  17. titan_cli/clients/gcloud_client.py +52 -0
  18. titan_cli/core/__init__.py +3 -0
  19. titan_cli/core/config.py +274 -0
  20. titan_cli/core/discovery.py +51 -0
  21. titan_cli/core/errors.py +81 -0
  22. titan_cli/core/models.py +52 -0
  23. titan_cli/core/plugins/available.py +36 -0
  24. titan_cli/core/plugins/models.py +67 -0
  25. titan_cli/core/plugins/plugin_base.py +108 -0
  26. titan_cli/core/plugins/plugin_registry.py +163 -0
  27. titan_cli/core/secrets.py +141 -0
  28. titan_cli/core/workflows/__init__.py +22 -0
  29. titan_cli/core/workflows/models.py +88 -0
  30. titan_cli/core/workflows/project_step_source.py +86 -0
  31. titan_cli/core/workflows/workflow_exceptions.py +17 -0
  32. titan_cli/core/workflows/workflow_filter_service.py +137 -0
  33. titan_cli/core/workflows/workflow_registry.py +419 -0
  34. titan_cli/core/workflows/workflow_sources.py +307 -0
  35. titan_cli/engine/__init__.py +39 -0
  36. titan_cli/engine/builder.py +159 -0
  37. titan_cli/engine/context.py +82 -0
  38. titan_cli/engine/mock_context.py +176 -0
  39. titan_cli/engine/results.py +91 -0
  40. titan_cli/engine/steps/ai_assistant_step.py +185 -0
  41. titan_cli/engine/steps/command_step.py +93 -0
  42. titan_cli/engine/utils/__init__.py +3 -0
  43. titan_cli/engine/utils/venv.py +31 -0
  44. titan_cli/engine/workflow_executor.py +187 -0
  45. titan_cli/external_cli/__init__.py +0 -0
  46. titan_cli/external_cli/configs.py +17 -0
  47. titan_cli/external_cli/launcher.py +65 -0
  48. titan_cli/messages.py +121 -0
  49. titan_cli/ui/tui/__init__.py +205 -0
  50. titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
  51. titan_cli/ui/tui/app.py +113 -0
  52. titan_cli/ui/tui/icons.py +70 -0
  53. titan_cli/ui/tui/screens/__init__.py +24 -0
  54. titan_cli/ui/tui/screens/ai_config.py +498 -0
  55. titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
  56. titan_cli/ui/tui/screens/base.py +110 -0
  57. titan_cli/ui/tui/screens/cli_launcher.py +151 -0
  58. titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
  59. titan_cli/ui/tui/screens/main_menu.py +162 -0
  60. titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
  61. titan_cli/ui/tui/screens/plugin_management.py +377 -0
  62. titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
  63. titan_cli/ui/tui/screens/workflow_execution.py +592 -0
  64. titan_cli/ui/tui/screens/workflows.py +249 -0
  65. titan_cli/ui/tui/textual_components.py +537 -0
  66. titan_cli/ui/tui/textual_workflow_executor.py +405 -0
  67. titan_cli/ui/tui/theme.py +102 -0
  68. titan_cli/ui/tui/widgets/__init__.py +40 -0
  69. titan_cli/ui/tui/widgets/button.py +108 -0
  70. titan_cli/ui/tui/widgets/header.py +116 -0
  71. titan_cli/ui/tui/widgets/panel.py +81 -0
  72. titan_cli/ui/tui/widgets/status_bar.py +115 -0
  73. titan_cli/ui/tui/widgets/table.py +77 -0
  74. titan_cli/ui/tui/widgets/text.py +177 -0
  75. titan_cli/utils/__init__.py +0 -0
  76. titan_cli/utils/autoupdate.py +155 -0
  77. titan_cli-0.1.0.dist-info/METADATA +149 -0
  78. titan_cli-0.1.0.dist-info/RECORD +146 -0
  79. titan_cli-0.1.0.dist-info/WHEEL +4 -0
  80. titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
  81. titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  82. titan_plugin_git/__init__.py +1 -0
  83. titan_plugin_git/clients/__init__.py +8 -0
  84. titan_plugin_git/clients/git_client.py +772 -0
  85. titan_plugin_git/exceptions.py +40 -0
  86. titan_plugin_git/messages.py +112 -0
  87. titan_plugin_git/models.py +39 -0
  88. titan_plugin_git/plugin.py +118 -0
  89. titan_plugin_git/steps/__init__.py +1 -0
  90. titan_plugin_git/steps/ai_commit_message_step.py +171 -0
  91. titan_plugin_git/steps/branch_steps.py +104 -0
  92. titan_plugin_git/steps/commit_step.py +80 -0
  93. titan_plugin_git/steps/push_step.py +63 -0
  94. titan_plugin_git/steps/status_step.py +59 -0
  95. titan_plugin_git/workflows/__previews__/__init__.py +1 -0
  96. titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
  97. titan_plugin_git/workflows/commit-ai.yaml +28 -0
  98. titan_plugin_github/__init__.py +11 -0
  99. titan_plugin_github/agents/__init__.py +6 -0
  100. titan_plugin_github/agents/config_loader.py +130 -0
  101. titan_plugin_github/agents/issue_generator.py +353 -0
  102. titan_plugin_github/agents/pr_agent.py +528 -0
  103. titan_plugin_github/clients/__init__.py +8 -0
  104. titan_plugin_github/clients/github_client.py +1105 -0
  105. titan_plugin_github/config/__init__.py +0 -0
  106. titan_plugin_github/config/pr_agent.toml +85 -0
  107. titan_plugin_github/exceptions.py +28 -0
  108. titan_plugin_github/messages.py +88 -0
  109. titan_plugin_github/models.py +330 -0
  110. titan_plugin_github/plugin.py +131 -0
  111. titan_plugin_github/steps/__init__.py +12 -0
  112. titan_plugin_github/steps/ai_pr_step.py +172 -0
  113. titan_plugin_github/steps/create_pr_step.py +86 -0
  114. titan_plugin_github/steps/github_prompt_steps.py +171 -0
  115. titan_plugin_github/steps/issue_steps.py +143 -0
  116. titan_plugin_github/steps/preview_step.py +40 -0
  117. titan_plugin_github/utils.py +82 -0
  118. titan_plugin_github/workflows/__previews__/__init__.py +1 -0
  119. titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
  120. titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
  121. titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
  122. titan_plugin_jira/__init__.py +8 -0
  123. titan_plugin_jira/agents/__init__.py +6 -0
  124. titan_plugin_jira/agents/config_loader.py +154 -0
  125. titan_plugin_jira/agents/jira_agent.py +553 -0
  126. titan_plugin_jira/agents/prompts.py +364 -0
  127. titan_plugin_jira/agents/response_parser.py +435 -0
  128. titan_plugin_jira/agents/token_tracker.py +223 -0
  129. titan_plugin_jira/agents/validators.py +246 -0
  130. titan_plugin_jira/clients/jira_client.py +745 -0
  131. titan_plugin_jira/config/jira_agent.toml +92 -0
  132. titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
  133. titan_plugin_jira/exceptions.py +37 -0
  134. titan_plugin_jira/formatters/__init__.py +6 -0
  135. titan_plugin_jira/formatters/markdown_formatter.py +245 -0
  136. titan_plugin_jira/messages.py +115 -0
  137. titan_plugin_jira/models.py +89 -0
  138. titan_plugin_jira/plugin.py +264 -0
  139. titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
  140. titan_plugin_jira/steps/get_issue_step.py +82 -0
  141. titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
  142. titan_plugin_jira/steps/search_saved_query_step.py +238 -0
  143. titan_plugin_jira/utils/__init__.py +13 -0
  144. titan_plugin_jira/utils/issue_sorter.py +140 -0
  145. titan_plugin_jira/utils/saved_queries.py +150 -0
  146. 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}")