1+ from dataclasses import dataclass
12from datetime import datetime
2- from enum import Enum
3- from typing import Optional
3+ from enum import Enum , auto
4+ from types import MappingProxyType
5+ from typing import Any , Final , Optional
46
57from src .domain .aggregate_root import AggregateRoot , BaseCommand , BaseEvent
68
1113# https://docs.github.com/en/rest/issues/timeline?apiVersion=2022-11-28
1214# https://github.blog/open-source/maintainers/metrics-for-issues-pull-requests-and-discussions/
1315
14- class IssueState (Enum ):
15- OPEN = "OPEN"
16- CLOSED = "CLOSED"
17-
18- @property
19- def is_open (self ):
20- return self == IssueState .OPEN
16+ ### Enums ###
2117
22- class IssueStateTransition (Enum ):
18+ class IssueTransitionState (Enum ):
2319 COMPLETED = "COMPLETED"
2420 NOT_PLANNED = "NOT_PLANNED"
2521 REOPENED = "REOPENED"
2622
23+
24+ class IssueTransitionType (Enum ):
25+ CLOSE_AS_COMPLETE = auto ()
26+ CLOSE_AS_NOT_PLANNED = auto ()
27+ REOPEN = auto ()
28+
29+
2730class IssueEventType (Enum ):
2831 OPENED = "OPENED"
2932 EDITED = "EDITED"
@@ -34,15 +37,94 @@ class IssueEventType(Enum):
3437 LABLED = "LABLED"
3538 UNLABLED = "UNLABLED"
3639
40+
41+ # https://github.com/pytransitions/transitions?tab=readme-ov-file#transitions
42+ # https://python-statemachine.readthedocs.io/en/latest/auto_examples/persistent_model_machine.html
43+ # important features of state machines:
44+ # States, Transistions, Events, Actions(state, transition), Conditions, Validators (guard), Listeners
45+ # State Actions: on_enter, on_exit
46+ # Transition Actions: before, on, after
47+
48+
49+ @dataclass (frozen = True )
50+ class StateTransition :
51+ """Represents a valid transition between states"""
52+
53+ from_state : "IssueState"
54+ to_state : "IssueState"
55+
56+
57+ class IssueState (Enum ):
58+ OPEN = "OPEN"
59+ CLOSED = "CLOSED"
60+
61+ @classmethod
62+ def transitions (cls ) -> dict [IssueTransitionType , StateTransition ]:
63+ """Get the valid state transitions"""
64+ return _ISSUE_STATE_TRANSITIONS
65+
66+ @property
67+ def is_open (self ) -> bool :
68+ return self == IssueState .OPEN
69+
70+ def transition (self , transition_type : IssueTransitionType ) -> "IssueState" :
71+ """
72+ Change the state based on the given transition type, enforcing valid transitions.
73+ Args:
74+ transition_type: The type of transition to perform
75+ Returns:
76+ The new IssueState after the transition
77+ Raises:
78+ ValueError: If the transition is not valid for the current state
79+ """
80+ if not isinstance (transition_type , IssueTransitionType ):
81+ raise ValueError (f"Unknown transition type: { transition_type } " )
82+
83+ try :
84+ transition = self .transitions ()[transition_type ]
85+ except KeyError as err :
86+ raise ValueError (f"Unknown transition type: { transition_type } " ) from err
87+
88+ if self != transition .from_state :
89+ raise ValueError (
90+ f"Cannot perform { transition_type .name } transition from state { self .value } "
91+ )
92+
93+ return transition .to_state
94+
95+
96+ # Define valid transitions using the type-safe StateTransition class
97+ _ISSUE_STATE_TRANSITIONS : Final [dict [IssueTransitionType , StateTransition ]] = (
98+ MappingProxyType (
99+ {
100+ IssueTransitionType .CLOSE_AS_COMPLETE : StateTransition (
101+ IssueState .OPEN , IssueState .CLOSED
102+ ),
103+ IssueTransitionType .CLOSE_AS_NOT_PLANNED : StateTransition (
104+ IssueState .OPEN , IssueState .CLOSED
105+ ),
106+ IssueTransitionType .REOPEN : StateTransition (
107+ IssueState .CLOSED , IssueState .OPEN
108+ ),
109+ }
110+ )
111+ )
112+
113+
114+ ### Exceptions ###
115+
116+ ### Value Objects ###
117+
118+
37119### Events ###
38120class IssueEvent (BaseEvent ):
39121 event_id : str
40122 timestamp : datetime
41123 issue_number : int
42124 issue_state : IssueState
43- issue_state_transition : IssueStateTransition
125+ issue_state_transition : IssueTransitionState
44126 issue_event_type : IssueEventType
45- changes : dict [str , any ]
127+ changes : dict [str , Any ]
46128 previous_title : str
47129 previous_body : str
48130 assignee : Optional [str ]
@@ -54,13 +136,13 @@ def __init__(
54136 timestamp : datetime ,
55137 issue_number : int ,
56138 issue_state : IssueState ,
57- issue_state_transition : IssueStateTransition ,
139+ issue_state_transition : IssueTransitionState ,
58140 issue_event_type : IssueEventType ,
59- changes : dict [str , any ],
141+ changes : dict [str , Any ],
60142 previous_title : str ,
61143 previous_body : str ,
62144 assignee : Optional [str ] = None ,
63- label : Optional [str ] = None
145+ label : Optional [str ] = None ,
64146 ):
65147 super ().__init__ ()
66148 self .event_id = event_id
@@ -75,6 +157,7 @@ def __init__(
75157 self .assignee = assignee
76158 self .label = label
77159
160+
78161## potential sub-events
79162# - connected
80163# - disconnected
@@ -95,19 +178,11 @@ def __init__(
95178# - user_blocked
96179# - commented OR IssueCommentEvent
97180
98- ### Commands ###
99- class IssueCommand (BaseCommand ):
100- command_id : str
101- timestamp : datetime
102- issue_number : int
103-
104- # might need specification pattern here
105- def validate (self ) -> bool :
106- return True
107181
108182### Entitites ###
109183class Issue (AggregateRoot ):
110184 issue_number : int
185+ issue_state : IssueState = IssueState .OPEN
111186
112187 def process (self , command : BaseCommand ) -> list [BaseEvent ]:
113188 if command .validate ():
@@ -120,6 +195,13 @@ def apply(self, event: BaseEvent) -> None:
120195
121196 # likely need a handler method here for 'domain' events and not aggregate events
122197
123- ### Exceptions ###
124198
125- ### Value Objects ###
199+ ### Commands ###
200+ class IssueCommand (BaseCommand ):
201+ command_id : str
202+ timestamp : datetime
203+ issue : Issue
204+
205+ # might need specification pattern here
206+ def validate (self ) -> bool :
207+ return True
0 commit comments