baobab-ai-dev-core 1.0.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.
- baobab_ai_dev_core/__init__.py +27 -0
- baobab_ai_dev_core/domain/__init__.py +5 -0
- baobab_ai_dev_core/domain/entities/__init__.py +13 -0
- baobab_ai_dev_core/domain/entities/ai_provider.py +134 -0
- baobab_ai_dev_core/domain/entities/artifact.py +91 -0
- baobab_ai_dev_core/domain/entities/backlog.py +171 -0
- baobab_ai_dev_core/domain/entities/blocker.py +110 -0
- baobab_ai_dev_core/domain/entities/feature.py +114 -0
- baobab_ai_dev_core/domain/entities/job.py +215 -0
- baobab_ai_dev_core/domain/entities/project.py +157 -0
- baobab_ai_dev_core/domain/entities/pull_request.py +85 -0
- baobab_ai_dev_core/domain/entities/quality_check.py +137 -0
- baobab_ai_dev_core/domain/entities/user_story.py +114 -0
- baobab_ai_dev_core/domain/entities/workflow_run.py +275 -0
- baobab_ai_dev_core/domain/entities/workflow_step.py +252 -0
- baobab_ai_dev_core/domain/enums/__init__.py +35 -0
- baobab_ai_dev_core/domain/enums/ai_provider_status.py +18 -0
- baobab_ai_dev_core/domain/enums/ai_provider_type.py +16 -0
- baobab_ai_dev_core/domain/enums/artifact_type.py +12 -0
- baobab_ai_dev_core/domain/enums/blocker_severity.py +16 -0
- baobab_ai_dev_core/domain/enums/branch_type.py +16 -0
- baobab_ai_dev_core/domain/enums/job_status.py +19 -0
- baobab_ai_dev_core/domain/enums/merge_decision.py +14 -0
- baobab_ai_dev_core/domain/enums/project_status.py +16 -0
- baobab_ai_dev_core/domain/enums/pull_request_status.py +12 -0
- baobab_ai_dev_core/domain/enums/quality_check_status.py +18 -0
- baobab_ai_dev_core/domain/enums/quality_check_type.py +15 -0
- baobab_ai_dev_core/domain/enums/quality_gate_status.py +14 -0
- baobab_ai_dev_core/domain/enums/work_item_status.py +20 -0
- baobab_ai_dev_core/domain/enums/workflow_run_status.py +19 -0
- baobab_ai_dev_core/domain/enums/workflow_step_status.py +19 -0
- baobab_ai_dev_core/domain/exceptions/__init__.py +41 -0
- baobab_ai_dev_core/domain/exceptions/baobab_ai_dev_core_error.py +14 -0
- baobab_ai_dev_core/domain/exceptions/domain_validation_error.py +17 -0
- baobab_ai_dev_core/domain/exceptions/hierarchy_violation_error.py +13 -0
- baobab_ai_dev_core/domain/exceptions/invalid_branch_name_error.py +13 -0
- baobab_ai_dev_core/domain/exceptions/invalid_identifier_error.py +15 -0
- baobab_ai_dev_core/domain/exceptions/invalid_status_transition_error.py +14 -0
- baobab_ai_dev_core/domain/exceptions/merge_not_allowed_error.py +13 -0
- baobab_ai_dev_core/domain/exceptions/provider_unavailable_error.py +13 -0
- baobab_ai_dev_core/domain/exceptions/quality_gate_failed_error.py +13 -0
- baobab_ai_dev_core/domain/policies/__init__.py +7 -0
- baobab_ai_dev_core/domain/policies/branch_policy.py +93 -0
- baobab_ai_dev_core/domain/policies/merge_eligibility_policy.py +91 -0
- baobab_ai_dev_core/domain/policies/provider_fallback_policy.py +40 -0
- baobab_ai_dev_core/domain/policies/pull_request_target_policy.py +51 -0
- baobab_ai_dev_core/domain/policies/quality_gate_policy.py +45 -0
- baobab_ai_dev_core/domain/policies/status_transition_policy.py +159 -0
- baobab_ai_dev_core/domain/policies/work_item_hierarchy_policy.py +159 -0
- baobab_ai_dev_core/domain/protocols/__init__.py +1 -0
- baobab_ai_dev_core/domain/protocols/ai_provider_protocol.py +25 -0
- baobab_ai_dev_core/domain/protocols/backlog_repository_protocol.py +31 -0
- baobab_ai_dev_core/domain/protocols/clock_protocol.py +17 -0
- baobab_ai_dev_core/domain/protocols/feature_repository_protocol.py +31 -0
- baobab_ai_dev_core/domain/protocols/git_client_protocol.py +37 -0
- baobab_ai_dev_core/domain/protocols/project_repository_protocol.py +36 -0
- baobab_ai_dev_core/domain/protocols/pull_request_client_protocol.py +38 -0
- baobab_ai_dev_core/domain/protocols/quality_check_repository_protocol.py +31 -0
- baobab_ai_dev_core/domain/protocols/quality_runner_protocol.py +21 -0
- baobab_ai_dev_core/domain/protocols/user_story_repository_protocol.py +31 -0
- baobab_ai_dev_core/domain/protocols/workflow_run_repository_protocol.py +31 -0
- baobab_ai_dev_core/domain/value_objects/__init__.py +23 -0
- baobab_ai_dev_core/domain/value_objects/backlog_code.py +38 -0
- baobab_ai_dev_core/domain/value_objects/branch_name.py +115 -0
- baobab_ai_dev_core/domain/value_objects/domain_description.py +39 -0
- baobab_ai_dev_core/domain/value_objects/domain_title.py +35 -0
- baobab_ai_dev_core/domain/value_objects/entity_id.py +39 -0
- baobab_ai_dev_core/domain/value_objects/feature_code.py +38 -0
- baobab_ai_dev_core/domain/value_objects/project_slug.py +34 -0
- baobab_ai_dev_core/domain/value_objects/semantic_version.py +58 -0
- baobab_ai_dev_core/domain/value_objects/user_story_code.py +38 -0
- baobab_ai_dev_core/py.typed +0 -0
- baobab_ai_dev_core-1.0.0.dist-info/METADATA +152 -0
- baobab_ai_dev_core-1.0.0.dist-info/RECORD +77 -0
- baobab_ai_dev_core-1.0.0.dist-info/WHEEL +5 -0
- baobab_ai_dev_core-1.0.0.dist-info/licenses/LICENSE +21 -0
- baobab_ai_dev_core-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""baobab-ai-dev-core — noyau métier Python pur de l'écosystème baobab-ai-development.
|
|
2
|
+
|
|
3
|
+
Ce package expose les briques de domaine (entités, objets de valeur, enums,
|
|
4
|
+
exceptions, politiques et contrats ``Protocol``) consommées par les modules
|
|
5
|
+
database, documents, git, quality, providers, workflow, API, CLI et workers.
|
|
6
|
+
|
|
7
|
+
Convention d'API publique
|
|
8
|
+
--------------------------
|
|
9
|
+
- Point d'entrée unique : les consommateurs importent depuis ``baobab_ai_dev_core``
|
|
10
|
+
(``from baobab_ai_dev_core import ...``), jamais depuis les sous-modules internes.
|
|
11
|
+
- Contrat explicite : seuls les symboles listés dans :data:`__all__` font partie de
|
|
12
|
+
l'API publique stable. Tout symbole absent de ``__all__`` est considéré interne et
|
|
13
|
+
peut changer sans préavis.
|
|
14
|
+
- Compatibilité : ``__all__`` est maintenu trié pour limiter les diffs et garantir un
|
|
15
|
+
ordre déterministe. Les sous-packages du domaine (``domain.entities``,
|
|
16
|
+
``domain.enums``, ``domain.value_objects``, ``domain.policies``,
|
|
17
|
+
``domain.protocols``, ``domain.exceptions``) y seront re-exportés au fur et à mesure
|
|
18
|
+
de leur stabilisation dans les user stories suivantes.
|
|
19
|
+
|
|
20
|
+
À ce stade, seul le numéro de version est exposé publiquement via ``__all__``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__version__ = "1.0.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Entités métier du domaine baobab-ai-dev-core."""
|
|
2
|
+
|
|
3
|
+
from baobab_ai_dev_core.domain.entities.backlog import Backlog
|
|
4
|
+
from baobab_ai_dev_core.domain.entities.feature import Feature
|
|
5
|
+
from baobab_ai_dev_core.domain.entities.project import Project
|
|
6
|
+
from baobab_ai_dev_core.domain.entities.user_story import UserStory
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Backlog",
|
|
10
|
+
"Feature",
|
|
11
|
+
"Project",
|
|
12
|
+
"UserStory",
|
|
13
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Entité métier représentant un provider IA au niveau domaine."""
|
|
2
|
+
|
|
3
|
+
from baobab_ai_dev_core.domain.enums.ai_provider_status import AiProviderStatus
|
|
4
|
+
from baobab_ai_dev_core.domain.enums.ai_provider_type import AiProviderType
|
|
5
|
+
from baobab_ai_dev_core.domain.exceptions.domain_validation_error import (
|
|
6
|
+
DomainValidationError,
|
|
7
|
+
)
|
|
8
|
+
from baobab_ai_dev_core.domain.value_objects.domain_title import DomainTitle
|
|
9
|
+
from baobab_ai_dev_core.domain.value_objects.entity_id import EntityId
|
|
10
|
+
|
|
11
|
+
_PRIORITE_MIN = 1
|
|
12
|
+
_QUOTA_OK = "ok"
|
|
13
|
+
_QUOTA_EXHAUSTED = "usage_exhausted"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _valider_priorite(priorite: int) -> int:
|
|
17
|
+
if priorite < _PRIORITE_MIN:
|
|
18
|
+
raise DomainValidationError(
|
|
19
|
+
f"La priorité doit être >= {_PRIORITE_MIN} (reçue : {priorite})."
|
|
20
|
+
)
|
|
21
|
+
return priorite
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _valider_quota_state(quota_state: str) -> str:
|
|
25
|
+
nettoye = quota_state.strip()
|
|
26
|
+
if nettoye not in {_QUOTA_OK, _QUOTA_EXHAUSTED}:
|
|
27
|
+
raise DomainValidationError(
|
|
28
|
+
f"quota_state invalide : '{quota_state}'. "
|
|
29
|
+
f"Valeurs attendues : '{_QUOTA_OK}', '{_QUOTA_EXHAUSTED}'."
|
|
30
|
+
)
|
|
31
|
+
return nettoye
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _valider_coherence_statut_quota(
|
|
35
|
+
status: AiProviderStatus,
|
|
36
|
+
quota_state: str,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Vérifie l'alignement entre statut et quota."""
|
|
39
|
+
if status is AiProviderStatus.USAGE_EXHAUSTED and quota_state != _QUOTA_EXHAUSTED:
|
|
40
|
+
raise DomainValidationError(
|
|
41
|
+
"Le statut 'usage_exhausted' exige quota_state='usage_exhausted'."
|
|
42
|
+
)
|
|
43
|
+
if status is AiProviderStatus.AVAILABLE and quota_state != _QUOTA_OK:
|
|
44
|
+
raise DomainValidationError(
|
|
45
|
+
"Le statut 'available' exige quota_state='ok'."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AiProvider:
|
|
50
|
+
"""Provider IA métier avec priorité, statut et état de quota.
|
|
51
|
+
|
|
52
|
+
Ne contient aucun client technique : l'intégration relève du module
|
|
53
|
+
``providers``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__( # noqa: PLR0913
|
|
57
|
+
self,
|
|
58
|
+
provider_id: EntityId,
|
|
59
|
+
name: DomainTitle,
|
|
60
|
+
provider_type: AiProviderType,
|
|
61
|
+
status: AiProviderStatus,
|
|
62
|
+
priority: int,
|
|
63
|
+
quota_state: str,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Construit un provider en validant priorité et quota."""
|
|
66
|
+
priorite = _valider_priorite(priority)
|
|
67
|
+
quota = _valider_quota_state(quota_state)
|
|
68
|
+
_valider_coherence_statut_quota(status, quota)
|
|
69
|
+
self._provider_id = provider_id
|
|
70
|
+
self._name = name
|
|
71
|
+
self._provider_type = provider_type
|
|
72
|
+
self._status = status
|
|
73
|
+
self._priority = priorite
|
|
74
|
+
self._quota_state = quota
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def provider_id(self) -> EntityId:
|
|
78
|
+
"""Identifiant stable du provider."""
|
|
79
|
+
return self._provider_id
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def name(self) -> DomainTitle:
|
|
83
|
+
"""Nom affiché du provider."""
|
|
84
|
+
return self._name
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def provider_type(self) -> AiProviderType:
|
|
88
|
+
"""Famille technique du provider."""
|
|
89
|
+
return self._provider_type
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def status(self) -> AiProviderStatus:
|
|
93
|
+
"""Statut de disponibilité courant."""
|
|
94
|
+
return self._status
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def priority(self) -> int:
|
|
98
|
+
"""Priorité de sélection (1 = la plus haute)."""
|
|
99
|
+
return self._priority
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def quota_state(self) -> str:
|
|
103
|
+
"""État du quota (``ok`` ou ``usage_exhausted``)."""
|
|
104
|
+
return self._quota_state
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def is_selectable(self) -> bool:
|
|
108
|
+
"""Indique si le provider peut être sélectionné pour un run."""
|
|
109
|
+
return (
|
|
110
|
+
self._status is AiProviderStatus.AVAILABLE
|
|
111
|
+
and self._quota_state == _QUOTA_OK
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def mark_usage_exhausted(self) -> None:
|
|
115
|
+
"""Marque le quota comme épuisé (distinct de indisponible ou erreur)."""
|
|
116
|
+
self._status = AiProviderStatus.USAGE_EXHAUSTED
|
|
117
|
+
self._quota_state = _QUOTA_EXHAUSTED
|
|
118
|
+
|
|
119
|
+
def mark_error(self) -> None:
|
|
120
|
+
"""Marque une erreur technique sans confondre avec quota épuisé."""
|
|
121
|
+
self._status = AiProviderStatus.ERROR
|
|
122
|
+
|
|
123
|
+
def disable(self) -> None:
|
|
124
|
+
"""Désactive volontairement le provider."""
|
|
125
|
+
self._status = AiProviderStatus.DISABLED
|
|
126
|
+
|
|
127
|
+
def mark_unavailable(self) -> None:
|
|
128
|
+
"""Marque une indisponibilité ponctuelle."""
|
|
129
|
+
self._status = AiProviderStatus.UNAVAILABLE
|
|
130
|
+
|
|
131
|
+
def restore_available(self) -> None:
|
|
132
|
+
"""Restaure un provider disponible avec quota OK."""
|
|
133
|
+
self._status = AiProviderStatus.AVAILABLE
|
|
134
|
+
self._quota_state = _QUOTA_OK
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Entité métier représentant un livrable produit pendant une exécution."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from baobab_ai_dev_core.domain.enums.artifact_type import ArtifactType
|
|
7
|
+
from baobab_ai_dev_core.domain.exceptions.domain_validation_error import (
|
|
8
|
+
DomainValidationError,
|
|
9
|
+
)
|
|
10
|
+
from baobab_ai_dev_core.domain.value_objects.entity_id import EntityId
|
|
11
|
+
|
|
12
|
+
_CHEMIN_RELATIF_PATTERN = re.compile(r"^[a-zA-Z0-9_./-]+$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _valider_chemin(path: str) -> str:
|
|
16
|
+
nettoye = path.strip()
|
|
17
|
+
if not nettoye:
|
|
18
|
+
raise DomainValidationError("Le chemin d'artefact ne peut pas être vide.")
|
|
19
|
+
if nettoye.startswith("/") or ".." in nettoye.split("/"):
|
|
20
|
+
raise DomainValidationError(
|
|
21
|
+
f"Le chemin '{nettoye}' doit être relatif et ne pas contenir '..'."
|
|
22
|
+
)
|
|
23
|
+
if not _CHEMIN_RELATIF_PATTERN.fullmatch(nettoye):
|
|
24
|
+
raise DomainValidationError(
|
|
25
|
+
f"Le chemin '{nettoye}' contient des caractères non autorisés."
|
|
26
|
+
)
|
|
27
|
+
return nettoye
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _valider_checksum(checksum: str | None) -> str | None:
|
|
31
|
+
if checksum is None:
|
|
32
|
+
return None
|
|
33
|
+
nettoye = checksum.strip()
|
|
34
|
+
if not nettoye:
|
|
35
|
+
raise DomainValidationError(
|
|
36
|
+
"Le checksum ne peut pas être vide lorsqu'il est renseigné."
|
|
37
|
+
)
|
|
38
|
+
return nettoye
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Artifact:
|
|
42
|
+
"""Livrable généré pendant un workflow run (patch, rapport, fichier, etc.)."""
|
|
43
|
+
|
|
44
|
+
def __init__( # noqa: PLR0913
|
|
45
|
+
self,
|
|
46
|
+
artifact_id: EntityId,
|
|
47
|
+
workflow_run_id: EntityId,
|
|
48
|
+
artifact_type: ArtifactType,
|
|
49
|
+
path: str,
|
|
50
|
+
checksum: str | None,
|
|
51
|
+
created_at: datetime,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Construit un artefact en validant le chemin relatif et le checksum."""
|
|
54
|
+
chemin = _valider_chemin(path)
|
|
55
|
+
empreinte = _valider_checksum(checksum)
|
|
56
|
+
self._artifact_id = artifact_id
|
|
57
|
+
self._workflow_run_id = workflow_run_id
|
|
58
|
+
self._artifact_type = artifact_type
|
|
59
|
+
self._path = chemin
|
|
60
|
+
self._checksum = empreinte
|
|
61
|
+
self._created_at = created_at
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def artifact_id(self) -> EntityId:
|
|
65
|
+
"""Identifiant stable de l'artefact."""
|
|
66
|
+
return self._artifact_id
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def workflow_run_id(self) -> EntityId:
|
|
70
|
+
"""Identifiant du run ayant produit l'artefact."""
|
|
71
|
+
return self._workflow_run_id
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def artifact_type(self) -> ArtifactType:
|
|
75
|
+
"""Type métier de l'artefact."""
|
|
76
|
+
return self._artifact_type
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def path(self) -> str:
|
|
80
|
+
"""Chemin relatif validé de l'artefact."""
|
|
81
|
+
return self._path
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def checksum(self) -> str | None:
|
|
85
|
+
"""Empreinte facultative du contenu."""
|
|
86
|
+
return self._checksum
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def created_at(self) -> datetime:
|
|
90
|
+
"""Horodatage de création."""
|
|
91
|
+
return self._created_at
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Entité métier représentant un backlog exécutable par une IA."""
|
|
2
|
+
|
|
3
|
+
from baobab_ai_dev_core.domain.enums.branch_type import BranchType
|
|
4
|
+
from baobab_ai_dev_core.domain.enums.work_item_status import WorkItemStatus
|
|
5
|
+
from baobab_ai_dev_core.domain.exceptions.domain_validation_error import (
|
|
6
|
+
DomainValidationError,
|
|
7
|
+
)
|
|
8
|
+
from baobab_ai_dev_core.domain.value_objects.backlog_code import BacklogCode
|
|
9
|
+
from baobab_ai_dev_core.domain.value_objects.branch_name import BranchName
|
|
10
|
+
from baobab_ai_dev_core.domain.value_objects.domain_description import DomainDescription
|
|
11
|
+
from baobab_ai_dev_core.domain.value_objects.domain_title import DomainTitle
|
|
12
|
+
from baobab_ai_dev_core.domain.value_objects.entity_id import EntityId
|
|
13
|
+
|
|
14
|
+
_COMPLEXITE_MIN = 1
|
|
15
|
+
_COMPLEXITE_MAX = 13
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _valider_acceptance_criteria(criteres: tuple[str, ...]) -> tuple[str, ...]:
|
|
19
|
+
"""Normalise et valide les critères d'acceptation d'un backlog."""
|
|
20
|
+
if not criteres:
|
|
21
|
+
raise DomainValidationError(
|
|
22
|
+
"Les acceptance_criteria doivent contenir au moins un critère non vide."
|
|
23
|
+
)
|
|
24
|
+
normalises: list[str] = []
|
|
25
|
+
for index, critere in enumerate(criteres):
|
|
26
|
+
nettoye = critere.strip()
|
|
27
|
+
if not nettoye:
|
|
28
|
+
raise DomainValidationError(
|
|
29
|
+
f"Le critère d'acceptation à l'index {index} ne peut pas être vide."
|
|
30
|
+
)
|
|
31
|
+
normalises.append(nettoye)
|
|
32
|
+
return tuple(normalises)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _valider_chemins_attendus(
|
|
36
|
+
chemins: tuple[str, ...],
|
|
37
|
+
libelle: str,
|
|
38
|
+
) -> tuple[str, ...]:
|
|
39
|
+
"""Valide une liste non vide de chemins relatifs attendus."""
|
|
40
|
+
if not chemins:
|
|
41
|
+
raise DomainValidationError(
|
|
42
|
+
f"Les {libelle} doivent contenir au moins un chemin non vide."
|
|
43
|
+
)
|
|
44
|
+
normalises: list[str] = []
|
|
45
|
+
for index, chemin in enumerate(chemins):
|
|
46
|
+
nettoye = chemin.strip()
|
|
47
|
+
if not nettoye:
|
|
48
|
+
raise DomainValidationError(
|
|
49
|
+
f"Le chemin {libelle} à l'index {index} ne peut pas être vide."
|
|
50
|
+
)
|
|
51
|
+
normalises.append(nettoye)
|
|
52
|
+
return tuple(normalises)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _valider_complexite_estimee(complexite: int) -> int:
|
|
56
|
+
"""Valide la complexité estimée sur une échelle de story points 1 à 13."""
|
|
57
|
+
if complexite < _COMPLEXITE_MIN or complexite > _COMPLEXITE_MAX:
|
|
58
|
+
raise DomainValidationError(
|
|
59
|
+
f"estimated_complexity doit être entre {_COMPLEXITE_MIN} et "
|
|
60
|
+
f"{_COMPLEXITE_MAX} (reçu : {complexite})."
|
|
61
|
+
)
|
|
62
|
+
return complexite
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _valider_branche_backlog(code: BacklogCode, branch_name: BranchName) -> None:
|
|
66
|
+
"""Vérifie que la branche Git correspond au code du backlog."""
|
|
67
|
+
if branch_name.branch_type is not BranchType.BACKLOG:
|
|
68
|
+
raise DomainValidationError(
|
|
69
|
+
f"La branche '{branch_name.value}' n'est pas une branche backlog (bl/*)."
|
|
70
|
+
)
|
|
71
|
+
branche_code = branch_name.backlog_code
|
|
72
|
+
if branche_code is None or branche_code != code:
|
|
73
|
+
raise DomainValidationError(
|
|
74
|
+
f"Le code de branche ({branche_code}) ne correspond pas au code "
|
|
75
|
+
f"du backlog ({code})."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Backlog:
|
|
80
|
+
"""Unité de travail exécutable portée par une branche ``bl/FEAT-XXX/BL-XXX-*``.
|
|
81
|
+
|
|
82
|
+
Définit un périmètre strict via les fichiers et tests attendus, une complexité
|
|
83
|
+
estimée et des critères d'acceptation pour guider l'IA de développement.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__( # noqa: PLR0913
|
|
87
|
+
self,
|
|
88
|
+
backlog_id: EntityId,
|
|
89
|
+
feature_id: EntityId,
|
|
90
|
+
code: BacklogCode,
|
|
91
|
+
title: DomainTitle,
|
|
92
|
+
description: DomainDescription,
|
|
93
|
+
status: WorkItemStatus,
|
|
94
|
+
acceptance_criteria: tuple[str, ...],
|
|
95
|
+
branch_name: BranchName,
|
|
96
|
+
estimated_complexity: int,
|
|
97
|
+
expected_files: tuple[str, ...],
|
|
98
|
+
expected_tests: tuple[str, ...],
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Construit un backlog en validant branche, périmètre et critères."""
|
|
101
|
+
criteres = _valider_acceptance_criteria(acceptance_criteria)
|
|
102
|
+
fichiers = _valider_chemins_attendus(expected_files, "expected_files")
|
|
103
|
+
tests = _valider_chemins_attendus(expected_tests, "expected_tests")
|
|
104
|
+
complexite = _valider_complexite_estimee(estimated_complexity)
|
|
105
|
+
_valider_branche_backlog(code, branch_name)
|
|
106
|
+
self._backlog_id = backlog_id
|
|
107
|
+
self._feature_id = feature_id
|
|
108
|
+
self._code = code
|
|
109
|
+
self._title = title
|
|
110
|
+
self._description = description
|
|
111
|
+
self._status = status
|
|
112
|
+
self._acceptance_criteria = criteres
|
|
113
|
+
self._branch_name = branch_name
|
|
114
|
+
self._estimated_complexity = complexite
|
|
115
|
+
self._expected_files = fichiers
|
|
116
|
+
self._expected_tests = tests
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def backlog_id(self) -> EntityId:
|
|
120
|
+
"""Identifiant stable du backlog."""
|
|
121
|
+
return self._backlog_id
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def feature_id(self) -> EntityId:
|
|
125
|
+
"""Identifiant de la feature parente."""
|
|
126
|
+
return self._feature_id
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def code(self) -> BacklogCode:
|
|
130
|
+
"""Code métier au format ``BL-XXX``."""
|
|
131
|
+
return self._code
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def title(self) -> DomainTitle:
|
|
135
|
+
"""Titre du backlog."""
|
|
136
|
+
return self._title
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def description(self) -> DomainDescription:
|
|
140
|
+
"""Description métier et périmètre fonctionnel."""
|
|
141
|
+
return self._description
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def status(self) -> WorkItemStatus:
|
|
145
|
+
"""Statut courant du work item."""
|
|
146
|
+
return self._status
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def acceptance_criteria(self) -> tuple[str, ...]:
|
|
150
|
+
"""Critères d'acceptation normalisés."""
|
|
151
|
+
return self._acceptance_criteria
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def branch_name(self) -> BranchName:
|
|
155
|
+
"""Branche Git conforme ``bl/FEAT-XXX/BL-XXX-*``."""
|
|
156
|
+
return self._branch_name
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def estimated_complexity(self) -> int:
|
|
160
|
+
"""Complexité estimée (story points de 1 à 13)."""
|
|
161
|
+
return self._estimated_complexity
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def expected_files(self) -> tuple[str, ...]:
|
|
165
|
+
"""Chemins des fichiers source attendus dans le périmètre."""
|
|
166
|
+
return self._expected_files
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def expected_tests(self) -> tuple[str, ...]:
|
|
170
|
+
"""Chemins des fichiers de test attendus dans le périmètre."""
|
|
171
|
+
return self._expected_tests
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Entité métier représentant un blocage sur une exécution."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from baobab_ai_dev_core.domain.enums.blocker_severity import BlockerSeverity
|
|
6
|
+
from baobab_ai_dev_core.domain.exceptions.domain_validation_error import (
|
|
7
|
+
DomainValidationError,
|
|
8
|
+
)
|
|
9
|
+
from baobab_ai_dev_core.domain.value_objects.entity_id import EntityId
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _valider_message(message: str) -> str:
|
|
13
|
+
nettoye = message.strip()
|
|
14
|
+
if not nettoye:
|
|
15
|
+
raise DomainValidationError("Le message du blocker ne peut pas être vide.")
|
|
16
|
+
return nettoye
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _valider_type_cible(target_type: str) -> str:
|
|
20
|
+
nettoye = target_type.strip()
|
|
21
|
+
if not nettoye:
|
|
22
|
+
raise DomainValidationError("Le target_type du blocker ne peut pas être vide.")
|
|
23
|
+
return nettoye
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Blocker:
|
|
27
|
+
"""Blocage métier ou technique empêchant la progression d'une exécution."""
|
|
28
|
+
|
|
29
|
+
def __init__( # noqa: PLR0913
|
|
30
|
+
self,
|
|
31
|
+
blocker_id: EntityId,
|
|
32
|
+
target_id: EntityId,
|
|
33
|
+
target_type: str,
|
|
34
|
+
severity: BlockerSeverity,
|
|
35
|
+
message: str,
|
|
36
|
+
created_at: datetime,
|
|
37
|
+
resolved_at: datetime | None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Construit un blocker en validant message, cible et résolution."""
|
|
40
|
+
type_cible = _valider_type_cible(target_type)
|
|
41
|
+
texte = _valider_message(message)
|
|
42
|
+
if resolved_at is not None and resolved_at < created_at:
|
|
43
|
+
raise DomainValidationError(
|
|
44
|
+
"resolved_at ne peut pas être antérieur à created_at pour un Blocker."
|
|
45
|
+
)
|
|
46
|
+
self._blocker_id = blocker_id
|
|
47
|
+
self._target_id = target_id
|
|
48
|
+
self._target_type = type_cible
|
|
49
|
+
self._severity = severity
|
|
50
|
+
self._message = texte
|
|
51
|
+
self._created_at = created_at
|
|
52
|
+
self._resolved_at = resolved_at
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def blocker_id(self) -> EntityId:
|
|
56
|
+
"""Identifiant stable du blocker."""
|
|
57
|
+
return self._blocker_id
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def target_id(self) -> EntityId:
|
|
61
|
+
"""Identifiant de la cible bloquée."""
|
|
62
|
+
return self._target_id
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def target_type(self) -> str:
|
|
66
|
+
"""Type de la cible (run, step, job, etc.)."""
|
|
67
|
+
return self._target_type
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def severity(self) -> BlockerSeverity:
|
|
71
|
+
"""Niveau de sévérité du blocage."""
|
|
72
|
+
return self._severity
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def message(self) -> str:
|
|
76
|
+
"""Message explicite du blocage."""
|
|
77
|
+
return self._message
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def created_at(self) -> datetime:
|
|
81
|
+
"""Horodatage de création."""
|
|
82
|
+
return self._created_at
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def resolved_at(self) -> datetime | None:
|
|
86
|
+
"""Horodatage de résolution, le cas échéant."""
|
|
87
|
+
return self._resolved_at
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def is_resolved(self) -> bool:
|
|
91
|
+
"""Indique si le blocker est résolu."""
|
|
92
|
+
return self._resolved_at is not None
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def is_critical(self) -> bool:
|
|
96
|
+
"""Un blocker critique empêche la progression du run."""
|
|
97
|
+
return self._severity is BlockerSeverity.CRITICAL
|
|
98
|
+
|
|
99
|
+
def resolve(self, at: datetime) -> None:
|
|
100
|
+
"""Marque le blocker comme résolu en conservant l'historique."""
|
|
101
|
+
if self._resolved_at is not None:
|
|
102
|
+
raise DomainValidationError(
|
|
103
|
+
f"Le blocker '{self._blocker_id}' est déjà résolu."
|
|
104
|
+
)
|
|
105
|
+
if at < self._created_at:
|
|
106
|
+
raise DomainValidationError(
|
|
107
|
+
f"resolved_at ({at.isoformat()}) ne peut pas être antérieur à "
|
|
108
|
+
f"created_at ({self._created_at.isoformat()})."
|
|
109
|
+
)
|
|
110
|
+
self._resolved_at = at
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Entité métier représentant une feature."""
|
|
2
|
+
|
|
3
|
+
from baobab_ai_dev_core.domain.enums.branch_type import BranchType
|
|
4
|
+
from baobab_ai_dev_core.domain.enums.work_item_status import WorkItemStatus
|
|
5
|
+
from baobab_ai_dev_core.domain.exceptions.domain_validation_error import (
|
|
6
|
+
DomainValidationError,
|
|
7
|
+
)
|
|
8
|
+
from baobab_ai_dev_core.domain.value_objects.branch_name import BranchName
|
|
9
|
+
from baobab_ai_dev_core.domain.value_objects.domain_description import DomainDescription
|
|
10
|
+
from baobab_ai_dev_core.domain.value_objects.domain_title import DomainTitle
|
|
11
|
+
from baobab_ai_dev_core.domain.value_objects.entity_id import EntityId
|
|
12
|
+
from baobab_ai_dev_core.domain.value_objects.feature_code import FeatureCode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _valider_acceptance_criteria(criteres: tuple[str, ...]) -> tuple[str, ...]:
|
|
16
|
+
"""Normalise et valide les critères d'acceptation d'une feature."""
|
|
17
|
+
if not criteres:
|
|
18
|
+
raise DomainValidationError(
|
|
19
|
+
"Les acceptance_criteria doivent contenir au moins un critère non vide."
|
|
20
|
+
)
|
|
21
|
+
normalises: list[str] = []
|
|
22
|
+
for index, critere in enumerate(criteres):
|
|
23
|
+
nettoye = critere.strip()
|
|
24
|
+
if not nettoye:
|
|
25
|
+
raise DomainValidationError(
|
|
26
|
+
f"Le critère d'acceptation à l'index {index} ne peut pas être vide."
|
|
27
|
+
)
|
|
28
|
+
normalises.append(nettoye)
|
|
29
|
+
return tuple(normalises)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _valider_branche_feature(code: FeatureCode, branch_name: BranchName) -> None:
|
|
33
|
+
"""Vérifie que la branche Git correspond au code de la feature."""
|
|
34
|
+
if branch_name.branch_type is not BranchType.FEATURE:
|
|
35
|
+
raise DomainValidationError(
|
|
36
|
+
f"La branche '{branch_name.value}' n'est pas une branche feature (feat/*)."
|
|
37
|
+
)
|
|
38
|
+
branche_code = branch_name.feature_code
|
|
39
|
+
if branche_code is None or branche_code != code:
|
|
40
|
+
raise DomainValidationError(
|
|
41
|
+
f"Le code de branche ({branche_code}) ne correspond pas au code "
|
|
42
|
+
f"de la feature ({code})."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Feature:
|
|
47
|
+
"""Capacité livrable portée par une branche ``feat/US-XXX/FEAT-XXX-*``.
|
|
48
|
+
|
|
49
|
+
Appartient à une user story, contient des backlogs et expose ses critères
|
|
50
|
+
d'acceptation et son statut de work item.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__( # noqa: PLR0913
|
|
54
|
+
self,
|
|
55
|
+
feature_id: EntityId,
|
|
56
|
+
user_story_id: EntityId,
|
|
57
|
+
code: FeatureCode,
|
|
58
|
+
title: DomainTitle,
|
|
59
|
+
description: DomainDescription,
|
|
60
|
+
status: WorkItemStatus,
|
|
61
|
+
acceptance_criteria: tuple[str, ...],
|
|
62
|
+
branch_name: BranchName,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Construit une feature en validant branche et critères."""
|
|
65
|
+
criteres = _valider_acceptance_criteria(acceptance_criteria)
|
|
66
|
+
_valider_branche_feature(code, branch_name)
|
|
67
|
+
self._feature_id = feature_id
|
|
68
|
+
self._user_story_id = user_story_id
|
|
69
|
+
self._code = code
|
|
70
|
+
self._title = title
|
|
71
|
+
self._description = description
|
|
72
|
+
self._status = status
|
|
73
|
+
self._acceptance_criteria = criteres
|
|
74
|
+
self._branch_name = branch_name
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def feature_id(self) -> EntityId:
|
|
78
|
+
"""Identifiant stable de la feature."""
|
|
79
|
+
return self._feature_id
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def user_story_id(self) -> EntityId:
|
|
83
|
+
"""Identifiant de la user story parente."""
|
|
84
|
+
return self._user_story_id
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def code(self) -> FeatureCode:
|
|
88
|
+
"""Code métier au format ``FEAT-XXX``."""
|
|
89
|
+
return self._code
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def title(self) -> DomainTitle:
|
|
93
|
+
"""Titre de la feature."""
|
|
94
|
+
return self._title
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def description(self) -> DomainDescription:
|
|
98
|
+
"""Description métier."""
|
|
99
|
+
return self._description
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def status(self) -> WorkItemStatus:
|
|
103
|
+
"""Statut courant du work item."""
|
|
104
|
+
return self._status
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def acceptance_criteria(self) -> tuple[str, ...]:
|
|
108
|
+
"""Critères d'acceptation normalisés."""
|
|
109
|
+
return self._acceptance_criteria
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def branch_name(self) -> BranchName:
|
|
113
|
+
"""Branche Git conforme ``feat/US-XXX/FEAT-XXX-*``."""
|
|
114
|
+
return self._branch_name
|