k-cli-for-devs 1.0.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 (75) hide show
  1. k_cli/__init__.py +77 -0
  2. k_cli/agents/__init__.py +0 -0
  3. k_cli/agents/adversarial_swarm.py +338 -0
  4. k_cli/agents/agent_core.py +255 -0
  5. k_cli/agents/background_daemon.py +141 -0
  6. k_cli/agents/orchestrator.py +376 -0
  7. k_cli/agents/persona.py +649 -0
  8. k_cli/agents/scaffold_engine.py +121 -0
  9. k_cli/agents/strands_agent.py +832 -0
  10. k_cli/agents/subagents.py +1496 -0
  11. k_cli/cli.py +3297 -0
  12. k_cli/core/__init__.py +0 -0
  13. k_cli/core/airgap.py +95 -0
  14. k_cli/core/credentials.py +548 -0
  15. k_cli/core/intent_sensor.py +177 -0
  16. k_cli/core/llm_driver.py +1028 -0
  17. k_cli/core/model_manager.py +1109 -0
  18. k_cli/core/models_hub.py +913 -0
  19. k_cli/core/prompting.py +41 -0
  20. k_cli/core/sdk.py +322 -0
  21. k_cli/core/session.py +826 -0
  22. k_cli/core/smart_router.py +230 -0
  23. k_cli/core/storage_manager.py +176 -0
  24. k_cli/core/viewport_engine.py +117 -0
  25. k_cli/demo/demo_runner.py +579 -0
  26. k_cli/git/__init__.py +0 -0
  27. k_cli/git/ai_bisect.py +208 -0
  28. k_cli/git/conflict_resolver.py +1039 -0
  29. k_cli/git/git_guard.py +417 -0
  30. k_cli/git/patcher.py +1175 -0
  31. k_cli/git/repo_map.py +1780 -0
  32. k_cli/git/smart_git.py +928 -0
  33. k_cli/git/verifier.py +969 -0
  34. k_cli/github/__init__.py +0 -0
  35. k_cli/github/dedup_engine.py +787 -0
  36. k_cli/github/github_client.py +1702 -0
  37. k_cli/github/github_engine.py +641 -0
  38. k_cli/github/local_hub.py +209 -0
  39. k_cli/github/pr_watcher.py +129 -0
  40. k_cli/github/trending.py +205 -0
  41. k_cli/tools/__init__.py +0 -0
  42. k_cli/tools/audit.py +79 -0
  43. k_cli/tools/chaos_immunity.py +377 -0
  44. k_cli/tools/codebase_qa.py +106 -0
  45. k_cli/tools/command_runner.py +256 -0
  46. k_cli/tools/diagram_generator.py +547 -0
  47. k_cli/tools/doc_retriever.py +1332 -0
  48. k_cli/tools/feature.py +105 -0
  49. k_cli/tools/ghost_daemon.py +122 -0
  50. k_cli/tools/incident_triage.py +1365 -0
  51. k_cli/tools/mcp_client.py +1846 -0
  52. k_cli/tools/repo_gardener.py +142 -0
  53. k_cli/tools/rules.py +109 -0
  54. k_cli/tools/security.py +52 -0
  55. k_cli/tools/security_healer.py +999 -0
  56. k_cli/tools/synapse_graph.py +155 -0
  57. k_cli/tui/__init__.py +0 -0
  58. k_cli/tui/diff_viewer.py +223 -0
  59. k_cli/tui/tui.py +1145 -0
  60. k_cli/tui/tui_animations.py +648 -0
  61. k_cli/tui/tui_app.py +2788 -0
  62. k_cli/ui/__init__.py +10 -0
  63. k_cli/ui/simple_repl.py +315 -0
  64. k_cli/web/__init__.py +7 -0
  65. k_cli/web/server.py +624 -0
  66. k_cli/web/static/app.js +830 -0
  67. k_cli/web/static/index.html +495 -0
  68. k_cli/web/static/monitor.html +189 -0
  69. k_cli/web/static/style.css +838 -0
  70. k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
  71. k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
  72. k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
  73. k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
  74. k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
  75. k_cli_for_devs-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1702 @@
1
+ """
2
+ github_client.py - GitHub REST API v3 Client & PR Lifecycle Specialist for K-CLI
3
+
4
+ Provides:
5
+ - Lightweight, dependency-free HTTP client (urllib.request / urllib.error with httpx fallback)
6
+ - Multi-tier GitHub token discovery (env vars, gh CLI config, .env, key.json)
7
+ - GitHubClient: Full repository, PR, diff, comment, review, CI check, and merge operations
8
+ - Dataclasses: PullRequest, CIStatus, PRReviewResult, PRFixResult, PRComment, PRFile
9
+ - PRLifecycleManager: Automated AI PR code review, PR debugging/fixing loop with verifier & patcher, and auto-merge
10
+ - MockGitHubClient / mock_mode for seamless offline operation and reliable unit testing.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
28
+
29
+ try:
30
+ from k_cli.git.git_guard import GitGuard
31
+ except (ModuleNotFoundError, ImportError):
32
+ try:
33
+ from git_guard import GitGuard
34
+ except (ModuleNotFoundError, ImportError):
35
+ GitGuard = None # type: ignore
36
+
37
+ try:
38
+ from k_cli.git.verifier import VerificationResult, Verifier
39
+ except (ModuleNotFoundError, ImportError):
40
+ try:
41
+ from verifier import VerificationResult, Verifier
42
+ except (ModuleNotFoundError, ImportError):
43
+ Verifier = None # type: ignore
44
+ VerificationResult = None # type: ignore
45
+
46
+ try:
47
+ from k_cli.git.patcher import FilePatch, Patcher
48
+ except (ModuleNotFoundError, ImportError):
49
+ try:
50
+ from patcher import FilePatch, Patcher
51
+ except (ModuleNotFoundError, ImportError):
52
+ Patcher = None # type: ignore
53
+ FilePatch = None # type: ignore
54
+
55
+ try:
56
+ from k_cli.core.llm_driver import LLMDriver
57
+ except (ModuleNotFoundError, ImportError):
58
+ try:
59
+ from k_cli.core.llm_driver import LLMDriver
60
+ except (ModuleNotFoundError, ImportError):
61
+ LLMDriver = None # type: ignore
62
+
63
+
64
+ # =============================================================================
65
+ # 1. Custom Exceptions
66
+ # =============================================================================
67
+
68
+
69
+ class GitHubAPIError(Exception):
70
+ """Base exception for GitHub REST API errors."""
71
+
72
+ def __init__(
73
+ self,
74
+ message: str,
75
+ status_code: Optional[int] = None,
76
+ response_body: Optional[Any] = None,
77
+ ):
78
+ super().__init__(message)
79
+ self.message = message
80
+ self.status_code = status_code
81
+ self.response_body = response_body
82
+
83
+ def __str__(self) -> str:
84
+ if self.status_code:
85
+ return f"[HTTP {self.status_code}] {self.message}"
86
+ return self.message
87
+
88
+
89
+ class GitHubAuthError(GitHubAPIError):
90
+ """Raised when authentication fails (HTTP 401 / missing credentials)."""
91
+ pass
92
+
93
+
94
+ class GitHubNotFoundError(GitHubAPIError):
95
+ """Raised when a requested resource is not found (HTTP 404)."""
96
+ pass
97
+
98
+
99
+ class GitHubRateLimitError(GitHubAPIError):
100
+ """Raised when GitHub API rate limit is exceeded (HTTP 403 / 429)."""
101
+ pass
102
+
103
+
104
+ # =============================================================================
105
+ # 2. Structured Dataclasses
106
+ # =============================================================================
107
+
108
+
109
+ @dataclass
110
+ class PullRequest:
111
+ """Represents a GitHub Pull Request."""
112
+ number: int
113
+ title: str
114
+ body: str = ""
115
+ state: str = "open"
116
+ head_branch: str = ""
117
+ head_sha: str = ""
118
+ base_branch: str = "main"
119
+ author: str = ""
120
+ created_at: str = ""
121
+ updated_at: str = ""
122
+ html_url: str = ""
123
+ draft: bool = False
124
+ mergeable: Optional[bool] = None
125
+ merged: bool = False
126
+ labels: List[str] = field(default_factory=list)
127
+ raw_data: Dict[str, Any] = field(default_factory=dict)
128
+
129
+ def to_dict(self) -> Dict[str, Any]:
130
+ """Serializes PullRequest into a dictionary."""
131
+ return {
132
+ "number": self.number,
133
+ "title": self.title,
134
+ "body": self.body,
135
+ "state": self.state,
136
+ "head_branch": self.head_branch,
137
+ "head_sha": self.head_sha,
138
+ "base_branch": self.base_branch,
139
+ "author": self.author,
140
+ "created_at": self.created_at,
141
+ "updated_at": self.updated_at,
142
+ "html_url": self.html_url,
143
+ "draft": self.draft,
144
+ "mergeable": self.mergeable,
145
+ "merged": self.merged,
146
+ "labels": list(self.labels),
147
+ }
148
+
149
+ @classmethod
150
+ def from_dict(cls, data: Dict[str, Any]) -> PullRequest:
151
+ """Constructs a PullRequest instance from GitHub API JSON response."""
152
+ head = data.get("head", {}) if isinstance(data.get("head"), dict) else {}
153
+ base = data.get("base", {}) if isinstance(data.get("base"), dict) else {}
154
+ user = data.get("user", {}) if isinstance(data.get("user"), dict) else {}
155
+
156
+ raw_labels = data.get("labels", [])
157
+ labels = [
158
+ lbl.get("name", "") if isinstance(lbl, dict) else str(lbl)
159
+ for lbl in raw_labels
160
+ if lbl
161
+ ]
162
+
163
+ return cls(
164
+ number=int(data.get("number", 0)),
165
+ title=str(data.get("title", "")),
166
+ body=str(data.get("body") or ""),
167
+ state=str(data.get("state", "open")),
168
+ head_branch=str(head.get("ref", "") or data.get("head_branch", "")),
169
+ head_sha=str(head.get("sha", "") or data.get("head_sha", "")),
170
+ base_branch=str(base.get("ref", "main") or data.get("base_branch", "main")),
171
+ author=str(user.get("login", "") or data.get("author", "")),
172
+ created_at=str(data.get("created_at", "")),
173
+ updated_at=str(data.get("updated_at", "")),
174
+ html_url=str(data.get("html_url", "")),
175
+ draft=bool(data.get("draft", False)),
176
+ mergeable=data.get("mergeable"),
177
+ merged=bool(data.get("merged", False)),
178
+ labels=labels,
179
+ raw_data=data,
180
+ )
181
+
182
+
183
+ @dataclass
184
+ class CIStatus:
185
+ """Aggregated CI / Check Runs / Workflow status."""
186
+ state: str = "pending" # "success", "failure", "pending", "error", "neutral", "unknown"
187
+ total_count: int = 0
188
+ passed_count: int = 0
189
+ failed_count: int = 0
190
+ pending_count: int = 0
191
+ check_runs: List[Dict[str, Any]] = field(default_factory=list)
192
+ workflow_runs: List[Dict[str, Any]] = field(default_factory=list)
193
+ summary: str = ""
194
+ is_passing: bool = True
195
+
196
+ def to_dict(self) -> Dict[str, Any]:
197
+ """Serializes CIStatus into a dictionary."""
198
+ return {
199
+ "state": self.state,
200
+ "total_count": self.total_count,
201
+ "passed_count": self.passed_count,
202
+ "failed_count": self.failed_count,
203
+ "pending_count": self.pending_count,
204
+ "summary": self.summary,
205
+ "is_passing": self.is_passing,
206
+ "check_runs": self.check_runs,
207
+ "workflow_runs": self.workflow_runs,
208
+ }
209
+
210
+ @classmethod
211
+ def from_github_data(
212
+ cls,
213
+ check_runs_data: Optional[Dict[str, Any]] = None,
214
+ status_data: Optional[Dict[str, Any]] = None,
215
+ workflow_runs_data: Optional[Dict[str, Any]] = None,
216
+ ) -> CIStatus:
217
+ """Constructs and calculates aggregated CIStatus from GitHub API responses."""
218
+ check_runs = (check_runs_data or {}).get("check_runs", [])
219
+ workflow_runs = (workflow_runs_data or {}).get("workflow_runs", [])
220
+
221
+ passed = 0
222
+ failed = 0
223
+ pending = 0
224
+
225
+ # Parse check runs
226
+ for run in check_runs:
227
+ status = run.get("status", "")
228
+ conclusion = run.get("conclusion", "")
229
+ if status in ("in_progress", "queued"):
230
+ pending += 1
231
+ elif conclusion in ("success", "neutral", "skipped"):
232
+ passed += 1
233
+ elif conclusion in ("failure", "timed_out", "action_required", "cancelled"):
234
+ failed += 1
235
+ else:
236
+ pending += 1
237
+
238
+ # Parse commit statuses if provided
239
+ statuses = (status_data or {}).get("statuses", [])
240
+ for st in statuses:
241
+ st_state = st.get("state", "")
242
+ if st_state == "success":
243
+ passed += 1
244
+ elif st_state in ("failure", "error"):
245
+ failed += 1
246
+ elif st_state == "pending":
247
+ pending += 1
248
+
249
+ total = passed + failed + pending
250
+ if total == 0:
251
+ overall_state = "neutral"
252
+ is_pass = True
253
+ summary = "No CI check runs found."
254
+ elif failed > 0:
255
+ overall_state = "failure"
256
+ is_pass = False
257
+ summary = f"CI Failing: {failed}/{total} checks failed, {passed} passed, {pending} pending."
258
+ elif pending > 0:
259
+ overall_state = "pending"
260
+ is_pass = False
261
+ summary = f"CI Pending: {pending}/{total} checks in progress, {passed} passed."
262
+ else:
263
+ overall_state = "success"
264
+ is_pass = True
265
+ summary = f"CI Passing: All {passed}/{total} checks succeeded."
266
+
267
+ return cls(
268
+ state=overall_state,
269
+ total_count=total,
270
+ passed_count=passed,
271
+ failed_count=failed,
272
+ pending_count=pending,
273
+ check_runs=check_runs,
274
+ workflow_runs=workflow_runs,
275
+ summary=summary,
276
+ is_passing=is_pass,
277
+ )
278
+
279
+
280
+ @dataclass
281
+ class PRReviewResult:
282
+ """Structured result of an automated AI PR code review."""
283
+ pr_number: int
284
+ verdict: str # "APPROVE", "REQUEST_CHANGES", "COMMENT"
285
+ summary: str
286
+ bugs: List[str] = field(default_factory=list)
287
+ security_issues: List[str] = field(default_factory=list)
288
+ performance_notes: List[str] = field(default_factory=list)
289
+ line_suggestions: List[Dict[str, Any]] = field(default_factory=list)
290
+ raw_llm_response: str = ""
291
+
292
+ def to_dict(self) -> Dict[str, Any]:
293
+ """Serializes review result to dictionary."""
294
+ return {
295
+ "pr_number": self.pr_number,
296
+ "verdict": self.verdict,
297
+ "summary": self.summary,
298
+ "bugs": list(self.bugs),
299
+ "security_issues": list(self.security_issues),
300
+ "performance_notes": list(self.performance_notes),
301
+ "line_suggestions": self.line_suggestions,
302
+ "raw_llm_response": self.raw_llm_response,
303
+ }
304
+
305
+ def format_markdown(self) -> str:
306
+ """Formats the review into a clean GitHub PR Markdown comment."""
307
+ verdict_badge = {
308
+ "APPROVE": "✅ **APPROVE**",
309
+ "REQUEST_CHANGES": "❌ **REQUEST CHANGES**",
310
+ "COMMENT": "💬 **COMMENT**",
311
+ }.get(self.verdict.upper(), f"🔍 **{self.verdict}**")
312
+
313
+ lines = [
314
+ f"## 🤖 K-CLI Automated PR Review (PR #{self.pr_number})",
315
+ f"**Verdict:** {verdict_badge}",
316
+ "",
317
+ f"### 📋 Summary",
318
+ self.summary or "No summary provided.",
319
+ "",
320
+ ]
321
+
322
+ if self.bugs:
323
+ lines.append("### 🐛 Potential Bugs & Correctness Issues")
324
+ for bug in self.bugs:
325
+ lines.append(f"- {bug}")
326
+ lines.append("")
327
+
328
+ if self.security_issues:
329
+ lines.append("### 🔒 Security Findings")
330
+ for sec in self.security_issues:
331
+ lines.append(f"- ⚠️ {sec}")
332
+ lines.append("")
333
+
334
+ if self.performance_notes:
335
+ lines.append("### ⚡ Performance & Efficiency")
336
+ for perf in self.performance_notes:
337
+ lines.append(f"- {perf}")
338
+ lines.append("")
339
+
340
+ if self.line_suggestions:
341
+ lines.append("### 💡 Actionable Suggestions")
342
+ for sug in self.line_suggestions:
343
+ file_name = sug.get("file", "general")
344
+ line_no = sug.get("line")
345
+ loc = f"`{file_name}:{line_no}`" if line_no else f"`{file_name}`"
346
+ text = sug.get("suggestion", sug.get("comment", str(sug)))
347
+ lines.append(f"- **{loc}**: {text}")
348
+ lines.append("")
349
+
350
+ lines.append("---")
351
+ lines.append("*Generated by K-CLI GitHub & PR Lifecycle Specialist with Ground-Truth Verification.*")
352
+ return "\n".join(lines)
353
+
354
+
355
+ @dataclass
356
+ class PRFixResult:
357
+ """Structured result of an automated PR fix workflow."""
358
+ pr_number: int
359
+ branch: str
360
+ success: bool
361
+ fixes_applied: List[str] = field(default_factory=list)
362
+ test_results: Optional[Dict[str, Any]] = None
363
+ commit_sha: Optional[str] = None
364
+ pushed: bool = False
365
+ error_message: str = ""
366
+ rolled_back: bool = False
367
+
368
+ def to_dict(self) -> Dict[str, Any]:
369
+ """Serializes fix result to dictionary."""
370
+ return {
371
+ "pr_number": self.pr_number,
372
+ "branch": self.branch,
373
+ "success": self.success,
374
+ "fixes_applied": list(self.fixes_applied),
375
+ "test_results": self.test_results,
376
+ "commit_sha": self.commit_sha,
377
+ "pushed": self.pushed,
378
+ "error_message": self.error_message,
379
+ "rolled_back": self.rolled_back,
380
+ }
381
+
382
+
383
+ # =============================================================================
384
+ # 3. Token Discovery & Git Remote Inference
385
+ # =============================================================================
386
+
387
+
388
+ def _parse_yaml_hosts(content: str) -> Optional[str]:
389
+ """Pure-Python fallback parser for GitHub CLI `hosts.yml` config."""
390
+ lines = content.splitlines()
391
+ in_github_block = False
392
+ for line in lines:
393
+ stripped = line.strip()
394
+ if not stripped or stripped.startswith("#"):
395
+ continue
396
+ # Check host header (e.g. github.com:)
397
+ if re.match(r"^github\.com\s*:", stripped):
398
+ in_github_block = True
399
+ continue
400
+ elif re.match(r"^[a-zA-Z0-9.-]+\s*:", stripped) and not line.startswith(" ") and not line.startswith("\t"):
401
+ in_github_block = False
402
+
403
+ if in_github_block:
404
+ m_token = re.search(r"(?:oauth_token|token):\s*([^\s\r\n]+)", stripped)
405
+ if m_token:
406
+ tok = m_token.group(1).strip("\"'")
407
+ if tok:
408
+ return tok
409
+ return None
410
+
411
+
412
+ def _parse_env_file(env_path: Path) -> Optional[str]:
413
+ """Reads a .env file and extracts GitHub token if present."""
414
+ if not env_path.exists() or not env_path.is_file():
415
+ return None
416
+ try:
417
+ content = env_path.read_text(encoding="utf-8")
418
+ for line in content.splitlines():
419
+ line = line.strip()
420
+ if not line or line.startswith("#"):
421
+ continue
422
+ if "=" in line:
423
+ key, val = line.split("=", 1)
424
+ key = key.strip().upper()
425
+ val = val.strip().strip("\"'")
426
+ if key in ("GITHUB_TOKEN", "GH_TOKEN", "GITHUB_PAT") and val:
427
+ return val
428
+ except Exception:
429
+ pass
430
+ return None
431
+
432
+
433
+ def _parse_json_key_file(json_path: Path) -> Optional[str]:
434
+ """Reads a JSON key file and extracts GitHub token if present."""
435
+ if not json_path.exists() or not json_path.is_file():
436
+ return None
437
+ try:
438
+ content = json_path.read_text(encoding="utf-8")
439
+ data = json.loads(content)
440
+ if isinstance(data, dict):
441
+ for k in ("github_token", "github", "gh_token", "token", "access_token", "GITHUB_TOKEN", "GH_TOKEN"):
442
+ if data.get(k) and isinstance(data[k], str):
443
+ return data[k].strip()
444
+ except Exception:
445
+ pass
446
+ return None
447
+
448
+
449
+ def discover_github_token(search_paths: Optional[List[Union[str, Path]]] = None) -> Optional[str]:
450
+ """
451
+ Discovers GitHub authentication token from multi-tier hierarchy:
452
+ 1. Environment variables: `GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_PAT`
453
+ 2. GitHub CLI configuration file (`~/.config/gh/hosts.yml` or XDG / AppData)
454
+ 3. Local `.env` files (in search paths, current working directory, git repo root, or parents)
455
+ 4. Key JSON files (`key.json`, `keys.json`, `github_key.json`, `~/.k_cli/keys.json`)
456
+
457
+ Returns:
458
+ Discovered token string or None if not found.
459
+ """
460
+ # 1. Environment Variables
461
+ for env_var in ("GITHUB_TOKEN", "GH_TOKEN", "GITHUB_PAT"):
462
+ tok = os.getenv(env_var)
463
+ if tok and tok.strip():
464
+ return tok.strip()
465
+
466
+ # 2. GitHub CLI hosts.yml
467
+ gh_config_candidates: List[Path] = [
468
+ Path.home() / ".config" / "gh" / "hosts.yml",
469
+ Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) / "gh" / "hosts.yml",
470
+ ]
471
+ appdata = os.getenv("APPDATA")
472
+ if appdata:
473
+ gh_config_candidates.append(Path(appdata) / "GitHub CLI" / "hosts.yml")
474
+
475
+ for gh_path in gh_config_candidates:
476
+ if gh_path.exists() and gh_path.is_file():
477
+ try:
478
+ content = gh_path.read_text(encoding="utf-8")
479
+ tok = _parse_yaml_hosts(content)
480
+ if tok:
481
+ return tok
482
+ except Exception:
483
+ pass
484
+
485
+ # 3. Local .env files
486
+ check_dirs: List[Path] = []
487
+ if search_paths:
488
+ for sp in search_paths:
489
+ p = Path(sp).resolve()
490
+ if p.is_file():
491
+ p = p.parent
492
+ check_dirs.append(p)
493
+
494
+ cwd = Path.cwd().resolve()
495
+ check_dirs.extend([cwd, cwd.parent])
496
+
497
+ # Check .env in directories
498
+ seen_dirs = set()
499
+ for d in check_dirs:
500
+ if d in seen_dirs or not d.exists() or not d.is_dir():
501
+ continue
502
+ seen_dirs.add(d)
503
+
504
+ env_candidate = d / ".env"
505
+ tok = _parse_env_file(env_candidate)
506
+ if tok:
507
+ return tok
508
+
509
+ # 4. JSON Key files
510
+ key_candidates: List[Path] = [
511
+ cwd / "key.json",
512
+ cwd / "keys.json",
513
+ cwd / "github_key.json",
514
+ cwd / ".github_key.json",
515
+ Path.home() / ".k_cli" / "keys.json",
516
+ Path.home() / ".config" / "k_cli" / "keys.json",
517
+ ]
518
+ for kp in key_candidates:
519
+ tok = _parse_json_key_file(kp)
520
+ if tok:
521
+ return tok
522
+
523
+ return None
524
+
525
+
526
+ def infer_repo_from_git(repo_dir: Union[str, Path] = ".") -> Optional[Dict[str, str]]:
527
+ """
528
+ Infers GitHub repository owner and name from `git remote -v` in the workspace.
529
+
530
+ Supports:
531
+ - SSH: `git@github.com:owner/repo.git`
532
+ - SSH ssh://: `ssh://git@github.com/owner/repo.git`
533
+ - HTTPS: `https://github.com/owner/repo.git`
534
+ - HTTPS with credentials: `https://user:token@github.com/owner/repo.git`
535
+ - Git protocol: `git://github.com/owner/repo.git`
536
+
537
+ Returns:
538
+ Dictionary with `owner`, `repo`, `full_name`, `remote_name`, `remote_url` or None.
539
+ """
540
+ r_path = Path(repo_dir).resolve()
541
+ if not r_path.exists() or not r_path.is_dir():
542
+ return None
543
+
544
+ try:
545
+ res = subprocess.run(
546
+ ["git", "remote", "-v"],
547
+ cwd=str(r_path),
548
+ capture_output=True,
549
+ text=True,
550
+ timeout=5.0,
551
+ )
552
+ if res.returncode != 0 or not res.stdout.strip():
553
+ return None
554
+
555
+ # Regex patterns to extract owner and repo from remote URLs
556
+ patterns = [
557
+ # SSH format: git@github.com:owner/repo.git
558
+ r"github\.com[:/](?P<owner>[a-zA-Z0-9_.-]+)/(?P<repo>[a-zA-Z0-9_.-]+?)(?:\.git)?(?:\s|\(|$)",
559
+ ]
560
+
561
+ lines = res.stdout.strip().splitlines()
562
+ # Sort lines so 'origin' comes first
563
+ lines.sort(key=lambda l: 0 if l.startswith("origin") else 1)
564
+
565
+ for line in lines:
566
+ parts = line.split()
567
+ if not parts:
568
+ continue
569
+ remote_name = parts[0]
570
+ remote_url = parts[1] if len(parts) > 1 else ""
571
+
572
+ for pat in patterns:
573
+ m = re.search(pat, remote_url)
574
+ if m:
575
+ owner = m.group("owner")
576
+ repo = m.group("repo")
577
+ if repo.endswith(".git"):
578
+ repo = repo[:-4]
579
+ return {
580
+ "owner": owner,
581
+ "repo": repo,
582
+ "full_name": f"{owner}/{repo}",
583
+ "remote_name": remote_name,
584
+ "remote_url": remote_url,
585
+ }
586
+ except Exception:
587
+ pass
588
+
589
+ return None
590
+
591
+
592
+ # =============================================================================
593
+ # 4. GitHub REST API v3 Client
594
+ # =============================================================================
595
+
596
+
597
+ class GitHubClient:
598
+ """
599
+ Lightweight, dependency-free GitHub REST API v3 client.
600
+
601
+ Uses `urllib.request` / `urllib.error` with support for:
602
+ - Multi-tier token discovery
603
+ - Automatic git remote repository inference
604
+ - Pull Request queries, diff extraction, file listings, and comment retrieval
605
+ - CI / Check Runs / Commit status checking
606
+ - PR code review posting and automated pull request merging
607
+ - Deterministic Mock Mode for offline and test environments.
608
+ """
609
+
610
+ def __init__(
611
+ self,
612
+ token: Optional[str] = None,
613
+ repo: Optional[str] = None,
614
+ repo_dir: Union[str, Path] = ".",
615
+ base_url: str = "https://api.github.com",
616
+ mock_mode: bool = False,
617
+ timeout: float = 30.0,
618
+ ):
619
+ self.repo_dir = Path(repo_dir).resolve()
620
+ self.base_url = base_url.rstrip("/")
621
+ self.timeout = float(os.getenv("KCLI_GITHUB_TIMEOUT", timeout))
622
+ self.mock_mode = mock_mode or (os.getenv("KCLI_MOCK_GITHUB", "0").lower() in ("1", "true", "yes"))
623
+
624
+ # Token discovery
625
+ self.token = token or discover_github_token([self.repo_dir])
626
+
627
+ # Repository owner/name resolution
628
+ self.owner: str = ""
629
+ self.repo: str = ""
630
+ if repo and "/" in repo:
631
+ parts = repo.strip().split("/", 1)
632
+ self.owner = parts[0].strip()
633
+ self.repo = parts[1].strip()
634
+ else:
635
+ inferred = infer_repo_from_git(self.repo_dir)
636
+ if inferred:
637
+ self.owner = inferred["owner"]
638
+ self.repo = inferred["repo"]
639
+ elif repo:
640
+ self.repo = repo.strip()
641
+
642
+ # In-Memory Mock Store for Offline / Mock Testing
643
+ self._mock_prs: Dict[int, PullRequest] = {}
644
+ self._mock_diffs: Dict[int, str] = {}
645
+ self._mock_files: Dict[int, List[Dict[str, Any]]] = {}
646
+ self._mock_comments: Dict[int, List[Dict[str, Any]]] = {}
647
+ self._mock_ci: Dict[str, CIStatus] = {}
648
+ self._mock_reviews: Dict[int, List[Dict[str, Any]]] = {}
649
+
650
+ if self.mock_mode:
651
+ self._init_default_mocks()
652
+
653
+ def _init_default_mocks(self) -> None:
654
+ """Initializes default mock state for offline operation."""
655
+ # Mock PR 1: Vector Search Feature
656
+ self._mock_prs[1] = PullRequest(
657
+ number=1,
658
+ title="feat: implement fast vector search caching",
659
+ body="Adds in-memory LRU cache to vector embedding similarity lookups.",
660
+ state="open",
661
+ head_branch="feat/vector-cache",
662
+ head_sha="a1b2c3d4e5f6",
663
+ base_branch="main",
664
+ author="dev-specialist",
665
+ created_at="2026-08-20T10:00:00Z",
666
+ updated_at="2026-08-20T11:00:00Z",
667
+ html_url=f"https://github.com/{self.owner or 'k-cli'}/{self.repo or 'repo'}/pull/1",
668
+ mergeable=True,
669
+ merged=False,
670
+ labels=["enhancement", "performance"],
671
+ )
672
+ self._mock_diffs[1] = (
673
+ "diff --git a/cache.py b/cache.py\n"
674
+ "new file mode 100644\n"
675
+ "--- /dev/null\n"
676
+ "+++ b/cache.py\n"
677
+ "@@ -0,0 +1,15 @@\n"
678
+ "+class VectorCache:\n"
679
+ "+ def __init__(self, capacity: int = 100):\n"
680
+ "+ self.capacity = capacity\n"
681
+ "+ self._store = {}\n"
682
+ "+\n"
683
+ "+ def get(self, key: str):\n"
684
+ "+ return self._store.get(key)\n"
685
+ "+\n"
686
+ "+ def set(self, key: str, value: Any):\n"
687
+ "+ if len(self._store) >= self.capacity:\n"
688
+ "+ self._store.pop(next(iter(self._store)))\n"
689
+ "+ self._store[key] = value\n"
690
+ )
691
+ self._mock_files[1] = [
692
+ {"filename": "cache.py", "status": "added", "additions": 15, "deletions": 0, "changes": 15}
693
+ ]
694
+ self._mock_comments[1] = [
695
+ {"id": 101, "user": {"login": "reviewer1"}, "body": "Looks clean! Consider adding thread safety lock.", "created_at": "2026-08-20T10:30:00Z"}
696
+ ]
697
+ self._mock_ci["a1b2c3d4e5f6"] = CIStatus(
698
+ state="success",
699
+ total_count=2,
700
+ passed_count=2,
701
+ failed_count=0,
702
+ pending_count=0,
703
+ summary="CI Passing: All 2/2 checks succeeded.",
704
+ is_passing=True,
705
+ check_runs=[
706
+ {"name": "pytest-suite", "status": "completed", "conclusion": "success"},
707
+ {"name": "linter", "status": "completed", "conclusion": "success"},
708
+ ],
709
+ )
710
+
711
+ # Mock PR 2: Buggy Memory PR for Fix & Auto-Merge Testing
712
+ self._mock_prs[2] = PullRequest(
713
+ number=2,
714
+ title="fix: resolve streaming parser buffer overflow",
715
+ body="Fixes unclosed stream buffer causing high memory consumption.",
716
+ state="open",
717
+ head_branch="fix/stream-buffer",
718
+ head_sha="f6e5d4c3b2a1",
719
+ base_branch="main",
720
+ author="contributor2",
721
+ created_at="2026-08-21T09:00:00Z",
722
+ updated_at="2026-08-21T09:30:00Z",
723
+ html_url=f"https://github.com/{self.owner or 'k-cli'}/{self.repo or 'repo'}/pull/2",
724
+ mergeable=True,
725
+ merged=False,
726
+ labels=["bug", "memory"],
727
+ )
728
+ self._mock_diffs[2] = (
729
+ "diff --git a/stream_parser.py b/stream_parser.py\n"
730
+ "--- a/stream_parser.py\n"
731
+ "+++ b/stream_parser.py\n"
732
+ "@@ -10,4 +10,4 @@ def parse_stream(stream):\n"
733
+ "- buffer = []\n"
734
+ "+ buffer = bytearray()\n"
735
+ " return buffer\n"
736
+ )
737
+ self._mock_files[2] = [
738
+ {"filename": "stream_parser.py", "status": "modified", "additions": 1, "deletions": 1, "changes": 2}
739
+ ]
740
+ self._mock_ci["f6e5d4c3b2a1"] = CIStatus(
741
+ state="failure",
742
+ total_count=2,
743
+ passed_count=1,
744
+ failed_count=1,
745
+ pending_count=0,
746
+ summary="CI Failing: 1/2 checks failed.",
747
+ is_passing=False,
748
+ check_runs=[
749
+ {"name": "test_stream", "status": "completed", "conclusion": "failure"},
750
+ {"name": "linter", "status": "completed", "conclusion": "success"},
751
+ ],
752
+ )
753
+
754
+ # -------------------------------------------------------------------------
755
+ # HTTP Request Dispatcher
756
+ # -------------------------------------------------------------------------
757
+
758
+ def _request(
759
+ self,
760
+ method: str,
761
+ endpoint: str,
762
+ data: Optional[Union[Dict[str, Any], str]] = None,
763
+ headers: Optional[Dict[str, str]] = None,
764
+ params: Optional[Dict[str, Any]] = None,
765
+ raw_response: bool = False,
766
+ ) -> Any:
767
+ """
768
+ Executes HTTP request against GitHub REST API v3 using urllib.
769
+
770
+ Args:
771
+ method: HTTP method ("GET", "POST", "PUT", "PATCH", "DELETE").
772
+ endpoint: API endpoint path (e.g. "/repos/owner/repo/pulls").
773
+ data: Request payload (dict or string).
774
+ headers: Custom HTTP headers.
775
+ params: URL query parameters.
776
+ raw_response: If True, returns decoded string response instead of JSON.
777
+
778
+ Returns:
779
+ Parsed JSON (dict/list) or raw decoded text string.
780
+
781
+ Raises:
782
+ GitHubAuthError: On HTTP 401.
783
+ GitHubNotFoundError: On HTTP 404.
784
+ GitHubRateLimitError: On HTTP 403 or 429.
785
+ GitHubAPIError: On other HTTP error statuses.
786
+ """
787
+ url = endpoint if endpoint.startswith("http://") or endpoint.startswith("https://") else f"{self.base_url}{endpoint}"
788
+
789
+ if params:
790
+ query_str = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
791
+ if query_str:
792
+ url = f"{url}?{query_str}" if "?" not in url else f"{url}&{query_str}"
793
+
794
+ req_headers = {
795
+ "User-Agent": "K-CLI-GitHub-Client/1.0 (Project Bankai)",
796
+ "Accept": "application/vnd.github.v3+json",
797
+ "X-GitHub-Api-Version": "2022-11-28",
798
+ }
799
+
800
+ if self.token:
801
+ req_headers["Authorization"] = f"Bearer {self.token}"
802
+
803
+ if headers:
804
+ req_headers.update(headers)
805
+
806
+ body_bytes: Optional[bytes] = None
807
+ if data is not None:
808
+ if isinstance(data, (dict, list)):
809
+ body_bytes = json.dumps(data).encode("utf-8")
810
+ req_headers["Content-Type"] = "application/json"
811
+ elif isinstance(data, str):
812
+ body_bytes = data.encode("utf-8")
813
+ elif isinstance(data, bytes):
814
+ body_bytes = data
815
+
816
+ req = urllib.request.Request(
817
+ url=url,
818
+ data=body_bytes,
819
+ headers=req_headers,
820
+ method=method.upper(),
821
+ )
822
+
823
+ try:
824
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
825
+ status_code = resp.status
826
+ content = resp.read().decode("utf-8", errors="replace")
827
+
828
+ if raw_response:
829
+ return content
830
+
831
+ if not content.strip():
832
+ return {}
833
+
834
+ try:
835
+ return json.loads(content)
836
+ except json.JSONDecodeError:
837
+ return content
838
+
839
+ except urllib.error.HTTPError as err:
840
+ err_body = err.read().decode("utf-8", errors="replace")
841
+ err_json: Optional[Dict[str, Any]] = None
842
+ try:
843
+ err_json = json.loads(err_body)
844
+ except Exception:
845
+ pass
846
+
847
+ msg = (err_json.get("message") if isinstance(err_json, dict) else "") or err_body or err.reason
848
+
849
+ if err.code == 401:
850
+ raise GitHubAuthError(f"GitHub Authentication Failed: {msg}", status_code=401, response_body=err_json)
851
+ elif err.code == 403:
852
+ if "rate limit" in str(msg).lower() or err.headers.get("X-RateLimit-Remaining") == "0":
853
+ raise GitHubRateLimitError(f"GitHub Rate Limit Exceeded: {msg}", status_code=403, response_body=err_json)
854
+ raise GitHubAPIError(f"GitHub Forbidden: {msg}", status_code=403, response_body=err_json)
855
+ elif err.code == 404:
856
+ raise GitHubNotFoundError(f"GitHub Resource Not Found: {msg}", status_code=404, response_body=err_json)
857
+ elif err.code == 429:
858
+ raise GitHubRateLimitError(f"GitHub Rate Limit (429): {msg}", status_code=429, response_body=err_json)
859
+ else:
860
+ raise GitHubAPIError(f"GitHub API Error {err.code}: {msg}", status_code=err.code, response_body=err_json)
861
+
862
+ except urllib.error.URLError as err:
863
+ raise GitHubAPIError(f"GitHub Connection Error: {err.reason}")
864
+ except TimeoutError:
865
+ raise GitHubAPIError(f"GitHub API Request timed out after {self.timeout}s")
866
+
867
+ # -------------------------------------------------------------------------
868
+ # Public Client Operations
869
+ # -------------------------------------------------------------------------
870
+
871
+ def get_repo_info(self) -> Dict[str, Any]:
872
+ """
873
+ Returns repository metadata (owner, repo, full_name, and API metadata).
874
+ Infers repository from git if not explicitly supplied.
875
+ """
876
+ if not self.owner or not self.repo:
877
+ inferred = infer_repo_from_git(self.repo_dir)
878
+ if inferred:
879
+ self.owner = inferred["owner"]
880
+ self.repo = inferred["repo"]
881
+ else:
882
+ self.owner = self.owner or "k-cli"
883
+ self.repo = self.repo or "workspace"
884
+
885
+ info = {
886
+ "owner": self.owner,
887
+ "repo": self.repo,
888
+ "full_name": f"{self.owner}/{self.repo}",
889
+ "mock_mode": self.mock_mode,
890
+ "authenticated": bool(self.token),
891
+ }
892
+
893
+ if not self.mock_mode and self.owner and self.repo:
894
+ try:
895
+ api_data = self._request("GET", f"/repos/{self.owner}/{self.repo}")
896
+ if isinstance(api_data, dict):
897
+ info.update(api_data)
898
+ except Exception:
899
+ pass
900
+
901
+ return info
902
+
903
+ def list_pull_requests(self, state: str = "open", limit: int = 30) -> List[PullRequest]:
904
+ """
905
+ Lists pull requests in the repository.
906
+
907
+ Args:
908
+ state: Filter by PR state ("open", "closed", "all").
909
+ limit: Maximum number of PRs to return.
910
+
911
+ Returns:
912
+ List of PullRequest instances.
913
+ """
914
+ if self.mock_mode:
915
+ results = []
916
+ for pr in self._mock_prs.values():
917
+ if state == "all" or pr.state == state:
918
+ results.append(pr)
919
+ if len(results) >= limit:
920
+ break
921
+ return results
922
+
923
+ endpoint = f"/repos/{self.owner}/{self.repo}/pulls"
924
+ params = {"state": state, "per_page": min(limit, 100)}
925
+ data = self._request("GET", endpoint, params=params)
926
+
927
+ if not isinstance(data, list):
928
+ return []
929
+
930
+ prs = [PullRequest.from_dict(item) for item in data[:limit] if isinstance(item, dict)]
931
+ return prs
932
+
933
+ def get_pull_request(self, pr_number: int) -> PullRequest:
934
+ """
935
+ Fetches detailed information for a specific pull request.
936
+
937
+ Args:
938
+ pr_number: Pull request number.
939
+
940
+ Returns:
941
+ PullRequest instance.
942
+ """
943
+ if self.mock_mode:
944
+ pr = self._mock_prs.get(pr_number)
945
+ if not pr:
946
+ raise GitHubNotFoundError(f"Mock Pull Request #{pr_number} not found", status_code=404)
947
+ return pr
948
+
949
+ endpoint = f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}"
950
+ data = self._request("GET", endpoint)
951
+ if not isinstance(data, dict):
952
+ raise GitHubAPIError(f"Invalid PR response format for #{pr_number}")
953
+ return PullRequest.from_dict(data)
954
+
955
+ def get_pr_diff(self, pr_number: int) -> str:
956
+ """
957
+ Fetches the unified git diff of a pull request.
958
+
959
+ Args:
960
+ pr_number: Pull request number.
961
+
962
+ Returns:
963
+ Diff text string.
964
+ """
965
+ if self.mock_mode:
966
+ diff = self._mock_diffs.get(pr_number)
967
+ if diff is None:
968
+ # If PR exists, generate simple fallback diff
969
+ pr = self._mock_prs.get(pr_number)
970
+ if pr:
971
+ return f"--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-# {pr.title}\n+# {pr.title} (updated)\n"
972
+ raise GitHubNotFoundError(f"Mock Diff for PR #{pr_number} not found", status_code=404)
973
+ return diff
974
+
975
+ endpoint = f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}"
976
+ headers = {"Accept": "application/vnd.github.v3.diff"}
977
+ diff_text = self._request("GET", endpoint, headers=headers, raw_response=True)
978
+ return str(diff_text or "")
979
+
980
+ def get_pr_files(self, pr_number: int) -> List[Dict[str, Any]]:
981
+ """
982
+ Lists files changed in a pull request with patch chunks and line statistics.
983
+
984
+ Args:
985
+ pr_number: Pull request number.
986
+
987
+ Returns:
988
+ List of changed file dictionaries.
989
+ """
990
+ if self.mock_mode:
991
+ return self._mock_files.get(pr_number, [])
992
+
993
+ endpoint = f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}/files"
994
+ data = self._request("GET", endpoint)
995
+ return data if isinstance(data, list) else []
996
+
997
+ def get_pr_comments(self, pr_number: int) -> List[Dict[str, Any]]:
998
+ """
999
+ Fetches all conversation comments and review comments for a pull request.
1000
+
1001
+ Args:
1002
+ pr_number: Pull request number.
1003
+
1004
+ Returns:
1005
+ Chronologically sorted list of comment dictionaries.
1006
+ """
1007
+ if self.mock_mode:
1008
+ return self._mock_comments.get(pr_number, [])
1009
+
1010
+ all_comments: List[Dict[str, Any]] = []
1011
+
1012
+ # 1. Issue conversation comments
1013
+ try:
1014
+ issue_comments = self._request("GET", f"/repos/{self.owner}/{self.repo}/issues/{pr_number}/comments")
1015
+ if isinstance(issue_comments, list):
1016
+ all_comments.extend(issue_comments)
1017
+ except Exception:
1018
+ pass
1019
+
1020
+ # 2. PR review inline comments
1021
+ try:
1022
+ review_comments = self._request("GET", f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}/comments")
1023
+ if isinstance(review_comments, list):
1024
+ all_comments.extend(review_comments)
1025
+ except Exception:
1026
+ pass
1027
+
1028
+ # Sort chronologically
1029
+ all_comments.sort(key=lambda c: c.get("created_at", ""))
1030
+ return all_comments
1031
+
1032
+ def get_ci_status(self, branch_or_sha: str) -> CIStatus:
1033
+ """
1034
+ Retrieves check runs and commit status for a branch or commit SHA.
1035
+
1036
+ Args:
1037
+ branch_or_sha: Branch name or commit SHA.
1038
+
1039
+ Returns:
1040
+ CIStatus instance.
1041
+ """
1042
+ if self.mock_mode:
1043
+ # Match directly or by prefix
1044
+ for k, status in self._mock_ci.items():
1045
+ if k == branch_or_sha or k.startswith(branch_or_sha) or branch_or_sha.startswith(k):
1046
+ return status
1047
+ # Default passing CI mock if not explicitly set
1048
+ return CIStatus(
1049
+ state="success",
1050
+ total_count=1,
1051
+ passed_count=1,
1052
+ failed_count=0,
1053
+ pending_count=0,
1054
+ summary="CI Passing (Default Mock)",
1055
+ is_passing=True,
1056
+ )
1057
+
1058
+ check_runs_data = None
1059
+ status_data = None
1060
+
1061
+ # 1. Check Runs API
1062
+ try:
1063
+ check_runs_data = self._request(
1064
+ "GET", f"/repos/{self.owner}/{self.repo}/commits/{branch_or_sha}/check-runs"
1065
+ )
1066
+ except Exception:
1067
+ pass
1068
+
1069
+ # 2. Combined Commit Status API
1070
+ try:
1071
+ status_data = self._request(
1072
+ "GET", f"/repos/{self.owner}/{self.repo}/commits/{branch_or_sha}/status"
1073
+ )
1074
+ except Exception:
1075
+ pass
1076
+
1077
+ return CIStatus.from_github_data(
1078
+ check_runs_data=check_runs_data if isinstance(check_runs_data, dict) else None,
1079
+ status_data=status_data if isinstance(status_data, dict) else None,
1080
+ )
1081
+
1082
+ def post_review_comment(
1083
+ self,
1084
+ pr_number: int,
1085
+ body: str,
1086
+ event: str = "COMMENT",
1087
+ ) -> bool:
1088
+ """
1089
+ Submits a pull request review or comment.
1090
+
1091
+ Args:
1092
+ pr_number: Pull request number.
1093
+ body: Markdown comment body.
1094
+ event: Review event ("APPROVE", "REQUEST_CHANGES", "COMMENT").
1095
+
1096
+ Returns:
1097
+ True if successfully posted, False otherwise.
1098
+ """
1099
+ if not body.strip():
1100
+ return False
1101
+
1102
+ if self.mock_mode:
1103
+ rev_entry = {
1104
+ "id": int(time.time()),
1105
+ "body": body,
1106
+ "event": event.upper(),
1107
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
1108
+ "user": {"login": "k-cli-bot"},
1109
+ }
1110
+ self._mock_reviews.setdefault(pr_number, []).append(rev_entry)
1111
+ self._mock_comments.setdefault(pr_number, []).append(rev_entry)
1112
+ return True
1113
+
1114
+ event_clean = event.upper()
1115
+ if event_clean not in ("APPROVE", "REQUEST_CHANGES", "COMMENT"):
1116
+ event_clean = "COMMENT"
1117
+
1118
+ # 1. Try Pull Request Review Endpoint
1119
+ try:
1120
+ payload = {"body": body, "event": event_clean}
1121
+ res = self._request("POST", f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}/reviews", data=payload)
1122
+ if isinstance(res, dict) and res.get("id"):
1123
+ return True
1124
+ except Exception:
1125
+ # 2. Fallback to Issue Comment Endpoint if review submission fails
1126
+ try:
1127
+ res = self._request(
1128
+ "POST",
1129
+ f"/repos/{self.owner}/{self.repo}/issues/{pr_number}/comments",
1130
+ data={"body": body},
1131
+ )
1132
+ if isinstance(res, dict) and res.get("id"):
1133
+ return True
1134
+ except Exception:
1135
+ return False
1136
+
1137
+ return False
1138
+
1139
+ def merge_pull_request(
1140
+ self,
1141
+ pr_number: int,
1142
+ merge_method: str = "squash",
1143
+ commit_title: Optional[str] = None,
1144
+ commit_message: Optional[str] = None,
1145
+ sha: Optional[str] = None,
1146
+ ) -> bool:
1147
+ """
1148
+ Merges a pull request using GitHub REST API.
1149
+
1150
+ Args:
1151
+ pr_number: Pull request number.
1152
+ merge_method: Merge strategy ("merge", "squash", "rebase").
1153
+ commit_title: Optional title for the merge commit.
1154
+ commit_message: Optional extra detail for the merge commit message.
1155
+ sha: Optional expected HEAD SHA to prevent race conditions.
1156
+
1157
+ Returns:
1158
+ True if merged successfully, False otherwise.
1159
+ """
1160
+ if self.mock_mode:
1161
+ pr = self._mock_prs.get(pr_number)
1162
+ if pr:
1163
+ pr.merged = True
1164
+ pr.state = "closed"
1165
+ return True
1166
+ return False
1167
+
1168
+ payload: Dict[str, Any] = {
1169
+ "merge_method": merge_method.lower() if merge_method in ("merge", "squash", "rebase") else "squash",
1170
+ }
1171
+ if commit_title:
1172
+ payload["commit_title"] = commit_title
1173
+ if commit_message:
1174
+ payload["commit_message"] = commit_message
1175
+ if sha:
1176
+ payload["sha"] = sha
1177
+
1178
+ try:
1179
+ endpoint = f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}/merge"
1180
+ res = self._request("PUT", endpoint, data=payload)
1181
+ if isinstance(res, dict) and (res.get("merged") is True or "merged" in str(res.get("message", "")).lower()):
1182
+ return True
1183
+ except Exception:
1184
+ return False
1185
+
1186
+ return False
1187
+
1188
+ # -------------------------------------------------------------------------
1189
+ # Mock Configuration Helpers
1190
+ # -------------------------------------------------------------------------
1191
+
1192
+ def set_mock_pr(self, pr: PullRequest, diff: Optional[str] = None, ci_status: Optional[CIStatus] = None) -> None:
1193
+ """Registers or updates a mock Pull Request for offline testing."""
1194
+ self._mock_prs[pr.number] = pr
1195
+ if diff is not None:
1196
+ self._mock_diffs[pr.number] = diff
1197
+ if ci_status is not None and pr.head_sha:
1198
+ self._mock_ci[pr.head_sha] = ci_status
1199
+
1200
+ def set_mock_diff(self, pr_number: int, diff: str) -> None:
1201
+ """Registers a mock diff for a PR number."""
1202
+ self._mock_diffs[pr_number] = diff
1203
+
1204
+ def set_mock_ci(self, branch_or_sha: str, ci_status: CIStatus) -> None:
1205
+ """Registers a mock CI status for a branch or commit SHA."""
1206
+ self._mock_ci[branch_or_sha] = ci_status
1207
+
1208
+ def add_mock_comment(self, pr_number: int, comment: Dict[str, Any]) -> None:
1209
+ """Appends a mock comment to a PR."""
1210
+ self._mock_comments.setdefault(pr_number, []).append(comment)
1211
+
1212
+
1213
+ # =============================================================================
1214
+ # 5. PR Lifecycle Manager (AI Review, Bug Fixing & Auto-Merge)
1215
+ # =============================================================================
1216
+
1217
+
1218
+ class PRLifecycleManager:
1219
+ """
1220
+ Automated GitHub Pull Request Lifecycle Manager.
1221
+
1222
+ Features:
1223
+ - `review_pr`: Fetches PR diff, validates against security/performance/correctness criteria via LLM, and formats structured review.
1224
+ - `fix_pr`: Checks out PR branch, inspects review feedback and failing CI test traces, generates surgical patches, verifies with ground-truth test suite, and commits.
1225
+ - `auto_merge_pr`: Verifies CI status and local test suite, and automatically merges PR upon ground-truth passing.
1226
+ """
1227
+
1228
+ def __init__(
1229
+ self,
1230
+ client: Optional[GitHubClient] = None,
1231
+ repo_dir: Union[str, Path] = ".",
1232
+ ):
1233
+ self.repo_dir = Path(repo_dir).resolve()
1234
+ self.client = client or GitHubClient(repo_dir=self.repo_dir)
1235
+
1236
+ # -------------------------------------------------------------------------
1237
+ # AI PR Code Review
1238
+ # -------------------------------------------------------------------------
1239
+
1240
+ def review_pr(
1241
+ self,
1242
+ pr_number: int,
1243
+ llm_driver: Optional[Any] = None,
1244
+ model: Optional[str] = None,
1245
+ post_comment: bool = False,
1246
+ ) -> PRReviewResult:
1247
+ """
1248
+ Performs comprehensive AI code review on a pull request.
1249
+
1250
+ Evaluates:
1251
+ - Logic bugs and edge cases
1252
+ - Security vulnerabilities (CWE/OWASP, injection, unvalidated input, secrets)
1253
+ - Performance bottlenecks & algorithmic complexity
1254
+ - Line-by-line actionable code suggestions
1255
+ - Structured verdict (APPROVE / REQUEST_CHANGES / COMMENT)
1256
+
1257
+ Args:
1258
+ pr_number: Pull request number to review.
1259
+ llm_driver: Optional LLMDriver instance or callable.
1260
+ model: Optional model override.
1261
+ post_comment: If True, automatically posts review comment to PR.
1262
+
1263
+ Returns:
1264
+ PRReviewResult instance.
1265
+ """
1266
+ pr = self.client.get_pull_request(pr_number)
1267
+ diff = self.client.get_pr_diff(pr_number)
1268
+ files = self.client.get_pr_files(pr_number)
1269
+
1270
+ file_list_str = "\n".join([f"- {f.get('filename')} (+{f.get('additions', 0)} / -{f.get('deletions', 0)})" for f in files]) or "(No files listed)"
1271
+
1272
+ system_prompt = (
1273
+ "You are the K-CLI Ground-Truth Code Review & Security Specialist.\n"
1274
+ "Analyze the pull request diff with compiler-grade precision.\n"
1275
+ "Identify logic bugs, security vulnerabilities (OWASP/CWE), memory/performance bottlenecks, and missing edge case handling.\n"
1276
+ "Format your response with the following structured sections:\n"
1277
+ "VERDICT: [APPROVE, REQUEST_CHANGES, or COMMENT]\n"
1278
+ "SUMMARY: [Concise high-level overview]\n"
1279
+ "BUGS:\n- [List of bugs or 'None']\n"
1280
+ "SECURITY:\n- [List of security concerns or 'None']\n"
1281
+ "PERFORMANCE:\n- [List of performance issues or 'None']\n"
1282
+ "SUGGESTIONS:\n- [File:Line: Actionable suggestion]\n"
1283
+ "Alternatively, you may return a valid JSON object matching these keys."
1284
+ )
1285
+
1286
+ user_prompt = (
1287
+ f"Review Pull Request #{pr.number}: {pr.title}\n"
1288
+ f"Author: {pr.author} | Base: {pr.base_branch} <- Head: {pr.head_branch}\n\n"
1289
+ f"PR Description:\n{pr.body or '(No description)'}\n\n"
1290
+ f"Changed Files:\n{file_list_str}\n\n"
1291
+ f"Git Unified Diff:\n```diff\n{diff}\n```\n\n"
1292
+ "Please provide a structured code review."
1293
+ )
1294
+
1295
+ # Call LLM
1296
+ driver = llm_driver
1297
+ if driver is None:
1298
+ if LLMDriver is not None:
1299
+ driver = LLMDriver(mock_mode=self.client.mock_mode)
1300
+ else:
1301
+ driver = None
1302
+
1303
+ response_text = ""
1304
+ if driver is not None:
1305
+ if hasattr(driver, "generate"):
1306
+ response_text = driver.generate(user_prompt, system_prompt=system_prompt)
1307
+ elif callable(driver):
1308
+ response_text = driver(user_prompt)
1309
+
1310
+ # Parse LLM review output
1311
+ review_result = self._parse_review_response(pr_number, response_text, diff)
1312
+
1313
+ # Optionally post comment to PR
1314
+ if post_comment:
1315
+ md_comment = review_result.format_markdown()
1316
+ self.client.post_review_comment(pr_number, md_comment, event=review_result.verdict)
1317
+
1318
+ return review_result
1319
+
1320
+ def _parse_review_response(self, pr_number: int, response: str, diff: str) -> PRReviewResult:
1321
+ """Parses LLM response text (JSON or markdown/section formatted) into PRReviewResult."""
1322
+ if not response or not response.strip():
1323
+ # Fallback heuristic if empty LLM response
1324
+ return PRReviewResult(
1325
+ pr_number=pr_number,
1326
+ verdict="APPROVE" if diff.strip() else "COMMENT",
1327
+ summary="Automated review completed with zero critical issues detected.",
1328
+ bugs=[],
1329
+ security_issues=[],
1330
+ performance_notes=[],
1331
+ line_suggestions=[],
1332
+ raw_llm_response=response,
1333
+ )
1334
+
1335
+ # Try parsing JSON first
1336
+ clean_text = response.strip()
1337
+ if "```json" in clean_text:
1338
+ m = re.search(r"```json\s*([\s\S]*?)\s*```", clean_text)
1339
+ if m:
1340
+ clean_text = m.group(1).strip()
1341
+
1342
+ try:
1343
+ data = json.loads(clean_text)
1344
+ if isinstance(data, dict):
1345
+ verdict = str(data.get("verdict", "COMMENT")).upper()
1346
+ if verdict not in ("APPROVE", "REQUEST_CHANGES", "COMMENT"):
1347
+ verdict = "COMMENT"
1348
+
1349
+ def _to_list(val: Any) -> List[str]:
1350
+ if isinstance(val, list):
1351
+ return [str(v) for v in val if str(v).lower() != "none"]
1352
+ elif isinstance(val, str) and val.lower() != "none" and val.strip():
1353
+ return [val.strip()]
1354
+ return []
1355
+
1356
+ return PRReviewResult(
1357
+ pr_number=pr_number,
1358
+ verdict=verdict,
1359
+ summary=str(data.get("summary", "")),
1360
+ bugs=_to_list(data.get("bugs")),
1361
+ security_issues=_to_list(data.get("security_issues") or data.get("security")),
1362
+ performance_notes=_to_list(data.get("performance_notes") or data.get("performance")),
1363
+ line_suggestions=data.get("line_suggestions") if isinstance(data.get("line_suggestions"), list) else [],
1364
+ raw_llm_response=response,
1365
+ )
1366
+ except Exception:
1367
+ pass
1368
+
1369
+ # Section-based Text Parsing
1370
+ verdict = "COMMENT"
1371
+ summary = ""
1372
+ bugs: List[str] = []
1373
+ security: List[str] = []
1374
+ performance: List[str] = []
1375
+ suggestions: List[Dict[str, Any]] = []
1376
+
1377
+ m_verdict = re.search(r"(?:VERDICT|Verdict|verdict):\s*([a-zA-Z_]+)", response)
1378
+ if m_verdict:
1379
+ cand_v = m_verdict.group(1).upper().strip()
1380
+ if "APPROVE" in cand_v:
1381
+ verdict = "APPROVE"
1382
+ elif "REQUEST" in cand_v or "CHANGES" in cand_v or "REJECT" in cand_v:
1383
+ verdict = "REQUEST_CHANGES"
1384
+ elif "COMMENT" in cand_v:
1385
+ verdict = "COMMENT"
1386
+
1387
+ current_section = None
1388
+ for line in response.splitlines():
1389
+ line_str = line.strip()
1390
+ if not line_str:
1391
+ continue
1392
+
1393
+ upper_line = line_str.upper()
1394
+ if "SUMMARY:" in upper_line or upper_line.startswith("## SUMMARY") or upper_line.startswith("### SUMMARY"):
1395
+ current_section = "SUMMARY"
1396
+ rem = re.sub(r"^(?:##|###)?\s*SUMMARY:?\s*", "", line_str, flags=re.IGNORECASE).strip()
1397
+ if rem:
1398
+ summary += rem + " "
1399
+ continue
1400
+ elif "BUGS:" in upper_line or "### 🐛" in line_str or upper_line.startswith("## BUGS") or upper_line.startswith("### BUGS"):
1401
+ current_section = "BUGS"
1402
+ continue
1403
+ elif "SECURITY:" in upper_line or "### 🔒" in line_str or upper_line.startswith("## SECURITY") or upper_line.startswith("### SECURITY"):
1404
+ current_section = "SECURITY"
1405
+ continue
1406
+ elif "PERFORMANCE:" in upper_line or "### ⚡" in line_str or upper_line.startswith("## PERFORMANCE") or upper_line.startswith("### PERFORMANCE"):
1407
+ current_section = "PERFORMANCE"
1408
+ continue
1409
+ elif "SUGGESTIONS:" in upper_line or "### 💡" in line_str or upper_line.startswith("## SUGGESTIONS") or upper_line.startswith("### SUGGESTIONS"):
1410
+ current_section = "SUGGESTIONS"
1411
+ continue
1412
+
1413
+ if current_section == "SUMMARY":
1414
+ summary += line_str + " "
1415
+ elif current_section == "BUGS":
1416
+ clean_item = line_str.lstrip("*-#0123456789. ")
1417
+ if clean_item and clean_item.lower() != "none":
1418
+ bugs.append(clean_item)
1419
+ elif current_section == "SECURITY":
1420
+ clean_item = line_str.lstrip("*-#0123456789. ⚠️")
1421
+ if clean_item and clean_item.lower() != "none":
1422
+ security.append(clean_item)
1423
+ elif current_section == "PERFORMANCE":
1424
+ clean_item = line_str.lstrip("*-#0123456789. ")
1425
+ if clean_item and clean_item.lower() != "none":
1426
+ performance.append(clean_item)
1427
+ elif current_section == "SUGGESTIONS":
1428
+ clean_item = line_str.lstrip("*-#0123456789. ")
1429
+ if clean_item:
1430
+ suggestions.append({"suggestion": clean_item})
1431
+
1432
+ if not summary.strip():
1433
+ summary = "Automated PR analysis completed."
1434
+
1435
+ # If security issues or bugs exist and verdict was default COMMENT, elevate to REQUEST_CHANGES
1436
+ if (bugs or security) and verdict == "COMMENT":
1437
+ verdict = "REQUEST_CHANGES"
1438
+ elif not bugs and not security and verdict == "COMMENT":
1439
+ verdict = "APPROVE"
1440
+
1441
+ return PRReviewResult(
1442
+ pr_number=pr_number,
1443
+ verdict=verdict,
1444
+ summary=summary.strip(),
1445
+ bugs=bugs,
1446
+ security_issues=security,
1447
+ performance_notes=performance,
1448
+ line_suggestions=suggestions,
1449
+ raw_llm_response=response,
1450
+ )
1451
+
1452
+ # -------------------------------------------------------------------------
1453
+ # Automated PR Bug Fixing Loop
1454
+ # -------------------------------------------------------------------------
1455
+
1456
+ def fix_pr(
1457
+ self,
1458
+ pr_number: int,
1459
+ llm_driver: Optional[Any] = None,
1460
+ verifier: Optional[Any] = None,
1461
+ git_guard: Optional[Any] = None,
1462
+ patcher: Optional[Any] = None,
1463
+ auto_push: bool = False,
1464
+ max_fix_attempts: int = 3,
1465
+ ) -> PRFixResult:
1466
+ """
1467
+ Checks out PR branch, inspects review comments and failing CI status,
1468
+ generates surgical patches via LLM, validates fixes with Verifier,
1469
+ commits using GitGuard, and optionally pushes.
1470
+
1471
+ Args:
1472
+ pr_number: Pull request number to fix.
1473
+ llm_driver: LLMDriver instance.
1474
+ verifier: Verifier instance for ground-truth execution.
1475
+ git_guard: GitGuard instance for safe atomic commits & rollbacks.
1476
+ patcher: Patcher class or instance for SEARCH/REPLACE application.
1477
+ auto_push: Whether to push commit to remote branch on success.
1478
+ max_fix_attempts: Maximum retry loops for verification failures.
1479
+
1480
+ Returns:
1481
+ PRFixResult instance.
1482
+ """
1483
+ pr = self.client.get_pull_request(pr_number)
1484
+ diff = self.client.get_pr_diff(pr_number)
1485
+ comments = self.client.get_pr_comments(pr_number)
1486
+ ci = self.client.get_ci_status(pr.head_sha or pr.head_branch)
1487
+
1488
+ # Initialize tools
1489
+ guard = git_guard or (GitGuard(repo_dir=self.repo_dir) if GitGuard else None)
1490
+ veri = verifier or (Verifier() if Verifier else None)
1491
+ patch_engine = patcher or (Patcher if Patcher else None)
1492
+ driver = llm_driver or (LLMDriver(mock_mode=self.client.mock_mode) if LLMDriver else None)
1493
+
1494
+ target_branch = pr.head_branch or f"fix-pr-{pr_number}"
1495
+
1496
+ # 1. Safe Git Branch Checkout
1497
+ if guard and guard.is_git_repo():
1498
+ try:
1499
+ # Try checkout branch or create new
1500
+ res = subprocess.run(["git", "checkout", target_branch], cwd=str(self.repo_dir), capture_output=True, text=True)
1501
+ if res.returncode != 0:
1502
+ subprocess.run(["git", "checkout", "-b", target_branch], cwd=str(self.repo_dir), capture_output=True, text=True)
1503
+ except Exception:
1504
+ pass
1505
+
1506
+ # 2. Capture Checkpoint Before Patching
1507
+ ckpt_id = ""
1508
+ if guard and guard.is_git_repo():
1509
+ ckpt_id = guard.create_checkpoint(name=f"pr_fix_{pr_number}")
1510
+
1511
+ # Format Context
1512
+ comment_summary = "\n".join([f"Comment by {c.get('user', {}).get('login', 'reviewer')}: {c.get('body', '')}" for c in comments[-5:]]) or "(No recent comments)"
1513
+ ci_summary = ci.summary if not ci.is_passing else "CI is passing (or unverified)."
1514
+
1515
+ last_error_trace = ""
1516
+ applied_fixes: List[str] = []
1517
+
1518
+ # 3. Fix Loop with Ground-Truth Verification
1519
+ for attempt in range(1, max_fix_attempts + 1):
1520
+ fix_prompt = (
1521
+ f"Fix Pull Request #{pr_number}: {pr.title}\n"
1522
+ f"Branch: {target_branch}\n"
1523
+ f"CI Status: {ci_summary}\n"
1524
+ f"Review Comments:\n{comment_summary}\n\n"
1525
+ f"Current Diff:\n```diff\n{diff}\n```\n\n"
1526
+ )
1527
+
1528
+ if last_error_trace:
1529
+ fix_prompt += f"Previous Test Failure Error Trace (Attempt {attempt - 1}):\n```\n{last_error_trace}\n```\n\n"
1530
+
1531
+ fix_prompt += (
1532
+ "Generate exact surgical SEARCH/REPLACE blocks for the target files to fix the issue.\n"
1533
+ "Use the format:\n"
1534
+ "<<<<<<< SEARCH: path/to/file.py\n"
1535
+ "... original code ...\n"
1536
+ "=======\n"
1537
+ "... replacement code ...\n"
1538
+ ">>>>>>> REPLACE\n"
1539
+ )
1540
+
1541
+ system_prompt = (
1542
+ "You are the K-CLI Automated PR Bug Fixing Specialist.\n"
1543
+ "Output ONLY valid SEARCH/REPLACE blocks for the files that must be modified."
1544
+ )
1545
+
1546
+ response = ""
1547
+ if driver is not None:
1548
+ if hasattr(driver, "generate"):
1549
+ response = driver.generate(fix_prompt, system_prompt=system_prompt)
1550
+ elif callable(driver):
1551
+ response = driver(fix_prompt)
1552
+
1553
+ # Apply patches if patcher available
1554
+ patch_applied = False
1555
+ if patch_engine is not None and response:
1556
+ try:
1557
+ success, modified_files, err = patch_engine.apply_multi_file_patches(response, base_dir=self.repo_dir)
1558
+ if success:
1559
+ patch_applied = True
1560
+ for mf in modified_files:
1561
+ try:
1562
+ rel_p = str(Path(mf).relative_to(self.repo_dir))
1563
+ except Exception:
1564
+ rel_p = Path(mf).name
1565
+ applied_fixes.append(rel_p)
1566
+ except Exception:
1567
+ pass
1568
+
1569
+ # If mock mode and no patch applied, simulate a successful patch
1570
+ if self.client.mock_mode and not patch_applied:
1571
+ patch_applied = True
1572
+ applied_fixes.append("stream_parser.py")
1573
+
1574
+ # 4. Verify Fix with Test Suite
1575
+ test_success = True
1576
+ test_dict: Optional[Dict[str, Any]] = None
1577
+
1578
+ if veri is not None and hasattr(veri, "run_project_tests"):
1579
+ test_res = veri.run_project_tests(project_dir=self.repo_dir)
1580
+ test_success = test_res.success
1581
+ test_dict = test_res.to_dict()
1582
+ if not test_success:
1583
+ last_error_trace = test_res.error_trace or test_res.stderr
1584
+ elif self.client.mock_mode:
1585
+ test_success = True
1586
+ test_dict = {"success": True, "language": "pytest", "stdout": "1 passed"}
1587
+
1588
+ # 5. Handle Verification Outcome
1589
+ if patch_applied and test_success:
1590
+ commit_sha = None
1591
+ if guard and guard.is_git_repo():
1592
+ commit_msg = f"fix(pr-{pr_number}): resolve PR review feedback and test failures"
1593
+ commit_sha = guard.commit_success(message=commit_msg)
1594
+
1595
+ pushed = False
1596
+ if auto_push and not self.client.mock_mode:
1597
+ try:
1598
+ p_res = subprocess.run(["git", "push", "origin", target_branch], cwd=str(self.repo_dir), capture_output=True, text=True)
1599
+ pushed = p_res.returncode == 0
1600
+ except Exception:
1601
+ pushed = False
1602
+ elif auto_push and self.client.mock_mode:
1603
+ pushed = True
1604
+
1605
+ return PRFixResult(
1606
+ pr_number=pr_number,
1607
+ branch=target_branch,
1608
+ success=True,
1609
+ fixes_applied=list(set(applied_fixes)),
1610
+ test_results=test_dict,
1611
+ commit_sha=commit_sha or (f"mock_sha_{pr_number}" if self.client.mock_mode else None),
1612
+ pushed=pushed,
1613
+ )
1614
+ else:
1615
+ # Rollback on test failure
1616
+ if guard and guard.is_git_repo() and ckpt_id:
1617
+ guard.rollback(checkpoint_id=ckpt_id)
1618
+
1619
+ # All attempts failed; rollback completely
1620
+ if guard and guard.is_git_repo() and ckpt_id:
1621
+ guard.rollback(checkpoint_id=ckpt_id)
1622
+
1623
+ return PRFixResult(
1624
+ pr_number=pr_number,
1625
+ branch=target_branch,
1626
+ success=False,
1627
+ fixes_applied=list(set(applied_fixes)),
1628
+ test_results=test_dict,
1629
+ error_message=f"Fix verification failed after {max_fix_attempts} attempts: {last_error_trace[:200]}",
1630
+ rolled_back=True,
1631
+ )
1632
+
1633
+ # -------------------------------------------------------------------------
1634
+ # Automated PR Merging
1635
+ # -------------------------------------------------------------------------
1636
+
1637
+ def auto_merge_pr(
1638
+ self,
1639
+ pr_number: int,
1640
+ require_ci_pass: bool = True,
1641
+ merge_method: str = "squash",
1642
+ verifier: Optional[Any] = None,
1643
+ ) -> bool:
1644
+ """
1645
+ Evaluates PR mergeability, verifies CI status and local tests, and merges PR.
1646
+
1647
+ Args:
1648
+ pr_number: Pull request number to merge.
1649
+ require_ci_pass: If True, requires all CI check runs to pass.
1650
+ merge_method: Merge strategy ("merge", "squash", "rebase").
1651
+ verifier: Optional Verifier instance to run local project tests before merge.
1652
+
1653
+ Returns:
1654
+ True if PR was merged successfully, False otherwise.
1655
+ """
1656
+ pr = self.client.get_pull_request(pr_number)
1657
+ if pr.merged or pr.state == "closed":
1658
+ return True
1659
+
1660
+ # 1. CI Status Verification
1661
+ if require_ci_pass:
1662
+ ci = self.client.get_ci_status(pr.head_sha or pr.head_branch)
1663
+ if not ci.is_passing:
1664
+ return False
1665
+
1666
+ # 2. Local Ground-Truth Test Verification
1667
+ veri = verifier or (Verifier() if Verifier else None)
1668
+ if veri is not None and hasattr(veri, "run_project_tests") and not self.client.mock_mode:
1669
+ test_res = veri.run_project_tests(project_dir=self.repo_dir)
1670
+ if not test_res.success:
1671
+ return False
1672
+
1673
+ # 3. Execute GitHub API Merge
1674
+ return self.client.merge_pull_request(
1675
+ pr_number=pr_number,
1676
+ merge_method=merge_method,
1677
+ commit_title=f"{pr.title} (#{pr_number})",
1678
+ )
1679
+
1680
+
1681
+ # =============================================================================
1682
+ # 6. Mock GitHub Client Subclass for Testing Convenience
1683
+ # =============================================================================
1684
+
1685
+
1686
+ class MockGitHubClient(GitHubClient):
1687
+ """
1688
+ Pre-configured mock GitHub client for offline development and testing suites.
1689
+ """
1690
+
1691
+ def __init__(
1692
+ self,
1693
+ token: str = "ghp_mock_token_1234567890",
1694
+ repo: str = "k-cli/mock-repo",
1695
+ repo_dir: Union[str, Path] = ".",
1696
+ ):
1697
+ super().__init__(
1698
+ token=token,
1699
+ repo=repo,
1700
+ repo_dir=repo_dir,
1701
+ mock_mode=True,
1702
+ )