diff --git a/.gitignore b/.gitignore index 61a8573..fdfbf27 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,7 @@ dmypy.json .codesage/ report.md report.json +codesage.db +plan.yml +task.yml +enriched_snapshot.yml diff --git a/codesage.db b/codesage.db deleted file mode 100644 index cd33063..0000000 Binary files a/codesage.db and /dev/null differ diff --git a/codesage/cli/commands/diff.py b/codesage/cli/commands/diff.py index e0b4966..667a189 100644 --- a/codesage/cli/commands/diff.py +++ b/codesage/cli/commands/diff.py @@ -17,13 +17,14 @@ @click.command() @click.argument('version1') @click.argument('version2') +@click.option('--project', '-p', required=True, help='The name of the project.') @click.option('--output', '-o', type=click.Path(), help='Output file for the diff report.') @click.option('--format', '-f', type=click.Choice(['json', 'markdown']), default='json', help='Output format.') -def diff(version1, version2, output, format): +def diff(version1, version2, project, output, format): """ Compare two snapshots and show the differences. """ - manager = SnapshotVersionManager(SNAPSHOT_DIR, DEFAULT_CONFIG['snapshot']) + manager = SnapshotVersionManager(SNAPSHOT_DIR, project, DEFAULT_CONFIG['snapshot']) from codesage.snapshot.models import ProjectSnapshot import json diff --git a/codesage/cli/commands/governance_plan.py b/codesage/cli/commands/governance_plan.py index 96fe2e4..1016c16 100644 --- a/codesage/cli/commands/governance_plan.py +++ b/codesage/cli/commands/governance_plan.py @@ -10,13 +10,21 @@ from codesage.audit.models import AuditEvent from datetime import datetime +from codesage.snapshot.versioning import SnapshotVersionManager +from codesage.config.defaults import SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG + @click.command(name="governance-plan", help="Generate a governance plan from a project snapshot.") @click.option( - "--input", - "input_path", + "--snapshot-version", + "snapshot_version", + required=True, + help="The version of the snapshot to use.", +) +@click.option( + "--project", + "project_name", required=True, - type=click.Path(exists=True, dir_okay=False, resolve_path=True), - help="Path to the input project snapshot YAML file.", + help="The name of the project.", ) @click.option( "--output", @@ -30,21 +38,22 @@ @click.pass_context def governance_plan( ctx, - input_path: str, + snapshot_version: str, + project_name: str, output_path: str, group_by: str | None, max_tasks_per_file: int | None, ): """ - Generates a governance plan from a project snapshot YAML file. + Generates a governance plan from a project snapshot. """ audit_logger = ctx.obj.audit_logger - project_name = None try: - click.echo(f"Loading snapshot from {input_path}...") - snapshot_data = read_yaml_file(Path(input_path)) - snapshot = ProjectSnapshot.model_validate(snapshot_data) - project_name = snapshot.metadata.project_name + manager = SnapshotVersionManager(SNAPSHOT_DIR, project_name, DEFAULT_SNAPSHOT_CONFIG['snapshot']) + snapshot = manager.load_snapshot(snapshot_version) + if not snapshot: + click.echo(f"Snapshot {snapshot_version} not found for project '{project_name}'.", err=True) + return # Apply config overrides config = GovernanceConfig.default() @@ -74,7 +83,7 @@ def governance_plan( project_name=project_name, command="governance-plan", args={ - "input_path": input_path, + "snapshot_version": snapshot_version, "output_path": output_path, "group_by": group_by, "max_tasks_per_file": max_tasks_per_file, diff --git a/codesage/cli/commands/jules_prompt.py b/codesage/cli/commands/jules_prompt.py index d447694..dd0d3b6 100644 --- a/codesage/cli/commands/jules_prompt.py +++ b/codesage/cli/commands/jules_prompt.py @@ -13,12 +13,17 @@ from codesage.audit.models import AuditEvent from datetime import datetime +from codesage.snapshot.versioning import SnapshotVersionManager +from codesage.config.defaults import SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG + @click.command('jules-prompt', help="Generate a Jules prompt for a specific governance task.") @click.option('--plan', 'plan_path', type=click.Path(exists=True), help="Path to the governance_plan.yaml file.") @click.option('--task-id', help="The ID of the task within the governance plan.") @click.option('--task', 'task_path', type=click.Path(exists=True), help="Path to a single GovernanceTask YAML/JSON file.") +@click.option('--project', '-p', 'project_name', type=str, help='The name of the project.') +@click.option('--snapshot-version', '-s', 'snapshot_version', type=str, help='The version of the snapshot to use.') @click.pass_context -def jules_prompt(ctx, plan_path: Optional[str], task_id: Optional[str], task_path: Optional[str]): +def jules_prompt(ctx, plan_path: Optional[str], task_id: Optional[str], task_path: Optional[str], project_name: Optional[str], snapshot_version: Optional[str]): """ Generates a prompt for Jules from a governance task. """ @@ -60,18 +65,32 @@ def jules_prompt(ctx, plan_path: Optional[str], task_id: Optional[str], task_pat click.echo(f"Error: Template with ID '{recipe.template_id}' not found.", err=True) return - # The JulesTaskView requires a code snippet, which we don't have in the GovernanceTask alone. - # For this CLI tool, we'll use a placeholder. A more advanced implementation - # would need access to the project snapshot to get the real code. - # We will use the `llm_hint` field from the task metadata if it exists. + snapshot = None + if project_name and snapshot_version: + manager = SnapshotVersionManager(SNAPSHOT_DIR, project_name, DEFAULT_SNAPSHOT_CONFIG['snapshot']) + snapshot = manager.load_snapshot(snapshot_version) + if not snapshot: + click.echo(f"Warning: Snapshot {snapshot_version} not found for project '{project_name}'. Code snippet will not be available.", err=True) - code_snippet_placeholder = task.metadata.get("code_snippet", "[Code snippet not available in this context. Please refer to the file and line number.]") + code_snippet = "[Code snippet not available in this context. Please refer to the file and line number.]" + if snapshot: + for file_snapshot in snapshot.files: + if file_snapshot.path == task.file_path: + try: + with open(file_snapshot.path, 'r', encoding='utf-8') as f: + lines = f.readlines() + start_line = task.metadata.get("start_line", 1) - 1 + end_line = task.metadata.get("end_line", start_line + 10) + code_snippet = "".join(lines[start_line:end_line]) + except Exception as e: + click.echo(f"Warning: Could not read file {file_snapshot.path}: {e}", err=True) + break # Create a simple JulesTaskView from the task view = JulesTaskView( file_path=task.file_path, language=task.language, - code_snippet=code_snippet_placeholder, + code_snippet=code_snippet, issue_message=task.description, goal_description=task.metadata.get("goal_description", "Fix the issue described above."), line=task.metadata.get("start_line"), @@ -94,6 +113,8 @@ def jules_prompt(ctx, plan_path: Optional[str], task_id: Optional[str], task_pat "plan_path": plan_path, "task_id": task_id, "task_path": task_path, + "project_name": project_name, + "snapshot_version": snapshot_version, }, ) ) diff --git a/codesage/cli/commands/llm_suggest.py b/codesage/cli/commands/llm_suggest.py index cafd1cf..f57748c 100644 --- a/codesage/cli/commands/llm_suggest.py +++ b/codesage/cli/commands/llm_suggest.py @@ -8,18 +8,23 @@ from codesage.snapshot.yaml_generator import YAMLGenerator +from codesage.snapshot.versioning import SnapshotVersionManager +from codesage.config.defaults import SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG + @click.command('llm-suggest') -@click.option('--input', '-i', 'input_path', type=click.Path(exists=True, dir_okay=False), required=True, help='Input snapshot YAML file.') +@click.option('--snapshot-version', '-s', 'snapshot_version', type=str, required=True, help='The version of the snapshot to use.') +@click.option('--project', '-p', 'project_name', type=str, required=True, help='The name of the project.') @click.option('--output', '-o', 'output_path', type=click.Path(), required=True, help='Output snapshot YAML file.') @click.option('--provider', type=click.Choice(['dummy']), default='dummy', help='LLM provider to use.') @click.option('--model', type=str, default='dummy-model', help='LLM model to use.') -def llm_suggest(input_path, output_path, provider, model): +def llm_suggest(snapshot_version, project_name, output_path, provider, model): """Enrich a snapshot with LLM-powered suggestions.""" - with open(input_path, 'r') as f: - snapshot_data = yaml.safe_load(f) - - project_snapshot = ProjectSnapshot.model_validate(snapshot_data) + manager = SnapshotVersionManager(SNAPSHOT_DIR, project_name, DEFAULT_SNAPSHOT_CONFIG['snapshot']) + project_snapshot = manager.load_snapshot(snapshot_version) + if not project_snapshot: + click.echo(f"Snapshot {snapshot_version} not found for project '{project_name}'.", err=True) + return if provider == 'dummy': llm_client = DummyLLMClient() diff --git a/codesage/cli/commands/snapshot.py b/codesage/cli/commands/snapshot.py index 34fa0a7..1193367 100644 --- a/codesage/cli/commands/snapshot.py +++ b/codesage/cli/commands/snapshot.py @@ -3,7 +3,7 @@ import json import gzip import hashlib -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from codesage.snapshot.versioning import SnapshotVersionManager @@ -46,7 +46,7 @@ def snapshot(): from codesage.audit.models import AuditEvent -def _create_snapshot_data(path): +def _create_snapshot_data(path, project_name): file_snapshots = [] for root, dirs, files in os.walk(path): dirs[:] = [d for d in dirs if d not in DEFAULT_EXCLUDE_DIRS] @@ -81,7 +81,7 @@ def _create_snapshot_data(path): metadata=SnapshotMetadata( version="", timestamp=datetime.now(), - project_name=os.path.basename(os.path.abspath(path)), + project_name=project_name, file_count=len(file_snapshots), total_size=total_size, tool_version=tool_version, @@ -97,15 +97,16 @@ def _create_snapshot_data(path): @snapshot.command('create') @click.argument('path', type=click.Path(exists=True, dir_okay=True)) +@click.option('--project', '-p', 'project_name_override', help='Override the project name.') @click.option('--format', '-f', type=click.Choice(['json', 'python-semantic-digest']), default='json', help='Snapshot format.') @click.option('--output', '-o', type=click.Path(), default=None, help='Output file path.') @click.option('--compress', is_flag=True, help='Enable compression.') @click.option('--language', '-l', type=click.Choice(['python', 'go', 'shell', 'java', 'auto']), default='auto', help='Language to analyze.') @click.pass_context -def create(ctx, path, format, output, compress, language): +def create(ctx, path, project_name_override, format, output, compress, language): """Create a new snapshot from the given path.""" audit_logger = ctx.obj.audit_logger - project_name = os.path.basename(os.path.abspath(path)) + project_name = project_name_override or os.path.basename(os.path.abspath(path)) try: root_path = Path(path) @@ -168,31 +169,38 @@ def create(ctx, path, format, output, compress, language): click.echo(f"{language.capitalize()} semantic digest created at {output}") return - snapshot_data = _create_snapshot_data(path) + snapshot_data = _create_snapshot_data(path, project_name) if output: output_path = Path(output) output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, 'w') as f: + # Use model_dump_json for consistency + with open(output_path, 'w', encoding='utf-8') as f: f.write(snapshot_data.model_dump_json(indent=2)) click.echo(f"Snapshot created at {output}") else: - manager = SnapshotVersionManager(SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG['snapshot']) + manager = SnapshotVersionManager(SNAPSHOT_DIR, project_name, DEFAULT_SNAPSHOT_CONFIG['snapshot']) + + # The format for saving via manager is 'json', not the input format for semantic digests + save_format = 'json' + if compress: - snapshot_path = manager.save_snapshot(snapshot_data, format) + snapshot_path = manager.save_snapshot(snapshot_data, save_format) + + # Compress the file with open(snapshot_path, 'rb') as f_in: with gzip.open(f"{snapshot_path}.gz", 'wb') as f_out: f_out.writelines(f_in) os.remove(snapshot_path) click.echo(f"Compressed snapshot created at {snapshot_path}.gz") else: - snapshot_path = manager.save_snapshot(snapshot_data, format) + snapshot_path = manager.save_snapshot(snapshot_data, save_format) click.echo(f"Snapshot created at {snapshot_path}") finally: audit_logger.log( AuditEvent( - timestamp=datetime.now(), + timestamp=datetime.now(timezone.utc), event_type="cli.snapshot.create", project_name=project_name, command="snapshot create", @@ -207,56 +215,52 @@ def create(ctx, path, format, output, compress, language): ) @snapshot.command('list') -def list_snapshots(): - """List all available snapshots.""" - manager = SnapshotVersionManager(SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG['snapshot']) +@click.option('--project', '-p', required=True, help='The name of the project.') +def list_snapshots(project): + """List all available snapshots for a project.""" + manager = SnapshotVersionManager(SNAPSHOT_DIR, project, DEFAULT_SNAPSHOT_CONFIG['snapshot']) snapshots = manager.list_snapshots() if not snapshots: - click.echo("No snapshots found.") + click.echo(f"No snapshots found for project '{project}'.") return for s in snapshots: click.echo(f"- {s['version']} ({s['timestamp']})") @snapshot.command('show') @click.argument('version') -def show(version): +@click.option('--project', '-p', required=True, help='The name of the project.') +def show(version, project): """Show details of a specific snapshot.""" - manager = SnapshotVersionManager(SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG['snapshot']) + manager = SnapshotVersionManager(SNAPSHOT_DIR, project, DEFAULT_SNAPSHOT_CONFIG['snapshot']) snapshot_data = manager.load_snapshot(version) if not snapshot_data: - click.echo(f"Snapshot {version} not found.", err=True) + click.echo(f"Snapshot {version} not found for project '{project}'.", err=True) return click.echo(snapshot_data.model_dump_json(indent=2)) @snapshot.command('cleanup') +@click.option('--project', '-p', required=True, help='The name of the project.') @click.option('--dry-run', is_flag=True, help='Show which snapshots would be deleted.') -def cleanup(dry_run): - """Clean up old snapshots.""" - from datetime import timedelta - - manager = SnapshotVersionManager(SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG['snapshot']) +def cleanup(project, dry_run): + """Clean up old snapshots for a project.""" + manager = SnapshotVersionManager(SNAPSHOT_DIR, project, DEFAULT_SNAPSHOT_CONFIG['snapshot']) if dry_run: index = manager._load_index() - now = datetime.now() - - expired_by_date = [ - s for s in index - if now - datetime.fromisoformat(s["timestamp"]) > timedelta(days=manager.retention_days) - ] - - sorted_by_date = sorted(index, key=lambda s: s["timestamp"], reverse=True) - expired_by_count = sorted_by_date[manager.max_versions:] + if not index: + click.echo(f"No snapshots to clean up for project '{project}'.") + return - expired = {s['version']: s for s in expired_by_date + expired_by_count}.values() + now = datetime.now(timezone.utc) + expired_snapshots = manager._get_expired_snapshots(index, now) - if not expired: - click.echo("No snapshots to clean up.") + if not expired_snapshots: + click.echo(f"No snapshots to clean up for project '{project}'.") return click.echo("Snapshots to be deleted:") - for s in expired: + for s in expired_snapshots: click.echo(f"- {s['version']}") else: manager.cleanup_expired_snapshots() - click.echo("Expired snapshots have been cleaned up.") + click.echo(f"Expired snapshots for project '{project}' have been cleaned up.") diff --git a/codesage/snapshot/versioning.py b/codesage/snapshot/versioning.py index 47289e1..2420c69 100644 --- a/codesage/snapshot/versioning.py +++ b/codesage/snapshot/versioning.py @@ -10,9 +10,11 @@ class SnapshotVersionManager: """Manages the versioning and lifecycle of project snapshots.""" - def __init__(self, snapshot_dir: str, config: Dict[str, Any]): - self.snapshot_dir = snapshot_dir - self.index_file = os.path.join(snapshot_dir, "index.json") + def __init__(self, snapshot_dir: str, project_name: str, config: Dict[str, Any]): + self.base_snapshot_dir = snapshot_dir + self.project_name = project_name + self.snapshot_dir = os.path.join(self.base_snapshot_dir, self.project_name) + self.index_file = os.path.join(self.snapshot_dir, "index.json") versioning_config = config.get("versioning", {}) self.max_versions = versioning_config.get("max_versions", 10) self.retention_days = versioning_config.get("retention_days", 30) @@ -80,11 +82,8 @@ def _update_index(self, snapshot_path: str, metadata: SnapshotMetadata): ) self._save_index(index) - def cleanup_expired_snapshots(self): - """Removes expired snapshots based on retention days and max versions.""" - index = self._load_index() - now = datetime.now(timezone.utc) - + def _get_expired_snapshots(self, index: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]: + """Identifies expired snapshots.""" valid_snapshots = [] for s in index: try: @@ -102,7 +101,23 @@ def cleanup_expired_snapshots(self): valid_snapshots, key=lambda s: s["timestamp"], reverse=True )[:self.max_versions] - expired_snapshots = [s for s in index if s not in valid_snapshots] + valid_versions = {s["version"] for s in valid_snapshots} + return [s for s in index if s["version"] not in valid_versions] + + def cleanup_expired_snapshots(self): + """Removes expired snapshots based on retention days and max versions.""" + index = self._load_index() + if not index: + return + + now = datetime.now(timezone.utc) + expired_snapshots = self._get_expired_snapshots(index, now) + + if not expired_snapshots: + return + + expired_versions = {s["version"] for s in expired_snapshots} + valid_snapshots = [s for s in index if s["version"] not in expired_versions] for snapshot_data in expired_snapshots: if os.path.exists(snapshot_data["path"]): diff --git a/command-test.md b/command-test.md new file mode 100644 index 0000000..7834c67 --- /dev/null +++ b/command-test.md @@ -0,0 +1,126 @@ +# CodeSage Command Test Plan + +This document outlines the commands to be tested to ensure the new project-aware snapshot functionality is working correctly and that other commands have not been affected. + +## Test Setup + +1. Create two dummy project directories in `tests/fixtures`: `project-a` and `project-b`. +2. Each project should contain a few dummy source files (e.g., `main.py`, `utils.py`). +3. Create a dummy governance plan file `plan.yml` and a dummy task file `task.yml`. + +## Snapshot Command Tests (`codesage snapshot`) + +### Project A + +1. **Create first snapshot for Project A:** + ```bash + poetry run codesage snapshot create ./project-a + ``` + +2. **Create second snapshot for Project A:** + ```bash + # (after making some changes to a file in project-a) + poetry run codesage snapshot create ./project-a + ``` + +3. **List snapshots for Project A:** + ```bash + poetry run codesage snapshot list --project project-a + ``` + *Expected output: Should list v1 and v2.* + +4. **Show snapshot v1 for Project A:** + ```bash + poetry run codesage snapshot show v1 --project project-a + ``` + +### Project B + +1. **Create first snapshot for Project B:** + ```bash + poetry run codesage snapshot create ./project-b + ``` + +2. **List snapshots for Project B:** + ```bash + poetry run codesage snapshot list --project project-b + ``` + *Expected output: Should list only v1.* + +3. **List snapshots for Project A again:** + ```bash + poetry run codesage snapshot list --project project-a + ``` + *Expected output: Should still list v1 and v2, unaffected by Project B.* + +### Cleanup + +1. **Cleanup snapshots for Project A (dry run):** + ```bash + poetry run codesage snapshot cleanup --project project-a --dry-run + ``` + +2. **Cleanup snapshots for Project A:** + ```bash + poetry run codesage snapshot cleanup --project project-a + ``` + +8. **Create a snapshot with an overridden project name:** + ```bash + poetry run codesage snapshot create ./project-a --project project-c + ``` + +9. **List snapshots for the overridden project name:** + ```bash + poetry run codesage snapshot list --project project-c + ``` + *Expected output: Should list v1.* + + +## Diff Command Tests (`codesage diff`) + +1. **Compare two snapshots within Project A:** + ```bash + poetry run codesage diff v1 v2 --project project-a + ``` + +## Other Commands (Regression Testing) + +These commands were not expected to be changed, but we should run them to ensure they still work. + +1. **Analyze:** + ```bash + poetry run codesage analyze tests/fixtures/project-a + ``` + +2. **Config:** + ```bash + poetry run codesage config show + ``` + +3. **Scan:** + ```bash + poetry run codesage scan tests/fixtures/project-a + ``` + +## New Commands Tests + +1. **Governance Plan:** + ```bash + poetry run codesage governance-plan --snapshot-version v1 --project project-a --output plan.yml + ``` + +2. **LLM Suggest:** + ```bash + poetry run codesage llm-suggest --snapshot-version v1 --project project-a --output enriched_snapshot.yml + ``` + +3. **Jules Prompt (with plan):** + ```bash + poetry run codesage jules-prompt --plan plan.yml --task-id --project project-a --snapshot-version v1 + ``` + +4. **Jules Prompt (with task):** + ```bash + poetry run codesage jules-prompt --task task.yml --project project-a --snapshot-version v1 + ``` diff --git a/tests/fixtures/project-a/main.py b/tests/fixtures/project-a/main.py new file mode 100644 index 0000000..f5e910f --- /dev/null +++ b/tests/fixtures/project-a/main.py @@ -0,0 +1,5 @@ +# New content +# New content +# New content +# New content +# New content diff --git a/tests/fixtures/project-a/utils.py b/tests/fixtures/project-a/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/project-b/main.py b/tests/fixtures/project-b/main.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/project-b/utils.py b/tests/fixtures/project-b/utils.py new file mode 100644 index 0000000..e69de29