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.
- k_cli/__init__.py +77 -0
- k_cli/agents/__init__.py +0 -0
- k_cli/agents/adversarial_swarm.py +338 -0
- k_cli/agents/agent_core.py +255 -0
- k_cli/agents/background_daemon.py +141 -0
- k_cli/agents/orchestrator.py +376 -0
- k_cli/agents/persona.py +649 -0
- k_cli/agents/scaffold_engine.py +121 -0
- k_cli/agents/strands_agent.py +832 -0
- k_cli/agents/subagents.py +1496 -0
- k_cli/cli.py +3297 -0
- k_cli/core/__init__.py +0 -0
- k_cli/core/airgap.py +95 -0
- k_cli/core/credentials.py +548 -0
- k_cli/core/intent_sensor.py +177 -0
- k_cli/core/llm_driver.py +1028 -0
- k_cli/core/model_manager.py +1109 -0
- k_cli/core/models_hub.py +913 -0
- k_cli/core/prompting.py +41 -0
- k_cli/core/sdk.py +322 -0
- k_cli/core/session.py +826 -0
- k_cli/core/smart_router.py +230 -0
- k_cli/core/storage_manager.py +176 -0
- k_cli/core/viewport_engine.py +117 -0
- k_cli/demo/demo_runner.py +579 -0
- k_cli/git/__init__.py +0 -0
- k_cli/git/ai_bisect.py +208 -0
- k_cli/git/conflict_resolver.py +1039 -0
- k_cli/git/git_guard.py +417 -0
- k_cli/git/patcher.py +1175 -0
- k_cli/git/repo_map.py +1780 -0
- k_cli/git/smart_git.py +928 -0
- k_cli/git/verifier.py +969 -0
- k_cli/github/__init__.py +0 -0
- k_cli/github/dedup_engine.py +787 -0
- k_cli/github/github_client.py +1702 -0
- k_cli/github/github_engine.py +641 -0
- k_cli/github/local_hub.py +209 -0
- k_cli/github/pr_watcher.py +129 -0
- k_cli/github/trending.py +205 -0
- k_cli/tools/__init__.py +0 -0
- k_cli/tools/audit.py +79 -0
- k_cli/tools/chaos_immunity.py +377 -0
- k_cli/tools/codebase_qa.py +106 -0
- k_cli/tools/command_runner.py +256 -0
- k_cli/tools/diagram_generator.py +547 -0
- k_cli/tools/doc_retriever.py +1332 -0
- k_cli/tools/feature.py +105 -0
- k_cli/tools/ghost_daemon.py +122 -0
- k_cli/tools/incident_triage.py +1365 -0
- k_cli/tools/mcp_client.py +1846 -0
- k_cli/tools/repo_gardener.py +142 -0
- k_cli/tools/rules.py +109 -0
- k_cli/tools/security.py +52 -0
- k_cli/tools/security_healer.py +999 -0
- k_cli/tools/synapse_graph.py +155 -0
- k_cli/tui/__init__.py +0 -0
- k_cli/tui/diff_viewer.py +223 -0
- k_cli/tui/tui.py +1145 -0
- k_cli/tui/tui_animations.py +648 -0
- k_cli/tui/tui_app.py +2788 -0
- k_cli/ui/__init__.py +10 -0
- k_cli/ui/simple_repl.py +315 -0
- k_cli/web/__init__.py +7 -0
- k_cli/web/server.py +624 -0
- k_cli/web/static/app.js +830 -0
- k_cli/web/static/index.html +495 -0
- k_cli/web/static/monitor.html +189 -0
- k_cli/web/static/style.css +838 -0
- k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
- k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
- k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
- k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
- k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|