Skip to content

Commit 9528ca9

Browse files
authored
Merge pull request #7 from getmarkus/cm-branch-6
feat: add issue command and transition state management
2 parents c3fc154 + 07d1868 commit 9528ca9

File tree

12 files changed

+268
-31
lines changed

12 files changed

+268
-31
lines changed

.trunk/.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
*out
2+
*logs
3+
*actions
4+
*notifications
5+
*tools
6+
plugins
7+
user_trunk.yaml
8+
user.yaml
9+
tmp

.trunk/configs/.isort.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[settings]
2+
profile=black

.trunk/configs/.markdownlint.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Prettier friendly markdownlint config (all formatting rules disabled)
2+
extends: markdownlint/style/prettier

.trunk/configs/ruff.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Generic, formatter-friendly config.
2+
select = ["B", "D3", "E", "F"]
3+
4+
# Never enforce `E501` (line length violations). This should be handled by formatters.
5+
ignore = ["E501"]

.trunk/trunk.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# This file controls the behavior of Trunk: https://docs.trunk.io/cli
2+
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
3+
version: 0.1
4+
cli:
5+
version: 1.22.9
6+
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
7+
plugins:
8+
sources:
9+
- id: trunk
10+
ref: v1.6.6
11+
uri: https://github.com/trunk-io/plugins
12+
# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes)
13+
runtimes:
14+
enabled:
15+
16+
17+
# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration)
18+
lint:
19+
disabled:
20+
- bandit
21+
enabled:
22+
23+
24+
- git-diff-check
25+
26+
27+
28+
29+
30+

config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class Settings(BaseSettings):
2121
# "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]'
2222
backend_cors_origins: List[AnyHttpUrl] = []
2323

24-
model_config = SettingsConfigDict(env_file=".env")
24+
model_config = SettingsConfigDict(env_file=".env", extra="allow")
2525
# `.env.prod` takes priority over `.env`
2626
# env_file=('.env', '.env.prod')
2727

main.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
from contextlib import asynccontextmanager
21
import datetime
32
import os
3+
import uuid
4+
from contextlib import asynccontextmanager
45
from typing import Annotated, Any, Dict, Optional
56

67
from fastapi import Depends, FastAPI
@@ -106,13 +107,50 @@ def create_health_response(is_healthy: bool, check_name: str) -> JSONResponse:
106107
# Register global exception handler
107108
app.add_exception_handler(AppException, app_exception_handler)
108109

110+
# middleware options
111+
# https://levelup.gitconnected.com/17-useful-middlewares-for-fastapi-that-you-should-know-about-951c2b0869c7
112+
# https://fastapi.tiangolo.com/tutorial/middleware/
113+
# https://http.dev/headers
114+
# CORS
115+
# Authorization and Authentication
116+
# Localization
117+
# Caching
118+
# Rate Limiting
119+
# Tracing
120+
# Dependency Injection
121+
# A/B testing
122+
# Metrics
123+
# Route security, ip address and user agent - TrustedHostMiddleware
124+
# gzip compression - GZipMiddleware
125+
# ssl enforcement - HTTPSRedirectMiddleware
126+
127+
@app.middleware("http")
128+
async def add_request_id(request, call_next):
129+
request_id = str(uuid.uuid4())
130+
request.state.request_id = request_id
131+
response = await call_next(request)
132+
response.headers["X-Request-ID"] = request_id
133+
logger.info(f"Request {request_id} to {request.url.path}")
134+
return response
135+
136+
@app.middleware("http")
137+
async def add_process_time_header(request, call_next):
138+
import time
139+
start_time = time.perf_counter()
140+
response = await call_next(request)
141+
process_time = time.perf_counter() - start_time
142+
response.headers["X-Process-Time"] = str(process_time)
143+
logger.info(f"Request to {request.url.path} took {process_time:.4f} seconds")
144+
return response
145+
109146
app.add_middleware(
110147
CORSMiddleware,
111148
allow_origins=["*"], # Set this to lock down if applicable to your use case
112149
# allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
113150
allow_credentials=False, # Must be false if all origins (*) is allowed
114151
allow_methods=["*"],
115-
allow_headers=["X-Forwarded-For", "Authorization", "Content-Type"],
152+
allow_headers=["X-Forwarded-For", "Authorization", "Content-Type", "X-Request-ID"],
153+
expose_headers=["X-Process-Time", "X-Request-ID"]
116154
)
117155

118156

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pytest==8.0.0
2+
pytest-cov==4.1.0
3+
pydantic==2.6.1

src/domain/issue.py

Lines changed: 108 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
from dataclasses import dataclass
12
from 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

57
from src.domain.aggregate_root import AggregateRoot, BaseCommand, BaseEvent
68

@@ -11,19 +13,20 @@
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+
2730
class 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 ###
38120
class 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 ###
109183
class 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

src/resource_adapters/persistence/in_memory/issues.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Callable, Iterable, List
1+
from typing import Callable, List
22

33
from loguru import logger
44

0 commit comments

Comments
 (0)