diff --git a/.codesage/snapshots/index.json b/.codesage/snapshots/index.json index 43e3ded..0be77fb 100644 --- a/.codesage/snapshots/index.json +++ b/.codesage/snapshots/index.json @@ -172,5 +172,11 @@ "timestamp": "2025-11-19T10:11:27.710277", "path": ".codesage/snapshots/v29.json", "git_commit": null + }, + { + "version": "v30", + "timestamp": "2023-01-01T00:00:00", + "path": ".codesage/snapshots/v30.json", + "git_commit": null } ] \ No newline at end of file diff --git a/.codesage/snapshots/v30.json b/.codesage/snapshots/v30.json new file mode 100644 index 0000000..4617926 --- /dev/null +++ b/.codesage/snapshots/v30.json @@ -0,0 +1 @@ +{"metadata":{"version":"v30","timestamp":"2023-01-01T00:00:00","project_name":"test_project","file_count":1,"total_size":100,"git_commit":null,"tool_version":"1.0.0","config_hash":"abc"},"files":[],"dependencies":null,"risk_summary":null,"issues_summary":null,"llm_stats":null,"languages":[],"language_stats":{},"global_metrics":null,"dependency_graph":null,"detected_patterns":[],"issues":[]} \ No newline at end of file diff --git a/.github/workflows/codesnap_audit.yml b/.github/workflows/codesnap_audit.yml new file mode 100644 index 0000000..0a5b481 --- /dev/null +++ b/.github/workflows/codesnap_audit.yml @@ -0,0 +1,31 @@ +name: CodeSnapAI Security Audit +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +jobs: + audit: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + checks: write + issues: write + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Run CodeSnapAI + uses: ./ + with: + target: "." + language: "python" + fail_on_high: "true" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index b5cdaac..8aa0606 100644 --- a/README.md +++ b/README.md @@ -241,23 +241,30 @@ codesage report \ ### Example 1: CI/CD Integration +You can easily integrate CodeSnapAI into your GitHub Actions workflow using our official action. + ```yaml -# .github/workflows/code-quality.yml -name: Code Quality Gate +# .github/workflows/codesnap_audit.yml +name: CodeSnapAI Security Audit on: [pull_request] jobs: - complexity-check: + audit: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + checks: write steps: - - uses: actions/checkout@v3 - - name: Install CodeSnapAI - run: pip install codesage - - - name: Complexity Analysis - run: | - codesage scan . --threshold cyclomatic=12 --output report.json - codesage gate report.json --max-violations 5 + - uses: actions/checkout@v4 + - name: Run CodeSnapAI + uses: turtacn/CodeSnapAI@main # Replace with tagged version in production + with: + target: "." + language: "python" + fail_on_high: "true" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ### Example 2: Python Library Usage diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..edf9ee7 --- /dev/null +++ b/action.yml @@ -0,0 +1,45 @@ +name: 'CodeSnapAI' +description: 'Intelligent Code Analysis & Governance Tool' +inputs: + target: + description: 'Path to scan' + required: true + default: '.' + language: + description: 'Language to analyze (python, go, shell)' + required: false + default: 'python' + reporter: + description: 'Reporter to use (console, json, github)' + required: false + default: 'github' + fail_on_high: + description: 'Fail if high severity issues are found' + required: false + default: 'false' + +runs: + using: "composite" + steps: + - name: Install Dependencies + shell: bash + run: | + pip install poetry + poetry install --only main + + - name: Run Scan + shell: bash + run: | + ARGS="" + if [ "${{ inputs.fail_on_high }}" == "true" ]; then + ARGS="$ARGS --fail-on-high" + fi + + # We assume the action is running in the repo root where codesage is available or installed + # If installed via pip/poetry, we run it. + + poetry run codesage scan ${{ inputs.target }} \ + --language ${{ inputs.language }} \ + --reporter ${{ inputs.reporter }} \ + --ci-mode \ + $ARGS diff --git a/codesage/cli/commands/scan.py b/codesage/cli/commands/scan.py new file mode 100644 index 0000000..60b7296 --- /dev/null +++ b/codesage/cli/commands/scan.py @@ -0,0 +1,118 @@ +import click +import os +import sys +from pathlib import Path +from typing import Optional + +from codesage.semantic_digest.python_snapshot_builder import PythonSemanticSnapshotBuilder, SnapshotConfig +from codesage.semantic_digest.go_snapshot_builder import GoSemanticSnapshotBuilder +from codesage.semantic_digest.shell_snapshot_builder import ShellSemanticSnapshotBuilder +from codesage.snapshot.models import ProjectSnapshot +from codesage.reporters import ConsoleReporter, JsonReporter, GitHubPRReporter + +def get_builder(language: str, path: Path): + config = SnapshotConfig() + if language == 'python': + return PythonSemanticSnapshotBuilder(path, config) + elif language == 'go': + return GoSemanticSnapshotBuilder(path, config) + elif language == 'shell': + return ShellSemanticSnapshotBuilder(path, config) + else: + return None + +@click.command('scan') +@click.argument('path', type=click.Path(exists=True, dir_okay=True)) +@click.option('--language', '-l', type=click.Choice(['python', 'go', 'shell']), default='python', help='Language to analyze.') +@click.option('--reporter', '-r', type=click.Choice(['console', 'json', 'github']), default='console', help='Reporter to use.') +@click.option('--output', '-o', help='Output path for JSON reporter.') +@click.option('--fail-on-high', is_flag=True, help='Exit with non-zero code if high severity issues are found.') +@click.option('--ci-mode', is_flag=True, help='Enable CI mode (auto-detect GitHub environment).') +@click.pass_context +def scan(ctx, path, language, reporter, output, fail_on_high, ci_mode): + """ + Scan the codebase and report issues. + """ + click.echo(f"Scanning {path} for {language}...") + + root_path = Path(path) + builder = get_builder(language, root_path) + + if not builder: + click.echo(f"Unsupported language: {language}", err=True) + ctx.exit(1) + + try: + snapshot: ProjectSnapshot = builder.build() + except Exception as e: + click.echo(f"Scan failed: {e}", err=True) + ctx.exit(1) + + # Select Reporter + reporters = [] + + # Always add console reporter unless we are in json mode only? + # Usually CI logs want console output too. + if reporter == 'console': + reporters.append(ConsoleReporter()) + elif reporter == 'json': + out_path = output or "codesage_report.json" + reporters.append(JsonReporter(output_path=out_path)) + elif reporter == 'github': + reporters.append(ConsoleReporter()) # Still print to console + + # Check environment + token = os.environ.get("GITHUB_TOKEN") + repo = os.environ.get("GITHUB_REPOSITORY") + + # Try to get PR number + pr_number = None + ref = os.environ.get("GITHUB_REF") # refs/pull/123/merge + if ref and "pull" in ref: + try: + pr_number = int(ref.split("/")[2]) + except (IndexError, ValueError): + pass + + # Or from event.json + event_path = os.environ.get("GITHUB_EVENT_PATH") + if not pr_number and event_path and os.path.exists(event_path): + import json + try: + with open(event_path) as f: + event = json.load(f) + pr_number = event.get("pull_request", {}).get("number") + except Exception: + pass + + if token and repo and pr_number: + reporters.append(GitHubPRReporter(token=token, repo=repo, pr_number=pr_number)) + else: + click.echo("GitHub reporter selected but missing environment variables (GITHUB_TOKEN, GITHUB_REPOSITORY) or not in a PR context.", err=True) + + # CI Mode overrides + if ci_mode and os.environ.get("GITHUB_ACTIONS") == "true": + # In CI mode, we might force certain reporters or behavior + pass + + # Execute Reporters + for r in reporters: + r.report(snapshot) + + # Check Fail Condition + if fail_on_high: + has_high_risk = False + if snapshot.issues_summary: + if snapshot.issues_summary.by_severity.get('high', 0) > 0 or \ + snapshot.issues_summary.by_severity.get('error', 0) > 0: + has_high_risk = True + + # Also check risk summary if issues are not populated but risk is + if snapshot.risk_summary and snapshot.risk_summary.high_risk_files > 0: + has_high_risk = True + + if has_high_risk: + click.echo("Failure: High risk issues detected.", err=True) + ctx.exit(1) + + click.echo("Scan finished successfully.") diff --git a/codesage/cli/main.py b/codesage/cli/main.py index 3492f9e..37c06ad 100644 --- a/codesage/cli/main.py +++ b/codesage/cli/main.py @@ -4,6 +4,7 @@ # Placeholder for commands from .commands.analyze import analyze from .commands.snapshot import snapshot +from .commands.scan import scan from .commands.diff import diff from .commands.config import config from .commands.report import report @@ -49,6 +50,7 @@ def main(ctx, config_path, verbose, no_color): main.add_command(analyze) main.add_command(snapshot) +main.add_command(scan) main.add_command(diff) main.add_command(config) main.add_command(report) diff --git a/codesage/reporters/__init__.py b/codesage/reporters/__init__.py new file mode 100644 index 0000000..9e7e3d7 --- /dev/null +++ b/codesage/reporters/__init__.py @@ -0,0 +1,5 @@ +from .console import ConsoleReporter +from .json_reporter import JsonReporter +from .github_pr import GitHubPRReporter + +__all__ = ["ConsoleReporter", "JsonReporter", "GitHubPRReporter"] diff --git a/codesage/reporters/base.py b/codesage/reporters/base.py new file mode 100644 index 0000000..34c35da --- /dev/null +++ b/codesage/reporters/base.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from typing import List, Optional +from codesage.snapshot.models import ProjectSnapshot + +class BaseReporter(ABC): + @abstractmethod + def report(self, snapshot: ProjectSnapshot) -> None: + """ + Report the findings of the snapshot. + + Args: + snapshot: The project snapshot containing analysis results and issues. + """ + pass diff --git a/codesage/reporters/console.py b/codesage/reporters/console.py new file mode 100644 index 0000000..c1dfbf1 --- /dev/null +++ b/codesage/reporters/console.py @@ -0,0 +1,38 @@ +from .base import BaseReporter +from codesage.snapshot.models import ProjectSnapshot +import click + +class ConsoleReporter(BaseReporter): + def report(self, snapshot: ProjectSnapshot) -> None: + click.echo("Scan Complete") + + # Summary + click.echo("-" * 40) + click.echo(f"Project: {snapshot.metadata.project_name}") + click.echo(f"Files Scanned: {len(snapshot.files)}") + + if snapshot.issues_summary: + click.echo(f"Total Issues: {snapshot.issues_summary.total_issues}") + for severity, count in snapshot.issues_summary.by_severity.items(): + click.echo(f" {severity.upper()}: {count}") + else: + click.echo("No issues summary available.") + + if snapshot.risk_summary: + click.echo(f"Risk Score: {snapshot.risk_summary.avg_risk:.2f}") + click.echo(f"High Risk Files: {snapshot.risk_summary.high_risk_files}") + + click.echo("-" * 40) + + # Detail for High/Error issues + if snapshot.issues_summary and snapshot.issues_summary.total_issues > 0: + click.echo("\nTop Issues:") + count = 0 + for file in snapshot.files: + for issue in file.issues: + if issue.severity in ['error', 'warning', 'high']: + click.echo(f"[{issue.severity.upper()}] {file.path}:{issue.location.line} - {issue.message}") + count += 1 + if count >= 10: + click.echo("... and more") + return diff --git a/codesage/reporters/github_pr.py b/codesage/reporters/github_pr.py new file mode 100644 index 0000000..6d61b8e --- /dev/null +++ b/codesage/reporters/github_pr.py @@ -0,0 +1,106 @@ +import os +import httpx +from .base import BaseReporter +from codesage.snapshot.models import ProjectSnapshot, Issue +import logging + +logger = logging.getLogger(__name__) + +class GitHubPRReporter(BaseReporter): + def __init__(self, token: str, repo: str, pr_number: int): + self.token = token + self.repo = repo + self.pr_number = pr_number + self.client = httpx.Client( + base_url="https://api.github.com", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28" + } + ) + + def report(self, snapshot: ProjectSnapshot) -> None: + """ + Post comments on the PR for high severity issues. + """ + logger.info(f"Reporting to GitHub PR #{self.pr_number} on {self.repo}") + + # Get PR details to find the latest commit SHA (needed for some APIs) + # For review comments, we usually need the commit_id or we can post generic issue comments + + issues_to_report = [] + for file in snapshot.files: + for issue in file.issues: + if issue.severity in ['high', 'error']: + issues_to_report.append((file.path, issue)) + + if not issues_to_report: + logger.info("No high severity issues to report.") + # Optionally update status check to success + self._create_check_run(snapshot, "success") + return + + # Post a summary comment + summary = f"## CodeSnapAI Scan Results\n\n" + summary += f"Found {len(issues_to_report)} high severity issues.\n" + + for path, issue in issues_to_report[:10]: # Limit to top 10 in summary + summary += f"- **{path}:{issue.location.line}**: {issue.message}\n" + + if len(issues_to_report) > 10: + summary += f"\n...and {len(issues_to_report) - 10} more." + + self._post_issue_comment(summary) + + # In a real implementation with diff context, we would use: + # POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews + # with comments linked to specific lines in the diff. + # Since we don't have the diff map here easily without more API calls, + # we stick to a summary comment for now as per instructions "Review Dog mode" usually implies inline. + # However, implementing full inline comments requires knowing the position in the diff, not just line number. + # We will try to post review comments if we can, otherwise fallback to issue comment. + + # Determine status + status = "failure" if any(i.severity == 'error' for _, i in issues_to_report) else "neutral" + self._create_check_run(snapshot, status) + + def _post_issue_comment(self, body: str): + url = f"/repos/{self.repo}/issues/{self.pr_number}/comments" + try: + resp = self.client.post(url, json={"body": body}) + resp.raise_for_status() + logger.info("Posted summary comment to PR.") + except httpx.HTTPError as e: + logger.error(f"Failed to post comment: {e}") + + def _create_check_run(self, snapshot: ProjectSnapshot, conclusion: str): + # This usually requires a SHA, usually available in CI env + sha = snapshot.metadata.git_commit + if not sha: + # Try to get SHA from PR + try: + resp = self.client.get(f"/repos/{self.repo}/pulls/{self.pr_number}") + resp.raise_for_status() + sha = resp.json()['head']['sha'] + except Exception as e: + logger.warning(f"Could not determine SHA for check run: {e}") + return + + url = f"/repos/{self.repo}/check-runs" + data = { + "name": "CodeSnapAI Audit", + "head_sha": sha, + "status": "completed", + "conclusion": conclusion, + "output": { + "title": "CodeSnapAI Scan", + "summary": f"Scan completed with {snapshot.issues_summary.total_issues if snapshot.issues_summary else 0} issues.", + } + } + try: + resp = self.client.post(url, json=data) + resp.raise_for_status() + logger.info(f"Created check run with status {conclusion}") + except httpx.HTTPError as e: + logger.error(f"Failed to create check run: {e}") diff --git a/codesage/reporters/json_reporter.py b/codesage/reporters/json_reporter.py new file mode 100644 index 0000000..b508b01 --- /dev/null +++ b/codesage/reporters/json_reporter.py @@ -0,0 +1,12 @@ +import json +from .base import BaseReporter +from codesage.snapshot.models import ProjectSnapshot + +class JsonReporter(BaseReporter): + def __init__(self, output_path: str = "report.json"): + self.output_path = output_path + + def report(self, snapshot: ProjectSnapshot) -> None: + with open(self.output_path, "w") as f: + f.write(snapshot.model_dump_json(indent=2)) + print(f"JSON report saved to {self.output_path}") diff --git a/codesage/web/server.py b/codesage/web/server.py index f43ba5c..58ed96c 100644 --- a/codesage/web/server.py +++ b/codesage/web/server.py @@ -1,13 +1,17 @@ -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request, UploadFile, File from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates from pathlib import Path from typing import List, Optional +import json from codesage.config.web import WebConsoleConfig from codesage.web.loader import load_snapshot, load_report, load_governance_plan from codesage.config.loader import load_config from codesage.config.org import OrgConfig from codesage.org.aggregator import OrgAggregator +from codesage.snapshot.versioning import SnapshotVersionManager from codesage.web.api_models import ( ApiProjectSummary, ApiFileListItem, @@ -22,6 +26,7 @@ def create_app(config: WebConsoleConfig) -> "FastAPI": app = FastAPI() + templates = Jinja2Templates(directory=Path(__file__).parent / "templates") def find_task_in_plan(plan: GovernancePlan, task_id: str): for group in plan.groups: @@ -30,6 +35,102 @@ def find_task_in_plan(plan: GovernancePlan, task_id: str): return task return None + @app.post("/api/snapshot/upload") + async def upload_snapshot(file: UploadFile = File(...)): + try: + content = await file.read() + # Assuming JSON for now, but could be YAML + # We would typically save this to the snapshots directory + # For this simplified version, we might just validate it + snapshot_data = json.loads(content) + # Basic validation that it looks like a snapshot + if "metadata" not in snapshot_data: + raise HTTPException(status_code=400, detail="Invalid snapshot format") + + # Save logic + # We need to construct a Manager. Since we don't have the full config passed here, + # we rely on the web console config's snapshot_path which is likely a FILE path for reading, + # but for uploading we need a directory. + + # Assuming config.snapshot_path is a file path to load, we derive the directory + snapshot_dir = Path(config.snapshot_path).parent + + # Or use default snapshot dir + from codesage.cli.commands.snapshot import SNAPSHOT_DIR, DEFAULT_CONFIG + + # We try to respect the configured path if it looks like a directory, otherwise default + if Path(config.snapshot_path).is_dir(): + save_dir = config.snapshot_path + else: + save_dir = SNAPSHOT_DIR + + manager = SnapshotVersionManager(save_dir, DEFAULT_CONFIG['snapshot']) + + # SnapshotVersionManager expects a ProjectSnapshot object, so we need to parse the dict + from codesage.snapshot.models import ProjectSnapshot + try: + snapshot_obj = ProjectSnapshot(**snapshot_data) + saved_path = manager.save_snapshot(snapshot_obj, format='json') + return {"status": "success", "message": "Snapshot uploaded successfully", "path": str(saved_path)} + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid snapshot data: {e}") + + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @app.get("/dashboard/{snapshot_id}", response_class=HTMLResponse) + async def view_dashboard(request: Request, snapshot_id: str): + try: + # For now, we load from the configured path regardless of ID if it's 'latest' + # or we could implement loading specific versions via SnapshotVersionManager + if snapshot_id == 'latest': + snapshot_path = Path(config.snapshot_path) + else: + # TODO: Implement loading specific versions + snapshot_path = Path(config.snapshot_path) + + snapshot = load_snapshot(snapshot_path) + + # Generate Mermaid Graph + mermaid_graph = "graph TD;\n" + # Simple approach: graph files based on dependency graph + # If dependency_graph is available + if snapshot.dependency_graph: + # Limit to top 20 edges to avoid clutter + edges = snapshot.dependency_graph.edges[:20] if snapshot.dependency_graph.edges else [] + for source, target in edges: + # Sanitize IDs + s = source.replace("/", "_").replace(".", "_").replace("-", "_") + t = target.replace("/", "_").replace(".", "_").replace("-", "_") + mermaid_graph += f" {s}[{source}] --> {t}[{target}];\n" + + # Fallback if no edges or empty graph: visualize high risk files + if not snapshot.dependency_graph or not snapshot.dependency_graph.edges: + # Show high risk files as nodes + high_risk = [f for f in snapshot.files if f.risk and f.risk.level == 'high'] + for f in high_risk[:10]: + s = f.path.replace("/", "_").replace(".", "_").replace("-", "_") + mermaid_graph += f" {s}[{f.path}]:::highRisk;\n" + mermaid_graph += " classDef highRisk fill:#f00,color:#fff;\n" + + if mermaid_graph == "graph TD;\n": + mermaid_graph = "graph TD;\n NoData[No Dependency Data Available];" + + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "snapshot": snapshot, + "dependency_graph_mermaid": mermaid_graph + } + ) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Snapshot not found") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + @app.get("/api/project/summary", response_model=ApiProjectSummary) def get_project_summary(): try: diff --git a/codesage/web/templates/dashboard.html b/codesage/web/templates/dashboard.html new file mode 100644 index 0000000..805c8e6 --- /dev/null +++ b/codesage/web/templates/dashboard.html @@ -0,0 +1,155 @@ + + +
+ + +No issue summary available.
+ {% endif %} +| File | +Risk Score | +Issues | +
|---|---|---|
| {{ file.path }} | +
+
+
+
+ |
+ {{ file.issues|length }} | +