datasecops-cli 0.1.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.
- datasecops_cli/__init__.py +1 -0
- datasecops_cli/config.py +100 -0
- datasecops_cli/main.py +122 -0
- datasecops_cli/menus/__init__.py +1 -0
- datasecops_cli/menus/development.py +160 -0
- datasecops_cli/menus/downloads.py +79 -0
- datasecops_cli/menus/git_operations.py +213 -0
- datasecops_cli/models/__init__.py +1 -0
- datasecops_cli/models/git_helpers.py +29 -0
- datasecops_cli/models/project_config.py +87 -0
- datasecops_cli/services/__init__.py +1 -0
- datasecops_cli/services/dbt_runner.py +130 -0
- datasecops_cli/services/download_service.py +103 -0
- datasecops_cli/services/git_service.py +183 -0
- datasecops_cli/services/linting_service.py +47 -0
- datasecops_cli/services/skill_service.py +86 -0
- datasecops_cli/services/snowflake_service.py +62 -0
- datasecops_cli/utilities/__init__.py +1 -0
- datasecops_cli/utilities/display.py +122 -0
- datasecops_cli/utilities/file_utils.py +33 -0
- datasecops_cli/utilities/yaml_utils.py +39 -0
- datasecops_cli-0.1.0.dist-info/METADATA +16 -0
- datasecops_cli-0.1.0.dist-info/RECORD +26 -0
- datasecops_cli-0.1.0.dist-info/WHEEL +4 -0
- datasecops_cli-0.1.0.dist-info/entry_points.txt +2 -0
- datasecops_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class GitBranchState:
|
|
7
|
+
is_dirty: bool = False
|
|
8
|
+
commits_behind_main: int = 0
|
|
9
|
+
syncs_ahead: int = 0
|
|
10
|
+
syncs_behind: int = 0
|
|
11
|
+
uncommitted_file_count: int = 0
|
|
12
|
+
last_reset: Optional[datetime] = None
|
|
13
|
+
remote_exists: bool = True
|
|
14
|
+
|
|
15
|
+
def should_reset(self) -> bool:
|
|
16
|
+
if self.last_reset is None:
|
|
17
|
+
return True
|
|
18
|
+
return (datetime.now() - self.last_reset).total_seconds() > 60
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class GitCommitHelper:
|
|
22
|
+
file: str = ""
|
|
23
|
+
checked: bool = True
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class GitBranchComparison:
|
|
27
|
+
local: str = ""
|
|
28
|
+
remote: str = ""
|
|
29
|
+
is_current: bool = False
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
class DatasecopsConfig(BaseModel):
|
|
5
|
+
"""Local .datasecops.yml config."""
|
|
6
|
+
connection_name: str = ""
|
|
7
|
+
app_database: str = ""
|
|
8
|
+
profile_name: str = ""
|
|
9
|
+
|
|
10
|
+
class DbtTarget(BaseModel):
|
|
11
|
+
target_name: str = ""
|
|
12
|
+
branch_name: str = ""
|
|
13
|
+
target_role: str = ""
|
|
14
|
+
target_warehouse: str = ""
|
|
15
|
+
target_schema: str = "PUBLIC"
|
|
16
|
+
is_default: bool = False
|
|
17
|
+
|
|
18
|
+
class ProjectSettings(BaseModel):
|
|
19
|
+
project_dir: str = "./dbt"
|
|
20
|
+
profile_dir: str = "~/.dbt"
|
|
21
|
+
execution_mode: str = "dbt_cli"
|
|
22
|
+
targets: list[DbtTarget] = Field(default_factory=lambda: [
|
|
23
|
+
DbtTarget(target_name="dev", branch_name="dev", target_role="DEVELOPERS", target_warehouse="DEV_WH", is_default=True),
|
|
24
|
+
DbtTarget(target_name="test", branch_name="test", target_role="DATAOPS_ADMIN", target_warehouse="DATAOPS_WH"),
|
|
25
|
+
DbtTarget(target_name="prod", branch_name="prod", target_role="DATAOPS_ADMIN", target_warehouse="DATAOPS_WH"),
|
|
26
|
+
])
|
|
27
|
+
sources_database_prefix: list[str] = Field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
def get_default_target(self) -> Optional[DbtTarget]:
|
|
30
|
+
for t in self.targets:
|
|
31
|
+
if t.is_default:
|
|
32
|
+
return t
|
|
33
|
+
return self.targets[0] if self.targets else None
|
|
34
|
+
|
|
35
|
+
def get_deployment_branches(self) -> list[str]:
|
|
36
|
+
return [t.branch_name for t in self.targets if t.branch_name]
|
|
37
|
+
|
|
38
|
+
class BranchType(BaseModel):
|
|
39
|
+
name: str = ""
|
|
40
|
+
purpose: str = ""
|
|
41
|
+
lifecycle: str = ""
|
|
42
|
+
use_case: str = ""
|
|
43
|
+
|
|
44
|
+
class EnvironmentBranch(BaseModel):
|
|
45
|
+
branch_name: str = ""
|
|
46
|
+
purpose: str = ""
|
|
47
|
+
merge_into_using: str = ""
|
|
48
|
+
auto_released_to_environment: str = ""
|
|
49
|
+
|
|
50
|
+
class SourceControl(BaseModel):
|
|
51
|
+
branch_types: list[BranchType] = Field(default_factory=list)
|
|
52
|
+
ticket_number_required: bool = False
|
|
53
|
+
branch_format: str = "{branch_type}/{branch_name}"
|
|
54
|
+
source_control_platform: str = "GitHub"
|
|
55
|
+
environments: list[EnvironmentBranch] = Field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
class ProjectProfile(BaseModel):
|
|
58
|
+
project_id: int = 0
|
|
59
|
+
profile_name: str = ""
|
|
60
|
+
project_name: str = ""
|
|
61
|
+
project_description: str = ""
|
|
62
|
+
model_types: list[str] = Field(default_factory=list)
|
|
63
|
+
downstream_projects: list[str] = Field(default_factory=list)
|
|
64
|
+
git_url: str = ""
|
|
65
|
+
target_database: str = ""
|
|
66
|
+
dbt_version: str = "1.9"
|
|
67
|
+
dbt_packages: list[str] = Field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
class DbtPackage(BaseModel):
|
|
70
|
+
name: str = ""
|
|
71
|
+
source: str = "git"
|
|
72
|
+
url: str = ""
|
|
73
|
+
description: str = ""
|
|
74
|
+
latest_version: str = ""
|
|
75
|
+
|
|
76
|
+
class CortexSkillFile(BaseModel):
|
|
77
|
+
filename: str = ""
|
|
78
|
+
content: str = ""
|
|
79
|
+
|
|
80
|
+
class CortexSkill(BaseModel):
|
|
81
|
+
skill_id: str = ""
|
|
82
|
+
name: str = ""
|
|
83
|
+
description: str = ""
|
|
84
|
+
version: str = "0.1.0"
|
|
85
|
+
category: str = "general"
|
|
86
|
+
files: list[CortexSkillFile] = Field(default_factory=list)
|
|
87
|
+
enabled: bool = True
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from datasecops_cli.utilities.display import info_line, error_line, success_line
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DbtRunner:
|
|
10
|
+
"""Runs dbt commands via subprocess (dbt Fusion)."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, project_dir: Path, profiles_dir: Path, target: str = "dev"):
|
|
13
|
+
self.project_dir = project_dir
|
|
14
|
+
self.profiles_dir = profiles_dir
|
|
15
|
+
self.target = target
|
|
16
|
+
|
|
17
|
+
def _default_args(self) -> list[str]:
|
|
18
|
+
return [
|
|
19
|
+
f"--project-dir={self.project_dir}",
|
|
20
|
+
f"--profiles-dir={self.profiles_dir}",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
def _run_command(self, command: str, extra_args: list[str] = None) -> subprocess.CompletedProcess:
|
|
24
|
+
cmd = ["dbt", command] + (extra_args or []) + self._default_args()
|
|
25
|
+
info_line(f"Running: {' '.join(cmd)}")
|
|
26
|
+
result = subprocess.run(cmd, capture_output=False)
|
|
27
|
+
if result.returncode != 0:
|
|
28
|
+
error_line(f"dbt {command} failed with exit code {result.returncode}")
|
|
29
|
+
else:
|
|
30
|
+
success_line(f"dbt {command} completed successfully")
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
def run(self, select: str = None, exclude: str = None, full_refresh: bool = False,
|
|
34
|
+
modified_only: bool = False) -> subprocess.CompletedProcess:
|
|
35
|
+
args = [f"--target={self.target}"]
|
|
36
|
+
if modified_only and (self.project_dir / "manifest.json").exists():
|
|
37
|
+
args += ["--select", "state:modified+", "--state", str(self.project_dir)]
|
|
38
|
+
elif select:
|
|
39
|
+
args += ["--select", select]
|
|
40
|
+
if exclude:
|
|
41
|
+
args += ["--exclude", exclude]
|
|
42
|
+
if full_refresh:
|
|
43
|
+
args.append("--full-refresh")
|
|
44
|
+
result = self._run_command("run", args)
|
|
45
|
+
self._copy_manifest()
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
def build(self, select: str = None, exclude: str = None) -> subprocess.CompletedProcess:
|
|
49
|
+
args = [f"--target={self.target}"]
|
|
50
|
+
if select:
|
|
51
|
+
args += ["--select", select]
|
|
52
|
+
if exclude:
|
|
53
|
+
args += ["--exclude", exclude]
|
|
54
|
+
result = self._run_command("build", args)
|
|
55
|
+
self._copy_manifest()
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
def test(self, select: str = None) -> subprocess.CompletedProcess:
|
|
59
|
+
args = [f"--target={self.target}"]
|
|
60
|
+
if select:
|
|
61
|
+
args += ["--select", select]
|
|
62
|
+
return self._run_command("test", args)
|
|
63
|
+
|
|
64
|
+
def compile(self, select: str = None) -> subprocess.CompletedProcess:
|
|
65
|
+
args = [f"--target={self.target}"]
|
|
66
|
+
if select:
|
|
67
|
+
args += ["--select", select]
|
|
68
|
+
return self._run_command("compile", args)
|
|
69
|
+
|
|
70
|
+
def deps(self) -> subprocess.CompletedProcess:
|
|
71
|
+
return self._run_command("deps")
|
|
72
|
+
|
|
73
|
+
def seed(self, select: str = None, full_refresh: bool = False) -> subprocess.CompletedProcess:
|
|
74
|
+
args = [f"--target={self.target}"]
|
|
75
|
+
if select:
|
|
76
|
+
args += ["--select", select]
|
|
77
|
+
if full_refresh:
|
|
78
|
+
args.append("--full-refresh")
|
|
79
|
+
return self._run_command("seed", args)
|
|
80
|
+
|
|
81
|
+
def snapshot(self, select: str = None) -> subprocess.CompletedProcess:
|
|
82
|
+
args = [f"--target={self.target}"]
|
|
83
|
+
if select:
|
|
84
|
+
args += ["--select", select]
|
|
85
|
+
return self._run_command("snapshot", args)
|
|
86
|
+
|
|
87
|
+
def source_freshness(self, select: str = None) -> subprocess.CompletedProcess:
|
|
88
|
+
args = [f"--target={self.target}"]
|
|
89
|
+
if select:
|
|
90
|
+
args += ["--select", select]
|
|
91
|
+
return self._run_command("source", ["freshness"] + args)
|
|
92
|
+
|
|
93
|
+
def docs_generate(self) -> subprocess.CompletedProcess:
|
|
94
|
+
return self._run_command("docs", ["generate", f"--target={self.target}"])
|
|
95
|
+
|
|
96
|
+
def docs_serve(self) -> subprocess.Popen:
|
|
97
|
+
cmd = ["dbt", "docs", "serve"] + self._default_args()
|
|
98
|
+
info_line(f"Running: {' '.join(cmd)}")
|
|
99
|
+
return subprocess.Popen(cmd)
|
|
100
|
+
|
|
101
|
+
def clean(self) -> subprocess.CompletedProcess:
|
|
102
|
+
return self._run_command("clean")
|
|
103
|
+
|
|
104
|
+
def debug(self) -> subprocess.CompletedProcess:
|
|
105
|
+
return self._run_command("debug", [f"--target={self.target}"])
|
|
106
|
+
|
|
107
|
+
def list_models(self, select: str = None, resource_type: str = None) -> subprocess.CompletedProcess:
|
|
108
|
+
args = [f"--target={self.target}"]
|
|
109
|
+
if select:
|
|
110
|
+
args += ["--select", select]
|
|
111
|
+
if resource_type:
|
|
112
|
+
args += ["--resource-type", resource_type]
|
|
113
|
+
return self._run_command("list", args)
|
|
114
|
+
|
|
115
|
+
def retry(self) -> subprocess.CompletedProcess:
|
|
116
|
+
result = self._run_command("retry", [f"--target={self.target}"])
|
|
117
|
+
self._copy_manifest()
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
def run_operation(self, macro: str, args_str: str = None) -> subprocess.CompletedProcess:
|
|
121
|
+
cmd_args = [f"--target={self.target}"]
|
|
122
|
+
if args_str:
|
|
123
|
+
cmd_args += ["--args", args_str]
|
|
124
|
+
return self._run_command("run-operation", [macro] + cmd_args)
|
|
125
|
+
|
|
126
|
+
def _copy_manifest(self) -> None:
|
|
127
|
+
manifest = self.project_dir / "target" / "manifest.json"
|
|
128
|
+
dest = self.project_dir / "manifest.json"
|
|
129
|
+
if manifest.exists():
|
|
130
|
+
shutil.copy2(manifest, dest)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import yaml
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from datasecops_cli.services.snowflake_service import SnowflakeService
|
|
7
|
+
from datasecops_cli.utilities.display import info_line, success_line, error_line
|
|
8
|
+
from datasecops_cli.utilities.file_utils import write_file, ensure_dir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DownloadService:
|
|
12
|
+
"""Downloads configurations from the native app."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, snowflake_service: SnowflakeService, project_dir: Path):
|
|
15
|
+
self.sf = snowflake_service
|
|
16
|
+
self.project_dir = project_dir
|
|
17
|
+
|
|
18
|
+
def download_sqlfluff_config(self) -> bool:
|
|
19
|
+
info_line("Downloading SQLFluff configuration...")
|
|
20
|
+
raw = self.sf.get_framework_config("SQLFLUFF_RULES")
|
|
21
|
+
if not raw:
|
|
22
|
+
error_line("No SQLFluff configuration found in native app")
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
lines = ["[sqlfluff]", "dialect = snowflake", "templater = dbt", ""]
|
|
26
|
+
|
|
27
|
+
core = raw.get("core", {})
|
|
28
|
+
for code, state in core.items():
|
|
29
|
+
if isinstance(state, dict) and state.get("enabled", True):
|
|
30
|
+
opts = state.get("options", {})
|
|
31
|
+
for opt_key, opt_val in opts.items():
|
|
32
|
+
lines.append(f"{opt_key} = {opt_val}")
|
|
33
|
+
|
|
34
|
+
lines.append("")
|
|
35
|
+
lines.append("[sqlfluff:indentation]")
|
|
36
|
+
indentation = raw.get("indentation", {})
|
|
37
|
+
for code, state in indentation.items():
|
|
38
|
+
if isinstance(state, dict):
|
|
39
|
+
opts = state.get("options", {})
|
|
40
|
+
for opt_key, opt_val in opts.items():
|
|
41
|
+
lines.append(f"{opt_key} = {opt_val}")
|
|
42
|
+
|
|
43
|
+
content = "\n".join(lines) + "\n"
|
|
44
|
+
dest = self.project_dir / ".sqlfluff"
|
|
45
|
+
write_file(dest, content)
|
|
46
|
+
success_line(f"SQLFluff config written to {dest}")
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def download_pipelines(self, platform: str = "github") -> bool:
|
|
50
|
+
info_line(f"Downloading {platform} pipeline configurations...")
|
|
51
|
+
raw = self.sf.get_framework_config("PIPELINES")
|
|
52
|
+
if not raw:
|
|
53
|
+
error_line("No pipeline configuration found in native app")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
pipelines = raw.get("pipelines", [])
|
|
57
|
+
count = 0
|
|
58
|
+
for pipe in pipelines:
|
|
59
|
+
if pipe.get("platform", "github") != platform or not pipe.get("enabled", True):
|
|
60
|
+
continue
|
|
61
|
+
filename = pipe.get("filename", "")
|
|
62
|
+
yaml_content = pipe.get("yaml_content", "")
|
|
63
|
+
if filename and yaml_content:
|
|
64
|
+
if platform == "github":
|
|
65
|
+
dest = self.project_dir / ".github" / "workflows" / filename
|
|
66
|
+
else:
|
|
67
|
+
dest = self.project_dir / filename
|
|
68
|
+
write_file(dest, yaml_content)
|
|
69
|
+
info_line(f" Written: {dest}")
|
|
70
|
+
count += 1
|
|
71
|
+
|
|
72
|
+
success_line(f"Downloaded {count} pipeline file(s)")
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def download_dbt_packages(self, dbt_project_dir: Path) -> bool:
|
|
76
|
+
info_line("Downloading dbt package versions...")
|
|
77
|
+
raw = self.sf.get_framework_config("DBT_PACKAGES")
|
|
78
|
+
if not raw:
|
|
79
|
+
error_line("No dbt packages configuration found in native app")
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
packages = raw.get("packages", [])
|
|
83
|
+
pkg_list = []
|
|
84
|
+
for pkg in packages:
|
|
85
|
+
source = pkg.get("source", "git")
|
|
86
|
+
url = pkg.get("url", "")
|
|
87
|
+
version = pkg.get("latest_version", "")
|
|
88
|
+
if source == "git" and url:
|
|
89
|
+
entry = {"git": url}
|
|
90
|
+
if version:
|
|
91
|
+
entry["revision"] = version
|
|
92
|
+
pkg_list.append(entry)
|
|
93
|
+
elif source == "package" and url:
|
|
94
|
+
entry = {"package": url}
|
|
95
|
+
if version:
|
|
96
|
+
entry["version"] = version
|
|
97
|
+
pkg_list.append(entry)
|
|
98
|
+
|
|
99
|
+
dest = dbt_project_dir / "packages.yml"
|
|
100
|
+
content = yaml.dump({"packages": pkg_list}, default_flow_style=False, sort_keys=False)
|
|
101
|
+
write_file(dest, content)
|
|
102
|
+
success_line(f"packages.yml written to {dest}")
|
|
103
|
+
return True
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from git import Repo, GitCommandError
|
|
4
|
+
|
|
5
|
+
from datasecops_cli.models.git_helpers import GitCommitHelper
|
|
6
|
+
from datasecops_cli.utilities.display import info_line, error_line, success_line, warning_line
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitService:
|
|
10
|
+
"""Git operations via GitPython."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, repo_path: Path):
|
|
13
|
+
self.repo = Repo(repo_path)
|
|
14
|
+
|
|
15
|
+
def get_current_branch(self) -> str:
|
|
16
|
+
try:
|
|
17
|
+
return self.repo.active_branch.name
|
|
18
|
+
except TypeError:
|
|
19
|
+
return "DETACHED HEAD"
|
|
20
|
+
|
|
21
|
+
def is_dirty(self) -> bool:
|
|
22
|
+
return self.repo.is_dirty(untracked_files=True)
|
|
23
|
+
|
|
24
|
+
def get_uncommitted_file_count(self) -> int:
|
|
25
|
+
return len(self.repo.index.diff(None)) + len(self.repo.untracked_files)
|
|
26
|
+
|
|
27
|
+
def get_changed_files(self) -> list[GitCommitHelper]:
|
|
28
|
+
return [GitCommitHelper(file=d.a_path) for d in self.repo.index.diff(None)]
|
|
29
|
+
|
|
30
|
+
def get_new_files(self) -> list[GitCommitHelper]:
|
|
31
|
+
return [GitCommitHelper(file=f) for f in self.repo.untracked_files]
|
|
32
|
+
|
|
33
|
+
def get_local_branches(self) -> dict[str, str]:
|
|
34
|
+
return {b.name: b.name for b in self.repo.branches}
|
|
35
|
+
|
|
36
|
+
def get_remote_branches(self) -> dict[str, str]:
|
|
37
|
+
self.repo.remotes.origin.fetch()
|
|
38
|
+
return {
|
|
39
|
+
ref.remote_head: ref.remote_head
|
|
40
|
+
for ref in self.repo.remotes.origin.refs
|
|
41
|
+
if ref.remote_head != "HEAD"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def create_branch(self, branch_type: str, ticket: str, name: str) -> None:
|
|
45
|
+
branch_name = f"{branch_type}/{ticket}_{name}" if ticket else f"{branch_type}/{name}"
|
|
46
|
+
info_line(f"Creating branch: {branch_name}")
|
|
47
|
+
self.repo.remotes.origin.fetch()
|
|
48
|
+
new_branch = self.repo.create_head(branch_name, "origin/main")
|
|
49
|
+
new_branch.checkout()
|
|
50
|
+
self.repo.remotes.origin.push(branch_name, set_upstream=True)
|
|
51
|
+
success_line(f"Branch {branch_name} created and checked out")
|
|
52
|
+
|
|
53
|
+
def checkout_branch(self, remote_name: str) -> None:
|
|
54
|
+
info_line(f"Checking out: {remote_name}")
|
|
55
|
+
self.repo.remotes.origin.fetch()
|
|
56
|
+
try:
|
|
57
|
+
self.repo.git.checkout(remote_name)
|
|
58
|
+
except GitCommandError:
|
|
59
|
+
self.repo.git.checkout("-b", remote_name, f"origin/{remote_name}")
|
|
60
|
+
success_line(f"Switched to {remote_name}")
|
|
61
|
+
|
|
62
|
+
def switch_branch(self, name: str) -> None:
|
|
63
|
+
self.repo.branches[name].checkout()
|
|
64
|
+
success_line(f"Switched to {name}")
|
|
65
|
+
|
|
66
|
+
def delete_branch(self, name: str) -> None:
|
|
67
|
+
if name == self.get_current_branch():
|
|
68
|
+
error_line("Cannot delete the current branch")
|
|
69
|
+
return
|
|
70
|
+
self.repo.delete_head(name, force=True)
|
|
71
|
+
try:
|
|
72
|
+
self.repo.remotes.origin.push(refspec=f":{name}")
|
|
73
|
+
except GitCommandError:
|
|
74
|
+
pass
|
|
75
|
+
success_line(f"Deleted branch {name}")
|
|
76
|
+
|
|
77
|
+
def commit_changes(self, message: str) -> None:
|
|
78
|
+
self.repo.git.add(A=True)
|
|
79
|
+
self.repo.index.commit(message)
|
|
80
|
+
success_line(f"Committed: {message}")
|
|
81
|
+
|
|
82
|
+
def push_branch(self) -> None:
|
|
83
|
+
branch = self.get_current_branch()
|
|
84
|
+
info_line(f"Pushing {branch}...")
|
|
85
|
+
self.repo.remotes.origin.push(branch)
|
|
86
|
+
success_line(f"Pushed {branch}")
|
|
87
|
+
|
|
88
|
+
def pull_branch(self) -> None:
|
|
89
|
+
branch = self.get_current_branch()
|
|
90
|
+
info_line(f"Pulling {branch}...")
|
|
91
|
+
self.repo.remotes.origin.pull(branch)
|
|
92
|
+
success_line(f"Pulled {branch}")
|
|
93
|
+
|
|
94
|
+
def push_branch_to_destination(self, destination: str, force: bool = False) -> None:
|
|
95
|
+
current = self.get_current_branch()
|
|
96
|
+
info_line(f"Pushing {current} to {destination}...")
|
|
97
|
+
refspec = f"{current}:{destination}"
|
|
98
|
+
self.repo.remotes.origin.push(refspec=refspec, force=force)
|
|
99
|
+
success_line(f"Pushed to {destination}")
|
|
100
|
+
|
|
101
|
+
def rebase_with_main(self) -> bool:
|
|
102
|
+
try:
|
|
103
|
+
self.repo.remotes.origin.fetch()
|
|
104
|
+
self.repo.git.rebase("origin/main")
|
|
105
|
+
success_line("Rebase with main completed")
|
|
106
|
+
return True
|
|
107
|
+
except GitCommandError as e:
|
|
108
|
+
error_line(f"Rebase conflict: {e}")
|
|
109
|
+
warning_line("Resolve conflicts then use 'rebase continue' or 'rebase abort'")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def rebase_continue(self) -> None:
|
|
113
|
+
self.repo.git.rebase("--continue")
|
|
114
|
+
success_line("Rebase continued")
|
|
115
|
+
|
|
116
|
+
def rebase_abort(self) -> None:
|
|
117
|
+
self.repo.git.rebase("--abort")
|
|
118
|
+
success_line("Rebase aborted")
|
|
119
|
+
|
|
120
|
+
def squash_and_rebase(self) -> bool:
|
|
121
|
+
try:
|
|
122
|
+
self.repo.remotes.origin.fetch()
|
|
123
|
+
main_commit = self.repo.commit("origin/main")
|
|
124
|
+
current = self.get_current_branch()
|
|
125
|
+
self.repo.git.reset("--soft", main_commit.hexsha)
|
|
126
|
+
self.repo.index.commit(f"squash: {current}")
|
|
127
|
+
self.repo.git.rebase("origin/main")
|
|
128
|
+
success_line("Squash and rebase completed")
|
|
129
|
+
return True
|
|
130
|
+
except GitCommandError as e:
|
|
131
|
+
error_line(f"Squash rebase failed: {e}")
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def squash_merge_into_test(self) -> bool:
|
|
135
|
+
current = self.get_current_branch()
|
|
136
|
+
try:
|
|
137
|
+
self.repo.remotes.origin.fetch()
|
|
138
|
+
self.repo.git.checkout("test")
|
|
139
|
+
self.repo.remotes.origin.pull("test")
|
|
140
|
+
self.repo.git.merge("--squash", current)
|
|
141
|
+
self.repo.index.commit(f"squash merge: {current} into test")
|
|
142
|
+
self.repo.remotes.origin.push("test")
|
|
143
|
+
self.repo.git.checkout(current)
|
|
144
|
+
success_line(f"Squash merged {current} into test")
|
|
145
|
+
return True
|
|
146
|
+
except GitCommandError as e:
|
|
147
|
+
error_line(f"Squash merge failed: {e}")
|
|
148
|
+
try:
|
|
149
|
+
self.repo.git.checkout(current)
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
def cherry_pick_from_test(self, commit_sha: str) -> bool:
|
|
155
|
+
try:
|
|
156
|
+
self.repo.git.cherry_pick(commit_sha)
|
|
157
|
+
success_line(f"Cherry-picked {commit_sha[:8]}")
|
|
158
|
+
return True
|
|
159
|
+
except GitCommandError as e:
|
|
160
|
+
error_line(f"Cherry-pick failed: {e}")
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
def get_test_commits(self, limit: int = 20) -> list[dict]:
|
|
164
|
+
try:
|
|
165
|
+
self.repo.remotes.origin.fetch()
|
|
166
|
+
commits = list(self.repo.iter_commits("origin/test", max_count=limit))
|
|
167
|
+
return [{"sha": c.hexsha, "message": c.message.strip(), "author": str(c.author), "date": str(c.committed_datetime)} for c in commits]
|
|
168
|
+
except Exception:
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
def prune_remote_branches(self) -> None:
|
|
172
|
+
self.repo.remotes.origin.fetch(prune=True)
|
|
173
|
+
success_line("Pruned remote branches")
|
|
174
|
+
|
|
175
|
+
def reset_to_main(self) -> None:
|
|
176
|
+
self.repo.remotes.origin.fetch()
|
|
177
|
+
self.repo.git.checkout("main")
|
|
178
|
+
self.repo.git.reset("--hard", "origin/main")
|
|
179
|
+
# Delete all local branches except main
|
|
180
|
+
for branch in self.repo.branches:
|
|
181
|
+
if branch.name != "main":
|
|
182
|
+
self.repo.delete_head(branch, force=True)
|
|
183
|
+
success_line("Reset to main completed")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from datasecops_cli.utilities.display import info_line, error_line, success_line
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LintingService:
|
|
8
|
+
"""SQLFluff linting operations."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, project_dir: Path):
|
|
11
|
+
self.project_dir = project_dir
|
|
12
|
+
|
|
13
|
+
def lint_file(self, file_path: str = None, fix: bool = False) -> subprocess.CompletedProcess:
|
|
14
|
+
action = "fix" if fix else "lint"
|
|
15
|
+
cmd = ["sqlfluff", action]
|
|
16
|
+
if file_path:
|
|
17
|
+
cmd.append(file_path)
|
|
18
|
+
else:
|
|
19
|
+
cmd.append(str(self.project_dir / "models"))
|
|
20
|
+
|
|
21
|
+
config_path = self.project_dir / ".sqlfluff"
|
|
22
|
+
if config_path.exists():
|
|
23
|
+
cmd += ["--config", str(config_path)]
|
|
24
|
+
|
|
25
|
+
info_line(f"Running: sqlfluff {action}")
|
|
26
|
+
result = subprocess.run(cmd, capture_output=False, cwd=str(self.project_dir))
|
|
27
|
+
if result.returncode == 0:
|
|
28
|
+
success_line(f"SQLFluff {action} completed — no issues found")
|
|
29
|
+
elif result.returncode == 1 and not fix:
|
|
30
|
+
info_line(f"SQLFluff {action} found linting issues")
|
|
31
|
+
else:
|
|
32
|
+
error_line(f"SQLFluff {action} failed with exit code {result.returncode}")
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
def lint_modified(self, fix: bool = False, changed_files: list[str] = None) -> None:
|
|
36
|
+
if not changed_files:
|
|
37
|
+
info_line("No modified SQL files to lint")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
sql_files = [f for f in changed_files if f.endswith(".sql")]
|
|
41
|
+
if not sql_files:
|
|
42
|
+
info_line("No modified SQL files to lint")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
info_line(f"Linting {len(sql_files)} modified SQL file(s)...")
|
|
46
|
+
for f in sql_files:
|
|
47
|
+
self.lint_file(f, fix=fix)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from datasecops_cli.services.snowflake_service import SnowflakeService
|
|
5
|
+
from datasecops_cli.models.project_config import CortexSkill, CortexSkillFile
|
|
6
|
+
from datasecops_cli.utilities.display import info_line, success_line, error_line, warning_line
|
|
7
|
+
from datasecops_cli.utilities.file_utils import write_file, ensure_dir, get_cortex_skills_dir
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SkillService:
|
|
11
|
+
"""Manages Cortex Code skill installation."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, snowflake_service: SnowflakeService):
|
|
14
|
+
self.sf = snowflake_service
|
|
15
|
+
|
|
16
|
+
def get_available_skills(self) -> list[CortexSkill]:
|
|
17
|
+
raw = self.sf.get_framework_config("CORTEX_SKILLS")
|
|
18
|
+
if not raw:
|
|
19
|
+
return []
|
|
20
|
+
skills_data = raw.get("skills", [])
|
|
21
|
+
result = []
|
|
22
|
+
for s in skills_data:
|
|
23
|
+
if isinstance(s, dict):
|
|
24
|
+
files = [CortexSkillFile(**f) for f in s.get("files", []) if isinstance(f, dict)]
|
|
25
|
+
skill = CortexSkill(
|
|
26
|
+
skill_id=s.get("skill_id", ""),
|
|
27
|
+
name=s.get("name", ""),
|
|
28
|
+
description=s.get("description", ""),
|
|
29
|
+
version=s.get("version", "0.1.0"),
|
|
30
|
+
category=s.get("category", "general"),
|
|
31
|
+
files=files,
|
|
32
|
+
enabled=s.get("enabled", True),
|
|
33
|
+
)
|
|
34
|
+
if skill.enabled:
|
|
35
|
+
result.append(skill)
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
def list_skills(self) -> None:
|
|
39
|
+
skills = self.get_available_skills()
|
|
40
|
+
if not skills:
|
|
41
|
+
info_line("No skills available in the native app")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
info_line(f"Available Cortex Code skills ({len(skills)}):")
|
|
45
|
+
for s in skills:
|
|
46
|
+
installed = self._is_installed(s.skill_id)
|
|
47
|
+
status = "installed" if installed else "not installed"
|
|
48
|
+
info_line(f" [{s.category}] {s.name} v{s.version} — {status}")
|
|
49
|
+
if s.description:
|
|
50
|
+
info_line(f" {s.description}")
|
|
51
|
+
|
|
52
|
+
def install_skill(self, skill: CortexSkill, target_dir: Path = None) -> bool:
|
|
53
|
+
target = target_dir or get_cortex_skills_dir()
|
|
54
|
+
skill_dir = target / skill.skill_id
|
|
55
|
+
ensure_dir(skill_dir)
|
|
56
|
+
|
|
57
|
+
for f in skill.files:
|
|
58
|
+
if f.filename and f.content:
|
|
59
|
+
write_file(skill_dir / f.filename, f.content)
|
|
60
|
+
|
|
61
|
+
success_line(f"Installed skill: {skill.name} v{skill.version} -> {skill_dir}")
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
def install_all(self, target_dir: Path = None) -> int:
|
|
65
|
+
skills = self.get_available_skills()
|
|
66
|
+
count = 0
|
|
67
|
+
for skill in skills:
|
|
68
|
+
if self.install_skill(skill, target_dir):
|
|
69
|
+
count += 1
|
|
70
|
+
success_line(f"Installed {count} skill(s)")
|
|
71
|
+
return count
|
|
72
|
+
|
|
73
|
+
def update_skills(self, target_dir: Path = None) -> int:
|
|
74
|
+
skills = self.get_available_skills()
|
|
75
|
+
target = target_dir or get_cortex_skills_dir()
|
|
76
|
+
count = 0
|
|
77
|
+
for skill in skills:
|
|
78
|
+
if self._is_installed(skill.skill_id, target):
|
|
79
|
+
self.install_skill(skill, target)
|
|
80
|
+
count += 1
|
|
81
|
+
success_line(f"Updated {count} skill(s)")
|
|
82
|
+
return count
|
|
83
|
+
|
|
84
|
+
def _is_installed(self, skill_id: str, target_dir: Path = None) -> bool:
|
|
85
|
+
target = target_dir or get_cortex_skills_dir()
|
|
86
|
+
return (target / skill_id).exists()
|