devflow-cli 2.3.0__py3-none-any.whl → 2.8.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.
- devflow_cli/__init__.py +26 -1
- devflow_cli/adapters/__init__.py +19 -0
- devflow_cli/adapters/agents.py +148 -0
- devflow_cli/adapters/linear.py +79 -0
- devflow_cli/cli.py +16 -0
- devflow_cli/commands/docs_sync.py +33 -0
- devflow_cli/commands/extension.py +3 -1
- devflow_cli/commands/feature.py +4 -4
- devflow_cli/commands/hooks_cmd.py +193 -0
- devflow_cli/commands/linear.py +30 -0
- devflow_cli/commands/migrate_speckit.py +139 -22
- devflow_cli/commands/review.py +54 -0
- devflow_cli/commands/run.py +123 -0
- devflow_cli/commands/status.py +24 -29
- devflow_cli/commands/step.py +118 -0
- devflow_cli/commands/traceability.py +47 -0
- devflow_cli/commands/verify.py +78 -0
- devflow_cli/core/docgen.py +93 -0
- devflow_cli/core/hooks.py +143 -2
- devflow_cli/core/orchestrator.py +170 -0
- devflow_cli/core/state.py +207 -6
- devflow_cli/core/state_store.py +45 -0
- devflow_cli/core/traceability.py +81 -0
- devflow_cli/core/validators.py +25 -5
- devflow_cli/core/verification.py +296 -0
- devflow_cli/schemas/state.schema.json +39 -0
- devflow_cli/utils/paths.py +78 -8
- {devflow_cli-2.3.0.dist-info → devflow_cli-2.8.0.dist-info}/METADATA +15 -7
- devflow_cli-2.8.0.dist-info/RECORD +52 -0
- {devflow_cli-2.3.0.dist-info → devflow_cli-2.8.0.dist-info}/WHEEL +1 -1
- devflow_cli-2.3.0.dist-info/RECORD +0 -35
- {devflow_cli-2.3.0.dist-info → devflow_cli-2.8.0.dist-info}/entry_points.txt +0 -0
- {devflow_cli-2.3.0.dist-info → devflow_cli-2.8.0.dist-info}/licenses/LICENSE +0 -0
devflow_cli/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""devflow CLI — Spec-Driven Development workflow."""
|
|
2
2
|
|
|
3
|
-
VERSION = "2.
|
|
3
|
+
VERSION = "2.8.0"
|
|
4
|
+
STATE_SCHEMA_VERSION = 3
|
|
4
5
|
|
|
5
6
|
STEPS = [
|
|
6
7
|
"constitution", # Pré-requis projet (1/projet). Auto-complétée si .specify/memory/constitution.md existe.
|
|
@@ -20,6 +21,12 @@ STEPS = [
|
|
|
20
21
|
|
|
21
22
|
OPTIONAL_STEPS = {"clarify", "research", "contracts", "docs"}
|
|
22
23
|
|
|
24
|
+
# Le mode fast conserve les gates et les bornes du pipeline. Il compte donc
|
|
25
|
+
# 9 etapes effectives, dont 4 etapes productives principales.
|
|
26
|
+
FAST_PRODUCTIVE_STEPS = ("spec", "plan", "tasks", "implement")
|
|
27
|
+
FAST_STEP_COUNT = len(STEPS) - len(OPTIONAL_STEPS)
|
|
28
|
+
FULL_STEP_COUNT = len(STEPS)
|
|
29
|
+
|
|
23
30
|
REVIEW_GATES = {"review-spec", "review-tasks", "review-impl"}
|
|
24
31
|
|
|
25
32
|
STEP_ARTIFACTS = {
|
|
@@ -51,6 +58,20 @@ STEP_LINEAR_MAPPING = {
|
|
|
51
58
|
"done": "Done",
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
# Ordre de progression des statuts Linear (pour non-régression lors de la sync).
|
|
62
|
+
# Un statut ne doit jamais régresser : si l'index courant >= index cible, on ne met pas à jour.
|
|
63
|
+
STEP_LINEAR_ORDER = [
|
|
64
|
+
"Backlog",
|
|
65
|
+
"Spec",
|
|
66
|
+
"Research",
|
|
67
|
+
"Plan",
|
|
68
|
+
"Tasks",
|
|
69
|
+
"Review",
|
|
70
|
+
"In Progress",
|
|
71
|
+
"Doc",
|
|
72
|
+
"Done",
|
|
73
|
+
]
|
|
74
|
+
|
|
54
75
|
SUPPORTED_AGENTS = ["claude-code", "cursor"]
|
|
55
76
|
|
|
56
77
|
# Mapping speckit → devflow pour la migration
|
|
@@ -85,4 +106,8 @@ assert OPTIONAL_STEPS <= _steps_set, f"OPTIONAL_STEPS not subset of STEPS: {OPTI
|
|
|
85
106
|
assert REVIEW_GATES <= _steps_set, f"REVIEW_GATES not subset of STEPS: {REVIEW_GATES - _steps_set}"
|
|
86
107
|
assert set(STEP_ARTIFACTS.keys()) <= _steps_set, f"STEP_ARTIFACTS has unknown keys: {set(STEP_ARTIFACTS.keys()) - _steps_set}"
|
|
87
108
|
assert set(STEP_LINEAR_MAPPING.keys()) <= _steps_set, f"STEP_LINEAR_MAPPING has unknown keys: {set(STEP_LINEAR_MAPPING.keys()) - _steps_set}"
|
|
109
|
+
assert set(STEP_LINEAR_MAPPING.values()) <= set(STEP_LINEAR_ORDER), (
|
|
110
|
+
f"STEP_LINEAR_MAPPING values not in STEP_LINEAR_ORDER: "
|
|
111
|
+
f"{set(STEP_LINEAR_MAPPING.values()) - set(STEP_LINEAR_ORDER)}"
|
|
112
|
+
)
|
|
88
113
|
assert set(SPECKIT_STEP_MAP.values()) <= _steps_set, f"SPECKIT_STEP_MAP maps to unknown steps: {set(SPECKIT_STEP_MAP.values()) - _steps_set}"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Adaptateurs des services externes utilisés par devflow."""
|
|
2
|
+
|
|
3
|
+
from devflow_cli.adapters.agents import (
|
|
4
|
+
AgentAdapter,
|
|
5
|
+
AgentExecution,
|
|
6
|
+
ClaudeCodeAdapter,
|
|
7
|
+
CodexAdapter,
|
|
8
|
+
CursorAdapter,
|
|
9
|
+
get_agent_adapter,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AgentAdapter",
|
|
14
|
+
"AgentExecution",
|
|
15
|
+
"ClaudeCodeAdapter",
|
|
16
|
+
"CodexAdapter",
|
|
17
|
+
"CursorAdapter",
|
|
18
|
+
"get_agent_adapter",
|
|
19
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Adaptateurs d'exécution pour les agents IA en mode non interactif."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import re
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
PermissionMode = Literal["auto", "acceptEdits", "manual"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class AgentExecution:
|
|
18
|
+
returncode: int
|
|
19
|
+
stdout: str = ""
|
|
20
|
+
stderr: str = ""
|
|
21
|
+
timed_out: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AgentAdapter(ABC):
|
|
25
|
+
"""Contrat minimal d'un agent pilotable par le CLI."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
executable: str
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
|
|
32
|
+
"""Construit une commande sans passer par un shell."""
|
|
33
|
+
|
|
34
|
+
def build_named_agent_command(self, agent_name: str, prompt: str) -> list[str]:
|
|
35
|
+
raise ValueError(f"L'adaptateur {self.name} ne supporte pas les agents nommés")
|
|
36
|
+
|
|
37
|
+
def execute(
|
|
38
|
+
self,
|
|
39
|
+
prompt: str,
|
|
40
|
+
cwd: Path,
|
|
41
|
+
timeout: int,
|
|
42
|
+
permission_mode: PermissionMode = "auto",
|
|
43
|
+
*,
|
|
44
|
+
capture_output: bool = False,
|
|
45
|
+
) -> AgentExecution:
|
|
46
|
+
command = self.build_command(prompt, permission_mode)
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
command,
|
|
50
|
+
cwd=cwd,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
text=True,
|
|
53
|
+
capture_output=capture_output,
|
|
54
|
+
shell=False,
|
|
55
|
+
)
|
|
56
|
+
return AgentExecution(
|
|
57
|
+
result.returncode,
|
|
58
|
+
result.stdout or "",
|
|
59
|
+
result.stderr or "",
|
|
60
|
+
)
|
|
61
|
+
except subprocess.TimeoutExpired as exc:
|
|
62
|
+
return AgentExecution(
|
|
63
|
+
124,
|
|
64
|
+
_text(exc.stdout),
|
|
65
|
+
_text(exc.stderr),
|
|
66
|
+
timed_out=True,
|
|
67
|
+
)
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ClaudeCodeAdapter(AgentAdapter):
|
|
73
|
+
name = "claude-code"
|
|
74
|
+
executable = "claude"
|
|
75
|
+
|
|
76
|
+
def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
|
|
77
|
+
return [
|
|
78
|
+
self.executable,
|
|
79
|
+
"--print",
|
|
80
|
+
"--permission-mode",
|
|
81
|
+
permission_mode,
|
|
82
|
+
prompt,
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def build_named_agent_command(self, agent_name: str, prompt: str) -> list[str]:
|
|
86
|
+
return [self.executable, "--print", "--agent", agent_name, prompt]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class CodexAdapter(AgentAdapter):
|
|
90
|
+
name = "codex"
|
|
91
|
+
executable = "codex"
|
|
92
|
+
|
|
93
|
+
def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
|
|
94
|
+
command = [self.executable, "exec", "--sandbox", "workspace-write"]
|
|
95
|
+
if permission_mode in {"auto", "acceptEdits"}:
|
|
96
|
+
command.append("--approve-for-me")
|
|
97
|
+
command.append(_portable_prompt(prompt))
|
|
98
|
+
return command
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CursorAdapter(AgentAdapter):
|
|
102
|
+
name = "cursor"
|
|
103
|
+
executable = "cursor-agent"
|
|
104
|
+
|
|
105
|
+
def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
|
|
106
|
+
command = [self.executable, "--print", "--output-format", "text"]
|
|
107
|
+
if permission_mode in {"auto", "acceptEdits"}:
|
|
108
|
+
command.append("--force")
|
|
109
|
+
command.append(_portable_prompt(prompt))
|
|
110
|
+
return command
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_AGENT_ADAPTERS = {
|
|
114
|
+
"claude": ClaudeCodeAdapter,
|
|
115
|
+
"claude-code": ClaudeCodeAdapter,
|
|
116
|
+
"codex": CodexAdapter,
|
|
117
|
+
"cursor": CursorAdapter,
|
|
118
|
+
"cursor-agent": CursorAdapter,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_agent_adapter(name: str) -> AgentAdapter:
|
|
123
|
+
try:
|
|
124
|
+
adapter_type = _AGENT_ADAPTERS[name.casefold()]
|
|
125
|
+
except KeyError as exc:
|
|
126
|
+
supported = ", ".join(sorted({kind().name for kind in _AGENT_ADAPTERS.values()}))
|
|
127
|
+
raise ValueError(f"Agent inconnu '{name}'. Agents supportés: {supported}") from exc
|
|
128
|
+
return adapter_type()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _portable_prompt(prompt: str) -> str:
|
|
132
|
+
"""Traduit une slash-command Claude en instruction portable pour les autres agents."""
|
|
133
|
+
match = re.fullmatch(r"/devflow\.([a-z0-9-]+)(?:\s+(.*))?", prompt.strip())
|
|
134
|
+
if not match:
|
|
135
|
+
return prompt
|
|
136
|
+
command_name, arguments = match.groups()
|
|
137
|
+
arguments = arguments or ""
|
|
138
|
+
return (
|
|
139
|
+
f"Exécute strictement le workflow décrit dans "
|
|
140
|
+
f"commands/devflow.{command_name}.md avec $ARGUMENTS = {arguments!r}. "
|
|
141
|
+
"Travaille dans le dépôt courant et respecte toutes les validations de ce fichier."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _text(value: str | bytes | None) -> str:
|
|
146
|
+
if value is None:
|
|
147
|
+
return ""
|
|
148
|
+
return value.decode(errors="replace") if isinstance(value, bytes) else value
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Politique de synchronisation Linear, indépendante du transport MCP/API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal, Protocol
|
|
7
|
+
|
|
8
|
+
from devflow_cli import STEP_LINEAR_MAPPING, STEP_LINEAR_ORDER
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
LinearAction = Literal["update", "keep", "skip"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LinearAdapter(Protocol):
|
|
15
|
+
"""Transport minimal requis pour synchroniser le statut d'une issue."""
|
|
16
|
+
|
|
17
|
+
def get_status(self, issue_id: str) -> str | None: ...
|
|
18
|
+
|
|
19
|
+
def update_status(self, issue_id: str, status: str) -> None: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class LinearDecision:
|
|
24
|
+
action: LinearAction
|
|
25
|
+
step: str
|
|
26
|
+
current_status: str | None
|
|
27
|
+
target_status: str | None
|
|
28
|
+
reason: str
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def should_update(self) -> bool:
|
|
32
|
+
return self.action == "update"
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, str | bool | None]:
|
|
35
|
+
return {
|
|
36
|
+
"action": self.action,
|
|
37
|
+
"step": self.step,
|
|
38
|
+
"currentStatus": self.current_status,
|
|
39
|
+
"targetStatus": self.target_status,
|
|
40
|
+
"shouldUpdate": self.should_update,
|
|
41
|
+
"reason": self.reason,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def decide_linear_transition(step: str, current_status: str | None) -> LinearDecision:
|
|
46
|
+
"""Applique le mapping et la règle de non-régression canonique."""
|
|
47
|
+
target = STEP_LINEAR_MAPPING.get(step)
|
|
48
|
+
if target is None:
|
|
49
|
+
return LinearDecision("skip", step, current_status, None, "Étape sans statut Linear")
|
|
50
|
+
if current_status not in STEP_LINEAR_ORDER:
|
|
51
|
+
return LinearDecision(
|
|
52
|
+
"skip",
|
|
53
|
+
step,
|
|
54
|
+
current_status,
|
|
55
|
+
target,
|
|
56
|
+
"Statut Linear courant absent de l'ordre canonique",
|
|
57
|
+
)
|
|
58
|
+
current_index = STEP_LINEAR_ORDER.index(current_status)
|
|
59
|
+
target_index = STEP_LINEAR_ORDER.index(target)
|
|
60
|
+
if current_index >= target_index:
|
|
61
|
+
return LinearDecision(
|
|
62
|
+
"keep",
|
|
63
|
+
step,
|
|
64
|
+
current_status,
|
|
65
|
+
target,
|
|
66
|
+
"Statut courant identique ou plus avancé",
|
|
67
|
+
)
|
|
68
|
+
return LinearDecision("update", step, current_status, target, "Progression autorisée")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sync_linear_status(
|
|
72
|
+
adapter: LinearAdapter, issue_id: str, step: str
|
|
73
|
+
) -> LinearDecision:
|
|
74
|
+
"""Synchronise via un transport injecté lorsque la politique l'autorise."""
|
|
75
|
+
current_status = adapter.get_status(issue_id)
|
|
76
|
+
decision = decide_linear_transition(step, current_status)
|
|
77
|
+
if decision.should_update and decision.target_status is not None:
|
|
78
|
+
adapter.update_status(issue_id, decision.target_status)
|
|
79
|
+
return decision
|
devflow_cli/cli.py
CHANGED
|
@@ -18,6 +18,14 @@ from devflow_cli.commands.upgrade_cmd import upgrade
|
|
|
18
18
|
from devflow_cli.commands.rollback import rollback
|
|
19
19
|
from devflow_cli.commands.migrate_speckit import migrate_speckit
|
|
20
20
|
from devflow_cli.commands import extension
|
|
21
|
+
from devflow_cli.commands import hooks_cmd
|
|
22
|
+
from devflow_cli.commands import linear
|
|
23
|
+
from devflow_cli.commands import review
|
|
24
|
+
from devflow_cli.commands import step
|
|
25
|
+
from devflow_cli.commands.run import run
|
|
26
|
+
from devflow_cli.commands.docs_sync import docs_sync
|
|
27
|
+
from devflow_cli.commands.traceability import traceability
|
|
28
|
+
from devflow_cli.commands.verify import verify
|
|
21
29
|
|
|
22
30
|
app = typer.Typer(
|
|
23
31
|
name="devflow",
|
|
@@ -53,6 +61,14 @@ app.command()(upgrade)
|
|
|
53
61
|
app.command()(rollback)
|
|
54
62
|
app.command(name="migrate-speckit")(migrate_speckit)
|
|
55
63
|
app.add_typer(extension.app, name="extension")
|
|
64
|
+
app.add_typer(hooks_cmd.app, name="hooks")
|
|
65
|
+
app.add_typer(linear.app, name="linear")
|
|
66
|
+
app.add_typer(step.app, name="step")
|
|
67
|
+
app.add_typer(review.app, name="review")
|
|
68
|
+
app.command()(run)
|
|
69
|
+
app.command(name="docs-sync")(docs_sync)
|
|
70
|
+
app.command()(traceability)
|
|
71
|
+
app.command()(verify)
|
|
56
72
|
|
|
57
73
|
|
|
58
74
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""devflow docs-sync — Synchronise la documentation dérivée des constantes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from devflow_cli.core.docgen import sync_pipeline_docs
|
|
10
|
+
from devflow_cli.utils.console import fail, ok
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def docs_sync(
|
|
14
|
+
path: Path = typer.Argument(Path("."), help="Racine du depot devflow"),
|
|
15
|
+
check: bool = typer.Option(False, "--check", help="Vérifie sans modifier"),
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Génère les références du pipeline depuis les constantes Python."""
|
|
18
|
+
root = path.resolve()
|
|
19
|
+
try:
|
|
20
|
+
stale = sync_pipeline_docs(root, check=check)
|
|
21
|
+
except (OSError, ValueError) as exc:
|
|
22
|
+
fail(str(exc))
|
|
23
|
+
raise typer.Exit(1) from exc
|
|
24
|
+
|
|
25
|
+
if check and stale:
|
|
26
|
+
for path in stale:
|
|
27
|
+
fail(f"Documentation obsolète: {path.relative_to(root)}")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
if stale:
|
|
30
|
+
for path in stale:
|
|
31
|
+
ok(f"Synchronisé: {path.relative_to(root)}")
|
|
32
|
+
else:
|
|
33
|
+
ok("Documentation déjà synchronisée.")
|
|
@@ -76,7 +76,9 @@ def add(name: str = typer.Argument(..., help="Nom de l'extension a installer"))
|
|
|
76
76
|
console.print("Utilisez 'devflow extension catalog' pour voir les extensions disponibles.")
|
|
77
77
|
raise typer.Exit(1)
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
# Des crochets nus sont interpretes comme du markup Rich et rendent le
|
|
80
|
+
# libelle invisible (ex: "[communaute]").
|
|
81
|
+
source_label = f" ({ext.source})" if ext.source != "principal" else ""
|
|
80
82
|
console.print(f"Installation de '{name}'{source_label}...")
|
|
81
83
|
console.print()
|
|
82
84
|
|
devflow_cli/commands/feature.py
CHANGED
|
@@ -65,7 +65,7 @@ def _init_feature_files(
|
|
|
65
65
|
def feature(
|
|
66
66
|
issue_id: str = typer.Argument(..., help="Identifiant de l'issue (ex: KS-123)"),
|
|
67
67
|
short_name: Optional[str] = typer.Option(None, "--short-name", help="Nom court pour le repertoire (ex: user-auth)"),
|
|
68
|
-
fast: bool = typer.Option(False, "--fast", help="Mode
|
|
68
|
+
fast: bool = typer.Option(False, "--fast", help="Mode accelere (9 etapes, dont 4 productives)"),
|
|
69
69
|
) -> None:
|
|
70
70
|
"""Initialise une feature : branche git + state.json + review-log.md."""
|
|
71
71
|
_validate_issue_id(issue_id)
|
|
@@ -102,9 +102,9 @@ def feature(
|
|
|
102
102
|
console.print(f"Feature [bold]{issue_id}[/bold] initialisee :")
|
|
103
103
|
console.print(f" Branche : {branch}")
|
|
104
104
|
console.print(f" Dossier : {feature_dir}/")
|
|
105
|
-
from devflow_cli import
|
|
106
|
-
total =
|
|
107
|
-
fast_count =
|
|
105
|
+
from devflow_cli import FAST_STEP_COUNT, FULL_STEP_COUNT
|
|
106
|
+
total = FULL_STEP_COUNT
|
|
107
|
+
fast_count = FAST_STEP_COUNT
|
|
108
108
|
console.print(f" Mode : {mode} ({fast_count} etapes)" if fast else f" Mode : {mode} ({total} etapes)")
|
|
109
109
|
console.print(f" Etat : spec (etape initiale)")
|
|
110
110
|
console.print()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""devflow hooks — Gestion et exécution contrôlée des hooks devflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from devflow_cli.core.git import is_git_repo
|
|
12
|
+
from devflow_cli.core.hooks import (
|
|
13
|
+
DEFAULT_HOOK_TIMEOUT,
|
|
14
|
+
MAX_HOOK_TIMEOUT,
|
|
15
|
+
VALID_HOOK_POINTS,
|
|
16
|
+
run_hook_point,
|
|
17
|
+
)
|
|
18
|
+
from devflow_cli.utils.console import console, ok, fail, warn
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Gestion et execution des hooks devflow")
|
|
21
|
+
|
|
22
|
+
HOOKS_DIR_NAME = ".githooks"
|
|
23
|
+
HOOK_FILES = ("post-checkout", "post-merge")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _configure_hooks_path(repo: Path) -> None:
|
|
27
|
+
subprocess.run(
|
|
28
|
+
["git", "-C", str(repo), "config", "core.hooksPath", HOOKS_DIR_NAME],
|
|
29
|
+
check=True,
|
|
30
|
+
capture_output=True,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _current_hooks_path(repo: Path) -> str | None:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
["git", "-C", str(repo), "config", "--get", "core.hooksPath"],
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
)
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
return None
|
|
42
|
+
return result.stdout.strip() or None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _unset_hooks_path(repo: Path) -> None:
|
|
46
|
+
subprocess.run(
|
|
47
|
+
["git", "-C", str(repo), "config", "--unset", "core.hooksPath"],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("install")
|
|
53
|
+
def install() -> None:
|
|
54
|
+
"""Active les git hooks devflow (core.hooksPath + chmod +x)."""
|
|
55
|
+
repo = Path.cwd().resolve()
|
|
56
|
+
|
|
57
|
+
if not is_git_repo(repo):
|
|
58
|
+
fail(f"{repo} n'est pas un depot git.")
|
|
59
|
+
raise typer.Exit(1)
|
|
60
|
+
|
|
61
|
+
hooks_dir = repo / HOOKS_DIR_NAME
|
|
62
|
+
if not hooks_dir.is_dir():
|
|
63
|
+
fail(f"Dossier {HOOKS_DIR_NAME}/ introuvable a la racine du repo.")
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
|
|
66
|
+
missing = [h for h in HOOK_FILES if not (hooks_dir / h).is_file()]
|
|
67
|
+
if missing:
|
|
68
|
+
fail(f"Hooks manquants dans {HOOKS_DIR_NAME}/ : {', '.join(missing)}")
|
|
69
|
+
raise typer.Exit(1)
|
|
70
|
+
|
|
71
|
+
for name in HOOK_FILES:
|
|
72
|
+
path = hooks_dir / name
|
|
73
|
+
mode = path.stat().st_mode
|
|
74
|
+
path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
75
|
+
ok(f"chmod +x {HOOKS_DIR_NAME}/{name}")
|
|
76
|
+
|
|
77
|
+
_configure_hooks_path(repo)
|
|
78
|
+
ok(f"git config core.hooksPath {HOOKS_DIR_NAME}")
|
|
79
|
+
|
|
80
|
+
console.print()
|
|
81
|
+
console.print("[green]Hooks devflow actifs.[/green] Un checkout ou merge sur develop lancera `devflow init --here --force`.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("uninstall")
|
|
85
|
+
def uninstall() -> None:
|
|
86
|
+
"""Desactive les git hooks devflow (supprime core.hooksPath)."""
|
|
87
|
+
repo = Path.cwd().resolve()
|
|
88
|
+
|
|
89
|
+
if not is_git_repo(repo):
|
|
90
|
+
fail(f"{repo} n'est pas un depot git.")
|
|
91
|
+
raise typer.Exit(1)
|
|
92
|
+
|
|
93
|
+
current = _current_hooks_path(repo)
|
|
94
|
+
if current is None:
|
|
95
|
+
warn("core.hooksPath n'est pas configure — rien a desactiver.")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if current != HOOKS_DIR_NAME:
|
|
99
|
+
warn(f"core.hooksPath pointe sur '{current}' (pas sur '{HOOKS_DIR_NAME}'). Abandon.")
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
|
|
102
|
+
_unset_hooks_path(repo)
|
|
103
|
+
ok(f"git config core.hooksPath supprime (etait: {current})")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command("status")
|
|
107
|
+
def status() -> None:
|
|
108
|
+
"""Affiche l'etat des hooks devflow pour ce repo."""
|
|
109
|
+
repo = Path.cwd().resolve()
|
|
110
|
+
|
|
111
|
+
if not is_git_repo(repo):
|
|
112
|
+
fail(f"{repo} n'est pas un depot git.")
|
|
113
|
+
raise typer.Exit(1)
|
|
114
|
+
|
|
115
|
+
hooks_path = _current_hooks_path(repo)
|
|
116
|
+
hooks_dir = repo / HOOKS_DIR_NAME
|
|
117
|
+
|
|
118
|
+
console.print(f"Repo : {repo}")
|
|
119
|
+
console.print(f"hooksPath : {hooks_path or '[dim](non configure)[/dim]'}")
|
|
120
|
+
console.print(f"{HOOKS_DIR_NAME}/ : {'present' if hooks_dir.is_dir() else '[dim]absent[/dim]'}")
|
|
121
|
+
|
|
122
|
+
if hooks_dir.is_dir():
|
|
123
|
+
for name in HOOK_FILES:
|
|
124
|
+
p = hooks_dir / name
|
|
125
|
+
if not p.is_file():
|
|
126
|
+
console.print(f" {name:<15} : [dim]absent[/dim]")
|
|
127
|
+
continue
|
|
128
|
+
executable = bool(p.stat().st_mode & stat.S_IXUSR)
|
|
129
|
+
console.print(f" {name:<15} : present ({'exec' if executable else 'non-exec'})")
|
|
130
|
+
|
|
131
|
+
active = hooks_path == HOOKS_DIR_NAME and hooks_dir.is_dir()
|
|
132
|
+
console.print()
|
|
133
|
+
console.print(f"Statut : {'[green]actif[/green]' if active else '[yellow]inactif[/yellow]'}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command("run")
|
|
137
|
+
def run(
|
|
138
|
+
hook_point: str = typer.Argument(..., help="Point de hook devflow"),
|
|
139
|
+
issue_id: str = typer.Argument(..., help="ID de l'issue courante"),
|
|
140
|
+
allow_shell: bool = typer.Option(
|
|
141
|
+
False,
|
|
142
|
+
"--allow-shell",
|
|
143
|
+
help="Autorise explicitement les hooks de type shell",
|
|
144
|
+
),
|
|
145
|
+
timeout: int = typer.Option(
|
|
146
|
+
DEFAULT_HOOK_TIMEOUT,
|
|
147
|
+
min=1,
|
|
148
|
+
max=MAX_HOOK_TIMEOUT,
|
|
149
|
+
help="Timeout par defaut par hook",
|
|
150
|
+
),
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Execute un point de hook depuis devflow-hooks.yml."""
|
|
153
|
+
if hook_point not in VALID_HOOK_POINTS:
|
|
154
|
+
fail(f"Hook point inconnu: {hook_point}")
|
|
155
|
+
raise typer.Exit(1)
|
|
156
|
+
|
|
157
|
+
repo = Path.cwd().resolve()
|
|
158
|
+
hooks_file = repo / "devflow-hooks.yml"
|
|
159
|
+
if not hooks_file.is_file():
|
|
160
|
+
console.print("Aucun devflow-hooks.yml — rien a executer.")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
executions = run_hook_point(
|
|
165
|
+
hooks_file,
|
|
166
|
+
hook_point,
|
|
167
|
+
issue_id,
|
|
168
|
+
repo,
|
|
169
|
+
allow_shell=allow_shell,
|
|
170
|
+
default_timeout=timeout,
|
|
171
|
+
)
|
|
172
|
+
except (ValueError, PermissionError) as exc:
|
|
173
|
+
fail(str(exc))
|
|
174
|
+
raise typer.Exit(1) from exc
|
|
175
|
+
|
|
176
|
+
if not executions:
|
|
177
|
+
console.print(f"Aucun hook configure pour {hook_point}.")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
failed = False
|
|
181
|
+
for execution in executions:
|
|
182
|
+
label = " ".join(execution.command)
|
|
183
|
+
if execution.succeeded:
|
|
184
|
+
ok(f"{execution.hook_type}: {label}")
|
|
185
|
+
else:
|
|
186
|
+
failed = True
|
|
187
|
+
reason = "timeout" if execution.timed_out else f"code {execution.returncode}"
|
|
188
|
+
fail(f"{execution.hook_type}: {label} ({reason})")
|
|
189
|
+
if execution.stderr.strip():
|
|
190
|
+
console.print(f" {execution.stderr.strip()}")
|
|
191
|
+
|
|
192
|
+
if failed:
|
|
193
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Commandes de politique Linear indépendantes du transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from devflow_cli.adapters.linear import decide_linear_transition
|
|
10
|
+
from devflow_cli.utils.console import console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Évalue la politique de synchronisation Linear.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command()
|
|
17
|
+
def decision(
|
|
18
|
+
step: str = typer.Argument(..., help="Étape devflow"),
|
|
19
|
+
current_status: str = typer.Argument(..., help="Statut Linear courant"),
|
|
20
|
+
json_output: bool = typer.Option(False, "--json", help="Sortie JSON"),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Décide si le statut Linear peut progresser sans régression."""
|
|
23
|
+
result = decide_linear_transition(step, current_status)
|
|
24
|
+
if json_output:
|
|
25
|
+
console.print_json(json.dumps(result.to_dict(), ensure_ascii=False))
|
|
26
|
+
return
|
|
27
|
+
console.print(
|
|
28
|
+
f"{result.action}: {current_status} → {result.target_status or '—'} "
|
|
29
|
+
f"({result.reason})"
|
|
30
|
+
)
|