issues-fs 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. issues_fs/issues/Issue__Path__Config.py +25 -0
  2. issues_fs/issues/__init__.py +3 -0
  3. issues_fs/issues/graph_services/Comments__Service.py +235 -0
  4. issues_fs/issues/graph_services/Graph__Repository.py +307 -0
  5. issues_fs/issues/graph_services/Graph__Repository__Factory.py +106 -0
  6. issues_fs/issues/graph_services/Link__Service.py +227 -0
  7. issues_fs/issues/graph_services/Node__Service.py +392 -0
  8. issues_fs/issues/graph_services/Type__Service.py +209 -0
  9. issues_fs/issues/graph_services/__init__.py +3 -0
  10. issues_fs/issues/phase_1/Issue__Children__Service.py +340 -0
  11. issues_fs/issues/phase_1/Root__Issue__Service.py +137 -0
  12. issues_fs/issues/phase_1/Root__Selection__Service.py +310 -0
  13. issues_fs/issues/phase_1/__init__.py +3 -0
  14. issues_fs/issues/status/Git__Status__Service.py +115 -0
  15. issues_fs/issues/status/Index__Status__Service.py +131 -0
  16. issues_fs/issues/status/Server__Status__Service.py +113 -0
  17. issues_fs/issues/status/Storage__Status__Service.py +75 -0
  18. issues_fs/issues/status/Types__Status__Service.py +75 -0
  19. issues_fs/issues/status/__init__.py +3 -0
  20. issues_fs/issues/storage/Path__Handler__Graph_Node.py +185 -0
  21. issues_fs/issues/storage/Path__Handler__Issues.py +57 -0
  22. issues_fs/issues/storage/__init__.py +3 -0
  23. issues_fs/schemas/__init__.py +0 -0
  24. issues_fs/schemas/enums/Enum__Comment__Author.py +10 -0
  25. issues_fs/schemas/enums/Enum__Graph__Storage__Backend.py +8 -0
  26. issues_fs/schemas/enums/Enum__Issue__Status.py +13 -0
  27. issues_fs/schemas/enums/__init__.py +3 -0
  28. issues_fs/schemas/graph/Safe_Str__Graph_Types.py +63 -0
  29. issues_fs/schemas/graph/Schema__Global__Index.py +16 -0
  30. issues_fs/schemas/graph/Schema__Graph__Link.py +8 -0
  31. issues_fs/schemas/graph/Schema__Graph__Node.py +10 -0
  32. issues_fs/schemas/graph/Schema__Graph__Response.py +21 -0
  33. issues_fs/schemas/graph/Schema__Link__Create__Request.py +12 -0
  34. issues_fs/schemas/graph/Schema__Link__Create__Response.py +15 -0
  35. issues_fs/schemas/graph/Schema__Link__Delete__Response.py +16 -0
  36. issues_fs/schemas/graph/Schema__Link__List__Response.py +15 -0
  37. issues_fs/schemas/graph/Schema__Link__Type.py +20 -0
  38. issues_fs/schemas/graph/Schema__Node.py +43 -0
  39. issues_fs/schemas/graph/Schema__Node__Create__Request.py +19 -0
  40. issues_fs/schemas/graph/Schema__Node__Create__Response.py +14 -0
  41. issues_fs/schemas/graph/Schema__Node__Delete__Response.py +15 -0
  42. issues_fs/schemas/graph/Schema__Node__Link.py +17 -0
  43. issues_fs/schemas/graph/Schema__Node__List__Response.py +16 -0
  44. issues_fs/schemas/graph/Schema__Node__Summary.py +15 -0
  45. issues_fs/schemas/graph/Schema__Node__Type.py +28 -0
  46. issues_fs/schemas/graph/Schema__Node__Update__Request.py +18 -0
  47. issues_fs/schemas/graph/Schema__Node__Update__Response.py +14 -0
  48. issues_fs/schemas/graph/Schema__Property__Definition.py +27 -0
  49. issues_fs/schemas/graph/Schema__Type__Index.py +16 -0
  50. issues_fs/schemas/graph/Schema__Type__Summary.py +13 -0
  51. issues_fs/schemas/graph/__init__.py +0 -0
  52. issues_fs/schemas/identifiers/Comment_Id.py +5 -0
  53. issues_fs/schemas/identifiers/Issue_Id.py +13 -0
  54. issues_fs/schemas/identifiers/__init__.py +0 -0
  55. issues_fs/schemas/issues/Schema__Comment.py +61 -0
  56. issues_fs/schemas/issues/__init__.py +0 -0
  57. issues_fs/schemas/issues/phase_1/Schema__Issue__Children.py +85 -0
  58. issues_fs/schemas/issues/phase_1/Schema__Root.py +60 -0
  59. issues_fs/schemas/issues/phase_1/__init__.py +0 -0
  60. issues_fs/schemas/safe_str/Safe_Str__Hex_Color.py +10 -0
  61. issues_fs/schemas/safe_str/Safe_Str__Issue_Id.py +14 -0
  62. issues_fs/schemas/safe_str/Safe_Str__Issue__Node__Description.py +15 -0
  63. issues_fs/schemas/safe_str/Safe_Str__Label_Name.py +9 -0
  64. issues_fs/schemas/safe_str/__init__.py +0 -0
  65. issues_fs/schemas/status/Schema__API__Info.py +15 -0
  66. issues_fs/schemas/status/Schema__Git__Status.py +21 -0
  67. issues_fs/schemas/status/Schema__Index__Status.py +22 -0
  68. issues_fs/schemas/status/Schema__Server__Status.py +28 -0
  69. issues_fs/schemas/status/Schema__Storage__Status.py +23 -0
  70. issues_fs/schemas/status/Schema__Types__Status.py +30 -0
  71. issues_fs/schemas/status/__init__.py +0 -0
  72. issues_fs/version +1 -1
  73. {issues_fs-0.3.0.dist-info → issues_fs-0.4.0.dist-info}/METADATA +2 -1
  74. issues_fs-0.4.0.dist-info/RECORD +79 -0
  75. issues_fs-0.3.0.dist-info/RECORD +0 -8
  76. {issues_fs-0.3.0.dist-info → issues_fs-0.4.0.dist-info}/LICENSE +0 -0
  77. {issues_fs-0.3.0.dist-info → issues_fs-0.4.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,310 @@
1
+ # ═══════════════════════════════════════════════════════════════════════════════
2
+ # Root__Selection__Service - Service for managing which folder is the current root
3
+ # Phase 1: Enables selecting any folder with issue.json/node.json as the root
4
+ # ═══════════════════════════════════════════════════════════════════════════════
5
+
6
+ from typing import List
7
+ from osbot_utils.type_safe.Type_Safe import Type_Safe
8
+ from osbot_utils.type_safe.primitives.core.Safe_UInt import Safe_UInt
9
+ from osbot_utils.type_safe.primitives.domains.files.safe_str.Safe_Str__File__Path import Safe_Str__File__Path
10
+ from osbot_utils.type_safe.type_safe_core.decorators.type_safe import type_safe
11
+ from osbot_utils.utils.Json import json_loads
12
+ from issues_fs.schemas.issues.phase_1.Schema__Root import Schema__Root__Candidate, Schema__Root__List__Response, Schema__Root__Current__Response, Schema__Root__Select__Response, Schema__Root__Select__Request
13
+ from issues_fs.issues.graph_services.Graph__Repository import Graph__Repository
14
+ from issues_fs.issues.storage.Path__Handler__Graph_Node import Path__Handler__Graph_Node, FILE_NAME__ISSUE_JSON, FILE_NAME__NODE_JSON
15
+
16
+
17
+ class Root__Selection__Service(Type_Safe): # Service for root folder selection
18
+ repository : Graph__Repository # Data access layer
19
+ path_handler : Path__Handler__Graph_Node # Path generation
20
+ current_root : Safe_Str__File__Path # Currently selected root path
21
+
22
+ # ═══════════════════════════════════════════════════════════════════════════════
23
+ # Get Available Roots
24
+ # ═══════════════════════════════════════════════════════════════════════════════
25
+
26
+ def get_available_roots(self) -> Schema__Root__List__Response: # Find all folders that could serve as roots
27
+ candidates = []
28
+
29
+ root_candidate = self.create_issues_root_candidate() # The .issues/ folder itself is always first
30
+ candidates.append(root_candidate)
31
+
32
+ issue_folders = self.scan_for_issue_folders() # Find all folders with issue.json or node.json
33
+ for folder_path in sorted(issue_folders):
34
+ candidate = self.create_candidate_from_folder(folder_path)
35
+ if candidate:
36
+ candidates.append(candidate)
37
+
38
+ return Schema__Root__List__Response(success = True ,
39
+ roots = candidates ,
40
+ total = Safe_UInt(len(candidates)))
41
+
42
+ def get_roots_with_children(self) -> Schema__Root__List__Response: # Get only roots that have issues/ folders
43
+ all_roots = self.get_available_roots()
44
+
45
+ if all_roots.success is False:
46
+ return all_roots
47
+
48
+ roots_with_children = [r for r in all_roots.roots if r.has_issues is True]
49
+
50
+ return Schema__Root__List__Response(success = True ,
51
+ roots = roots_with_children ,
52
+ total = Safe_UInt(len(roots_with_children)))
53
+
54
+ # ═══════════════════════════════════════════════════════════════════════════════
55
+ # Get/Set Current Root
56
+ # ═══════════════════════════════════════════════════════════════════════════════
57
+
58
+ def get_current_root(self) -> Schema__Root__Current__Response: # Get currently selected root
59
+ if not self.current_root:
60
+ return Schema__Root__Current__Response(success = True , # Default to .issues/ root
61
+ path = '' ,
62
+ label = 'Root' ,
63
+ issue_type = 'root' )
64
+
65
+ candidate = self.create_candidate_from_path(str(self.current_root)) # Create candidate for current root
66
+
67
+ if candidate is None:
68
+ return Schema__Root__Current__Response(success = True ,
69
+ path = '' ,
70
+ label = 'Root' ,
71
+ issue_type = 'root' ,
72
+ message = 'Previous root invalid, reset to default')
73
+
74
+ return Schema__Root__Current__Response(success = True ,
75
+ path = str(candidate.path) ,
76
+ label = str(candidate.label) ,
77
+ issue_type = str(candidate.issue_type))
78
+
79
+ @type_safe
80
+ def set_current_root(self , # Set the current root
81
+ request : Schema__Root__Select__Request
82
+ ) -> Schema__Root__Select__Response:
83
+ previous_root = self.current_root
84
+ new_path = request.path
85
+
86
+ if self.is_valid_root(new_path) is False: # Validate new root
87
+ return Schema__Root__Select__Response(success = False ,
88
+ path = '' ,
89
+ previous = previous_root ,
90
+ message = f'Invalid root path: {new_path}')
91
+
92
+ self.current_root = new_path # Set new root
93
+
94
+ return Schema__Root__Select__Response(success = True ,
95
+ path = new_path ,
96
+ previous = previous_root )
97
+
98
+ # ═══════════════════════════════════════════════════════════════════════════════
99
+ # Root Validation
100
+ # ═══════════════════════════════════════════════════════════════════════════════
101
+
102
+ def is_valid_root(self, path: str) -> bool: # Check if path is a valid root
103
+ if not path:
104
+ return True # Empty path = default root
105
+
106
+ base_path = str(self.path_handler.base_path)
107
+
108
+ if base_path and base_path != '.':
109
+ if path == base_path: # base_path itself is always valid
110
+ return True
111
+
112
+ full_path = path
113
+ if base_path and base_path != '.': # Build full path if relative
114
+ if path.startswith(base_path) is False:
115
+ full_path = f"{base_path}/{path}"
116
+
117
+ issue_json = f"{full_path}/{FILE_NAME__ISSUE_JSON}" # Check for issue.json or node.json
118
+ node_json = f"{full_path}/{FILE_NAME__NODE_JSON}"
119
+
120
+ if self.repository.storage_fs.file__exists(issue_json):
121
+ return True
122
+ if self.repository.storage_fs.file__exists(node_json):
123
+ return True
124
+
125
+ return False
126
+
127
+ # ═══════════════════════════════════════════════════════════════════════════════
128
+ # Candidate Creation Helpers
129
+ # ═══════════════════════════════════════════════════════════════════════════════
130
+
131
+ def create_issues_root_candidate(self) -> Schema__Root__Candidate: # Create candidate for .issues/ root
132
+ base_path = str(self.path_handler.base_path)
133
+ root_path = self.path_handler.path_for_root_issue()
134
+ root_issue = self.load_issue_from_path(root_path)
135
+
136
+ effective_base = base_path if base_path and base_path != '.' else '' # Normalize empty/dot to ''
137
+ has_issues = self.has_issues_folder(effective_base)
138
+ child_count = self.count_top_level_issues()
139
+
140
+ if root_issue: # If root issue.json exists
141
+ return Schema__Root__Candidate(path = '' ,
142
+ label = root_issue.get('label', 'Root') ,
143
+ title = root_issue.get('title', 'Issues Root') ,
144
+ issue_type = root_issue.get('node_type', 'git-repo') ,
145
+ depth = Safe_UInt(0) ,
146
+ has_issues = has_issues ,
147
+ has_children = Safe_UInt(child_count) )
148
+
149
+ return Schema__Root__Candidate(path = '' , # No root issue.json
150
+ label = 'Root' ,
151
+ title = 'Issues Root' ,
152
+ issue_type = 'root' ,
153
+ depth = Safe_UInt(0) ,
154
+ has_issues = has_issues ,
155
+ has_children = Safe_UInt(child_count) )
156
+
157
+ def create_candidate_from_folder(self, folder_path: str) -> Schema__Root__Candidate: # Create candidate from folder
158
+ issue_data = self.load_issue_summary(folder_path)
159
+ if issue_data is None:
160
+ return None
161
+
162
+ base_path = str(self.path_handler.base_path)
163
+ relative_path = folder_path
164
+
165
+ if base_path and base_path != '.': # Make path relative if base_path is set
166
+ prefix = f"{base_path}/"
167
+ if folder_path.startswith(prefix):
168
+ relative_path = folder_path[len(prefix):]
169
+
170
+ depth = self.calculate_depth(folder_path)
171
+ has_issues = self.has_issues_folder(folder_path)
172
+ child_count = self.count_children_in_folder(folder_path)
173
+
174
+ return Schema__Root__Candidate(path = relative_path ,
175
+ label = issue_data.get('label', '') ,
176
+ title = issue_data.get('title', '') ,
177
+ issue_type = issue_data.get('node_type', '') ,
178
+ depth = Safe_UInt(depth) ,
179
+ has_issues = has_issues ,
180
+ has_children = Safe_UInt(child_count) )
181
+
182
+ def create_candidate_from_path(self, path: str) -> Schema__Root__Candidate: # Create candidate from any path
183
+ if not path:
184
+ return self.create_issues_root_candidate()
185
+
186
+ base_path = str(self.path_handler.base_path)
187
+ full_path = path
188
+
189
+ if base_path and base_path != '.': # Prepend base_path if set and path is relative
190
+ if path.startswith(base_path) is False:
191
+ full_path = f"{base_path}/{path}"
192
+
193
+ return self.create_candidate_from_folder(full_path)
194
+
195
+ # ═══════════════════════════════════════════════════════════════════════════════
196
+ # Folder Scanning
197
+ # ═══════════════════════════════════════════════════════════════════════════════
198
+
199
+ def scan_for_issue_folders(self) -> List[str]: # Find all folders with issue/node.json
200
+ folders = set()
201
+ all_paths = self.repository.storage_fs.files__paths()
202
+ base_path = str(self.path_handler.base_path)
203
+ data_prefix = f"{base_path}/data/" if base_path and base_path != '.' else "data/"
204
+
205
+ for path in all_paths:
206
+ if path.startswith(data_prefix) is False:
207
+ continue
208
+
209
+ if path.endswith(f'/{FILE_NAME__ISSUE_JSON}') or path.endswith(f'/{FILE_NAME__NODE_JSON}'):
210
+ folder = path.rsplit('/', 1)[0] # Get parent folder
211
+ if folder != base_path and folder != '.': # Exclude root
212
+ folders.add(folder)
213
+
214
+ return list(folders)
215
+
216
+ def has_issues_folder(self, folder_path: str) -> bool: # Check if folder has issues/ subfolder
217
+ issues_folder = f"{folder_path}/issues"
218
+ all_paths = self.repository.storage_fs.files__paths()
219
+ prefix = f"{issues_folder}/"
220
+
221
+ for path in all_paths:
222
+ if path.startswith(prefix):
223
+ return True
224
+
225
+ return False
226
+
227
+ def count_top_level_issues(self) -> int: # Count issues in data/ folders
228
+ base_path = str(self.path_handler.base_path)
229
+ data_path = f"{base_path}/data/" if base_path and base_path != '.' else "data/"
230
+ all_paths = self.repository.storage_fs.files__paths()
231
+ folders = set()
232
+
233
+ for path in all_paths:
234
+ if path.startswith(data_path) is False:
235
+ continue
236
+ if path.endswith(f'/{FILE_NAME__ISSUE_JSON}') or path.endswith(f'/{FILE_NAME__NODE_JSON}'):
237
+ folder = path.rsplit('/', 1)[0]
238
+ folders.add(folder)
239
+
240
+ return len(folders)
241
+
242
+ def count_children_in_folder(self, folder_path: str) -> int: # Count children in issues/ subfolder
243
+ issues_folder = f"{folder_path}/issues/"
244
+ all_paths = self.repository.storage_fs.files__paths()
245
+ folders = set()
246
+
247
+ for path in all_paths:
248
+ if path.startswith(issues_folder) is False:
249
+ continue
250
+ if path.endswith(f'/{FILE_NAME__ISSUE_JSON}') or path.endswith(f'/{FILE_NAME__NODE_JSON}'):
251
+ folder = path.rsplit('/', 1)[0]
252
+ folders.add(folder)
253
+
254
+ return len(folders)
255
+
256
+ # ═══════════════════════════════════════════════════════════════════════════════
257
+ # Issue Data Loading
258
+ # ═══════════════════════════════════════════════════════════════════════════════
259
+
260
+ def load_issue_summary(self, folder_path: str) -> dict: # Load issue summary from folder
261
+ issue_path = f"{folder_path}/{FILE_NAME__ISSUE_JSON}" # Try issue.json first
262
+ data = self.load_issue_from_path(issue_path)
263
+ if data:
264
+ return data
265
+
266
+ node_path = f"{folder_path}/{FILE_NAME__NODE_JSON}" # Fall back to node.json
267
+ return self.load_issue_from_path(node_path)
268
+
269
+ def load_issue_from_path(self, file_path: str) -> dict: # Load issue data from specific path
270
+ if self.repository.storage_fs.file__exists(file_path) is False:
271
+ return None
272
+
273
+ content = self.repository.storage_fs.file__str(file_path)
274
+ if not content:
275
+ return None
276
+
277
+ return json_loads(content)
278
+
279
+ # ═══════════════════════════════════════════════════════════════════════════════
280
+ # Depth Calculation
281
+ # ═══════════════════════════════════════════════════════════════════════════════
282
+
283
+ def calculate_depth(self, folder_path: str) -> int: # Calculate nesting depth from root
284
+ base_path = str(self.path_handler.base_path)
285
+
286
+ if not folder_path:
287
+ return 0
288
+
289
+ if base_path and base_path != '.':
290
+ if folder_path == base_path:
291
+ return 0
292
+
293
+ if folder_path.startswith(base_path) is False:
294
+ relative = folder_path # Already relative
295
+ else:
296
+ relative = folder_path[len(base_path):].strip('/') # Remove base path
297
+ else:
298
+ relative = folder_path.strip('/') # No base path to remove
299
+
300
+ if not relative:
301
+ return 0
302
+
303
+ parts = relative.split('/')
304
+
305
+ depth = 0 # Count issues/ segments for depth
306
+ for part in parts:
307
+ if part == 'issues':
308
+ depth += 1
309
+
310
+ return depth
@@ -0,0 +1,3 @@
1
+ # ═══════════════════════════════════════════════════════════════════════════════
2
+ # Issue tracking services package
3
+ # ═══════════════════════════════════════════════════════════════════════════════
@@ -0,0 +1,115 @@
1
+ # ═══════════════════════════════════════════════════════════════════════════════
2
+ # Git__Status__Service - Git repository integration status
3
+ # Detects git repository and provides integration information
4
+ # ═══════════════════════════════════════════════════════════════════════════════
5
+
6
+ import subprocess
7
+ from osbot_utils.type_safe.Type_Safe import Type_Safe
8
+ from osbot_utils.type_safe.primitives.core.Safe_UInt import Safe_UInt
9
+ from osbot_utils.type_safe.primitives.domains.common.safe_str.Safe_Str__Text import Safe_Str__Text
10
+ from osbot_utils.type_safe.primitives.domains.files.safe_str.Safe_Str__File__Path import Safe_Str__File__Path
11
+
12
+ from issues_fs.schemas.status.Schema__Git__Status import Schema__Git__Status
13
+
14
+
15
+ class Git__Status__Service(Type_Safe): # Git status service
16
+ root_path : Safe_Str__File__Path = '' # Path to check for git
17
+
18
+ # ═══════════════════════════════════════════════════════════════════════════════
19
+ # Main Status Method
20
+ # ═══════════════════════════════════════════════════════════════════════════════
21
+
22
+ def get_status(self) -> Schema__Git__Status: # Get git status
23
+ work_dir = str(self.root_path) if self.root_path else None
24
+
25
+ is_git_repo = self._is_git_repository(work_dir)
26
+ if not is_git_repo:
27
+ return Schema__Git__Status(is_git_repo = False)
28
+
29
+ git_root = self._get_git_root(work_dir)
30
+ current_branch = self._get_current_branch(work_dir)
31
+ current_commit = self._get_current_commit(work_dir)
32
+ is_dirty = self._is_dirty(work_dir)
33
+ issues_tracked = self._is_issues_tracked(work_dir)
34
+ untracked = self._count_untracked_issues(work_dir)
35
+ modified = self._count_modified_issues(work_dir)
36
+ remote_name = self._get_remote_name(work_dir)
37
+ remote_url = self._get_remote_url(work_dir, remote_name)
38
+
39
+ return Schema__Git__Status(is_git_repo = True ,
40
+ git_root = Safe_Str__Text(git_root) ,
41
+ current_branch = Safe_Str__Text(current_branch) ,
42
+ current_commit = Safe_Str__Text(current_commit) ,
43
+ is_dirty = is_dirty ,
44
+ issues_tracked = issues_tracked ,
45
+ untracked_issues = Safe_UInt(untracked) ,
46
+ modified_issues = Safe_UInt(modified) ,
47
+ remote_name = Safe_Str__Text(remote_name) ,
48
+ remote_url = Safe_Str__Text(remote_url) )
49
+
50
+ # ═══════════════════════════════════════════════════════════════════════════════
51
+ # Git Command Helpers
52
+ # ═══════════════════════════════════════════════════════════════════════════════
53
+
54
+ def _run_git_command(self, args: list, work_dir: str = None) -> str: # Run git command
55
+ try:
56
+ result = subprocess.run(['git'] + args ,
57
+ cwd = work_dir ,
58
+ capture_output = True ,
59
+ text = True ,
60
+ timeout = 5 )
61
+ if result.returncode == 0:
62
+ return result.stdout.strip()
63
+ except Exception:
64
+ pass
65
+ return ''
66
+
67
+ # ═══════════════════════════════════════════════════════════════════════════════
68
+ # Status Detection Methods
69
+ # ═══════════════════════════════════════════════════════════════════════════════
70
+
71
+ def _is_git_repository(self, work_dir: str = None) -> bool: # Check if git repo
72
+ result = self._run_git_command(['rev-parse', '--git-dir'], work_dir)
73
+ return bool(result)
74
+
75
+ def _get_git_root(self, work_dir: str = None) -> str: # Get git root path
76
+ return self._run_git_command(['rev-parse', '--show-toplevel'], work_dir)
77
+
78
+ def _get_current_branch(self, work_dir: str = None) -> str: # Get current branch
79
+ return self._run_git_command(['rev-parse', '--abbrev-ref', 'HEAD'], work_dir)
80
+
81
+ def _get_current_commit(self, work_dir: str = None) -> str: # Get short commit hash
82
+ return self._run_git_command(['rev-parse', '--short', 'HEAD'], work_dir)
83
+
84
+ def _is_dirty(self, work_dir: str = None) -> bool: # Check for uncommitted changes
85
+ result = self._run_git_command(['status', '--porcelain'], work_dir)
86
+ return bool(result)
87
+
88
+ def _is_issues_tracked(self, work_dir: str = None) -> bool: # Check if .issues is tracked
89
+ result = self._run_git_command(['ls-files', '.issues'], work_dir)
90
+ return bool(result)
91
+
92
+ def _count_untracked_issues(self, work_dir: str = None) -> int: # Count untracked issue files
93
+ result = self._run_git_command(['ls-files', '--others', '--exclude-standard', '.issues'], work_dir)
94
+ if result:
95
+ return len(result.splitlines())
96
+ return 0
97
+
98
+ def _count_modified_issues(self, work_dir: str = None) -> int: # Count modified issue files
99
+ result = self._run_git_command(['diff', '--name-only', '.issues'], work_dir)
100
+ if result:
101
+ return len(result.splitlines())
102
+ return 0
103
+
104
+ def _get_remote_name(self, work_dir: str = None) -> str: # Get first remote name
105
+ result = self._run_git_command(['remote'], work_dir)
106
+ if result:
107
+ remotes = result.splitlines()
108
+ if remotes:
109
+ return remotes[0]
110
+ return ''
111
+
112
+ def _get_remote_url(self, work_dir: str = None, remote_name: str = '') -> str: # Get remote URL
113
+ if not remote_name:
114
+ return ''
115
+ return self._run_git_command(['remote', 'get-url', remote_name], work_dir)
@@ -0,0 +1,131 @@
1
+ # ═══════════════════════════════════════════════════════════════════════════════
2
+ # Index__Status__Service - Index and node count statistics
3
+ # Reports on global index and per-type node counts
4
+ # ═══════════════════════════════════════════════════════════════════════════════
5
+
6
+ from typing import List
7
+ from osbot_utils.type_safe.Type_Safe import Type_Safe
8
+ from osbot_utils.type_safe.primitives.core.Safe_UInt import Safe_UInt
9
+ from osbot_utils.type_safe.primitives.domains.common.safe_str.Safe_Str__Text import Safe_Str__Text
10
+ from issues_fs.schemas.status.Schema__Index__Status import Schema__Index__Status
11
+ from issues_fs.schemas.status.Schema__Index__Status import Schema__Type__Count
12
+ from issues_fs.issues.graph_services.Graph__Repository import Graph__Repository
13
+ from issues_fs.issues.graph_services.Type__Service import Type__Service
14
+
15
+ # todo: see why we are using type_service below and not index_service
16
+
17
+ class Index__Status__Service(Type_Safe): # Index status service
18
+ repository : Graph__Repository = None # Graph__Repository instance
19
+ type_service : Type__Service = None # Type__Service instance
20
+
21
+ # ═══════════════════════════════════════════════════════════════════════════════
22
+ # Main Status Method
23
+ # ═══════════════════════════════════════════════════════════════════════════════
24
+
25
+ def get_status(self) -> Schema__Index__Status: # Get index status
26
+ if self.repository is None:
27
+ return Schema__Index__Status(global_index_exists = False ,
28
+ type_counts = [] )
29
+
30
+ global_exists = self._check_global_index_exists()
31
+ type_counts = self._get_type_counts()
32
+ total_nodes = self._calculate_total_nodes(type_counts)
33
+ #total_links = self._count_total_links()
34
+ last_updated = self._get_last_updated()
35
+
36
+ return Schema__Index__Status(global_index_exists = global_exists ,
37
+ total_nodes = Safe_UInt(total_nodes) ,
38
+ #total_links = Safe_UInt(total_links) ,
39
+ type_counts = type_counts ,
40
+ last_updated = Safe_Str__Text(last_updated) )
41
+
42
+ # ═══════════════════════════════════════════════════════════════════════════════
43
+ # Index Checks
44
+ # ═══════════════════════════════════════════════════════════════════════════════
45
+
46
+ def _check_global_index_exists(self) -> bool: # Check global index exists
47
+ index = self.repository.global_index_load()
48
+ return index is not None
49
+
50
+ def _get_last_updated(self) -> str: # Get last update timestamp
51
+ try:
52
+ if hasattr(self.repository, 'global_index_load'):
53
+ index = self.repository.global_index_load()
54
+ if index and hasattr(index, 'last_updated'):
55
+ return str(index.last_updated or '')
56
+ except Exception:
57
+ pass
58
+ return ''
59
+
60
+ # ═══════════════════════════════════════════════════════════════════════════════
61
+ # Type Counts
62
+ # ═══════════════════════════════════════════════════════════════════════════════
63
+
64
+
65
+ def _get_type_counts(self) -> List[Schema__Type__Count]: # Get per-type counts
66
+ counts = []
67
+
68
+
69
+ # Get list of node types
70
+ node_type_names = self._get_node_type_names()
71
+
72
+ for type_name in node_type_names:
73
+ type_count = self._get_count_for_type(type_name)
74
+ next_index = self._get_next_index_for_type(type_name)
75
+
76
+ counts.append(Schema__Type__Count(node_type = type_name ,
77
+ count = type_count ,
78
+ next_index = next_index ))
79
+
80
+ return counts
81
+
82
+ def _get_node_type_names(self) -> List[str]: # Get all node type names
83
+ names = []
84
+
85
+ node_types = self.type_service.list_node_types()
86
+ if node_types:
87
+ for node_type in node_types:
88
+ name = node_type.name
89
+ if name:
90
+ names.append(name)
91
+ return names
92
+
93
+ def _get_count_for_type(self, type_name: str) -> int: # Get node count for type
94
+ try:
95
+ if hasattr(self.repository, 'type_index_load'):
96
+ type_index = self.repository.type_index_load(type_name)
97
+ if type_index and hasattr(type_index, 'count'):
98
+ return int(type_index.count)
99
+ except Exception:
100
+ pass
101
+ return 0
102
+
103
+ def _get_next_index_for_type(self, type_name: str) -> int: # Get next index for type
104
+ try:
105
+ if hasattr(self.repository, 'type_index_load'):
106
+ type_index = self.repository.type_index_load(type_name)
107
+ if type_index and hasattr(type_index, 'next_index'):
108
+ return int(type_index.next_index)
109
+ except Exception:
110
+ pass
111
+ return 1
112
+
113
+ def _calculate_total_nodes(self, type_counts: List[Schema__Type__Count]) -> int: # Sum all counts
114
+ total = 0
115
+ for tc in type_counts:
116
+ total += int(tc.count)
117
+ return total
118
+
119
+ # # ═══════════════════════════════════════════════════════════════════════════════
120
+ # # Link Counts
121
+ # # ═══════════════════════════════════════════════════════════════════════════════
122
+ #
123
+ # def _count_total_links(self) -> int: # Count total links
124
+ # total = 0
125
+ # try:
126
+ # # This would require loading all nodes and counting links
127
+ # # For now, return 0 - can be enhanced to iterate nodes
128
+ # pass
129
+ # except Exception:
130
+ # pass
131
+ # return total