sci-agent 0.2.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.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: sci-agent
3
+ Version: 0.2.0
4
+ Summary: Agent-first lightweight CI/CD tool
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: fastapi>=0.110.0
7
+ Requires-Dist: uvicorn>=0.29.0
8
+ Requires-Dist: httpx>=0.27.0
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: jinja2>=3.1.0
11
+ Requires-Dist: python-dotenv>=1.0.0
@@ -0,0 +1,32 @@
1
+ simple_ci/__init__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
2
+ simple_ci/__main__.py,sha256=YIoplXdVfuRoyOvqsoug6Od_-a249Ac1vvHpLQZHwhM,944
3
+ simple_ci/errors.py,sha256=ynp6VbS1qW9AjfEYwXhaY3IKCl0IGGRoQqIS7TpEw0c,833
4
+ simple_ci/models.py,sha256=6rCqAdyHQ-ZmFC9Jw7knf8Ll0ikJyNUF44AMhZvc12w,3289
5
+ simple_ci/actions/__init__.py,sha256=eK5B9ao0l5smSECBFWhHAmRlmJ6iZ3mdEAe3LKKwcAA,5295
6
+ simple_ci/actions/base.py,sha256=o-iTWZTqyHlCFC4jSv6_RS41YUULxZM44v_o3PJPIQ4,11205
7
+ simple_ci/actions/build.py,sha256=C_yh33gUDYJzEdceYkuJQRdExU-vmtV8c9FSeg7zpuM,1028
8
+ simple_ci/actions/deploy.py,sha256=JeSmmhnyd4zeDJqIAJKVr6LVxl5VJzoZJBRnXbYGJAc,774
9
+ simple_ci/actions/hooks.py,sha256=WcG4xxaMhgMSH47jRrMvn63R7Op-BXgfTXVEX9K5uYU,5652
10
+ simple_ci/actions/image.py,sha256=QRZbZ6CR1fTEKtTGlf0pbPqUk4b7XJVzXad7QsF1ahI,927
11
+ simple_ci/actions/source.py,sha256=zpLlCyDebOHHbHXnOwrpYTqcM5E6iSwk9OuMYTfVhMo,1303
12
+ simple_ci/actions/target.py,sha256=dQix1l8L_k6MZFTZs06WT736p7wB2P5VbVWH3OrGXvE,2452
13
+ simple_ci/cli/__init__.py,sha256=HIkUk4xoUaWl5Ofb_TvkeI69rrjsn-8U5t2B9u-62t0,43
14
+ simple_ci/cli/client.py,sha256=q15rE0lgtZsv2tGrmJ-ftjsI-ZpMdwf1tG6fW_Ysw5s,3753
15
+ simple_ci/cli/config.py,sha256=5witFegb_enyu3DUS_CtehsaG3t74nInykW3hsuAb_g,10221
16
+ simple_ci/cli/history.py,sha256=GJGIfT-XqVdWz48sUcZ-aVeepaAw6HhiYCO8YQmJQGs,3182
17
+ simple_ci/cli/main.py,sha256=xgJ3J3hqBlS9UUvWzy1V7Pd5jOSihanKqiAApdmYmsI,12319
18
+ simple_ci/cli/render.py,sha256=0w7ORoT5bDJD3XtsAAsHxpE3-Pp5ba4QLmTZTLd6R-A,4475
19
+ simple_ci/server/__init__.py,sha256=LKhOcdwXsWF0t6Q_EEPns-ejWLOyUbkv3xOcSx_Pej0,37
20
+ simple_ci/server/app.py,sha256=8rNfOfQkQD9PWaz-q6lpidRRxE-Rh_uSdtg2KOwMIUY,4290
21
+ simple_ci/server/auth.py,sha256=5dJNbj4qYVghI4SOb27eDxDcPh51CFUmWl-UOSrcwtY,1238
22
+ simple_ci/server/executor.py,sha256=iFj9wx7Isu7-RCWtSSBeiyTcXzfK2nuSyxZ8-fmqtjg,11510
23
+ simple_ci/server/main.py,sha256=QRENvOsETmVZ54H0FqqyVsB83b5sUdSZ3OTWWE4Fqag,1277
24
+ simple_ci/server/store.py,sha256=rb61GnyDTG8NMLnLM1UN8JG7IiUFNRC1yXJ4j-qllXM,5683
25
+ simple_ci/ui/__init__.py,sha256=vS3jZTRIuS_rR1jQpByrZUZmdsAON_RKVJHF6nmEiuM,36
26
+ simple_ci/ui/server.py,sha256=U1NdrD_1WjyhANXb1SZApPKbFWq0WuGqWkrIxTwsiLU,10490
27
+ simple_ci/ui/static/index.html,sha256=DZCKag6wIkyFSmnglxugFA9u3HLqjo554MqRgIAb6i4,7421
28
+ sci_agent-0.2.0.dist-info/METADATA,sha256=hXS44DQFzYwNrlPpSpz-mBIFEfXuAw17gzUM4d-_wPE,316
29
+ sci_agent-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
30
+ sci_agent-0.2.0.dist-info/entry_points.txt,sha256=lRMsTKjsUiJIN91rWAgolkwiMMHmV6w0rt6qXpfraiY,54
31
+ sci_agent-0.2.0.dist-info/top_level.txt,sha256=hTpiAlOFooM9fjs8LKNs9u2T-ZhJimLn0rDiVQOk85M,10
32
+ sci_agent-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sci-agent = simple_ci.__main__:main
@@ -0,0 +1 @@
1
+ simple_ci
simple_ci/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
simple_ci/__main__.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import sys
5
+
6
+
7
+ def _configure_stdio_encoding() -> None:
8
+ if sys.platform == "win32":
9
+ try:
10
+ kernel32 = ctypes.windll.kernel32
11
+ kernel32.SetConsoleOutputCP(65001)
12
+ kernel32.SetConsoleCP(65001)
13
+ except Exception:
14
+ pass
15
+
16
+ for stream in (sys.stdout, sys.stderr, sys.stdin):
17
+ try:
18
+ stream.reconfigure(encoding="utf-8", errors="replace")
19
+ except Exception:
20
+ pass
21
+
22
+
23
+ def main() -> None:
24
+ _configure_stdio_encoding()
25
+
26
+ argv = sys.argv[1:]
27
+ if argv and argv[0] == "server":
28
+ from simple_ci.server.main import handle_server
29
+
30
+ handle_server(argv[1:])
31
+ elif argv and argv[0] == "run":
32
+ from simple_ci.cli.main import handle_run
33
+
34
+ handle_run(argv[1:])
35
+ else:
36
+ from simple_ci.ui.server import run_ui
37
+
38
+ run_ui(argv)
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
@@ -0,0 +1,136 @@
1
+ """内置动作入口与调度注册表。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from simple_ci.actions import base
9
+ from simple_ci.actions.build import build_maven_command
10
+ from simple_ci.actions.deploy import build_compose_command
11
+ from simple_ci.actions.image import build_image_command, build_push_command
12
+ from simple_ci.actions.source import build_git_pull_command
13
+ from simple_ci.actions.target import build_target_shell_command
14
+
15
+ ActionHandler = Callable[[dict[str, Any], dict[str, Any]], base.ActionResult]
16
+
17
+
18
+ def dispatch_action(step: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
19
+ """根据 step.uses 分发内置动作,并返回可 JSON 序列化的动作结果。"""
20
+
21
+ action_name = str(step.get("uses") or step.get("action") or "")
22
+ handler = ACTION_REGISTRY.get(action_name)
23
+ if handler is None:
24
+ return base.ActionResult(
25
+ rendered_command="",
26
+ exit_code=1,
27
+ stderr_tail=f"Unknown action: {action_name or '<missing>'}",
28
+ ).to_dict()
29
+ return handler(step, payload).to_dict()
30
+
31
+
32
+ def _params(step: dict[str, Any]) -> dict[str, Any]:
33
+ """兼容配置文件中的 with 字段与 Python 调用中的 with_ 字段。"""
34
+
35
+ value = step.get("with", step.get("with_", {}))
36
+ return dict(value) if isinstance(value, dict) else {}
37
+
38
+
39
+ def _run_built_command(
40
+ command: list[str] | list[list[str]] | str,
41
+ params: dict[str, Any],
42
+ *,
43
+ allow_shell: bool = False,
44
+ ) -> base.ActionResult:
45
+ """统一执行命令并注入动作参数中的敏感值用于结果脱敏。"""
46
+
47
+ return base.run_command(
48
+ command,
49
+ cwd=params.get("cwd") or params.get("workdir"),
50
+ env=params.get("env"),
51
+ secret_values=base.collect_secret_values(params),
52
+ timeout_seconds=float(params.get("timeout_seconds", 300)),
53
+ allow_shell=allow_shell,
54
+ )
55
+
56
+
57
+ def _source_git_pull(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
58
+ params = _params(step)
59
+ if isinstance(params.get("credential"), str):
60
+ message = "Unresolved credential id for source.git_pull; CLI must send a credential dict."
61
+ return base.ActionResult(rendered_command="", exit_code=1, stderr_tail=message, error=message)
62
+ return _run_built_command(build_git_pull_command(params), params)
63
+
64
+
65
+ def _build_maven(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
66
+ params = _params(step)
67
+ return _run_built_command(build_maven_command(params), params)
68
+
69
+
70
+ def _shell_action(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
71
+ params = _params(step)
72
+ command = params.get("command") or step.get("command")
73
+ if not isinstance(command, (str, list)):
74
+ return base.ActionResult(rendered_command="", exit_code=1, stderr_tail="Missing shell command.")
75
+ return _run_built_command(command, params, allow_shell=True)
76
+
77
+
78
+ def _target_shell(step: dict[str, Any], payload: dict[str, Any]) -> base.ActionResult:
79
+ """执行目标脚本或 inline 命令;script 模式使用 argv,inline 字符串保留 shell 能力。"""
80
+
81
+ params = _params(step)
82
+ try:
83
+ command = build_target_shell_command(params, payload)
84
+ except ValueError as exc:
85
+ return base.ActionResult(rendered_command="", exit_code=1, stderr_tail=str(exc), error=str(exc))
86
+ return _run_built_command(command, params, allow_shell=isinstance(command, str))
87
+
88
+
89
+ def _image_build(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
90
+ params = _params(step)
91
+ result = _run_built_command(build_image_command(params), params)
92
+ if result.exit_code == 0:
93
+ result.image_tag = f"{params['image']}:{params.get('tag', 'latest')}"
94
+ return result
95
+
96
+
97
+ def _image_push(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
98
+ params = _params(step)
99
+ return _run_built_command(build_push_command(params), params)
100
+
101
+
102
+ def _deploy_compose(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
103
+ params = _params(step)
104
+ return _run_built_command(build_compose_command(params), params)
105
+
106
+
107
+ def _deploy_healthcheck(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
108
+ """执行 HTTP 健康检查;失败时以非零退出码进入流水线短路。"""
109
+
110
+ params = _params(step)
111
+ return base.run_healthcheck(
112
+ str(params.get("url", "")),
113
+ timeout=float(params.get("timeout", 10)),
114
+ expected_status=int(params.get("expected_status", 200)),
115
+ retries=int(params.get("retries", 0)),
116
+ interval_seconds=float(params.get("interval_seconds", 0)),
117
+ expect_body_contains=(
118
+ str(params["expect_body_contains"]) if params.get("expect_body_contains") is not None else None
119
+ ),
120
+ secret_values=base.collect_secret_values(params),
121
+ )
122
+
123
+
124
+ ACTION_REGISTRY: dict[str, ActionHandler] = {
125
+ "source.git_pull": _source_git_pull,
126
+ "build.maven": _build_maven,
127
+ "build.shell": _shell_action,
128
+ "image.build": _image_build,
129
+ "image.push": _image_push,
130
+ "deploy.compose": _deploy_compose,
131
+ "deploy.healthcheck": _deploy_healthcheck,
132
+ "deploy.shell": _shell_action,
133
+ "target.shell": _target_shell,
134
+ }
135
+
136
+ __all__ = ["ACTION_REGISTRY", "dispatch_action"]
@@ -0,0 +1,318 @@
1
+ """动作执行结果、命令运行与脱敏工具。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+ from urllib.parse import quote, urlsplit, urlunsplit
11
+
12
+ import httpx
13
+
14
+ TAIL_LIMIT = 10 * 1024
15
+ SECRET_KEY_RE = re.compile(r"token|secret|password|credential", re.IGNORECASE)
16
+ URL_CREDENTIAL_RE = re.compile(r"(https?://)([^/\s@]+)@")
17
+ INLINE_SECRET_RE = re.compile(
18
+ r"(?i)(\b(?:--)?(?:token|password|secret|credential)(?:[_-]?[a-z0-9]+)?\b\s*(?:=|\s+))([^\s\"']+)"
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class ActionResult:
24
+ """动作统一结果;stdout/stderr 只保留尾部,命令与输出默认脱敏。"""
25
+
26
+ rendered_command: str = ""
27
+ exit_code: int | None = None
28
+ stdout_tail: str = ""
29
+ stderr_tail: str = ""
30
+ http_status: int | None = None
31
+ http_body: str | None = None
32
+ image_tag: str | None = None
33
+ retries: int = 0
34
+ error: str | None = None
35
+
36
+ @classmethod
37
+ def from_completed_command(
38
+ cls,
39
+ command: list[str] | str,
40
+ *,
41
+ exit_code: int,
42
+ stdout: str,
43
+ stderr: str,
44
+ secret_values: set[str] | None = None,
45
+ ) -> "ActionResult":
46
+ """从子进程结果构造动作结果,并对命令及日志尾部做统一脱敏。"""
47
+
48
+ rendered_command = render_command(command)
49
+ secrets = set(secret_values or set()) | infer_secrets_from_text(rendered_command)
50
+ return cls(
51
+ rendered_command=redact_secrets(rendered_command, secrets),
52
+ exit_code=exit_code,
53
+ stdout_tail=redact_secrets(tail_text(stdout), secrets),
54
+ stderr_tail=redact_secrets(tail_text(stderr), secrets),
55
+ )
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ """返回执行器可直接落库或序列化的稳定字典结构。"""
59
+
60
+ result: dict[str, Any] = {
61
+ "rendered_command": self.rendered_command,
62
+ "exit_code": self.exit_code,
63
+ "stdout_tail": self.stdout_tail,
64
+ "stderr_tail": self.stderr_tail,
65
+ }
66
+ optional_fields = {
67
+ "http_status": self.http_status,
68
+ "http_body": self.http_body,
69
+ "image_tag": self.image_tag,
70
+ "retries": self.retries,
71
+ "error": self.error,
72
+ }
73
+ for key, value in optional_fields.items():
74
+ if value not in (None, 0):
75
+ result[key] = value
76
+ return result
77
+
78
+
79
+ def run_command(
80
+ command: list[str] | list[list[str]] | str,
81
+ *,
82
+ cwd: str | None = None,
83
+ env: dict[str, str] | None = None,
84
+ secret_values: set[str] | None = None,
85
+ timeout_seconds: float | None = None,
86
+ allow_shell: bool = False,
87
+ ) -> ActionResult:
88
+ """运行本地命令;仅显式 shell 动作允许字符串命令交给 shell 处理。"""
89
+
90
+ rendered_command = render_command(command)
91
+ commands = _normalize_commands(command)
92
+ stdout_parts: list[str] = []
93
+ stderr_parts: list[str] = []
94
+ exit_code = 0
95
+ timeout = 300 if timeout_seconds is None else timeout_seconds
96
+
97
+ for current in commands:
98
+ use_shell = isinstance(current, str)
99
+ if use_shell and not allow_shell:
100
+ return ActionResult(
101
+ rendered_command=redact_secrets(rendered_command, secret_values),
102
+ exit_code=1,
103
+ stderr_tail="String commands require an explicit shell action.",
104
+ error="String commands require an explicit shell action.",
105
+ )
106
+ try:
107
+ completed = subprocess.run(
108
+ current,
109
+ cwd=cwd,
110
+ env=env,
111
+ shell=use_shell,
112
+ check=False,
113
+ capture_output=True,
114
+ text=True,
115
+ encoding="utf-8",
116
+ errors="replace",
117
+ timeout=timeout,
118
+ )
119
+ except subprocess.TimeoutExpired as exc:
120
+ stdout = _coerce_timeout_text(exc.output)
121
+ stderr = _coerce_timeout_text(exc.stderr)
122
+ message = f"Command timed out after {timeout:g}s."
123
+ result = ActionResult.from_completed_command(
124
+ command,
125
+ exit_code=1,
126
+ stdout=stdout,
127
+ stderr=stderr or message,
128
+ secret_values=secret_values,
129
+ )
130
+ result.error = redact_secrets(message, set(secret_values or set()) | infer_secrets_from_text(rendered_command))
131
+ return result
132
+
133
+ stdout_parts.append(completed.stdout)
134
+ stderr_parts.append(completed.stderr)
135
+ exit_code = completed.returncode
136
+ if exit_code != 0:
137
+ break
138
+
139
+ return ActionResult.from_completed_command(
140
+ command,
141
+ exit_code=exit_code,
142
+ stdout="".join(stdout_parts),
143
+ stderr="".join(stderr_parts),
144
+ secret_values=secret_values,
145
+ )
146
+
147
+
148
+ def _normalize_commands(command: list[str] | list[list[str]] | str) -> list[list[str] | str]:
149
+ """把单命令或多命令统一为顺序执行列表。"""
150
+
151
+ if isinstance(command, str):
152
+ return [command]
153
+ if command and all(isinstance(item, list) for item in command):
154
+ return command # type: ignore[return-value]
155
+ return [command] # type: ignore[list-item]
156
+
157
+
158
+ def _coerce_timeout_text(value: str | bytes | None) -> str:
159
+ """TimeoutExpired 可能返回 bytes,统一转成可脱敏文本。"""
160
+
161
+ if value is None:
162
+ return ""
163
+ if isinstance(value, bytes):
164
+ return value.decode("utf-8", errors="replace")
165
+ return value
166
+
167
+
168
+ def run_healthcheck(
169
+ url: str,
170
+ *,
171
+ timeout: float,
172
+ expected_status: int,
173
+ retries: int = 0,
174
+ interval_seconds: float = 0,
175
+ expect_body_contains: str | None = None,
176
+ secret_values: set[str] | None = None,
177
+ ) -> ActionResult:
178
+ """执行 HTTP GET 健康检查,支持状态码、正文包含断言和有限重试。"""
179
+
180
+ rendered = f"GET {url}"
181
+ secrets = set(secret_values or set()) | infer_secrets_from_text(rendered)
182
+ attempts = max(1, retries + 1)
183
+ last_result: ActionResult | None = None
184
+ for attempt_index in range(attempts):
185
+ try:
186
+ response = httpx.get(url, timeout=timeout)
187
+ except Exception as exc: # noqa: BLE001 - 动作边界需要转换为失败结果。
188
+ message = redact_secrets(str(exc), secrets)
189
+ last_result = ActionResult(
190
+ rendered_command=redact_secrets(rendered, secrets),
191
+ exit_code=1,
192
+ stderr_tail=message,
193
+ retries=attempt_index,
194
+ error=message,
195
+ )
196
+ else:
197
+ last_result = _healthcheck_result(
198
+ rendered,
199
+ response,
200
+ expected_status=expected_status,
201
+ expect_body_contains=expect_body_contains,
202
+ retries=attempt_index,
203
+ secrets=secrets,
204
+ )
205
+ if last_result.exit_code == 0 or attempt_index == attempts - 1:
206
+ return last_result
207
+ if interval_seconds > 0:
208
+ time.sleep(interval_seconds)
209
+ return last_result or ActionResult(rendered_command=redact_secrets(rendered, secrets), exit_code=1)
210
+
211
+
212
+ def _healthcheck_result(
213
+ rendered: str,
214
+ response: httpx.Response,
215
+ *,
216
+ expected_status: int,
217
+ expect_body_contains: str | None,
218
+ retries: int,
219
+ secrets: set[str],
220
+ ) -> ActionResult:
221
+ """把一次 HTTP 响应转换为健康检查结果,并生成可读失败原因。"""
222
+
223
+ body_tail = redact_secrets(tail_text(response.text), secrets)
224
+ errors: list[str] = []
225
+ if response.status_code != expected_status:
226
+ errors.append(f"Expected status {expected_status}, got {response.status_code}.")
227
+ if expect_body_contains is not None and expect_body_contains not in response.text:
228
+ errors.append("Expected response body to contain configured text.")
229
+ return ActionResult(
230
+ rendered_command=redact_secrets(rendered, secrets),
231
+ exit_code=0 if not errors else 1,
232
+ stdout_tail=body_tail,
233
+ stderr_tail=" ".join(errors),
234
+ http_status=response.status_code,
235
+ http_body=body_tail,
236
+ retries=retries,
237
+ error=" ".join(errors) or None,
238
+ )
239
+
240
+
241
+ def render_command(command: list[str] | list[list[str]] | str) -> str:
242
+ """将命令参数渲染为日志友好的字符串;不负责 shell 转义语义。"""
243
+
244
+ if isinstance(command, str):
245
+ return command
246
+ if command and all(isinstance(item, list) for item in command):
247
+ return " && ".join(render_command(item) for item in command) # type: ignore[arg-type]
248
+ return " ".join(str(part) for part in command)
249
+
250
+
251
+ def tail_text(text: str, limit: int = TAIL_LIMIT) -> str:
252
+ """保留文本末尾指定字节级近似长度,避免超大日志进入 API 响应。"""
253
+
254
+ return text[-limit:]
255
+
256
+
257
+ def collect_secret_values(value: Any) -> set[str]:
258
+ """递归收集 token/password/credential 等敏感字段值。"""
259
+
260
+ secrets: set[str] = set()
261
+
262
+ def collect(current: Any, key: str | None = None) -> None:
263
+ if isinstance(current, dict):
264
+ for child_key, child_value in current.items():
265
+ child_key_text = str(child_key)
266
+ if (
267
+ (SECRET_KEY_RE.search(child_key_text) or key == "credential")
268
+ and isinstance(child_value, str)
269
+ and child_value
270
+ ):
271
+ secrets.add(child_value)
272
+ secrets.add(quote(child_value, safe=""))
273
+ collect(child_value, child_key_text)
274
+ elif isinstance(current, list):
275
+ for item in current:
276
+ collect(item, key)
277
+
278
+ collect(value)
279
+ return secrets
280
+
281
+
282
+ def infer_secrets_from_text(value: str) -> set[str]:
283
+ """从 URL 凭据片段中提取用户名和密码,补足显式 secret 列表。"""
284
+
285
+ secrets: set[str] = set()
286
+ for match in URL_CREDENTIAL_RE.finditer(value):
287
+ credential = match.group(2)
288
+ if ":" in credential:
289
+ username, password = credential.split(":", 1)
290
+ secrets.add(username)
291
+ secrets.add(password)
292
+ return secrets
293
+
294
+
295
+ def redact_secrets(value: str, secret_values: set[str] | None = None) -> str:
296
+ """脱敏 URL 凭据、敏感字段赋值和已知敏感值。"""
297
+
298
+ redacted = URL_CREDENTIAL_RE.sub(r"\1***:***@", value)
299
+ for secret in sorted(secret_values or set(), key=len, reverse=True):
300
+ if secret:
301
+ redacted = redacted.replace(secret, "***")
302
+ return INLINE_SECRET_RE.sub(r"\1***", redacted)
303
+
304
+
305
+ def inject_basic_auth(repo: str, credential: dict[str, Any] | None) -> str:
306
+ """向 HTTPS Git URL 注入基础认证;非 HTTPS 地址保持原样。"""
307
+
308
+ if not credential:
309
+ return repo
310
+ username = credential.get("username")
311
+ password = credential.get("password")
312
+ if not username or not password:
313
+ return repo
314
+ parts = urlsplit(repo)
315
+ if parts.scheme not in {"http", "https"} or "@" in parts.netloc:
316
+ return repo
317
+ netloc = f"{quote(str(username), safe='')}:{quote(str(password), safe='')}@{parts.netloc}"
318
+ return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
@@ -0,0 +1,31 @@
1
+ """构建相关内置动作的命令构造。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def build_maven_command(params: dict[str, Any]) -> list[str]:
9
+ """根据模块、目标和属性构造 Maven 命令,函数本身不产生副作用。"""
10
+
11
+ command = ["mvn"]
12
+ module = params.get("module")
13
+ if module:
14
+ command.extend(["-pl", str(module)])
15
+ goals = params.get("goals") or [params.get("goal", "test")]
16
+ command.extend(str(goal) for goal in goals)
17
+ if params.get("skip_tests"):
18
+ command.append("-DskipTests")
19
+ properties = params.get("properties") or {}
20
+ for key, value in properties.items():
21
+ command.append(f"-D{key}={value}")
22
+ return command
23
+
24
+
25
+ def build_shell_command(params: dict[str, Any]) -> list[str] | str:
26
+ """返回调用方声明的 shell 命令,供 build.shell 等动作复用。"""
27
+
28
+ command = params.get("command")
29
+ if not isinstance(command, (str, list)):
30
+ raise ValueError("Missing shell command.")
31
+ return command
@@ -0,0 +1,23 @@
1
+ """部署相关内置动作的命令构造。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def build_compose_command(params: dict[str, Any]) -> list[str]:
9
+ """构造 docker compose up -d 命令,支持 file/project/env_file/services。"""
10
+
11
+ command = ["docker", "compose"]
12
+ compose_file = params.get("file")
13
+ if compose_file:
14
+ command.extend(["-f", str(compose_file)])
15
+ project_name = params.get("project_name")
16
+ if project_name:
17
+ command.extend(["--project-name", str(project_name)])
18
+ env_file = params.get("env_file")
19
+ if env_file:
20
+ command.extend(["--env-file", str(env_file)])
21
+ command.extend(["up", "-d"])
22
+ command.extend(str(service) for service in params.get("services", []))
23
+ return command