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.
Files changed (77) hide show
  1. baobab_ai_dev_core/__init__.py +27 -0
  2. baobab_ai_dev_core/domain/__init__.py +5 -0
  3. baobab_ai_dev_core/domain/entities/__init__.py +13 -0
  4. baobab_ai_dev_core/domain/entities/ai_provider.py +134 -0
  5. baobab_ai_dev_core/domain/entities/artifact.py +91 -0
  6. baobab_ai_dev_core/domain/entities/backlog.py +171 -0
  7. baobab_ai_dev_core/domain/entities/blocker.py +110 -0
  8. baobab_ai_dev_core/domain/entities/feature.py +114 -0
  9. baobab_ai_dev_core/domain/entities/job.py +215 -0
  10. baobab_ai_dev_core/domain/entities/project.py +157 -0
  11. baobab_ai_dev_core/domain/entities/pull_request.py +85 -0
  12. baobab_ai_dev_core/domain/entities/quality_check.py +137 -0
  13. baobab_ai_dev_core/domain/entities/user_story.py +114 -0
  14. baobab_ai_dev_core/domain/entities/workflow_run.py +275 -0
  15. baobab_ai_dev_core/domain/entities/workflow_step.py +252 -0
  16. baobab_ai_dev_core/domain/enums/__init__.py +35 -0
  17. baobab_ai_dev_core/domain/enums/ai_provider_status.py +18 -0
  18. baobab_ai_dev_core/domain/enums/ai_provider_type.py +16 -0
  19. baobab_ai_dev_core/domain/enums/artifact_type.py +12 -0
  20. baobab_ai_dev_core/domain/enums/blocker_severity.py +16 -0
  21. baobab_ai_dev_core/domain/enums/branch_type.py +16 -0
  22. baobab_ai_dev_core/domain/enums/job_status.py +19 -0
  23. baobab_ai_dev_core/domain/enums/merge_decision.py +14 -0
  24. baobab_ai_dev_core/domain/enums/project_status.py +16 -0
  25. baobab_ai_dev_core/domain/enums/pull_request_status.py +12 -0
  26. baobab_ai_dev_core/domain/enums/quality_check_status.py +18 -0
  27. baobab_ai_dev_core/domain/enums/quality_check_type.py +15 -0
  28. baobab_ai_dev_core/domain/enums/quality_gate_status.py +14 -0
  29. baobab_ai_dev_core/domain/enums/work_item_status.py +20 -0
  30. baobab_ai_dev_core/domain/enums/workflow_run_status.py +19 -0
  31. baobab_ai_dev_core/domain/enums/workflow_step_status.py +19 -0
  32. baobab_ai_dev_core/domain/exceptions/__init__.py +41 -0
  33. baobab_ai_dev_core/domain/exceptions/baobab_ai_dev_core_error.py +14 -0
  34. baobab_ai_dev_core/domain/exceptions/domain_validation_error.py +17 -0
  35. baobab_ai_dev_core/domain/exceptions/hierarchy_violation_error.py +13 -0
  36. baobab_ai_dev_core/domain/exceptions/invalid_branch_name_error.py +13 -0
  37. baobab_ai_dev_core/domain/exceptions/invalid_identifier_error.py +15 -0
  38. baobab_ai_dev_core/domain/exceptions/invalid_status_transition_error.py +14 -0
  39. baobab_ai_dev_core/domain/exceptions/merge_not_allowed_error.py +13 -0
  40. baobab_ai_dev_core/domain/exceptions/provider_unavailable_error.py +13 -0
  41. baobab_ai_dev_core/domain/exceptions/quality_gate_failed_error.py +13 -0
  42. baobab_ai_dev_core/domain/policies/__init__.py +7 -0
  43. baobab_ai_dev_core/domain/policies/branch_policy.py +93 -0
  44. baobab_ai_dev_core/domain/policies/merge_eligibility_policy.py +91 -0
  45. baobab_ai_dev_core/domain/policies/provider_fallback_policy.py +40 -0
  46. baobab_ai_dev_core/domain/policies/pull_request_target_policy.py +51 -0
  47. baobab_ai_dev_core/domain/policies/quality_gate_policy.py +45 -0
  48. baobab_ai_dev_core/domain/policies/status_transition_policy.py +159 -0
  49. baobab_ai_dev_core/domain/policies/work_item_hierarchy_policy.py +159 -0
  50. baobab_ai_dev_core/domain/protocols/__init__.py +1 -0
  51. baobab_ai_dev_core/domain/protocols/ai_provider_protocol.py +25 -0
  52. baobab_ai_dev_core/domain/protocols/backlog_repository_protocol.py +31 -0
  53. baobab_ai_dev_core/domain/protocols/clock_protocol.py +17 -0
  54. baobab_ai_dev_core/domain/protocols/feature_repository_protocol.py +31 -0
  55. baobab_ai_dev_core/domain/protocols/git_client_protocol.py +37 -0
  56. baobab_ai_dev_core/domain/protocols/project_repository_protocol.py +36 -0
  57. baobab_ai_dev_core/domain/protocols/pull_request_client_protocol.py +38 -0
  58. baobab_ai_dev_core/domain/protocols/quality_check_repository_protocol.py +31 -0
  59. baobab_ai_dev_core/domain/protocols/quality_runner_protocol.py +21 -0
  60. baobab_ai_dev_core/domain/protocols/user_story_repository_protocol.py +31 -0
  61. baobab_ai_dev_core/domain/protocols/workflow_run_repository_protocol.py +31 -0
  62. baobab_ai_dev_core/domain/value_objects/__init__.py +23 -0
  63. baobab_ai_dev_core/domain/value_objects/backlog_code.py +38 -0
  64. baobab_ai_dev_core/domain/value_objects/branch_name.py +115 -0
  65. baobab_ai_dev_core/domain/value_objects/domain_description.py +39 -0
  66. baobab_ai_dev_core/domain/value_objects/domain_title.py +35 -0
  67. baobab_ai_dev_core/domain/value_objects/entity_id.py +39 -0
  68. baobab_ai_dev_core/domain/value_objects/feature_code.py +38 -0
  69. baobab_ai_dev_core/domain/value_objects/project_slug.py +34 -0
  70. baobab_ai_dev_core/domain/value_objects/semantic_version.py +58 -0
  71. baobab_ai_dev_core/domain/value_objects/user_story_code.py +38 -0
  72. baobab_ai_dev_core/py.typed +0 -0
  73. baobab_ai_dev_core-1.0.0.dist-info/METADATA +152 -0
  74. baobab_ai_dev_core-1.0.0.dist-info/RECORD +77 -0
  75. baobab_ai_dev_core-1.0.0.dist-info/WHEEL +5 -0
  76. baobab_ai_dev_core-1.0.0.dist-info/licenses/LICENSE +21 -0
  77. 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,5 @@
1
+ """Sous-package domaine de baobab-ai-dev-core.
2
+
3
+ Regroupe les briques métier pures : ``entities``, ``value_objects``, ``enums``,
4
+ ``exceptions``, ``policies`` et ``protocols``.
5
+ """
@@ -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