adop-cli 0.1.3__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.
- ado_pipeline/__init__.py +3 -0
- ado_pipeline/api.py +281 -0
- ado_pipeline/cli.py +1402 -0
- ado_pipeline/config.py +225 -0
- ado_pipeline/favorites.py +109 -0
- ado_pipeline/pipelines.py +154 -0
- ado_pipeline/plan.py +164 -0
- adop_cli-0.1.3.dist-info/METADATA +429 -0
- adop_cli-0.1.3.dist-info/RECORD +13 -0
- adop_cli-0.1.3.dist-info/WHEEL +5 -0
- adop_cli-0.1.3.dist-info/entry_points.txt +2 -0
- adop_cli-0.1.3.dist-info/licenses/LICENSE +21 -0
- adop_cli-0.1.3.dist-info/top_level.txt +1 -0
ado_pipeline/config.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Configuration management for Azure DevOps Pipeline CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = Path.home() / ".azure-pipeline-cli"
|
|
12
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
13
|
+
PIPELINES_FILE = CONFIG_DIR / "pipelines.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PipelineParamConfig:
|
|
18
|
+
"""Pipeline parameter configuration."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
param_type: str = "string" # string, boolean, choice
|
|
22
|
+
default: Any = None
|
|
23
|
+
choices: list[str] | None = None
|
|
24
|
+
description: str = ""
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
"""Convert to dict, excluding None values."""
|
|
28
|
+
result = {"name": self.name, "type": self.param_type}
|
|
29
|
+
if self.default is not None:
|
|
30
|
+
result["default"] = self.default
|
|
31
|
+
if self.choices:
|
|
32
|
+
result["choices"] = self.choices
|
|
33
|
+
if self.description:
|
|
34
|
+
result["description"] = self.description
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_dict(cls, data: dict[str, Any]) -> PipelineParamConfig:
|
|
39
|
+
"""Create from dict."""
|
|
40
|
+
return cls(
|
|
41
|
+
name=data["name"],
|
|
42
|
+
param_type=data.get("type", "string"),
|
|
43
|
+
default=data.get("default"),
|
|
44
|
+
choices=data.get("choices"),
|
|
45
|
+
description=data.get("description", ""),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class PipelineConfig:
|
|
51
|
+
"""Pipeline configuration."""
|
|
52
|
+
|
|
53
|
+
alias: str
|
|
54
|
+
name: str
|
|
55
|
+
description: str = ""
|
|
56
|
+
parameters: list[PipelineParamConfig] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, Any]:
|
|
59
|
+
"""Convert to dict."""
|
|
60
|
+
result: dict[str, Any] = {"name": self.name}
|
|
61
|
+
if self.description:
|
|
62
|
+
result["description"] = self.description
|
|
63
|
+
if self.parameters:
|
|
64
|
+
result["parameters"] = [p.to_dict() for p in self.parameters]
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_dict(cls, alias: str, data: dict[str, Any]) -> PipelineConfig:
|
|
69
|
+
"""Create from dict."""
|
|
70
|
+
params = []
|
|
71
|
+
for p in data.get("parameters", []):
|
|
72
|
+
params.append(PipelineParamConfig.from_dict(p))
|
|
73
|
+
return cls(
|
|
74
|
+
alias=alias,
|
|
75
|
+
name=data["name"],
|
|
76
|
+
description=data.get("description", ""),
|
|
77
|
+
parameters=params,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Config:
|
|
83
|
+
"""Configuration for Azure DevOps Pipeline CLI."""
|
|
84
|
+
|
|
85
|
+
organization: str = ""
|
|
86
|
+
project: str = ""
|
|
87
|
+
repository: str = ""
|
|
88
|
+
pat: str = ""
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def load(cls) -> Config:
|
|
92
|
+
"""Load config from file, or return defaults if not found."""
|
|
93
|
+
if not CONFIG_FILE.exists():
|
|
94
|
+
return cls()
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
with CONFIG_FILE.open() as f:
|
|
98
|
+
data = json.load(f)
|
|
99
|
+
except (json.JSONDecodeError, OSError):
|
|
100
|
+
return cls()
|
|
101
|
+
|
|
102
|
+
return cls(
|
|
103
|
+
organization=data.get("organization", ""),
|
|
104
|
+
project=data.get("project", ""),
|
|
105
|
+
repository=data.get("repository", ""),
|
|
106
|
+
pat=data.get("pat", ""),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def save(self) -> None:
|
|
110
|
+
"""Save config to file with restricted permissions."""
|
|
111
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
with CONFIG_FILE.open("w") as f:
|
|
113
|
+
json.dump(asdict(self), f, indent=2)
|
|
114
|
+
# Restrict file permissions to owner only (contains PAT)
|
|
115
|
+
CONFIG_FILE.chmod(0o600)
|
|
116
|
+
|
|
117
|
+
def is_configured(self) -> bool:
|
|
118
|
+
"""Check if required fields are configured."""
|
|
119
|
+
return bool(self.pat and self.organization and self.project)
|
|
120
|
+
|
|
121
|
+
def get_missing_fields(self) -> list[str]:
|
|
122
|
+
"""Get list of missing required fields."""
|
|
123
|
+
missing = []
|
|
124
|
+
if not self.organization:
|
|
125
|
+
missing.append("organization")
|
|
126
|
+
if not self.project:
|
|
127
|
+
missing.append("project")
|
|
128
|
+
if not self.pat:
|
|
129
|
+
missing.append("PAT")
|
|
130
|
+
return missing
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class PipelinesConfig:
|
|
135
|
+
"""Pipeline definitions configuration."""
|
|
136
|
+
|
|
137
|
+
pipelines: dict[str, PipelineConfig] = field(default_factory=dict)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def load(cls) -> PipelinesConfig:
|
|
141
|
+
"""Load pipelines from file."""
|
|
142
|
+
if not PIPELINES_FILE.exists():
|
|
143
|
+
return cls()
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
with PIPELINES_FILE.open() as f:
|
|
147
|
+
data = json.load(f)
|
|
148
|
+
except (json.JSONDecodeError, OSError):
|
|
149
|
+
return cls()
|
|
150
|
+
|
|
151
|
+
pipelines = {}
|
|
152
|
+
for alias, pipeline_data in data.get("pipelines", {}).items():
|
|
153
|
+
try:
|
|
154
|
+
pipelines[alias] = PipelineConfig.from_dict(alias, pipeline_data)
|
|
155
|
+
except (KeyError, TypeError):
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
return cls(pipelines=pipelines)
|
|
159
|
+
|
|
160
|
+
def save(self) -> None:
|
|
161
|
+
"""Save pipelines to file."""
|
|
162
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
|
|
164
|
+
data = {
|
|
165
|
+
"pipelines": {
|
|
166
|
+
alias: p.to_dict() for alias, p in self.pipelines.items()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
with PIPELINES_FILE.open("w") as f:
|
|
171
|
+
json.dump(data, f, indent=2)
|
|
172
|
+
|
|
173
|
+
def add(self, pipeline: PipelineConfig) -> None:
|
|
174
|
+
"""Add or update a pipeline."""
|
|
175
|
+
self.pipelines[pipeline.alias] = pipeline
|
|
176
|
+
self.save()
|
|
177
|
+
|
|
178
|
+
def remove(self, alias: str) -> bool:
|
|
179
|
+
"""Remove a pipeline. Returns True if removed."""
|
|
180
|
+
if alias in self.pipelines:
|
|
181
|
+
del self.pipelines[alias]
|
|
182
|
+
self.save()
|
|
183
|
+
return True
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
def get(self, alias: str) -> PipelineConfig | None:
|
|
187
|
+
"""Get a pipeline by alias."""
|
|
188
|
+
return self.pipelines.get(alias)
|
|
189
|
+
|
|
190
|
+
def list_all(self) -> list[PipelineConfig]:
|
|
191
|
+
"""List all pipelines."""
|
|
192
|
+
return list(self.pipelines.values())
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def init_config(
|
|
196
|
+
organization: str,
|
|
197
|
+
project: str,
|
|
198
|
+
pat: str,
|
|
199
|
+
repository: str = "",
|
|
200
|
+
) -> Config:
|
|
201
|
+
"""Initialize config with required fields."""
|
|
202
|
+
config = Config(
|
|
203
|
+
organization=organization,
|
|
204
|
+
project=project,
|
|
205
|
+
repository=repository,
|
|
206
|
+
pat=pat,
|
|
207
|
+
)
|
|
208
|
+
config.save()
|
|
209
|
+
return config
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_config() -> Config:
|
|
213
|
+
"""Get current config, raising error if not configured."""
|
|
214
|
+
config = Config.load()
|
|
215
|
+
if not config.is_configured():
|
|
216
|
+
missing = config.get_missing_fields()
|
|
217
|
+
raise ConfigError(
|
|
218
|
+
f"Configuration incomplete. Missing: {', '.join(missing)}. "
|
|
219
|
+
"Run 'ado-pipeline config init' to set up."
|
|
220
|
+
)
|
|
221
|
+
return config
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class ConfigError(Exception):
|
|
225
|
+
"""Configuration error."""
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Favorites management for quick pipeline access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
FAVORITES_FILE = Path.home() / ".azure-pipeline-cli" / "favorites.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Favorite:
|
|
15
|
+
"""A saved favorite pipeline configuration."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
pipeline_alias: str
|
|
19
|
+
branch: str | None = None
|
|
20
|
+
deploy: bool | None = None
|
|
21
|
+
output_format: str | None = None
|
|
22
|
+
release_notes: str | None = None
|
|
23
|
+
environment: str | None = None
|
|
24
|
+
fail_if_no_changes: bool | None = None
|
|
25
|
+
fail_on_push_error: bool | None = None
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
"""Convert to dict, excluding None values."""
|
|
29
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, data: dict[str, Any]) -> Favorite:
|
|
33
|
+
"""Create from dict."""
|
|
34
|
+
return cls(
|
|
35
|
+
name=data["name"],
|
|
36
|
+
pipeline_alias=data["pipeline_alias"],
|
|
37
|
+
branch=data.get("branch"),
|
|
38
|
+
deploy=data.get("deploy"),
|
|
39
|
+
output_format=data.get("output_format"),
|
|
40
|
+
release_notes=data.get("release_notes"),
|
|
41
|
+
environment=data.get("environment"),
|
|
42
|
+
fail_if_no_changes=data.get("fail_if_no_changes"),
|
|
43
|
+
fail_on_push_error=data.get("fail_on_push_error"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class FavoritesStore:
|
|
49
|
+
"""Store for favorite pipeline configurations."""
|
|
50
|
+
|
|
51
|
+
favorites: dict[str, Favorite] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def load(cls) -> FavoritesStore:
|
|
55
|
+
"""Load favorites from file."""
|
|
56
|
+
if not FAVORITES_FILE.exists():
|
|
57
|
+
return cls()
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
with FAVORITES_FILE.open() as f:
|
|
61
|
+
data = json.load(f)
|
|
62
|
+
except (json.JSONDecodeError, OSError):
|
|
63
|
+
# Return empty store if file is corrupted or unreadable
|
|
64
|
+
return cls()
|
|
65
|
+
|
|
66
|
+
favorites = {}
|
|
67
|
+
for name, fav_data in data.get("favorites", {}).items():
|
|
68
|
+
try:
|
|
69
|
+
fav_data["name"] = name
|
|
70
|
+
favorites[name] = Favorite.from_dict(fav_data)
|
|
71
|
+
except (KeyError, TypeError):
|
|
72
|
+
# Skip invalid favorites
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
return cls(favorites=favorites)
|
|
76
|
+
|
|
77
|
+
def save(self) -> None:
|
|
78
|
+
"""Save favorites to file."""
|
|
79
|
+
FAVORITES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
data = {
|
|
82
|
+
"favorites": {
|
|
83
|
+
name: fav.to_dict() for name, fav in self.favorites.items()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
with FAVORITES_FILE.open("w") as f:
|
|
88
|
+
json.dump(data, f, indent=2)
|
|
89
|
+
|
|
90
|
+
def add(self, favorite: Favorite) -> None:
|
|
91
|
+
"""Add or update a favorite."""
|
|
92
|
+
self.favorites[favorite.name] = favorite
|
|
93
|
+
self.save()
|
|
94
|
+
|
|
95
|
+
def remove(self, name: str) -> bool:
|
|
96
|
+
"""Remove a favorite. Returns True if removed."""
|
|
97
|
+
if name in self.favorites:
|
|
98
|
+
del self.favorites[name]
|
|
99
|
+
self.save()
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def get(self, name: str) -> Favorite | None:
|
|
104
|
+
"""Get a favorite by name."""
|
|
105
|
+
return self.favorites.get(name)
|
|
106
|
+
|
|
107
|
+
def list_all(self) -> list[Favorite]:
|
|
108
|
+
"""List all favorites."""
|
|
109
|
+
return list(self.favorites.values())
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Pipeline definitions and registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .config import PipelineConfig, PipelineParamConfig, PipelinesConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PipelineNotFoundError(Exception):
|
|
12
|
+
"""Pipeline not found error."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class PipelineParam:
|
|
17
|
+
"""Pipeline parameter definition."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
param_type: str # string, boolean, choice
|
|
21
|
+
default: Any
|
|
22
|
+
choices: list[str] | None = None
|
|
23
|
+
description: str = ""
|
|
24
|
+
|
|
25
|
+
def validate(self, value: Any) -> Any:
|
|
26
|
+
"""Validate and convert value to appropriate type."""
|
|
27
|
+
if self.param_type == "boolean":
|
|
28
|
+
if isinstance(value, bool):
|
|
29
|
+
return value
|
|
30
|
+
if isinstance(value, str):
|
|
31
|
+
return value.lower() in ("true", "yes", "1")
|
|
32
|
+
return bool(value)
|
|
33
|
+
|
|
34
|
+
if self.param_type == "choice" and self.choices:
|
|
35
|
+
if value not in self.choices:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"Invalid value '{value}' for {self.name}. "
|
|
38
|
+
f"Must be one of: {', '.join(self.choices)}"
|
|
39
|
+
)
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
return str(value) if value is not None else self.default
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_config(cls, config: PipelineParamConfig) -> PipelineParam:
|
|
46
|
+
"""Create from PipelineParamConfig."""
|
|
47
|
+
return cls(
|
|
48
|
+
name=config.name,
|
|
49
|
+
param_type=config.param_type,
|
|
50
|
+
default=config.default,
|
|
51
|
+
choices=config.choices,
|
|
52
|
+
description=config.description,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Pipeline:
|
|
58
|
+
"""Pipeline definition."""
|
|
59
|
+
|
|
60
|
+
alias: str
|
|
61
|
+
name: str
|
|
62
|
+
parameters: list[PipelineParam] = field(default_factory=list)
|
|
63
|
+
description: str = ""
|
|
64
|
+
|
|
65
|
+
def get_param(self, name: str) -> PipelineParam | None:
|
|
66
|
+
"""Get parameter by name."""
|
|
67
|
+
return next((p for p in self.parameters if p.name == name), None)
|
|
68
|
+
|
|
69
|
+
def build_parameters(self, **kwargs: Any) -> dict[str, Any]:
|
|
70
|
+
"""Build parameters dict with defaults and overrides."""
|
|
71
|
+
result = {}
|
|
72
|
+
for param in self.parameters:
|
|
73
|
+
if param.name in kwargs:
|
|
74
|
+
result[param.name] = param.validate(kwargs[param.name])
|
|
75
|
+
elif param.default is not None:
|
|
76
|
+
result[param.name] = param.default
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_config(cls, config: PipelineConfig) -> Pipeline:
|
|
81
|
+
"""Create from PipelineConfig."""
|
|
82
|
+
params = [PipelineParam.from_config(p) for p in config.parameters]
|
|
83
|
+
return cls(
|
|
84
|
+
alias=config.alias,
|
|
85
|
+
name=config.name,
|
|
86
|
+
parameters=params,
|
|
87
|
+
description=config.description,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _calculate_similarity(s1: str, s2: str) -> float:
|
|
92
|
+
"""Calculate similarity ratio between two strings (0.0 to 1.0)."""
|
|
93
|
+
if not s1 or not s2:
|
|
94
|
+
return 0.0
|
|
95
|
+
s1_lower, s2_lower = s1.lower(), s2.lower()
|
|
96
|
+
# Check substring match first
|
|
97
|
+
if s1_lower in s2_lower or s2_lower in s1_lower:
|
|
98
|
+
return 0.8
|
|
99
|
+
# Count matching characters in order
|
|
100
|
+
matches = 0
|
|
101
|
+
j = 0
|
|
102
|
+
for char in s1_lower:
|
|
103
|
+
while j < len(s2_lower):
|
|
104
|
+
if s2_lower[j] == char:
|
|
105
|
+
matches += 1
|
|
106
|
+
j += 1
|
|
107
|
+
break
|
|
108
|
+
j += 1
|
|
109
|
+
return matches / max(len(s1), len(s2))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def find_similar_pipelines(alias: str, threshold: float = 0.4) -> list[str]:
|
|
113
|
+
"""Find pipelines similar to the given alias."""
|
|
114
|
+
pipelines_config = PipelinesConfig.load()
|
|
115
|
+
similarities = []
|
|
116
|
+
for name in pipelines_config.pipelines:
|
|
117
|
+
score = _calculate_similarity(alias, name)
|
|
118
|
+
if score >= threshold:
|
|
119
|
+
similarities.append((name, score))
|
|
120
|
+
similarities.sort(key=lambda x: x[1], reverse=True)
|
|
121
|
+
return [name for name, _ in similarities[:3]]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_pipeline(alias: str) -> Pipeline:
|
|
125
|
+
"""Get pipeline by alias."""
|
|
126
|
+
pipelines_config = PipelinesConfig.load()
|
|
127
|
+
config = pipelines_config.get(alias)
|
|
128
|
+
|
|
129
|
+
if config is None:
|
|
130
|
+
similar = find_similar_pipelines(alias)
|
|
131
|
+
if similar:
|
|
132
|
+
raise PipelineNotFoundError(
|
|
133
|
+
f"Pipeline '{alias}' not found. Did you mean: {', '.join(similar)}?"
|
|
134
|
+
)
|
|
135
|
+
if not pipelines_config.pipelines:
|
|
136
|
+
raise PipelineNotFoundError(
|
|
137
|
+
f"Pipeline '{alias}' not found. No pipelines configured. "
|
|
138
|
+
"Run 'ado-pipeline pipeline add' or 'ado-pipeline pipeline import' to add pipelines."
|
|
139
|
+
)
|
|
140
|
+
raise PipelineNotFoundError(f"Pipeline '{alias}' not found")
|
|
141
|
+
|
|
142
|
+
return Pipeline.from_config(config)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def list_pipelines() -> list[Pipeline]:
|
|
146
|
+
"""List all available pipelines."""
|
|
147
|
+
pipelines_config = PipelinesConfig.load()
|
|
148
|
+
return [Pipeline.from_config(c) for c in pipelines_config.list_all()]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_all_aliases() -> list[str]:
|
|
152
|
+
"""Get all pipeline aliases for completion."""
|
|
153
|
+
pipelines_config = PipelinesConfig.load()
|
|
154
|
+
return list(pipelines_config.pipelines.keys())
|
ado_pipeline/plan.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Execution plan generation and display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.syntax import Syntax
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .pipelines import Pipeline
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ExecutionPlan:
|
|
22
|
+
"""Execution plan for a pipeline run."""
|
|
23
|
+
|
|
24
|
+
pipeline: Pipeline
|
|
25
|
+
branch: str
|
|
26
|
+
parameters: dict[str, Any]
|
|
27
|
+
config: Config
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def api_url(self) -> str:
|
|
31
|
+
"""Generate API URL for pipeline run."""
|
|
32
|
+
org = self.config.organization
|
|
33
|
+
project = quote(self.config.project, safe="")
|
|
34
|
+
return (
|
|
35
|
+
f"https://dev.azure.com/{org}/{project}/"
|
|
36
|
+
f"_apis/pipelines/{{id}}/runs?api-version=7.1"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def request_body(self) -> dict[str, Any]:
|
|
41
|
+
"""Generate request body for API call."""
|
|
42
|
+
body: dict[str, Any] = {
|
|
43
|
+
"resources": {
|
|
44
|
+
"repositories": {
|
|
45
|
+
"self": {"refName": f"refs/heads/{self.branch}"}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Filter out empty strings - Azure DevOps doesn't accept them
|
|
51
|
+
filtered_params = {
|
|
52
|
+
k: v for k, v in self.parameters.items()
|
|
53
|
+
if v != ""
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if filtered_params:
|
|
57
|
+
body["templateParameters"] = filtered_params
|
|
58
|
+
|
|
59
|
+
return body
|
|
60
|
+
|
|
61
|
+
def display(self, console: Console | None = None) -> None:
|
|
62
|
+
"""Pretty-print the execution plan."""
|
|
63
|
+
if console is None:
|
|
64
|
+
console = Console()
|
|
65
|
+
|
|
66
|
+
# Header panel
|
|
67
|
+
console.print()
|
|
68
|
+
console.print(
|
|
69
|
+
Panel(
|
|
70
|
+
"[bold cyan]Azure DevOps Pipeline Trigger - PLAN[/bold cyan]",
|
|
71
|
+
expand=False,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
console.print()
|
|
75
|
+
|
|
76
|
+
# Pipeline info table
|
|
77
|
+
info_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
78
|
+
info_table.add_column("Key", style="bold")
|
|
79
|
+
info_table.add_column("Value")
|
|
80
|
+
|
|
81
|
+
info_table.add_row("Pipeline:", self.pipeline.name)
|
|
82
|
+
info_table.add_row("Alias:", self.pipeline.alias)
|
|
83
|
+
info_table.add_row("Branch:", self.branch)
|
|
84
|
+
info_table.add_row("Organization:", self.config.organization)
|
|
85
|
+
info_table.add_row("Project:", self.config.project)
|
|
86
|
+
|
|
87
|
+
console.print(info_table)
|
|
88
|
+
console.print()
|
|
89
|
+
|
|
90
|
+
# Parameters section
|
|
91
|
+
console.print("[bold]Parameters:[/bold]", end="")
|
|
92
|
+
if not self.parameters:
|
|
93
|
+
console.print(" [dim](none)[/dim]")
|
|
94
|
+
else:
|
|
95
|
+
console.print()
|
|
96
|
+
for key, value in self.parameters.items():
|
|
97
|
+
display_value = json.dumps(value) if isinstance(value, bool) else value
|
|
98
|
+
display_value = display_value if display_value != "" else '""'
|
|
99
|
+
console.print(f" [dim]*[/dim] {key}: {display_value}")
|
|
100
|
+
console.print()
|
|
101
|
+
|
|
102
|
+
# API endpoint
|
|
103
|
+
console.print("[bold]API Endpoint:[/bold]")
|
|
104
|
+
console.print(f" POST {self.api_url}")
|
|
105
|
+
console.print()
|
|
106
|
+
|
|
107
|
+
# Request body
|
|
108
|
+
console.print("[bold]Request Body:[/bold]")
|
|
109
|
+
body_json = json.dumps(self.request_body, indent=2)
|
|
110
|
+
syntax = Syntax(body_json, "json", theme="monokai", line_numbers=False)
|
|
111
|
+
console.print(syntax)
|
|
112
|
+
console.print()
|
|
113
|
+
|
|
114
|
+
# Footer warning
|
|
115
|
+
console.print("[dim]" + "-" * 60 + "[/dim]")
|
|
116
|
+
console.print(
|
|
117
|
+
"[yellow bold]!![/yellow bold] "
|
|
118
|
+
"[yellow]This is a PLAN. No API call was made.[/yellow]"
|
|
119
|
+
)
|
|
120
|
+
console.print(
|
|
121
|
+
"[dim]Run with 'apply' to trigger the pipeline.[/dim]"
|
|
122
|
+
)
|
|
123
|
+
console.print()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class GitError(Exception):
|
|
127
|
+
"""Git command error."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_current_branch() -> str:
|
|
131
|
+
"""Get current git branch name."""
|
|
132
|
+
try:
|
|
133
|
+
result = subprocess.run(
|
|
134
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
check=True,
|
|
138
|
+
)
|
|
139
|
+
return result.stdout.strip()
|
|
140
|
+
except FileNotFoundError:
|
|
141
|
+
raise GitError("Git is not installed or not in PATH")
|
|
142
|
+
except subprocess.CalledProcessError:
|
|
143
|
+
raise GitError("Not a git repository. Use --branch to specify.")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def create_plan(
|
|
147
|
+
pipeline: Pipeline,
|
|
148
|
+
branch: str | None = None,
|
|
149
|
+
config: Config | None = None,
|
|
150
|
+
**kwargs: Any,
|
|
151
|
+
) -> ExecutionPlan:
|
|
152
|
+
"""Create an execution plan for a pipeline run."""
|
|
153
|
+
if config is None:
|
|
154
|
+
config = Config.load()
|
|
155
|
+
|
|
156
|
+
if branch is None:
|
|
157
|
+
branch = get_current_branch()
|
|
158
|
+
|
|
159
|
+
return ExecutionPlan(
|
|
160
|
+
pipeline=pipeline,
|
|
161
|
+
branch=branch,
|
|
162
|
+
parameters=pipeline.build_parameters(**kwargs),
|
|
163
|
+
config=config,
|
|
164
|
+
)
|