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,392 @@
|
|
|
1
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
# Node__Service - Business logic for node operations
|
|
3
|
+
# Handles create, update, delete, and query operations for graph nodes
|
|
4
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
5
|
+
|
|
6
|
+
from typing import List, Optional
|
|
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.identifiers.Obj_Id import Obj_Id
|
|
10
|
+
from osbot_utils.type_safe.primitives.domains.identifiers.safe_int.Timestamp_Now import Timestamp_Now
|
|
11
|
+
from issues_fs.schemas.graph.Safe_Str__Graph_Types import Safe_Str__Node_Type, Safe_Str__Node_Label
|
|
12
|
+
from issues_fs.schemas.graph.Schema__Global__Index import Schema__Global__Index
|
|
13
|
+
from issues_fs.schemas.graph.Schema__Graph__Link import Schema__Graph__Link
|
|
14
|
+
from issues_fs.schemas.graph.Schema__Graph__Node import Schema__Graph__Node
|
|
15
|
+
from issues_fs.schemas.graph.Schema__Graph__Response import Schema__Graph__Response
|
|
16
|
+
from issues_fs.schemas.graph.Schema__Node import Schema__Node
|
|
17
|
+
from issues_fs.schemas.graph.Schema__Node__Create__Request import Schema__Node__Create__Request
|
|
18
|
+
from issues_fs.schemas.graph.Schema__Node__Create__Response import Schema__Node__Create__Response
|
|
19
|
+
from issues_fs.schemas.graph.Schema__Node__Delete__Response import Schema__Node__Delete__Response
|
|
20
|
+
from issues_fs.schemas.graph.Schema__Node__Link import Schema__Node__Link
|
|
21
|
+
from issues_fs.schemas.graph.Schema__Node__List__Response import Schema__Node__List__Response
|
|
22
|
+
from issues_fs.schemas.graph.Schema__Node__Summary import Schema__Node__Summary
|
|
23
|
+
from issues_fs.schemas.graph.Schema__Node__Update__Request import Schema__Node__Update__Request
|
|
24
|
+
from issues_fs.schemas.graph.Schema__Node__Update__Response import Schema__Node__Update__Response
|
|
25
|
+
from issues_fs.schemas.graph.Schema__Type__Summary import Schema__Type__Summary
|
|
26
|
+
from issues_fs.issues.graph_services.Graph__Repository import Graph__Repository
|
|
27
|
+
|
|
28
|
+
# todo: refactor to Issue__Node__Service
|
|
29
|
+
class Node__Service(Type_Safe): # Node business logic service
|
|
30
|
+
repository : Graph__Repository # Data access layer
|
|
31
|
+
|
|
32
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
33
|
+
# Query Operations
|
|
34
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
35
|
+
|
|
36
|
+
def get_node(self , # Get single node by label
|
|
37
|
+
node_type : Safe_Str__Node_Type ,
|
|
38
|
+
label : Safe_Str__Node_Label
|
|
39
|
+
) -> Optional[Schema__Node]:
|
|
40
|
+
return self.repository.node_load(node_type = node_type ,
|
|
41
|
+
label = label )
|
|
42
|
+
|
|
43
|
+
def node_exists(self , # Check if node exists
|
|
44
|
+
node_type : Safe_Str__Node_Type ,
|
|
45
|
+
label : Safe_Str__Node_Label
|
|
46
|
+
) -> bool:
|
|
47
|
+
return self.repository.node_exists(node_type = node_type ,
|
|
48
|
+
label = label )
|
|
49
|
+
|
|
50
|
+
def list_nodes(self , # List nodes, optionally filtered by type
|
|
51
|
+
node_type : Safe_Str__Node_Type = None
|
|
52
|
+
) -> Schema__Node__List__Response:
|
|
53
|
+
summaries = []
|
|
54
|
+
|
|
55
|
+
if node_type: # List nodes of specific type
|
|
56
|
+
summaries = self.list_nodes_for_type(node_type)
|
|
57
|
+
else: # List all nodes across all types
|
|
58
|
+
node_types = self.repository.node_types_load()
|
|
59
|
+
for nt in node_types:
|
|
60
|
+
type_summaries = self.list_nodes_for_type(nt.name)
|
|
61
|
+
summaries.extend(type_summaries)
|
|
62
|
+
|
|
63
|
+
return Schema__Node__List__Response(success = True ,
|
|
64
|
+
nodes = summaries ,
|
|
65
|
+
total = len(summaries) )
|
|
66
|
+
|
|
67
|
+
def list_nodes_for_type(self , # List nodes for a specific type
|
|
68
|
+
node_type : Safe_Str__Node_Type
|
|
69
|
+
) -> List[Schema__Node__Summary]:
|
|
70
|
+
summaries = []
|
|
71
|
+
labels = self.repository.nodes_list_labels(node_type)
|
|
72
|
+
|
|
73
|
+
for label in labels:
|
|
74
|
+
node = self.repository.node_load(node_type = node_type ,
|
|
75
|
+
label = label )
|
|
76
|
+
if node:
|
|
77
|
+
summary = Schema__Node__Summary(label = node.label ,
|
|
78
|
+
node_type = node.node_type ,
|
|
79
|
+
title = node.title ,
|
|
80
|
+
status = node.status )
|
|
81
|
+
summaries.append(summary)
|
|
82
|
+
|
|
83
|
+
return summaries
|
|
84
|
+
|
|
85
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
86
|
+
# Create Operations
|
|
87
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
88
|
+
|
|
89
|
+
def create_node(self , # Create new node
|
|
90
|
+
request : Schema__Node__Create__Request
|
|
91
|
+
) -> Schema__Node__Create__Response:
|
|
92
|
+
# Validate title is not empty
|
|
93
|
+
if str(request.title).strip() == '':
|
|
94
|
+
return Schema__Node__Create__Response(success = False ,
|
|
95
|
+
message = 'Title is required' )
|
|
96
|
+
|
|
97
|
+
# Validate node type exists
|
|
98
|
+
node_types = self.repository.node_types_load()
|
|
99
|
+
node_type_def = None
|
|
100
|
+
for nt in node_types:
|
|
101
|
+
if str(nt.name) == str(request.node_type):
|
|
102
|
+
node_type_def = nt
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
if node_type_def is None:
|
|
106
|
+
return Schema__Node__Create__Response(success = False ,
|
|
107
|
+
message = f'Unknown node type: {request.node_type}')
|
|
108
|
+
|
|
109
|
+
# Get next index for this type
|
|
110
|
+
type_index = self.repository.type_index_load(request.node_type)
|
|
111
|
+
next_num = int(type_index.next_index)
|
|
112
|
+
label = self.label_from_type_and_index(request.node_type, next_num)
|
|
113
|
+
|
|
114
|
+
# SAFETY CHECK: If label already exists, find actual next available index
|
|
115
|
+
while self.repository.node_exists(node_type=request.node_type, label=label):
|
|
116
|
+
next_num += 1
|
|
117
|
+
label = self.label_from_type_and_index(request.node_type, next_num)
|
|
118
|
+
|
|
119
|
+
now = Timestamp_Now()
|
|
120
|
+
|
|
121
|
+
# Determine status
|
|
122
|
+
status = request.status if request.status else node_type_def.default_status
|
|
123
|
+
|
|
124
|
+
# Create node
|
|
125
|
+
node = Schema__Node(node_id = Obj_Id() ,
|
|
126
|
+
node_type = request.node_type ,
|
|
127
|
+
node_index = Safe_UInt(next_num) ,
|
|
128
|
+
label = label ,
|
|
129
|
+
title = request.title ,
|
|
130
|
+
description = request.description ,
|
|
131
|
+
status = status ,
|
|
132
|
+
created_at = now ,
|
|
133
|
+
updated_at = now ,
|
|
134
|
+
created_by = Obj_Id() , # TODO: actual creator
|
|
135
|
+
tags = list(request.tags) if request.tags else [],
|
|
136
|
+
links = [] ,
|
|
137
|
+
properties = dict(request.properties) if request.properties else {})
|
|
138
|
+
|
|
139
|
+
# Save node
|
|
140
|
+
if self.repository.node_save(node) is False:
|
|
141
|
+
return Schema__Node__Create__Response(success = False ,
|
|
142
|
+
message = 'Failed to save node')
|
|
143
|
+
|
|
144
|
+
# Update type index
|
|
145
|
+
type_index.next_index = Safe_UInt(next_num + 1)
|
|
146
|
+
type_index.count = Safe_UInt(int(type_index.count) + 1)
|
|
147
|
+
type_index.last_updated = now
|
|
148
|
+
self.repository.type_index_save(type_index)
|
|
149
|
+
|
|
150
|
+
# Update global index
|
|
151
|
+
self.update_global_index()
|
|
152
|
+
|
|
153
|
+
return Schema__Node__Create__Response(success = True ,
|
|
154
|
+
node = node )
|
|
155
|
+
|
|
156
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
157
|
+
# Update Operations
|
|
158
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
159
|
+
|
|
160
|
+
def update_node(self , # Update existing node
|
|
161
|
+
node_type : Safe_Str__Node_Type ,
|
|
162
|
+
label : Safe_Str__Node_Label ,
|
|
163
|
+
request : Schema__Node__Update__Request
|
|
164
|
+
) -> Schema__Node__Update__Response:
|
|
165
|
+
node = self.repository.node_load(node_type = node_type ,
|
|
166
|
+
label = label )
|
|
167
|
+
if node is None:
|
|
168
|
+
return Schema__Node__Update__Response(success = False ,
|
|
169
|
+
message = f'Node not found: {label}')
|
|
170
|
+
|
|
171
|
+
# Apply updates - use truthiness check because Type_Safe auto-initializes
|
|
172
|
+
# empty strings for Safe_Str types (so `is not None` doesn't work)
|
|
173
|
+
if request.title: # Only update if non-empty
|
|
174
|
+
node.title = request.title
|
|
175
|
+
if request.description: # Only update if non-empty
|
|
176
|
+
node.description = request.description
|
|
177
|
+
if request.status: # Only update if non-empty
|
|
178
|
+
node.status = request.status
|
|
179
|
+
if request.tags is not None: # Tags can be empty list
|
|
180
|
+
node.tags = list(request.tags)
|
|
181
|
+
if request.properties is not None: # Deep merge properties
|
|
182
|
+
node.properties = self.deep_merge_properties(node.properties, request.properties)
|
|
183
|
+
|
|
184
|
+
node.updated_at = Timestamp_Now()
|
|
185
|
+
|
|
186
|
+
# Save
|
|
187
|
+
if self.repository.node_save(node) is False:
|
|
188
|
+
return Schema__Node__Update__Response(success = False ,
|
|
189
|
+
message = 'Failed to save node' )
|
|
190
|
+
|
|
191
|
+
return Schema__Node__Update__Response(success = True ,
|
|
192
|
+
node = node )
|
|
193
|
+
|
|
194
|
+
def deep_merge_properties(self , # Deep merge properties dicts
|
|
195
|
+
existing : dict ,
|
|
196
|
+
updates : dict
|
|
197
|
+
) -> dict:
|
|
198
|
+
result = dict(existing) if existing else {} # Copy existing properties
|
|
199
|
+
|
|
200
|
+
for key, value in updates.items():
|
|
201
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
202
|
+
result[key] = self.deep_merge_properties(result[key], value) # Recursively merge nested dicts
|
|
203
|
+
else:
|
|
204
|
+
result[key] = value # Replace or add key
|
|
205
|
+
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
209
|
+
# Delete Operations
|
|
210
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
211
|
+
|
|
212
|
+
def delete_node(self , # Delete node
|
|
213
|
+
node_type : Safe_Str__Node_Type ,
|
|
214
|
+
label : Safe_Str__Node_Label
|
|
215
|
+
) -> Schema__Node__Delete__Response:
|
|
216
|
+
if self.repository.node_exists(node_type, label) is False:
|
|
217
|
+
return Schema__Node__Delete__Response(success = False ,
|
|
218
|
+
deleted = False ,
|
|
219
|
+
label = label ,
|
|
220
|
+
message = f'Node not found: {label}')
|
|
221
|
+
|
|
222
|
+
# TODO: Remove links from other nodes pointing to this one
|
|
223
|
+
|
|
224
|
+
# Delete node
|
|
225
|
+
if self.repository.node_delete(node_type, label) is False:
|
|
226
|
+
return Schema__Node__Delete__Response(success = False ,
|
|
227
|
+
deleted = False ,
|
|
228
|
+
label = label ,
|
|
229
|
+
message = 'Failed to delete node' )
|
|
230
|
+
|
|
231
|
+
# Update type index
|
|
232
|
+
type_index = self.repository.type_index_load(node_type)
|
|
233
|
+
type_index.count = Safe_UInt(max(0, int(type_index.count) - 1))
|
|
234
|
+
type_index.last_updated = Timestamp_Now()
|
|
235
|
+
self.repository.type_index_save(type_index)
|
|
236
|
+
|
|
237
|
+
# Update global index
|
|
238
|
+
self.update_global_index()
|
|
239
|
+
|
|
240
|
+
return Schema__Node__Delete__Response(success = True ,
|
|
241
|
+
deleted = True ,
|
|
242
|
+
label = label )
|
|
243
|
+
|
|
244
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
245
|
+
# Helper Methods
|
|
246
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
247
|
+
|
|
248
|
+
def label_from_type_and_index(self , # Generate label
|
|
249
|
+
node_type : Safe_Str__Node_Type ,
|
|
250
|
+
node_index : int
|
|
251
|
+
) -> Safe_Str__Node_Label:
|
|
252
|
+
display_type = str(node_type).capitalize()
|
|
253
|
+
return Safe_Str__Node_Label(f"{display_type}-{node_index}")
|
|
254
|
+
|
|
255
|
+
def update_global_index(self) -> None: # Recalculate global index
|
|
256
|
+
node_types = self.repository.node_types_load()
|
|
257
|
+
total_nodes = 0
|
|
258
|
+
type_counts = []
|
|
259
|
+
|
|
260
|
+
for nt in node_types:
|
|
261
|
+
type_index = self.repository.type_index_load(nt.name)
|
|
262
|
+
count = int(type_index.count)
|
|
263
|
+
total_nodes += count
|
|
264
|
+
type_counts.append(Schema__Type__Summary(node_type = nt.name ,
|
|
265
|
+
count = Safe_UInt(count) ))
|
|
266
|
+
|
|
267
|
+
global_index = Schema__Global__Index(total_nodes = Safe_UInt(total_nodes) ,
|
|
268
|
+
last_updated = Timestamp_Now() ,
|
|
269
|
+
type_counts = type_counts )
|
|
270
|
+
|
|
271
|
+
self.repository.global_index_save(global_index)
|
|
272
|
+
|
|
273
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
274
|
+
# Graph Traversal Operations
|
|
275
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
276
|
+
|
|
277
|
+
def get_node_graph(self , # Get node with connected nodes
|
|
278
|
+
node_type : Safe_Str__Node_Type ,
|
|
279
|
+
label : Safe_Str__Node_Label ,
|
|
280
|
+
depth : int = 1
|
|
281
|
+
) -> 'Schema__Graph__Response':
|
|
282
|
+
|
|
283
|
+
if depth > 3: # Cap depth to prevent expensive traversals
|
|
284
|
+
depth = 3
|
|
285
|
+
|
|
286
|
+
root_node = self.repository.node_load(node_type = node_type ,
|
|
287
|
+
label = label )
|
|
288
|
+
if root_node is None:
|
|
289
|
+
return Schema__Graph__Response(success = False ,
|
|
290
|
+
root = label ,
|
|
291
|
+
nodes = [] ,
|
|
292
|
+
links = [] ,
|
|
293
|
+
depth = depth ,
|
|
294
|
+
message = f'Node not found: {label}')
|
|
295
|
+
|
|
296
|
+
visited_labels = set()
|
|
297
|
+
nodes = []
|
|
298
|
+
links = []
|
|
299
|
+
|
|
300
|
+
self._traverse_graph(root_node, depth, visited_labels, nodes, links)
|
|
301
|
+
|
|
302
|
+
return Schema__Graph__Response(success = True ,
|
|
303
|
+
root = label ,
|
|
304
|
+
nodes = nodes ,
|
|
305
|
+
links = links ,
|
|
306
|
+
depth = depth )
|
|
307
|
+
|
|
308
|
+
def _traverse_graph(self , # Recursively traverse graph
|
|
309
|
+
node : Schema__Node ,
|
|
310
|
+
depth : int ,
|
|
311
|
+
visited : set ,
|
|
312
|
+
nodes : list ,
|
|
313
|
+
links : list
|
|
314
|
+
) -> None:
|
|
315
|
+
|
|
316
|
+
label_str = str(node.label)
|
|
317
|
+
if label_str in visited or depth < 0:
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
visited.add(label_str)
|
|
321
|
+
nodes.append(Schema__Graph__Node(label = node.label ,
|
|
322
|
+
title = node.title ,
|
|
323
|
+
node_type = node.node_type ,
|
|
324
|
+
status = node.status ))
|
|
325
|
+
|
|
326
|
+
if depth == 0:
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
# Traverse outgoing links
|
|
330
|
+
if node.links:
|
|
331
|
+
for link in node.links:
|
|
332
|
+
target_label = link.target_label
|
|
333
|
+
if target_label and str(target_label) not in visited:
|
|
334
|
+
target_node = self._resolve_link_target(link)
|
|
335
|
+
if target_node:
|
|
336
|
+
links.append(Schema__Graph__Link(source = node.label ,
|
|
337
|
+
target = target_node.label,
|
|
338
|
+
link_type = link.verb))
|
|
339
|
+
self._traverse_graph(target_node, depth - 1, visited, nodes, links)
|
|
340
|
+
|
|
341
|
+
# Find and traverse incoming links
|
|
342
|
+
incoming = self._find_incoming_links(node.label)
|
|
343
|
+
for source_node, link_type in incoming:
|
|
344
|
+
if str(source_node.label) not in visited:
|
|
345
|
+
links.append(Schema__Graph__Link(source = source_node.label ,
|
|
346
|
+
target = node.label ,
|
|
347
|
+
link_type = link_type ))
|
|
348
|
+
self._traverse_graph(source_node, depth - 1, visited, nodes, links)
|
|
349
|
+
|
|
350
|
+
def _resolve_link_target(self , # Load target node from link
|
|
351
|
+
link : Schema__Node__Link
|
|
352
|
+
) -> Schema__Node:
|
|
353
|
+
if not link.target_label:
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
target_label = str(link.target_label)
|
|
357
|
+
parts = target_label.split('-') # Parse "Bug-1" -> type="bug", label="Bug-1"
|
|
358
|
+
|
|
359
|
+
if len(parts) != 2:
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
target_type = parts[0].lower() # "Bug" -> "bug"
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
return self.repository.node_load(node_type = Safe_Str__Node_Type(target_type) ,
|
|
366
|
+
label = Safe_Str__Node_Label(target_label))
|
|
367
|
+
except Exception:
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
# todo: this should not be a tuple, this should be a Type_Safe class
|
|
371
|
+
def _find_incoming_links(self , # Find nodes that link TO this node
|
|
372
|
+
label : Safe_Str__Node_Label
|
|
373
|
+
) -> List[tuple]:
|
|
374
|
+
incoming = []
|
|
375
|
+
label_str = str(label)
|
|
376
|
+
node_types = self.repository.node_types_load()
|
|
377
|
+
|
|
378
|
+
for nt in node_types:
|
|
379
|
+
labels = self.repository.nodes_list_labels(nt.name)
|
|
380
|
+
for node_label in labels:
|
|
381
|
+
if str(node_label) == label_str: # Skip self
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
node = self.repository.node_load(node_type = nt.name ,
|
|
385
|
+
label = node_label)
|
|
386
|
+
if node and node.links:
|
|
387
|
+
for link in node.links:
|
|
388
|
+
if link.target_label and str(link.target_label) == label_str:
|
|
389
|
+
incoming.append((node, str(link.verb)))
|
|
390
|
+
break # Only add node once
|
|
391
|
+
|
|
392
|
+
return incoming
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
# Type__Service - Business logic for type definitions
|
|
3
|
+
# Manages node types (bug, task, feature) and link types (blocks, has-task)
|
|
4
|
+
# Phase 1: Added git-repo type for root issue support
|
|
5
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
6
|
+
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
from osbot_utils.type_safe.Type_Safe import Type_Safe
|
|
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.identifiers.Obj_Id import Obj_Id
|
|
11
|
+
from osbot_utils.type_safe.type_safe_core.decorators.type_safe import type_safe
|
|
12
|
+
|
|
13
|
+
from issues_fs.schemas.graph.Safe_Str__Graph_Types import Safe_Str__Node_Type, Safe_Str__Status, Safe_Str__Node_Type_Display, Safe_Str__Link_Verb
|
|
14
|
+
from issues_fs.schemas.graph.Schema__Node__Type import Schema__Node__Type
|
|
15
|
+
from issues_fs.schemas.graph.Schema__Link__Type import Schema__Link__Type
|
|
16
|
+
from issues_fs.schemas.safe_str.Safe_Str__Hex_Color import Safe_Str__Hex_Color
|
|
17
|
+
from issues_fs.issues.graph_services.Graph__Repository import Graph__Repository
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Type__Service(Type_Safe): # Type definition service
|
|
21
|
+
repository : Graph__Repository # Data access layer
|
|
22
|
+
|
|
23
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
24
|
+
# Node Type Operations
|
|
25
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
26
|
+
|
|
27
|
+
@type_safe
|
|
28
|
+
def list_node_types(self) -> List[Schema__Node__Type]: # Get all node types
|
|
29
|
+
return self.repository.node_types_load()
|
|
30
|
+
|
|
31
|
+
def get_node_type(self , # Get single node type
|
|
32
|
+
name : Safe_Str__Node_Type
|
|
33
|
+
) -> Schema__Node__Type:
|
|
34
|
+
types = self.repository.node_types_load()
|
|
35
|
+
for t in types:
|
|
36
|
+
if str(t.name) == str(name):
|
|
37
|
+
return t
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
@type_safe
|
|
41
|
+
def create_node_type(self , # Create new node type
|
|
42
|
+
name : Safe_Str__Node_Type ,
|
|
43
|
+
display_name : Safe_Str__Node_Type_Display ,
|
|
44
|
+
description : Safe_Str__Text = '' ,
|
|
45
|
+
color : Safe_Str__Hex_Color = '#888888',
|
|
46
|
+
statuses : List[str] = None ,
|
|
47
|
+
default_status : Safe_Str__Status = 'backlog'
|
|
48
|
+
) -> Schema__Node__Type:
|
|
49
|
+
types = self.repository.node_types_load()
|
|
50
|
+
|
|
51
|
+
for t in types:
|
|
52
|
+
if t.name == name:
|
|
53
|
+
return None # Already exists
|
|
54
|
+
|
|
55
|
+
status_list = statuses or ['backlog', 'in-progress', 'done']
|
|
56
|
+
|
|
57
|
+
node_type = Schema__Node__Type(type_id = Obj_Id() ,
|
|
58
|
+
name = name ,
|
|
59
|
+
display_name = display_name ,
|
|
60
|
+
description = description ,
|
|
61
|
+
color = color ,
|
|
62
|
+
statuses = status_list ,
|
|
63
|
+
default_status = default_status )
|
|
64
|
+
|
|
65
|
+
types.append(node_type)
|
|
66
|
+
self.repository.node_types_save(types)
|
|
67
|
+
return node_type
|
|
68
|
+
|
|
69
|
+
def delete_node_type(self , # Delete node type
|
|
70
|
+
name : Safe_Str__Node_Type
|
|
71
|
+
) -> bool:
|
|
72
|
+
types = self.repository.node_types_load()
|
|
73
|
+
|
|
74
|
+
type_index = self.repository.type_index_load(name) # Check if any nodes of this type exist
|
|
75
|
+
if int(type_index.count) > 0:
|
|
76
|
+
return False # Cannot delete type with existing nodes
|
|
77
|
+
|
|
78
|
+
types = [t for t in types if str(t.name) != str(name)] # Remove type
|
|
79
|
+
self.repository.node_types_save(types)
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
83
|
+
# Link Type Operations
|
|
84
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
85
|
+
|
|
86
|
+
@type_safe
|
|
87
|
+
def list_link_types(self) -> List[Schema__Link__Type]: # Get all link types
|
|
88
|
+
return self.repository.link_types_load()
|
|
89
|
+
|
|
90
|
+
def get_link_type(self , # Get link type by verb
|
|
91
|
+
verb : Safe_Str__Link_Verb
|
|
92
|
+
) -> Optional[Schema__Link__Type]:
|
|
93
|
+
types = self.repository.link_types_load()
|
|
94
|
+
for t in types:
|
|
95
|
+
if str(t.verb) == str(verb):
|
|
96
|
+
return t
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
@type_safe
|
|
100
|
+
def create_link_type(self , # Create new link type
|
|
101
|
+
verb : Safe_Str__Link_Verb ,
|
|
102
|
+
inverse_verb : Safe_Str__Link_Verb ,
|
|
103
|
+
description : Safe_Str__Text = '' ,
|
|
104
|
+
source_types : List[Safe_Str__Node_Type] = None ,
|
|
105
|
+
target_types : List[Safe_Str__Node_Type] = None
|
|
106
|
+
) -> Schema__Link__Type:
|
|
107
|
+
types = self.repository.link_types_load()
|
|
108
|
+
|
|
109
|
+
for t in types:
|
|
110
|
+
if str(t.verb) == str(verb):
|
|
111
|
+
return None # Already exists
|
|
112
|
+
|
|
113
|
+
link_type = Schema__Link__Type(link_type_id = Obj_Id() ,
|
|
114
|
+
verb = verb ,
|
|
115
|
+
inverse_verb = inverse_verb ,
|
|
116
|
+
description = description ,
|
|
117
|
+
source_types = source_types ,
|
|
118
|
+
target_types = target_types )
|
|
119
|
+
|
|
120
|
+
types.append(link_type)
|
|
121
|
+
self.repository.link_types_save(types)
|
|
122
|
+
return link_type
|
|
123
|
+
|
|
124
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
125
|
+
# Default Type Initialization
|
|
126
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
127
|
+
|
|
128
|
+
def initialize_default_types(self) -> None: # Set up default types
|
|
129
|
+
if len(self.repository.node_types_load()) > 0: # Check if already initialized
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# ───────────────────────────────────────────────────────────────────────────
|
|
133
|
+
# Node Types
|
|
134
|
+
# ───────────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
self.create_node_type(name = Safe_Str__Node_Type('git-repo') , # NEW: Root issue type for git repositories
|
|
137
|
+
display_name = 'GitRepo' ,
|
|
138
|
+
description = 'Git repository root - contains all issues',
|
|
139
|
+
color = '#6366f1' , # Indigo color
|
|
140
|
+
statuses = ['active', 'archived'] ,
|
|
141
|
+
default_status = 'active' )
|
|
142
|
+
|
|
143
|
+
self.create_node_type(name = Safe_Str__Node_Type('bug') ,
|
|
144
|
+
display_name = 'Bug' ,
|
|
145
|
+
description = 'Defect or error in the system' ,
|
|
146
|
+
color = '#ef4444' ,
|
|
147
|
+
statuses = ['backlog', 'confirmed', 'in-progress', 'testing', 'resolved', 'closed'],
|
|
148
|
+
default_status = 'backlog' )
|
|
149
|
+
|
|
150
|
+
self.create_node_type(name = Safe_Str__Node_Type('task') ,
|
|
151
|
+
display_name = 'Task' ,
|
|
152
|
+
description = 'Unit of work to be completed' ,
|
|
153
|
+
color = '#3b82f6' ,
|
|
154
|
+
statuses = ['backlog', 'todo', 'in-progress', 'review', 'done'],
|
|
155
|
+
default_status = 'backlog' )
|
|
156
|
+
|
|
157
|
+
self.create_node_type(name = Safe_Str__Node_Type('feature') ,
|
|
158
|
+
display_name = 'Feature' ,
|
|
159
|
+
description = 'High-level capability' ,
|
|
160
|
+
color = '#22c55e' ,
|
|
161
|
+
statuses = ['proposed', 'approved', 'in-progress', 'released'],
|
|
162
|
+
default_status = 'proposed' )
|
|
163
|
+
|
|
164
|
+
self.create_node_type(name = Safe_Str__Node_Type('person') ,
|
|
165
|
+
display_name = 'Person' ,
|
|
166
|
+
description = 'Human or agent identity' ,
|
|
167
|
+
color = '#8b5cf6' ,
|
|
168
|
+
statuses = ['active', 'inactive'] ,
|
|
169
|
+
default_status = 'active' )
|
|
170
|
+
|
|
171
|
+
# ───────────────────────────────────────────────────────────────────────────
|
|
172
|
+
# Link Types
|
|
173
|
+
# ───────────────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
self.create_link_type(verb = Safe_Str__Link_Verb('blocks') ,
|
|
176
|
+
inverse_verb = 'blocked-by' ,
|
|
177
|
+
description = 'Prevents progress on target' ,
|
|
178
|
+
source_types = ['bug', 'task'] ,
|
|
179
|
+
target_types = ['task', 'feature'] )
|
|
180
|
+
|
|
181
|
+
self.create_link_type(verb = Safe_Str__Link_Verb('has-task') ,
|
|
182
|
+
inverse_verb = 'task-of' ,
|
|
183
|
+
description = 'Contains as sub-work' ,
|
|
184
|
+
source_types = ['feature', 'git-repo'] , # git-repo can have tasks
|
|
185
|
+
target_types = ['task'] )
|
|
186
|
+
|
|
187
|
+
self.create_link_type(verb = Safe_Str__Link_Verb('assigned-to') ,
|
|
188
|
+
inverse_verb = 'assignee-of' ,
|
|
189
|
+
description = 'Work assigned to person/agent' ,
|
|
190
|
+
source_types = ['bug', 'task', 'feature'] ,
|
|
191
|
+
target_types = ['person'] )
|
|
192
|
+
|
|
193
|
+
self.create_link_type(verb = Safe_Str__Link_Verb('depends-on') ,
|
|
194
|
+
inverse_verb = 'dependency-of' ,
|
|
195
|
+
description = 'Requires target to complete first' ,
|
|
196
|
+
source_types = ['task', 'feature'] ,
|
|
197
|
+
target_types = ['task', 'feature'] )
|
|
198
|
+
|
|
199
|
+
self.create_link_type(verb = Safe_Str__Link_Verb('relates-to') ,
|
|
200
|
+
inverse_verb = 'relates-to' ,
|
|
201
|
+
description = 'General association (symmetric)' ,
|
|
202
|
+
source_types = ['bug', 'task', 'feature', 'git-repo'] ,
|
|
203
|
+
target_types = ['bug', 'task', 'feature', 'git-repo'] )
|
|
204
|
+
|
|
205
|
+
self.create_link_type(verb = Safe_Str__Link_Verb('contains') , # NEW: For hierarchical structure
|
|
206
|
+
inverse_verb = 'contained-by' ,
|
|
207
|
+
description = 'Parent contains child issue' ,
|
|
208
|
+
source_types = ['git-repo', 'feature', 'task'] ,
|
|
209
|
+
target_types = ['bug', 'task', 'feature'] )
|