ci-parity-cli 0.1.0__tar.gz
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.
- ci_parity_cli-0.1.0/.github/workflows/ci.yml +20 -0
- ci_parity_cli-0.1.0/.gitignore +18 -0
- ci_parity_cli-0.1.0/PKG-INFO +9 -0
- ci_parity_cli-0.1.0/README.md +0 -0
- ci_parity_cli-0.1.0/ci_parity/__init__.py +0 -0
- ci_parity_cli-0.1.0/ci_parity/compare/versions.py +19 -0
- ci_parity_cli-0.1.0/ci_parity/explain/analyzer.py +17 -0
- ci_parity_cli-0.1.0/ci_parity/fixes/suggestions.py +12 -0
- ci_parity_cli-0.1.0/ci_parity/main.py +80 -0
- ci_parity_cli-0.1.0/ci_parity/parsers/workflows.py +38 -0
- ci_parity_cli-0.1.0/ci_parity/probes/local.py +49 -0
- ci_parity_cli-0.1.0/pyproject.toml +22 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: CI Parity Test
|
|
2
|
+
on: [push]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-24.04
|
|
6
|
+
services:
|
|
7
|
+
postgres:
|
|
8
|
+
image: postgres:16
|
|
9
|
+
env:
|
|
10
|
+
DATABASE_URL: "postgres://user:pass@localhost:5432/db"
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- name: Setup Python
|
|
14
|
+
uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.12"
|
|
17
|
+
- name: Setup Node
|
|
18
|
+
uses: actions/setup-node@v4
|
|
19
|
+
with:
|
|
20
|
+
node-version: "22.4"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ci-parity-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Compare your local environment to CI workflows to prevent 'works on my machine' errors.
|
|
5
|
+
Author-email: Braveen R <your.email@example.com>
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
8
|
+
Requires-Dist: rich>=13.7.0
|
|
9
|
+
Requires-Dist: typer>=0.12.0
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
def evaluate_mismatch(ci_val, local_val, item_type):
|
|
2
|
+
if ci_val == "unknown" or local_val == "not installed":
|
|
3
|
+
return "[danger]❌ missing[/danger]"
|
|
4
|
+
|
|
5
|
+
if item_type == "os":
|
|
6
|
+
if ci_val.split("-")[0].lower() not in local_val.lower():
|
|
7
|
+
return "[warning]⚠ different[/warning]"
|
|
8
|
+
return "[success]✅ match[/success]"
|
|
9
|
+
|
|
10
|
+
# Semantic versioning comparison (e.g., 22.4 vs 26.12.1)
|
|
11
|
+
ci_parts = ci_val.split(".")
|
|
12
|
+
local_parts = local_val.split(".")
|
|
13
|
+
|
|
14
|
+
if ci_parts[0] != local_parts[0]:
|
|
15
|
+
return "[danger]❌ major differs[/danger]"
|
|
16
|
+
elif len(ci_parts) > 1 and len(local_parts) > 1 and ci_parts[1] != local_parts[1]:
|
|
17
|
+
return "[warning]⚠ minor differs[/warning]"
|
|
18
|
+
|
|
19
|
+
return "[success]✅ match[/success]"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
def analyze_log(log_text):
|
|
2
|
+
"""Scans a CI log for common environment-related failure signatures."""
|
|
3
|
+
insights = []
|
|
4
|
+
|
|
5
|
+
if "Cannot find module" in log_text or "MODULE_NOT_FOUND" in log_text:
|
|
6
|
+
insights.append("[danger]Node.js Mismatch:[/danger] CI failed to resolve a module. This often happens if the CI Node version is older than your local version and lacks support for newer built-in packages.")
|
|
7
|
+
|
|
8
|
+
if "psql: error: connection to server" in log_text or "Connection refused" in log_text:
|
|
9
|
+
insights.append("[danger]Database Missing:[/danger] The CI pipeline attempted to connect to a database service, but it was either not defined in the workflow's `services:` block or failed to start.")
|
|
10
|
+
|
|
11
|
+
if "KeyError:" in log_text or "undefined" in log_text.lower():
|
|
12
|
+
insights.append("[danger]Missing Env Var:[/danger] The application crashed trying to read an environment variable that wasn't exposed to the CI runner.")
|
|
13
|
+
|
|
14
|
+
if not insights:
|
|
15
|
+
insights.append("[info]No obvious environment-related crashes found in the log snippet.[/info]")
|
|
16
|
+
|
|
17
|
+
return insights
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
def generate_fixes(ci_reqs, local_state):
|
|
2
|
+
fixes = []
|
|
3
|
+
|
|
4
|
+
# Python fix using uv
|
|
5
|
+
if ci_reqs["python"] != "unknown" and not local_state["python"].startswith(ci_reqs["python"]):
|
|
6
|
+
fixes.append(f"uv python install {ci_reqs['python']} && uv venv --python {ci_reqs['python']}")
|
|
7
|
+
|
|
8
|
+
# Node fix using nvm
|
|
9
|
+
if ci_reqs["node"] != "unknown" and not local_state["node"].startswith(ci_reqs["node"]):
|
|
10
|
+
fixes.append(f"nvm install {ci_reqs['node']} && nvm use {ci_reqs['node']}")
|
|
11
|
+
|
|
12
|
+
return fixes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.theme import Theme
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from ci_parity.parsers.workflows import get_ci_requirements
|
|
7
|
+
from ci_parity.probes.local import get_local_state
|
|
8
|
+
from ci_parity.compare.versions import evaluate_mismatch
|
|
9
|
+
from ci_parity.fixes.suggestions import generate_fixes
|
|
10
|
+
from ci_parity.explain.analyzer import analyze_log
|
|
11
|
+
|
|
12
|
+
custom_theme = Theme({
|
|
13
|
+
"info": "dim cyan",
|
|
14
|
+
"warning": "magenta",
|
|
15
|
+
"danger": "bold #B21318",
|
|
16
|
+
"success": "bold green"
|
|
17
|
+
})
|
|
18
|
+
console = Console(theme=custom_theme)
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="ci-parity: Sync your local dev environment with CI.", no_args_is_help=True)
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def check():
|
|
24
|
+
console.print("[info]Scanning .github/workflows...[/info]\n")
|
|
25
|
+
ci_reqs = get_ci_requirements()
|
|
26
|
+
local_state = get_local_state(ci_reqs)
|
|
27
|
+
|
|
28
|
+
table = Table(header_style="info", border_style="dim cyan")
|
|
29
|
+
table.add_column("ITEM")
|
|
30
|
+
table.add_column("CI")
|
|
31
|
+
table.add_column("YOU")
|
|
32
|
+
table.add_column("RESULT")
|
|
33
|
+
|
|
34
|
+
table.add_row("OS", ci_reqs["os"], local_state["os"], evaluate_mismatch(ci_reqs["os"], local_state["os"], "os"))
|
|
35
|
+
table.add_row("Python", ci_reqs["python"], local_state["python"], evaluate_mismatch(ci_reqs["python"], local_state["python"], "version"))
|
|
36
|
+
table.add_row("Node", ci_reqs["node"], local_state["node"], evaluate_mismatch(ci_reqs["node"], local_state["node"], "version"))
|
|
37
|
+
|
|
38
|
+
for svc, img in ci_reqs["services"].items():
|
|
39
|
+
is_running = local_state["services"].get(svc, False)
|
|
40
|
+
table.add_row(f"{svc.capitalize()} (service)", img, "running" if is_running else "not running", "[success]✅ match[/success]" if is_running else "[danger]❌ missing[/danger]")
|
|
41
|
+
|
|
42
|
+
for env_var in ci_reqs["env"]:
|
|
43
|
+
is_set = local_state["env"].get(env_var, False)
|
|
44
|
+
table.add_row(env_var, "required", "set" if is_set else "not set", "[success]✅ match[/success]" if is_set else "[danger]❌ missing[/danger]")
|
|
45
|
+
|
|
46
|
+
console.print(table)
|
|
47
|
+
|
|
48
|
+
fixes = generate_fixes(ci_reqs, local_state)
|
|
49
|
+
if not local_state["services"].get("postgres", True):
|
|
50
|
+
fixes.append("docker run -d --name postgres -e POSTGRES_PASSWORD=pass -p 5432:5432 postgres:16")
|
|
51
|
+
if not local_state["env"].get("DATABASE_URL", True):
|
|
52
|
+
fixes.append("export DATABASE_URL='postgres://...' (add to .env)")
|
|
53
|
+
|
|
54
|
+
if fixes:
|
|
55
|
+
console.print("\n[warning]Likely cause:[/warning] Code using newer language features, or missing database/env vars.")
|
|
56
|
+
fix_text = "\n".join([f"• {f}" for f in fixes])
|
|
57
|
+
console.print(Panel(fix_text, title="[info]Recommended Fixes[/info]", border_style="dim cyan", expand=False))
|
|
58
|
+
else:
|
|
59
|
+
console.print("\n[success]Environments are perfectly synced![/success]")
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def explain(run_url: str):
|
|
63
|
+
console.print(f"[info]Connecting to GitHub API for:[/info] {run_url}\n")
|
|
64
|
+
|
|
65
|
+
# Simulating a fetched failed log containing a Node version error
|
|
66
|
+
dummy_log = """
|
|
67
|
+
Step 4: Run tests
|
|
68
|
+
Error: Cannot find module 'node:test'
|
|
69
|
+
Require stack:
|
|
70
|
+
- /home/runner/work/app/app/test.js
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
console.print(Panel(dummy_log.strip(), title="[warning]Extracted CI Log Snippet[/warning]", border_style="dim magenta", expand=False))
|
|
74
|
+
|
|
75
|
+
insights = analyze_log(dummy_log)
|
|
76
|
+
for insight in insights:
|
|
77
|
+
console.print(f"💡 {insight}")
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
app()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
def get_ci_requirements(directory=".github/workflows"):
|
|
5
|
+
requirements = {"os": "unknown", "python": "unknown", "node": "unknown", "services": {}, "env": []}
|
|
6
|
+
|
|
7
|
+
path = Path(directory)
|
|
8
|
+
if not path.exists():
|
|
9
|
+
return requirements
|
|
10
|
+
|
|
11
|
+
for yml_file in path.glob("*.yml"):
|
|
12
|
+
with open(yml_file, "r") as f:
|
|
13
|
+
try:
|
|
14
|
+
data = yaml.safe_load(f)
|
|
15
|
+
jobs = data.get("jobs", {})
|
|
16
|
+
for job_name, job_data in jobs.items():
|
|
17
|
+
if "runs-on" in job_data:
|
|
18
|
+
requirements["os"] = job_data["runs-on"]
|
|
19
|
+
|
|
20
|
+
# Extract Environment Variables
|
|
21
|
+
if "env" in job_data:
|
|
22
|
+
requirements["env"].extend(job_data["env"].keys())
|
|
23
|
+
|
|
24
|
+
# Extract Services (like Postgres/Redis)
|
|
25
|
+
if "services" in job_data:
|
|
26
|
+
for svc_name, svc_details in job_data["services"].items():
|
|
27
|
+
requirements["services"][svc_name] = svc_details.get("image", "unknown")
|
|
28
|
+
|
|
29
|
+
for step in job_data.get("steps", []):
|
|
30
|
+
with_block = step.get("with", {})
|
|
31
|
+
if "python-version" in with_block:
|
|
32
|
+
requirements["python"] = str(with_block["python-version"])
|
|
33
|
+
if "node-version" in with_block:
|
|
34
|
+
requirements["node"] = str(with_block["node-version"])
|
|
35
|
+
except Exception:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
return requirements
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import subprocess
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
def get_local_os():
|
|
6
|
+
try:
|
|
7
|
+
with open("/etc/os-release") as f:
|
|
8
|
+
for line in f:
|
|
9
|
+
if line.startswith("PRETTY_NAME="):
|
|
10
|
+
return line.split("=")[1].strip().strip('"')
|
|
11
|
+
except FileNotFoundError:
|
|
12
|
+
pass
|
|
13
|
+
return platform.system()
|
|
14
|
+
|
|
15
|
+
def get_local_python():
|
|
16
|
+
return platform.python_version()
|
|
17
|
+
|
|
18
|
+
def get_local_node():
|
|
19
|
+
try:
|
|
20
|
+
result = subprocess.run(["node", "-v"], capture_output=True, text=True, check=True)
|
|
21
|
+
return result.stdout.strip().lstrip("v")
|
|
22
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
23
|
+
return "not installed"
|
|
24
|
+
|
|
25
|
+
def check_local_env(env_vars):
|
|
26
|
+
"""Checks if required CI env vars are set locally."""
|
|
27
|
+
return {var: (var in os.environ) for var in env_vars}
|
|
28
|
+
|
|
29
|
+
def check_local_services(services):
|
|
30
|
+
"""Checks if docker containers for required services are running."""
|
|
31
|
+
status = {}
|
|
32
|
+
try:
|
|
33
|
+
result = subprocess.run(["docker", "ps", "--format", "{{.Names}} {{.Image}}"], capture_output=True, text=True)
|
|
34
|
+
running_containers = result.stdout.lower()
|
|
35
|
+
for svc in services:
|
|
36
|
+
status[svc] = svc.lower() in running_containers
|
|
37
|
+
except Exception:
|
|
38
|
+
for svc in services:
|
|
39
|
+
status[svc] = False
|
|
40
|
+
return status
|
|
41
|
+
|
|
42
|
+
def get_local_state(ci_reqs):
|
|
43
|
+
return {
|
|
44
|
+
"os": get_local_os(),
|
|
45
|
+
"python": get_local_python(),
|
|
46
|
+
"node": get_local_node(),
|
|
47
|
+
"env": check_local_env(ci_reqs.get("env", [])),
|
|
48
|
+
"services": check_local_services(ci_reqs.get("services", {}))
|
|
49
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ci-parity-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Compare your local environment to CI workflows to prevent 'works on my machine' errors."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
authors = [{ name = "Braveen R", email = "your.email@example.com" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"typer>=0.12.0",
|
|
14
|
+
"rich>=13.7.0",
|
|
15
|
+
"pyyaml>=6.0.1",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
ci-parity-cli = "ci_parity.main:app"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["ci_parity"]
|