forestui 0.9.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.
- forestui/__init__.py +3 -0
- forestui/__main__.py +6 -0
- forestui/app.py +1012 -0
- forestui/cli.py +169 -0
- forestui/components/__init__.py +21 -0
- forestui/components/messages.py +76 -0
- forestui/components/modals.py +668 -0
- forestui/components/repository_detail.py +377 -0
- forestui/components/sidebar.py +256 -0
- forestui/components/worktree_detail.py +326 -0
- forestui/models.py +221 -0
- forestui/services/__init__.py +16 -0
- forestui/services/claude_session.py +179 -0
- forestui/services/git.py +254 -0
- forestui/services/github.py +242 -0
- forestui/services/settings.py +84 -0
- forestui/services/tmux.py +320 -0
- forestui/state.py +248 -0
- forestui/theme.py +657 -0
- forestui-0.9.0.dist-info/METADATA +152 -0
- forestui-0.9.0.dist-info/RECORD +23 -0
- forestui-0.9.0.dist-info/WHEEL +4 -0
- forestui-0.9.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Service for managing Claude Code session history."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from forestui.models import ClaudeSession
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClaudeSessionService:
|
|
13
|
+
"""Service for reading and managing Claude Code sessions."""
|
|
14
|
+
|
|
15
|
+
_instance: ClaudeSessionService | None = None
|
|
16
|
+
|
|
17
|
+
def __new__(cls) -> ClaudeSessionService:
|
|
18
|
+
if cls._instance is None:
|
|
19
|
+
cls._instance = super().__new__(cls)
|
|
20
|
+
return cls._instance
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def _path_to_claude_folder(path: str | Path) -> str:
|
|
24
|
+
"""Convert a path to Claude's folder naming convention."""
|
|
25
|
+
path_str = str(Path(path).expanduser().resolve())
|
|
26
|
+
return path_str.replace("/", "-")
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def _get_claude_projects_dir() -> Path:
|
|
30
|
+
"""Get the Claude projects directory."""
|
|
31
|
+
return Path.home() / ".claude" / "projects"
|
|
32
|
+
|
|
33
|
+
def get_sessions_for_path(
|
|
34
|
+
self, path: str | Path, limit: int = 5
|
|
35
|
+
) -> list[ClaudeSession]:
|
|
36
|
+
"""Get Claude sessions for a given path."""
|
|
37
|
+
folder_name = self._path_to_claude_folder(path)
|
|
38
|
+
sessions_dir = self._get_claude_projects_dir() / folder_name
|
|
39
|
+
|
|
40
|
+
if not sessions_dir.exists():
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
sessions: list[ClaudeSession] = []
|
|
44
|
+
|
|
45
|
+
for session_file in sessions_dir.glob("*.jsonl"):
|
|
46
|
+
# Skip agent files
|
|
47
|
+
if session_file.name.startswith("agent-"):
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
session = self._parse_session_file(session_file)
|
|
51
|
+
if session:
|
|
52
|
+
sessions.append(session)
|
|
53
|
+
|
|
54
|
+
# Sort by timestamp (newest first) and limit
|
|
55
|
+
sessions.sort(key=lambda s: s.last_timestamp, reverse=True)
|
|
56
|
+
return sessions[:limit]
|
|
57
|
+
|
|
58
|
+
def _parse_session_file(self, file_path: Path) -> ClaudeSession | None:
|
|
59
|
+
"""Parse a session JSONL file."""
|
|
60
|
+
|
|
61
|
+
session_id = file_path.stem
|
|
62
|
+
title = ""
|
|
63
|
+
last_message = ""
|
|
64
|
+
last_timestamp = datetime.min.replace(tzinfo=UTC)
|
|
65
|
+
message_count = 0
|
|
66
|
+
git_branches: list[str] = []
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with file_path.open(encoding="utf-8") as f:
|
|
70
|
+
for line in f:
|
|
71
|
+
line = line.strip()
|
|
72
|
+
if not line:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
data = json.loads(line)
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
# Extract timestamp
|
|
81
|
+
if "timestamp" in data:
|
|
82
|
+
try:
|
|
83
|
+
ts = datetime.fromisoformat(
|
|
84
|
+
data["timestamp"].replace("Z", "+00:00")
|
|
85
|
+
)
|
|
86
|
+
# Ensure timezone-aware (assume UTC if naive)
|
|
87
|
+
if ts.tzinfo is None:
|
|
88
|
+
ts = ts.replace(tzinfo=UTC)
|
|
89
|
+
if ts > last_timestamp:
|
|
90
|
+
last_timestamp = ts
|
|
91
|
+
except (ValueError, AttributeError):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
# Check for user messages
|
|
95
|
+
if data.get("type") == "user" or data.get("role") == "user":
|
|
96
|
+
message_count += 1
|
|
97
|
+
|
|
98
|
+
# Extract message content
|
|
99
|
+
content = data.get("message", {}).get(
|
|
100
|
+
"content", ""
|
|
101
|
+
) or data.get("content", "")
|
|
102
|
+
if isinstance(content, list):
|
|
103
|
+
# Handle block format
|
|
104
|
+
for block in content:
|
|
105
|
+
if (
|
|
106
|
+
isinstance(block, dict)
|
|
107
|
+
and block.get("type") == "text"
|
|
108
|
+
):
|
|
109
|
+
content = block.get("text", "")
|
|
110
|
+
break
|
|
111
|
+
else:
|
|
112
|
+
content = ""
|
|
113
|
+
|
|
114
|
+
# Use for title/last_message if valid
|
|
115
|
+
if (
|
|
116
|
+
isinstance(content, str)
|
|
117
|
+
and content
|
|
118
|
+
and not content.startswith("<")
|
|
119
|
+
):
|
|
120
|
+
# Collapse 3+ newlines to 2 (preserve single blank lines)
|
|
121
|
+
normalized = re.sub(r"\n{3,}", "\n\n", content)
|
|
122
|
+
if not title:
|
|
123
|
+
title = normalized[:100]
|
|
124
|
+
# Always update last_message (will end up with the last one)
|
|
125
|
+
last_message = normalized[:100]
|
|
126
|
+
|
|
127
|
+
# Extract git branches
|
|
128
|
+
if "gitBranches" in data:
|
|
129
|
+
branches = data["gitBranches"]
|
|
130
|
+
if isinstance(branches, list):
|
|
131
|
+
for branch in branches:
|
|
132
|
+
if branch and branch not in git_branches:
|
|
133
|
+
git_branches.append(branch)
|
|
134
|
+
|
|
135
|
+
except OSError:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
if message_count == 0:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
if last_timestamp == datetime.min.replace(tzinfo=UTC):
|
|
142
|
+
last_timestamp = datetime.fromtimestamp(file_path.stat().st_mtime, tz=UTC)
|
|
143
|
+
|
|
144
|
+
return ClaudeSession(
|
|
145
|
+
id=session_id,
|
|
146
|
+
title=title or "Untitled session",
|
|
147
|
+
last_message=last_message,
|
|
148
|
+
last_timestamp=last_timestamp,
|
|
149
|
+
message_count=message_count,
|
|
150
|
+
git_branches=git_branches,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def migrate_sessions(self, old_path: str | Path, new_path: str | Path) -> None:
|
|
154
|
+
"""Migrate session history from old path to new path."""
|
|
155
|
+
old_folder = self._path_to_claude_folder(old_path)
|
|
156
|
+
new_folder = self._path_to_claude_folder(new_path)
|
|
157
|
+
|
|
158
|
+
old_dir = self._get_claude_projects_dir() / old_folder
|
|
159
|
+
new_dir = self._get_claude_projects_dir() / new_folder
|
|
160
|
+
|
|
161
|
+
if not old_dir.exists():
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
new_dir.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
|
|
166
|
+
# Move session files
|
|
167
|
+
for session_file in old_dir.glob("*.jsonl"):
|
|
168
|
+
dest = new_dir / session_file.name
|
|
169
|
+
if not dest.exists():
|
|
170
|
+
shutil.move(str(session_file), str(dest))
|
|
171
|
+
|
|
172
|
+
# Clean up empty directory
|
|
173
|
+
if old_dir.exists() and not any(old_dir.iterdir()):
|
|
174
|
+
old_dir.rmdir()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_claude_session_service() -> ClaudeSessionService:
|
|
178
|
+
"""Get the singleton ClaudeSessionService instance."""
|
|
179
|
+
return ClaudeSessionService()
|
forestui/services/git.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Git service for executing git commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitError(Exception):
|
|
10
|
+
"""Git operation error."""
|
|
11
|
+
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WorktreeInfo(NamedTuple):
|
|
16
|
+
"""Information about a git worktree."""
|
|
17
|
+
|
|
18
|
+
path: str
|
|
19
|
+
head: str
|
|
20
|
+
branch: str | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CommitInfo(NamedTuple):
|
|
24
|
+
"""Information about a git commit."""
|
|
25
|
+
|
|
26
|
+
hash: str
|
|
27
|
+
short_hash: str
|
|
28
|
+
timestamp: datetime
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GitService:
|
|
32
|
+
"""Service for executing git operations."""
|
|
33
|
+
|
|
34
|
+
_instance: GitService | None = None
|
|
35
|
+
|
|
36
|
+
def __new__(cls) -> GitService:
|
|
37
|
+
if cls._instance is None:
|
|
38
|
+
cls._instance = super().__new__(cls)
|
|
39
|
+
return cls._instance
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
async def _run_git(
|
|
43
|
+
*args: str, cwd: str | Path | None = None
|
|
44
|
+
) -> tuple[int, str, str]:
|
|
45
|
+
"""Run a git command and return exit code, stdout, stderr."""
|
|
46
|
+
cmd = ["git", *args]
|
|
47
|
+
process = await asyncio.create_subprocess_exec(
|
|
48
|
+
*cmd,
|
|
49
|
+
stdout=asyncio.subprocess.PIPE,
|
|
50
|
+
stderr=asyncio.subprocess.PIPE,
|
|
51
|
+
cwd=str(cwd) if cwd else None,
|
|
52
|
+
)
|
|
53
|
+
stdout, stderr = await process.communicate()
|
|
54
|
+
return (
|
|
55
|
+
process.returncode or 0,
|
|
56
|
+
stdout.decode("utf-8").strip(),
|
|
57
|
+
stderr.decode("utf-8").strip(),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def is_git_repository(self, path: str | Path) -> bool:
|
|
61
|
+
"""Check if a path is a git repository."""
|
|
62
|
+
path = Path(path).expanduser()
|
|
63
|
+
if not path.exists():
|
|
64
|
+
return False
|
|
65
|
+
code, _, _ = await self._run_git("rev-parse", "--git-dir", cwd=path)
|
|
66
|
+
return code == 0
|
|
67
|
+
|
|
68
|
+
async def get_current_branch(self, path: str | Path) -> str:
|
|
69
|
+
"""Get the current branch of a repository."""
|
|
70
|
+
path = Path(path).expanduser()
|
|
71
|
+
code, stdout, stderr = await self._run_git("branch", "--show-current", cwd=path)
|
|
72
|
+
if code != 0:
|
|
73
|
+
raise GitError(f"Failed to get current branch: {stderr}")
|
|
74
|
+
return stdout or "HEAD"
|
|
75
|
+
|
|
76
|
+
async def list_branches(self, path: str | Path) -> list[str]:
|
|
77
|
+
"""List all branches (local and remote) for a repository."""
|
|
78
|
+
path = Path(path).expanduser()
|
|
79
|
+
code, stdout, stderr = await self._run_git(
|
|
80
|
+
"branch", "-a", "--format=%(refname:short)", cwd=path
|
|
81
|
+
)
|
|
82
|
+
if code != 0:
|
|
83
|
+
raise GitError(f"Failed to list branches: {stderr}")
|
|
84
|
+
branches = []
|
|
85
|
+
for line in stdout.split("\n"):
|
|
86
|
+
line = line.strip()
|
|
87
|
+
if line and not line.startswith("origin/HEAD"):
|
|
88
|
+
# Clean up remote branch names
|
|
89
|
+
if line.startswith("origin/"):
|
|
90
|
+
line = line[7:]
|
|
91
|
+
if line and line not in branches:
|
|
92
|
+
branches.append(line)
|
|
93
|
+
return sorted(branches)
|
|
94
|
+
|
|
95
|
+
async def create_worktree(
|
|
96
|
+
self,
|
|
97
|
+
repo_path: str | Path,
|
|
98
|
+
worktree_path: str | Path,
|
|
99
|
+
branch: str,
|
|
100
|
+
new_branch: bool = True,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Create a new worktree."""
|
|
103
|
+
repo_path = Path(repo_path).expanduser()
|
|
104
|
+
worktree_path = Path(worktree_path).expanduser()
|
|
105
|
+
|
|
106
|
+
# Ensure parent directory exists
|
|
107
|
+
worktree_path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
|
|
109
|
+
if new_branch:
|
|
110
|
+
code, _stdout, stderr = await self._run_git(
|
|
111
|
+
"worktree", "add", "-b", branch, str(worktree_path), cwd=repo_path
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
code, _stdout, stderr = await self._run_git(
|
|
115
|
+
"worktree", "add", str(worktree_path), branch, cwd=repo_path
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if code != 0:
|
|
119
|
+
raise GitError(f"Failed to create worktree: {stderr}")
|
|
120
|
+
|
|
121
|
+
async def remove_worktree(
|
|
122
|
+
self, repo_path: str | Path, worktree_path: str | Path, force: bool = False
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Remove a worktree."""
|
|
125
|
+
repo_path = Path(repo_path).expanduser()
|
|
126
|
+
worktree_path = Path(worktree_path).expanduser()
|
|
127
|
+
|
|
128
|
+
args = ["worktree", "remove"]
|
|
129
|
+
if force:
|
|
130
|
+
args.append("--force")
|
|
131
|
+
args.append(str(worktree_path))
|
|
132
|
+
|
|
133
|
+
code, _stdout, stderr = await self._run_git(*args, cwd=repo_path)
|
|
134
|
+
if code != 0:
|
|
135
|
+
# Try force remove if normal remove fails
|
|
136
|
+
if not force:
|
|
137
|
+
await self.remove_worktree(repo_path, worktree_path, force=True)
|
|
138
|
+
else:
|
|
139
|
+
raise GitError(f"Failed to remove worktree: {stderr}")
|
|
140
|
+
|
|
141
|
+
async def rename_branch(
|
|
142
|
+
self, path: str | Path, old_name: str, new_name: str
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Rename a branch."""
|
|
145
|
+
path = Path(path).expanduser()
|
|
146
|
+
code, _stdout, stderr = await self._run_git(
|
|
147
|
+
"branch", "-m", old_name, new_name, cwd=path
|
|
148
|
+
)
|
|
149
|
+
if code != 0:
|
|
150
|
+
raise GitError(f"Failed to rename branch: {stderr}")
|
|
151
|
+
|
|
152
|
+
async def repair_worktree(
|
|
153
|
+
self, repo_path: str | Path, worktree_path: str | Path
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Repair worktree references after moving."""
|
|
156
|
+
repo_path = Path(repo_path).expanduser()
|
|
157
|
+
worktree_path = Path(worktree_path).expanduser()
|
|
158
|
+
code, _stdout, stderr = await self._run_git(
|
|
159
|
+
"worktree", "repair", str(worktree_path), cwd=repo_path
|
|
160
|
+
)
|
|
161
|
+
if code != 0:
|
|
162
|
+
raise GitError(f"Failed to repair worktree: {stderr}")
|
|
163
|
+
|
|
164
|
+
async def list_worktrees(self, repo_path: str | Path) -> list[WorktreeInfo]:
|
|
165
|
+
"""List all worktrees for a repository."""
|
|
166
|
+
repo_path = Path(repo_path).expanduser()
|
|
167
|
+
code, stdout, stderr = await self._run_git(
|
|
168
|
+
"worktree", "list", "--porcelain", cwd=repo_path
|
|
169
|
+
)
|
|
170
|
+
if code != 0:
|
|
171
|
+
raise GitError(f"Failed to list worktrees: {stderr}")
|
|
172
|
+
|
|
173
|
+
worktrees: list[WorktreeInfo] = []
|
|
174
|
+
current_path: str | None = None
|
|
175
|
+
current_head: str | None = None
|
|
176
|
+
current_branch: str | None = None
|
|
177
|
+
|
|
178
|
+
for line in stdout.split("\n"):
|
|
179
|
+
line = line.strip()
|
|
180
|
+
if line.startswith("worktree "):
|
|
181
|
+
if current_path and current_head:
|
|
182
|
+
worktrees.append(
|
|
183
|
+
WorktreeInfo(current_path, current_head, current_branch)
|
|
184
|
+
)
|
|
185
|
+
current_path = line[9:]
|
|
186
|
+
current_head = None
|
|
187
|
+
current_branch = None
|
|
188
|
+
elif line.startswith("HEAD "):
|
|
189
|
+
current_head = line[5:]
|
|
190
|
+
elif line.startswith("branch "):
|
|
191
|
+
# refs/heads/branch-name -> branch-name
|
|
192
|
+
current_branch = line[7:].replace("refs/heads/", "")
|
|
193
|
+
elif line == "" and current_path and current_head:
|
|
194
|
+
worktrees.append(
|
|
195
|
+
WorktreeInfo(current_path, current_head, current_branch)
|
|
196
|
+
)
|
|
197
|
+
current_path = None
|
|
198
|
+
current_head = None
|
|
199
|
+
current_branch = None
|
|
200
|
+
|
|
201
|
+
# Don't forget the last one
|
|
202
|
+
if current_path and current_head:
|
|
203
|
+
worktrees.append(WorktreeInfo(current_path, current_head, current_branch))
|
|
204
|
+
|
|
205
|
+
return worktrees
|
|
206
|
+
|
|
207
|
+
async def branch_exists(self, repo_path: str | Path, branch: str) -> bool:
|
|
208
|
+
"""Check if a branch exists."""
|
|
209
|
+
branches = await self.list_branches(repo_path)
|
|
210
|
+
return branch in branches
|
|
211
|
+
|
|
212
|
+
async def get_latest_commit(self, path: str | Path) -> CommitInfo:
|
|
213
|
+
"""Get the latest commit info for a repository."""
|
|
214
|
+
path = Path(path).expanduser()
|
|
215
|
+
# Get commit hash and unix timestamp
|
|
216
|
+
code, stdout, stderr = await self._run_git(
|
|
217
|
+
"log", "-1", "--format=%H|%h|%ct", cwd=path
|
|
218
|
+
)
|
|
219
|
+
if code != 0:
|
|
220
|
+
raise GitError(f"Failed to get latest commit: {stderr}")
|
|
221
|
+
parts = stdout.split("|")
|
|
222
|
+
if len(parts) != 3:
|
|
223
|
+
raise GitError("Unexpected git log output format")
|
|
224
|
+
full_hash, short_hash, timestamp_str = parts
|
|
225
|
+
timestamp = datetime.fromtimestamp(int(timestamp_str), tz=UTC)
|
|
226
|
+
return CommitInfo(hash=full_hash, short_hash=short_hash, timestamp=timestamp)
|
|
227
|
+
|
|
228
|
+
async def fetch(self, path: str | Path) -> None:
|
|
229
|
+
"""Fetch from remote."""
|
|
230
|
+
path = Path(path).expanduser()
|
|
231
|
+
code, _stdout, stderr = await self._run_git("fetch", cwd=path)
|
|
232
|
+
if code != 0:
|
|
233
|
+
raise GitError(f"Failed to fetch: {stderr}")
|
|
234
|
+
|
|
235
|
+
async def pull(self, path: str | Path) -> None:
|
|
236
|
+
"""Pull from remote (fetch + merge)."""
|
|
237
|
+
path = Path(path).expanduser()
|
|
238
|
+
code, _stdout, stderr = await self._run_git("pull", cwd=path)
|
|
239
|
+
if code != 0:
|
|
240
|
+
raise GitError(f"Failed to pull: {stderr}")
|
|
241
|
+
|
|
242
|
+
async def has_remote_tracking(self, path: str | Path) -> bool:
|
|
243
|
+
"""Check if the current branch has a remote tracking branch."""
|
|
244
|
+
path = Path(path).expanduser()
|
|
245
|
+
code, stdout, _stderr = await self._run_git(
|
|
246
|
+
"rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", cwd=path
|
|
247
|
+
)
|
|
248
|
+
# Returns 0 if tracking branch exists, non-zero otherwise
|
|
249
|
+
return code == 0 and bool(stdout.strip())
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def get_git_service() -> GitService:
|
|
253
|
+
"""Get the singleton GitService instance."""
|
|
254
|
+
return GitService()
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""GitHub CLI service for interacting with gh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import NamedTuple
|
|
10
|
+
|
|
11
|
+
from forestui.models import GitHubIssue, GitHubLabel, GitHubUser
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IssueCache(NamedTuple):
|
|
15
|
+
"""Cached issues with timestamp."""
|
|
16
|
+
|
|
17
|
+
issues: list[GitHubIssue]
|
|
18
|
+
fetched_at: datetime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitHubService:
|
|
22
|
+
"""Service for interacting with GitHub via gh CLI."""
|
|
23
|
+
|
|
24
|
+
_instance: GitHubService | None = None
|
|
25
|
+
CACHE_TTL_SECONDS: int = 300 # 5 minutes
|
|
26
|
+
|
|
27
|
+
_cache: dict[str, IssueCache]
|
|
28
|
+
_auth_status: str | None
|
|
29
|
+
_username: str | None
|
|
30
|
+
|
|
31
|
+
def __new__(cls) -> GitHubService:
|
|
32
|
+
if cls._instance is None:
|
|
33
|
+
cls._instance = super().__new__(cls)
|
|
34
|
+
cls._instance._cache = {}
|
|
35
|
+
cls._instance._auth_status = None
|
|
36
|
+
cls._instance._username = None
|
|
37
|
+
return cls._instance
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
async def _run_gh(
|
|
41
|
+
*args: str, cwd: str | Path | None = None
|
|
42
|
+
) -> tuple[int, str, str]:
|
|
43
|
+
"""Run a gh command and return (exit_code, stdout, stderr)."""
|
|
44
|
+
try:
|
|
45
|
+
process = await asyncio.create_subprocess_exec(
|
|
46
|
+
"gh",
|
|
47
|
+
*args,
|
|
48
|
+
stdout=asyncio.subprocess.PIPE,
|
|
49
|
+
stderr=asyncio.subprocess.PIPE,
|
|
50
|
+
cwd=str(cwd) if cwd else None,
|
|
51
|
+
)
|
|
52
|
+
stdout, stderr = await process.communicate()
|
|
53
|
+
return (
|
|
54
|
+
process.returncode or 0,
|
|
55
|
+
stdout.decode().strip(),
|
|
56
|
+
stderr.decode().strip(),
|
|
57
|
+
)
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
return (-1, "", "gh not found")
|
|
60
|
+
|
|
61
|
+
async def get_auth_status(self) -> tuple[str, str | None]:
|
|
62
|
+
"""Get GitHub CLI authentication status and username.
|
|
63
|
+
|
|
64
|
+
Returns: (status, username) where status is "authenticated", "not_authenticated", or "not_installed"
|
|
65
|
+
"""
|
|
66
|
+
if self._auth_status is not None:
|
|
67
|
+
return self._auth_status, self._username
|
|
68
|
+
|
|
69
|
+
code, _stdout, _ = await self._run_gh("auth", "status")
|
|
70
|
+
if code == -1:
|
|
71
|
+
self._auth_status = "not_installed"
|
|
72
|
+
self._username = None
|
|
73
|
+
elif code == 0:
|
|
74
|
+
self._auth_status = "authenticated"
|
|
75
|
+
# Get username
|
|
76
|
+
code, stdout, _ = await self._run_gh("api", "user", "--jq", ".login")
|
|
77
|
+
self._username = stdout if code == 0 and stdout else None
|
|
78
|
+
else:
|
|
79
|
+
self._auth_status = "not_authenticated"
|
|
80
|
+
self._username = None
|
|
81
|
+
return self._auth_status, self._username
|
|
82
|
+
|
|
83
|
+
async def get_repo_info(self, path: str | Path) -> tuple[str, str] | None:
|
|
84
|
+
"""Get (owner, repo) for a git repository path. Returns None if not a GitHub repo."""
|
|
85
|
+
code, stdout, _ = await self._run_gh(
|
|
86
|
+
"repo", "view", "--json", "owner,name", cwd=path
|
|
87
|
+
)
|
|
88
|
+
if code != 0 or not stdout:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
data = json.loads(stdout)
|
|
92
|
+
return (data["owner"]["login"], data["name"])
|
|
93
|
+
except (json.JSONDecodeError, KeyError):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
async def list_issues(
|
|
97
|
+
self,
|
|
98
|
+
path: str | Path,
|
|
99
|
+
assigned_to_me: bool = True,
|
|
100
|
+
authored_by_me: bool = True,
|
|
101
|
+
limit: int = 10,
|
|
102
|
+
use_cache: bool = True,
|
|
103
|
+
) -> list[GitHubIssue]:
|
|
104
|
+
"""List GitHub issues for the repository at path."""
|
|
105
|
+
# Check auth first
|
|
106
|
+
auth, _ = await self.get_auth_status()
|
|
107
|
+
if auth != "authenticated":
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
repo_info = await self.get_repo_info(path)
|
|
111
|
+
if not repo_info:
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
cache_key = f"{repo_info[0]}/{repo_info[1]}"
|
|
115
|
+
|
|
116
|
+
# Check cache
|
|
117
|
+
if use_cache and cache_key in self._cache:
|
|
118
|
+
cached = self._cache[cache_key]
|
|
119
|
+
age = (datetime.now(UTC) - cached.fetched_at).total_seconds()
|
|
120
|
+
if age < self.CACHE_TTL_SECONDS:
|
|
121
|
+
return cached.issues
|
|
122
|
+
|
|
123
|
+
# Fetch from GitHub
|
|
124
|
+
issues = await self._fetch_issues(path, assigned_to_me, authored_by_me, limit)
|
|
125
|
+
|
|
126
|
+
# Update cache
|
|
127
|
+
self._cache[cache_key] = IssueCache(issues=issues, fetched_at=datetime.now(UTC))
|
|
128
|
+
return issues
|
|
129
|
+
|
|
130
|
+
async def _fetch_issues(
|
|
131
|
+
self,
|
|
132
|
+
path: str | Path,
|
|
133
|
+
assigned_to_me: bool,
|
|
134
|
+
authored_by_me: bool,
|
|
135
|
+
limit: int,
|
|
136
|
+
) -> list[GitHubIssue]:
|
|
137
|
+
"""Fetch issues from GitHub API."""
|
|
138
|
+
json_fields = (
|
|
139
|
+
"number,title,state,url,createdAt,updatedAt,author,assignees,labels"
|
|
140
|
+
)
|
|
141
|
+
issues: list[GitHubIssue] = []
|
|
142
|
+
seen_numbers: set[int] = set()
|
|
143
|
+
|
|
144
|
+
if assigned_to_me:
|
|
145
|
+
code, stdout, _ = await self._run_gh(
|
|
146
|
+
"issue",
|
|
147
|
+
"list",
|
|
148
|
+
"--assignee",
|
|
149
|
+
"@me",
|
|
150
|
+
"--state",
|
|
151
|
+
"open",
|
|
152
|
+
"--limit",
|
|
153
|
+
str(limit),
|
|
154
|
+
"--json",
|
|
155
|
+
json_fields,
|
|
156
|
+
cwd=path,
|
|
157
|
+
)
|
|
158
|
+
if code == 0 and stdout:
|
|
159
|
+
for item in json.loads(stdout):
|
|
160
|
+
if item["number"] not in seen_numbers:
|
|
161
|
+
issues.append(self._parse_issue(item))
|
|
162
|
+
seen_numbers.add(item["number"])
|
|
163
|
+
|
|
164
|
+
if authored_by_me:
|
|
165
|
+
code, stdout, _ = await self._run_gh(
|
|
166
|
+
"issue",
|
|
167
|
+
"list",
|
|
168
|
+
"--author",
|
|
169
|
+
"@me",
|
|
170
|
+
"--state",
|
|
171
|
+
"open",
|
|
172
|
+
"--limit",
|
|
173
|
+
str(limit),
|
|
174
|
+
"--json",
|
|
175
|
+
json_fields,
|
|
176
|
+
cwd=path,
|
|
177
|
+
)
|
|
178
|
+
if code == 0 and stdout:
|
|
179
|
+
for item in json.loads(stdout):
|
|
180
|
+
if item["number"] not in seen_numbers:
|
|
181
|
+
issues.append(self._parse_issue(item))
|
|
182
|
+
seen_numbers.add(item["number"])
|
|
183
|
+
|
|
184
|
+
issues.sort(key=lambda i: i.created_at, reverse=True)
|
|
185
|
+
return issues[:limit]
|
|
186
|
+
|
|
187
|
+
def _parse_issue(self, data: dict[str, object]) -> GitHubIssue:
|
|
188
|
+
"""Parse JSON response into GitHubIssue model."""
|
|
189
|
+
author_data = data.get("author") or {}
|
|
190
|
+
author_login = (
|
|
191
|
+
author_data.get("login", "unknown")
|
|
192
|
+
if isinstance(author_data, dict)
|
|
193
|
+
else "unknown"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
assignees_data = data.get("assignees", [])
|
|
197
|
+
assignees = []
|
|
198
|
+
if isinstance(assignees_data, list):
|
|
199
|
+
for a in assignees_data:
|
|
200
|
+
if isinstance(a, dict) and "login" in a:
|
|
201
|
+
assignees.append(GitHubUser(login=str(a["login"])))
|
|
202
|
+
|
|
203
|
+
labels_data = data.get("labels", [])
|
|
204
|
+
labels = []
|
|
205
|
+
if isinstance(labels_data, list):
|
|
206
|
+
for lbl in labels_data:
|
|
207
|
+
if isinstance(lbl, dict) and "name" in lbl:
|
|
208
|
+
labels.append(
|
|
209
|
+
GitHubLabel(
|
|
210
|
+
name=str(lbl["name"]), color=str(lbl.get("color", ""))
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
created_at_str = str(data.get("createdAt", ""))
|
|
215
|
+
updated_at_str = str(data.get("updatedAt", ""))
|
|
216
|
+
|
|
217
|
+
number_val = data.get("number", 0)
|
|
218
|
+
number = int(number_val) if isinstance(number_val, (int, str)) else 0
|
|
219
|
+
|
|
220
|
+
return GitHubIssue(
|
|
221
|
+
number=number,
|
|
222
|
+
title=str(data.get("title", "")),
|
|
223
|
+
state=str(data.get("state", "")),
|
|
224
|
+
url=str(data.get("url", "")),
|
|
225
|
+
created_at=datetime.fromisoformat(created_at_str.replace("Z", "+00:00")),
|
|
226
|
+
updated_at=datetime.fromisoformat(updated_at_str.replace("Z", "+00:00")),
|
|
227
|
+
author=GitHubUser(login=str(author_login)),
|
|
228
|
+
assignees=assignees,
|
|
229
|
+
labels=labels,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def invalidate_cache(self, repo_key: str | None = None) -> None:
|
|
233
|
+
"""Invalidate cache for a specific repo or all repos."""
|
|
234
|
+
if repo_key:
|
|
235
|
+
self._cache.pop(repo_key, None)
|
|
236
|
+
else:
|
|
237
|
+
self._cache.clear()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_github_service() -> GitHubService:
|
|
241
|
+
"""Get the singleton GitHubService instance."""
|
|
242
|
+
return GitHubService()
|