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.
@@ -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()
@@ -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()