better-notion 0.9.9__py3-none-any.whl → 1.0.1__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.
- better_notion/plugins/official/__init__.py +3 -1
- better_notion/plugins/official/agents.py +356 -0
- better_notion/utils/agents/__init__.py +65 -0
- better_notion/utils/agents/auth.py +235 -0
- better_notion/utils/agents/dependency_resolver.py +368 -0
- better_notion/utils/agents/project_context.py +232 -0
- better_notion/utils/agents/rbac.py +371 -0
- better_notion/utils/agents/schemas.py +614 -0
- better_notion/utils/agents/state_machine.py +216 -0
- better_notion/utils/agents/workspace.py +371 -0
- {better_notion-0.9.9.dist-info → better_notion-1.0.1.dist-info}/METADATA +2 -2
- {better_notion-0.9.9.dist-info → better_notion-1.0.1.dist-info}/RECORD +15 -6
- {better_notion-0.9.9.dist-info → better_notion-1.0.1.dist-info}/WHEEL +0 -0
- {better_notion-0.9.9.dist-info → better_notion-1.0.1.dist-info}/entry_points.txt +0 -0
- {better_notion-0.9.9.dist-info → better_notion-1.0.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Dependency resolution for task management in the agents workflow system.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to resolve task dependencies, determine
|
|
4
|
+
execution order, and find tasks that are ready to start.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import Any, AsyncIterator, Dict, List, Set, Tuple
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DependencyResolver:
|
|
12
|
+
"""Resolves task dependencies and determines execution order.
|
|
13
|
+
|
|
14
|
+
This class provides methods to:
|
|
15
|
+
- Build dependency graphs from tasks
|
|
16
|
+
- Perform topological sorting to find execution order
|
|
17
|
+
- Detect circular dependencies
|
|
18
|
+
- Find tasks that are ready to start (all dependencies completed)
|
|
19
|
+
- Find tasks that are blocked by incomplete dependencies
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> # Build dependency graph
|
|
23
|
+
>>> graph = {
|
|
24
|
+
... "task-a": ["task-b"], # A depends on B
|
|
25
|
+
... "task-b": [], # B has no dependencies
|
|
26
|
+
... }
|
|
27
|
+
>>>
|
|
28
|
+
>>> # Get execution order
|
|
29
|
+
>>> order = DependencyResolver.topological_sort(graph)
|
|
30
|
+
>>> print(order) # ["task-b", "task-a"]
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def build_dependency_graph(
|
|
35
|
+
tasks: List[Any],
|
|
36
|
+
get_task_id: callable,
|
|
37
|
+
get_dependency_ids: callable,
|
|
38
|
+
) -> Dict[str, List[str]]:
|
|
39
|
+
"""Build dependency graph (adjacency list) from tasks.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
tasks: List of task objects
|
|
43
|
+
get_task_id: Function to extract task ID from task object
|
|
44
|
+
get_dependency_ids: Function to extract dependency IDs from task object
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Dict mapping task_id → list of task_ids it depends on
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
>>> tasks = [task1, task2, task3]
|
|
51
|
+
>>> graph = DependencyResolver.build_dependency_graph(
|
|
52
|
+
... tasks,
|
|
53
|
+
... lambda t: t.id,
|
|
54
|
+
... lambda t: t.dependencies
|
|
55
|
+
... )
|
|
56
|
+
"""
|
|
57
|
+
graph: Dict[str, List[str]] = {}
|
|
58
|
+
|
|
59
|
+
for task in tasks:
|
|
60
|
+
task_id = get_task_id(task)
|
|
61
|
+
deps = get_dependency_ids(task)
|
|
62
|
+
|
|
63
|
+
# Ensure deps is a list
|
|
64
|
+
if deps is None:
|
|
65
|
+
deps = []
|
|
66
|
+
elif isinstance(deps, str):
|
|
67
|
+
deps = [deps]
|
|
68
|
+
elif not isinstance(deps, list):
|
|
69
|
+
deps = list(deps)
|
|
70
|
+
|
|
71
|
+
graph[task_id] = deps
|
|
72
|
+
|
|
73
|
+
return graph
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def topological_sort(graph: Dict[str, List[str]]) -> List[str]:
|
|
77
|
+
"""Perform topological sort to find execution order.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
graph: Dependency graph (task_id → list of dependencies)
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
List of task IDs in dependency order (dependencies before dependents)
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: If graph contains circular dependencies
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
>>> graph = {
|
|
90
|
+
... "A": ["B"],
|
|
91
|
+
... "B": ["C"],
|
|
92
|
+
... "C": [],
|
|
93
|
+
... }
|
|
94
|
+
>>> order = DependencyResolver.topological_sort(graph)
|
|
95
|
+
>>> print(order) # ["C", "B", "A"]
|
|
96
|
+
"""
|
|
97
|
+
# Calculate in-degrees
|
|
98
|
+
in_degree: Dict[str, int] = {node: 0 for node in graph}
|
|
99
|
+
|
|
100
|
+
for node in graph:
|
|
101
|
+
for dep in graph[node]:
|
|
102
|
+
# Only count dependencies that are in our graph
|
|
103
|
+
if dep in in_degree:
|
|
104
|
+
in_degree[node] += 1
|
|
105
|
+
|
|
106
|
+
# Start with nodes that have no dependencies
|
|
107
|
+
queue = deque([node for node, degree in in_degree.items() if degree == 0])
|
|
108
|
+
result: List[str] = []
|
|
109
|
+
|
|
110
|
+
while queue:
|
|
111
|
+
node = queue.popleft()
|
|
112
|
+
result.append(node)
|
|
113
|
+
|
|
114
|
+
# Reduce in-degree for dependent nodes
|
|
115
|
+
for dependent, deps in graph.items():
|
|
116
|
+
if node in deps:
|
|
117
|
+
in_degree[dependent] -= 1
|
|
118
|
+
if in_degree[dependent] == 0:
|
|
119
|
+
queue.append(dependent)
|
|
120
|
+
|
|
121
|
+
# Check for cycles
|
|
122
|
+
if len(result) != len(graph):
|
|
123
|
+
# Find which nodes are in the cycle
|
|
124
|
+
processed = set(result)
|
|
125
|
+
cycle_nodes = [node for node in graph if node not in processed]
|
|
126
|
+
|
|
127
|
+
if cycle_nodes:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Circular dependency detected involving tasks: {', '.join(cycle_nodes[:3])}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
raise ValueError("Circular dependency detected in task graph")
|
|
133
|
+
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def detect_cycles(graph: Dict[str, List[str]]) -> List[List[str]]:
|
|
138
|
+
"""Detect circular dependencies in the graph.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
graph: Dependency graph
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
List of cycles (each cycle is a list of task IDs)
|
|
145
|
+
|
|
146
|
+
Example:
|
|
147
|
+
>>> graph = {
|
|
148
|
+
... "A": ["B"],
|
|
149
|
+
... "B": ["C"],
|
|
150
|
+
... "C": ["A"], # Circular!
|
|
151
|
+
... }
|
|
152
|
+
>>> cycles = DependencyResolver.detect_cycles(graph)
|
|
153
|
+
>>> print(len(cycles)) # 1
|
|
154
|
+
"""
|
|
155
|
+
cycles: List[List[str]] = []
|
|
156
|
+
visited: Set[str] = set()
|
|
157
|
+
rec_stack: Set[str] = set()
|
|
158
|
+
path: List[str] = []
|
|
159
|
+
|
|
160
|
+
def dfs(node: str) -> None:
|
|
161
|
+
"""Depth-first search to detect cycles."""
|
|
162
|
+
visited.add(node)
|
|
163
|
+
rec_stack.add(node)
|
|
164
|
+
path.append(node)
|
|
165
|
+
|
|
166
|
+
for neighbor in graph.get(node, []):
|
|
167
|
+
if neighbor not in visited:
|
|
168
|
+
dfs(neighbor)
|
|
169
|
+
elif neighbor in rec_stack:
|
|
170
|
+
# Found a cycle
|
|
171
|
+
cycle_start = path.index(neighbor)
|
|
172
|
+
cycle = path[cycle_start:] + [neighbor]
|
|
173
|
+
cycles.append(cycle)
|
|
174
|
+
|
|
175
|
+
path.pop()
|
|
176
|
+
rec_stack.remove(node)
|
|
177
|
+
|
|
178
|
+
for node in graph:
|
|
179
|
+
if node not in visited:
|
|
180
|
+
dfs(node)
|
|
181
|
+
|
|
182
|
+
return cycles
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def get_ready_tasks(
|
|
186
|
+
tasks: List[Any],
|
|
187
|
+
get_task_id: callable,
|
|
188
|
+
get_dependency_ids: callable,
|
|
189
|
+
get_task_status: callable,
|
|
190
|
+
completed_status: str = "Completed",
|
|
191
|
+
) -> List[Any]:
|
|
192
|
+
"""Find tasks that can be started (all dependencies completed).
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
tasks: List of task objects
|
|
196
|
+
get_task_id: Function to extract task ID
|
|
197
|
+
get_dependency_ids: Function to extract dependency IDs
|
|
198
|
+
get_task_status: Function to extract task status
|
|
199
|
+
completed_status: Status that means dependencies are satisfied
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of tasks that are ready to start
|
|
203
|
+
|
|
204
|
+
Example:
|
|
205
|
+
>>> ready = DependencyResolver.get_ready_tasks(
|
|
206
|
+
... tasks,
|
|
207
|
+
... lambda t: t.id,
|
|
208
|
+
... lambda t: t.depends_on,
|
|
209
|
+
... lambda t: t.status
|
|
210
|
+
... )
|
|
211
|
+
"""
|
|
212
|
+
# Build task map for O(1) lookups
|
|
213
|
+
task_map = {get_task_id(t): t for t in tasks}
|
|
214
|
+
|
|
215
|
+
ready_tasks: List[Any] = []
|
|
216
|
+
|
|
217
|
+
for task in tasks:
|
|
218
|
+
task_id = get_task_id(task)
|
|
219
|
+
deps = get_dependency_ids(task)
|
|
220
|
+
|
|
221
|
+
if deps is None:
|
|
222
|
+
deps = []
|
|
223
|
+
elif isinstance(deps, str):
|
|
224
|
+
deps = [deps]
|
|
225
|
+
elif not isinstance(deps, list):
|
|
226
|
+
deps = list(deps)
|
|
227
|
+
|
|
228
|
+
# Check if all dependencies are satisfied
|
|
229
|
+
all_complete = True
|
|
230
|
+
|
|
231
|
+
for dep_id in deps:
|
|
232
|
+
dep_task = task_map.get(dep_id)
|
|
233
|
+
|
|
234
|
+
if dep_task is None:
|
|
235
|
+
# Dependency doesn't exist in our task list
|
|
236
|
+
# Assume it's not completed
|
|
237
|
+
all_complete = False
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
dep_status = get_task_status(dep_task)
|
|
241
|
+
|
|
242
|
+
if dep_status != completed_status:
|
|
243
|
+
all_complete = False
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
if all_complete and len(deps) > 0:
|
|
247
|
+
# Task has dependencies and all are complete
|
|
248
|
+
ready_tasks.append(task)
|
|
249
|
+
elif len(deps) == 0:
|
|
250
|
+
# Task has no dependencies - also ready
|
|
251
|
+
task_status = get_task_status(task)
|
|
252
|
+
# Only include if not already completed
|
|
253
|
+
if task_status != completed_status:
|
|
254
|
+
ready_tasks.append(task)
|
|
255
|
+
|
|
256
|
+
return ready_tasks
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def get_blocked_tasks(
|
|
260
|
+
tasks: List[Any],
|
|
261
|
+
get_task_id: callable,
|
|
262
|
+
get_dependency_ids: callable,
|
|
263
|
+
get_task_status: callable,
|
|
264
|
+
completed_status: str = "Completed",
|
|
265
|
+
) -> List[Tuple[Any, List[Any]]]:
|
|
266
|
+
"""Find tasks that are blocked by incomplete dependencies.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
tasks: List of task objects
|
|
270
|
+
get_task_id: Function to extract task ID
|
|
271
|
+
get_dependency_ids: Function to extract dependency IDs
|
|
272
|
+
get_task_status: Function to extract task status
|
|
273
|
+
completed_status: Status that means dependencies are satisfied
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
List of (task, blocking_tasks) tuples where blocking_tasks
|
|
277
|
+
are the incomplete dependencies
|
|
278
|
+
|
|
279
|
+
Example:
|
|
280
|
+
>>> blocked = DependencyResolver.get_blocked_tasks(
|
|
281
|
+
... tasks,
|
|
282
|
+
... lambda t: t.id,
|
|
283
|
+
... lambda t: t.depends_on,
|
|
284
|
+
... lambda t: t.status
|
|
285
|
+
... )
|
|
286
|
+
>>> for task, blockers in blocked:
|
|
287
|
+
... print(f"{task.id} blocked by {[b.id for b in blockers]}")
|
|
288
|
+
"""
|
|
289
|
+
# Build task map
|
|
290
|
+
task_map = {get_task_id(t): t for t in tasks}
|
|
291
|
+
|
|
292
|
+
blocked: List[Tuple[Any, List[Any]]] = []
|
|
293
|
+
|
|
294
|
+
for task in tasks:
|
|
295
|
+
task_id = get_task_id(task)
|
|
296
|
+
deps = get_dependency_ids(task)
|
|
297
|
+
|
|
298
|
+
if deps is None:
|
|
299
|
+
deps = []
|
|
300
|
+
elif isinstance(deps, str):
|
|
301
|
+
deps = [deps]
|
|
302
|
+
elif not isinstance(deps, list):
|
|
303
|
+
deps = list(deps)
|
|
304
|
+
|
|
305
|
+
if not deps:
|
|
306
|
+
# No dependencies = not blocked
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
# Check for incomplete dependencies
|
|
310
|
+
blocking: List[Any] = []
|
|
311
|
+
|
|
312
|
+
for dep_id in deps:
|
|
313
|
+
dep_task = task_map.get(dep_id)
|
|
314
|
+
|
|
315
|
+
if dep_task is None:
|
|
316
|
+
# Dependency doesn't exist - treat as blocker
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
dep_status = get_task_status(dep_task)
|
|
320
|
+
|
|
321
|
+
if dep_status != completed_status:
|
|
322
|
+
blocking.append(dep_task)
|
|
323
|
+
|
|
324
|
+
if blocking:
|
|
325
|
+
blocked.append((task, blocking))
|
|
326
|
+
|
|
327
|
+
return blocked
|
|
328
|
+
|
|
329
|
+
@staticmethod
|
|
330
|
+
def get_execution_order(
|
|
331
|
+
tasks: List[Any],
|
|
332
|
+
get_task_id: callable,
|
|
333
|
+
get_dependency_ids: callable,
|
|
334
|
+
) -> List[Any]:
|
|
335
|
+
"""Get tasks in dependency execution order.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
tasks: List of task objects
|
|
339
|
+
get_task_id: Function to extract task ID
|
|
340
|
+
get_dependency_ids: Function to extract dependency IDs
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
List of tasks in execution order
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
ValueError: If circular dependencies are detected
|
|
347
|
+
|
|
348
|
+
Example:
|
|
349
|
+
>>> ordered = DependencyResolver.get_execution_order(
|
|
350
|
+
... tasks,
|
|
351
|
+
... lambda t: t.id,
|
|
352
|
+
... lambda t: t.depends_on
|
|
353
|
+
... )
|
|
354
|
+
"""
|
|
355
|
+
# Build dependency graph
|
|
356
|
+
graph = DependencyResolver.build_dependency_graph(
|
|
357
|
+
tasks,
|
|
358
|
+
get_task_id,
|
|
359
|
+
get_dependency_ids,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Get execution order
|
|
363
|
+
order = DependencyResolver.topological_sort(graph)
|
|
364
|
+
|
|
365
|
+
# Build task map and return in order
|
|
366
|
+
task_map = {get_task_id(t): t for t in tasks}
|
|
367
|
+
|
|
368
|
+
return [task_map[task_id] for task_id in order if task_id in task_map]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Project context management for agents workflow system.
|
|
2
|
+
|
|
3
|
+
This module provides the ProjectContext class for managing project context
|
|
4
|
+
stored in .notion files. Each project directory contains a .notion file
|
|
5
|
+
that identifies the project, organization, and role for that directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ProjectContext:
|
|
17
|
+
"""Project context stored in .notion file.
|
|
18
|
+
|
|
19
|
+
The .notion file identifies which project the current directory belongs to,
|
|
20
|
+
allowing CLI commands to know which Notion database to query and what
|
|
21
|
+
permissions the current role has.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
project_id: Notion page ID for the project
|
|
25
|
+
project_name: Human-readable project name
|
|
26
|
+
org_id: Notion page ID for the organization
|
|
27
|
+
role: Current role for this project context (default: Developer)
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
>>> context = ProjectContext.create(
|
|
31
|
+
... project_id="abc123",
|
|
32
|
+
... project_name="my-project",
|
|
33
|
+
... org_id="org789",
|
|
34
|
+
... role="Developer"
|
|
35
|
+
... )
|
|
36
|
+
>>> print(context.project_id)
|
|
37
|
+
'abc123'
|
|
38
|
+
|
|
39
|
+
>>> # Load from current directory
|
|
40
|
+
>>> context = ProjectContext.from_current_directory()
|
|
41
|
+
>>> if context:
|
|
42
|
+
... print(f"Working on {context.project_name} as {context.role}")
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
project_id: str
|
|
46
|
+
project_name: str
|
|
47
|
+
org_id: str
|
|
48
|
+
role: str = "Developer"
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_current_directory(cls) -> Optional["ProjectContext"]:
|
|
52
|
+
"""Load .notion file from current directory or parent directories.
|
|
53
|
+
|
|
54
|
+
This method searches up the directory tree from the current working
|
|
55
|
+
directory until it finds a .notion file. This allows commands to work
|
|
56
|
+
from any subdirectory within a project.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
ProjectContext if .notion file is found, None otherwise
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
>>> # In /Users/dev/projects/my-project/src
|
|
63
|
+
>>> # .notion is in /Users/dev/projects/my-project
|
|
64
|
+
>>> context = ProjectContext.from_current_directory()
|
|
65
|
+
>>> # Will find and load the .notion file from parent directory
|
|
66
|
+
"""
|
|
67
|
+
cwd = Path.cwd()
|
|
68
|
+
|
|
69
|
+
# Search up the directory tree
|
|
70
|
+
for parent in [cwd, *cwd.parents]:
|
|
71
|
+
notion_file = parent / ".notion"
|
|
72
|
+
|
|
73
|
+
if notion_file.exists() and notion_file.is_file():
|
|
74
|
+
try:
|
|
75
|
+
with open(notion_file, encoding="utf-8") as f:
|
|
76
|
+
data = yaml.safe_load(f)
|
|
77
|
+
|
|
78
|
+
if data and isinstance(data, dict):
|
|
79
|
+
return cls(**data)
|
|
80
|
+
|
|
81
|
+
except (yaml.YAMLError, TypeError, ValueError) as e:
|
|
82
|
+
# Invalid YAML or data structure
|
|
83
|
+
# Log and continue searching
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_path(cls, path: Path) -> Optional["ProjectContext"]:
|
|
90
|
+
"""Load .notion file from specific path.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
path: Path to directory containing .notion file
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
ProjectContext if .notion file exists and is valid, None otherwise
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
>>> path = Path("/Users/dev/projects/my-project")
|
|
100
|
+
>>> context = ProjectContext.from_path(path)
|
|
101
|
+
"""
|
|
102
|
+
notion_file = path / ".notion"
|
|
103
|
+
|
|
104
|
+
if not notion_file.exists() or not notion_file.is_file():
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
with open(notion_file, encoding="utf-8") as f:
|
|
109
|
+
data = yaml.safe_load(f)
|
|
110
|
+
|
|
111
|
+
if data and isinstance(data, dict):
|
|
112
|
+
return cls(**data)
|
|
113
|
+
|
|
114
|
+
except (yaml.YAMLError, TypeError, ValueError):
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def create(
|
|
119
|
+
cls,
|
|
120
|
+
project_id: str,
|
|
121
|
+
project_name: str,
|
|
122
|
+
org_id: str,
|
|
123
|
+
role: str = "Developer",
|
|
124
|
+
path: Optional[Path] = None,
|
|
125
|
+
) -> "ProjectContext":
|
|
126
|
+
"""Create new .notion file with project context.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
project_id: Notion page ID for the project
|
|
130
|
+
project_name: Human-readable project name
|
|
131
|
+
org_id: Notion page ID for the organization
|
|
132
|
+
role: Role for this project context (default: Developer)
|
|
133
|
+
path: Directory where to create .notion file (default: cwd)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
New ProjectContext instance
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
IOError: If unable to write to the .notion file
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
>>> context = ProjectContext.create(
|
|
143
|
+
... project_id="abc123def456",
|
|
144
|
+
... project_name="my-awesome-project",
|
|
145
|
+
... org_id="org789xyz012",
|
|
146
|
+
... role="Developer",
|
|
147
|
+
... path=Path.cwd()
|
|
148
|
+
... )
|
|
149
|
+
"""
|
|
150
|
+
if path is None:
|
|
151
|
+
path = Path.cwd()
|
|
152
|
+
|
|
153
|
+
context = cls(
|
|
154
|
+
project_id=project_id,
|
|
155
|
+
project_name=project_name,
|
|
156
|
+
org_id=org_id,
|
|
157
|
+
role=role,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
notion_file = path / ".notion"
|
|
161
|
+
|
|
162
|
+
with open(notion_file, "w", encoding="utf-8") as f:
|
|
163
|
+
yaml.dump(asdict(context), f, default_flow_style=False, sort_keys=False)
|
|
164
|
+
|
|
165
|
+
return context
|
|
166
|
+
|
|
167
|
+
def save(self, path: Optional[Path] = None) -> None:
|
|
168
|
+
"""Save context to .notion file.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
path: Directory where to save .notion file (default: cwd)
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
IOError: If unable to write to the .notion file
|
|
175
|
+
|
|
176
|
+
Example:
|
|
177
|
+
>>> context.role = "PM"
|
|
178
|
+
>>> context.save()
|
|
179
|
+
"""
|
|
180
|
+
if path is None:
|
|
181
|
+
path = Path.cwd()
|
|
182
|
+
|
|
183
|
+
notion_file = path / ".notion"
|
|
184
|
+
|
|
185
|
+
with open(notion_file, "w", encoding="utf-8") as f:
|
|
186
|
+
yaml.dump(asdict(self), f, default_flow_style=False, sort_keys=False)
|
|
187
|
+
|
|
188
|
+
def update_role(self, new_role: str, path: Optional[Path] = None) -> None:
|
|
189
|
+
"""Update project role and save to .notion file.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
new_role: New role value
|
|
193
|
+
path: Directory where .notion file is located (default: cwd)
|
|
194
|
+
|
|
195
|
+
Example:
|
|
196
|
+
>>> context.update_role("PM")
|
|
197
|
+
>>> print(context.role)
|
|
198
|
+
'PM'
|
|
199
|
+
"""
|
|
200
|
+
self.role = new_role
|
|
201
|
+
self.save(path)
|
|
202
|
+
|
|
203
|
+
def has_permission(self, permission: str) -> bool:
|
|
204
|
+
"""Check if current role has a specific permission.
|
|
205
|
+
|
|
206
|
+
This is a convenience method that will integrate with the
|
|
207
|
+
RoleManager class. For now, it returns True for all permissions.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
permission: Permission string (e.g., "tasks:claim")
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
True if role has permission, False otherwise
|
|
214
|
+
|
|
215
|
+
Example:
|
|
216
|
+
>>> if context.has_permission("tasks:claim"):
|
|
217
|
+
... # Claim the task
|
|
218
|
+
"""
|
|
219
|
+
# TODO: Integrate with RoleManager
|
|
220
|
+
# For now, allow all permissions
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
def __repr__(self) -> str:
|
|
224
|
+
"""String representation of project context."""
|
|
225
|
+
return (
|
|
226
|
+
f"ProjectContext("
|
|
227
|
+
f"project_id={self.project_id!r}, "
|
|
228
|
+
f"project_name={self.project_name!r}, "
|
|
229
|
+
f"org_id={self.org_id!r}, "
|
|
230
|
+
f"role={self.role!r}"
|
|
231
|
+
f")"
|
|
232
|
+
)
|