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.
- issues_fs/issues/Issue__Path__Config.py +25 -0
- issues_fs/issues/__init__.py +3 -0
- issues_fs/issues/graph_services/Comments__Service.py +235 -0
- issues_fs/issues/graph_services/Graph__Repository.py +307 -0
- issues_fs/issues/graph_services/Graph__Repository__Factory.py +106 -0
- issues_fs/issues/graph_services/Link__Service.py +227 -0
- issues_fs/issues/graph_services/Node__Service.py +392 -0
- issues_fs/issues/graph_services/Type__Service.py +209 -0
- issues_fs/issues/graph_services/__init__.py +3 -0
- issues_fs/issues/phase_1/Issue__Children__Service.py +340 -0
- issues_fs/issues/phase_1/Root__Issue__Service.py +137 -0
- issues_fs/issues/phase_1/Root__Selection__Service.py +310 -0
- issues_fs/issues/phase_1/__init__.py +3 -0
- issues_fs/issues/status/Git__Status__Service.py +115 -0
- issues_fs/issues/status/Index__Status__Service.py +131 -0
- issues_fs/issues/status/Server__Status__Service.py +113 -0
- issues_fs/issues/status/Storage__Status__Service.py +75 -0
- issues_fs/issues/status/Types__Status__Service.py +75 -0
- issues_fs/issues/status/__init__.py +3 -0
- issues_fs/issues/storage/Path__Handler__Graph_Node.py +185 -0
- issues_fs/issues/storage/Path__Handler__Issues.py +57 -0
- issues_fs/issues/storage/__init__.py +3 -0
- issues_fs/schemas/__init__.py +0 -0
- issues_fs/schemas/enums/Enum__Comment__Author.py +10 -0
- issues_fs/schemas/enums/Enum__Graph__Storage__Backend.py +8 -0
- issues_fs/schemas/enums/Enum__Issue__Status.py +13 -0
- issues_fs/schemas/enums/__init__.py +3 -0
- issues_fs/schemas/graph/Safe_Str__Graph_Types.py +63 -0
- issues_fs/schemas/graph/Schema__Global__Index.py +16 -0
- issues_fs/schemas/graph/Schema__Graph__Link.py +8 -0
- issues_fs/schemas/graph/Schema__Graph__Node.py +10 -0
- issues_fs/schemas/graph/Schema__Graph__Response.py +21 -0
- issues_fs/schemas/graph/Schema__Link__Create__Request.py +12 -0
- issues_fs/schemas/graph/Schema__Link__Create__Response.py +15 -0
- issues_fs/schemas/graph/Schema__Link__Delete__Response.py +16 -0
- issues_fs/schemas/graph/Schema__Link__List__Response.py +15 -0
- issues_fs/schemas/graph/Schema__Link__Type.py +20 -0
- issues_fs/schemas/graph/Schema__Node.py +43 -0
- issues_fs/schemas/graph/Schema__Node__Create__Request.py +19 -0
- issues_fs/schemas/graph/Schema__Node__Create__Response.py +14 -0
- issues_fs/schemas/graph/Schema__Node__Delete__Response.py +15 -0
- issues_fs/schemas/graph/Schema__Node__Link.py +17 -0
- issues_fs/schemas/graph/Schema__Node__List__Response.py +16 -0
- issues_fs/schemas/graph/Schema__Node__Summary.py +15 -0
- issues_fs/schemas/graph/Schema__Node__Type.py +28 -0
- issues_fs/schemas/graph/Schema__Node__Update__Request.py +18 -0
- issues_fs/schemas/graph/Schema__Node__Update__Response.py +14 -0
- issues_fs/schemas/graph/Schema__Property__Definition.py +27 -0
- issues_fs/schemas/graph/Schema__Type__Index.py +16 -0
- issues_fs/schemas/graph/Schema__Type__Summary.py +13 -0
- issues_fs/schemas/graph/__init__.py +0 -0
- issues_fs/schemas/identifiers/Comment_Id.py +5 -0
- issues_fs/schemas/identifiers/Issue_Id.py +13 -0
- issues_fs/schemas/identifiers/__init__.py +0 -0
- issues_fs/schemas/issues/Schema__Comment.py +61 -0
- issues_fs/schemas/issues/__init__.py +0 -0
- issues_fs/schemas/issues/phase_1/Schema__Issue__Children.py +85 -0
- issues_fs/schemas/issues/phase_1/Schema__Root.py +60 -0
- issues_fs/schemas/issues/phase_1/__init__.py +0 -0
- issues_fs/schemas/safe_str/Safe_Str__Hex_Color.py +10 -0
- issues_fs/schemas/safe_str/Safe_Str__Issue_Id.py +14 -0
- issues_fs/schemas/safe_str/Safe_Str__Issue__Node__Description.py +15 -0
- issues_fs/schemas/safe_str/Safe_Str__Label_Name.py +9 -0
- issues_fs/schemas/safe_str/__init__.py +0 -0
- issues_fs/schemas/status/Schema__API__Info.py +15 -0
- issues_fs/schemas/status/Schema__Git__Status.py +21 -0
- issues_fs/schemas/status/Schema__Index__Status.py +22 -0
- issues_fs/schemas/status/Schema__Server__Status.py +28 -0
- issues_fs/schemas/status/Schema__Storage__Status.py +23 -0
- issues_fs/schemas/status/Schema__Types__Status.py +30 -0
- issues_fs/schemas/status/__init__.py +0 -0
- issues_fs/version +1 -1
- {issues_fs-0.3.0.dist-info → issues_fs-0.4.0.dist-info}/METADATA +2 -1
- issues_fs-0.4.0.dist-info/RECORD +79 -0
- issues_fs-0.3.0.dist-info/RECORD +0 -8
- {issues_fs-0.3.0.dist-info → issues_fs-0.4.0.dist-info}/LICENSE +0 -0
- {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,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
|