diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f0ac698 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY=your-api-key-here diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 4e5a474..2b00657 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ patreon: structproject +custom: ['https://www.paypal.me/httpdss'] diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..fcca4ea --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,31 @@ +name-template: 'v$RESOLVED_VERSION 🌈' +tag-template: 'v$RESOLVED_VERSION' +categories: + - title: '🚀 Features' + labels: + - 'feature' + - 'enhancement' + - title: '🐛 Bug Fixes' + labels: + - 'fix' + - 'bugfix' + - 'bug' + - title: '🧰 Maintenance' + label: 'chore' +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. +version-resolver: + major: + labels: + - 'major' + minor: + labels: + - 'minor' + patch: + labels: + - 'patch' + default: patch +template: | + ## Changes + + $CHANGES diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..863470c --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,15 @@ +name: pre-commit + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/push-to-registry.yaml b/.github/workflows/push-to-registry.yaml new file mode 100644 index 0000000..9c667ff --- /dev/null +++ b/.github/workflows/push-to-registry.yaml @@ -0,0 +1,51 @@ +name: push-to-registry + +on: + push: + branches: ['main'] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/release-drafter.yaml b/.github/workflows/release-drafter.yaml new file mode 100644 index 0000000..182f452 --- /dev/null +++ b/.github/workflows/release-drafter.yaml @@ -0,0 +1,22 @@ +name: release-drafter + +on: + push: + branches: + - main + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +jobs: + update_release_draft: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-script.yaml b/.github/workflows/test-script.yaml index 5393983..eccab8c 100644 --- a/.github/workflows/test-script.yaml +++ b/.github/workflows/test-script.yaml @@ -4,9 +4,9 @@ on: push: branches: - main - pull_request: - branches: - - main + # pull_request: + # branches: + # - main jobs: build: @@ -17,10 +17,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Python 3.10.14 + - name: Set up Python 3.10 uses: actions/setup-python@v5 with: - python-version: 3.10.14 + python-version: '3.10' cache: pip - name: Install dependencies diff --git a/.gitignore b/.gitignore index e1e8acc..0b6f299 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .venv __pycache__ *.egg-info +.env +*.log +example_project/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2ff5837 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml diff --git a/README.md b/README.md index 3d17441..f5ea0d1 100644 --- a/README.md +++ b/README.md @@ -53,17 +53,36 @@ Here is an example of a YAML configuration file: structure: - README.md: content: | - # {project_name} + # ${project_name} This is a template repository. - script.sh: permissions: '0777' content: | #!/bin/bash - echo "Hello, {author_name}!" + echo "Hello, ${author_name}!" - LICENSE: file: https://raw.githubusercontent.com/nishanths/license/master/LICENSE ``` +## Development + +To get started with development, follow these steps: + +1. Clone the repository +2. Create a virtual environment + +```sh +python3 -m venv .venv +source .venv/bin/activate +``` + +3. Install the dependencies + +```sh +pip install -r requirements.txt +pip install -r requirements.dev.txt +``` + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/_struct b/_struct deleted file mode 100755 index b0d2c2f..0000000 --- a/_struct +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python - -from struct_module import main - -if __name__ == "__main__": - main() diff --git a/docker-compose.yaml b/docker-compose.yaml index cc38a06..4430660 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,11 +1,11 @@ -version: '3.8' - services: struct: build: . volumes: - .:/app entrypoint: ["python", "struct_module/main.py"] + env_file: + - .env command: [ "--log=DEBUG", "--dry-run", @@ -13,6 +13,6 @@ services: "--backup=/app/backup", "--file-strategy=rename", "--log-file=/app/logfile.log", - "/app/project_structure.yaml", - "/app/project" + "/app/example/structure.yaml", + "/app/example_project" ] diff --git a/example/structure.yaml b/example/structure.yaml index 5e96f61..8dd56c0 100644 --- a/example/structure.yaml +++ b/example/structure.yaml @@ -1,12 +1,15 @@ structure: - README.md: content: | - # {project_name} + # ${project_name} This is a template repository. - script.sh: permissions: '0777' content: | #!/bin/bash - echo "Hello, {author_name}!" + echo "Hello, ${author_name}!" - LICENSE: file: https://raw.githubusercontent.com/nishanths/license/master/LICENSE + - .gitignore: + user_prompt: | + Generate the content for a .gitignore file that is appropriate for a Python project. Include common directories and file types that should be ignored. diff --git a/requirements.txt b/requirements.txt index 6c9fdba..f88263f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ PyYAML requests +openai +python-dotenv diff --git a/struct_module/main.py b/struct_module/main.py index fed20b2..dcab41b 100644 --- a/struct_module/main.py +++ b/struct_module/main.py @@ -4,6 +4,17 @@ import logging from string import Template import time +import yaml +from openai import OpenAI +from dotenv import load_dotenv + +load_dotenv() + +openai_api_key = os.getenv("OPENAI_API_KEY") +openai_model = os.getenv("OPENAI_MODEL") + +if not openai_api_key: + logging.warning("OpenAI API key not found. Skipping processing prompt.") class FileItem: def __init__(self, properties): @@ -12,14 +23,60 @@ def __init__(self, properties): self.remote_location = properties.get("file") self.permissions = properties.get("permissions") + self.system_prompt = properties.get("system_prompt") + self.user_prompt = properties.get("user_prompt") + self.openai_client = OpenAI( + api_key=openai_api_key + ) + + if not openai_model: + logging.info("OpenAI model not found. Using default model.") + self.openai_model = "gpt-3.5-turbo" + else: + logging.debug(f"Using OpenAI model: {openai_model}") + self.openai_model = openai_model + + def process_prompt(self, dry_run=False): + if self.user_prompt: + logging.debug(f"Using user prompt: {self.user_prompt}") + + if not openai_api_key: + logging.warning("Skipping processing prompt as OpenAI API key is not set.") + return + + if not self.system_prompt: + system_prompt = "You are a software developer working on a project. You need to create a file with the following content:" + else: + system_prompt = self.system_prompt + + if dry_run: + logging.info("[DRY RUN] Would generate content using OpenAI API.") + self.content = "[DRY RUN] Generating content using OpenAI" + return + + completion = self.openai_client.chat.completions.create( + model=self.openai_model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": self.user_prompt} + ] + ) + + self.content = completion.choices[0].message.content + logging.debug(f"Generated content: {self.content}") + def fetch_content(self): if self.remote_location: + logging.debug(f"Fetching content from: {self.remote_location}") response = requests.get(self.remote_location) + logging.debug(f"Response status code: {response.status_code}") response.raise_for_status() self.content = response.text + logging.debug(f"Fetched content: {self.content}") def apply_template_variables(self, template_vars): if self.content and template_vars: + logging.debug(f"Applying template variables: {template_vars}") template = Template(self.content) self.content = template.substitute(template_vars) @@ -65,12 +122,21 @@ def validate_configuration(structure): if not isinstance(name, str): raise ValueError("Each name in the 'structure' item must be a string.") if isinstance(content, dict): - if 'content' not in content and 'file' not in content: - raise ValueError(f"Dictionary item '{name}' must contain either 'content' or 'file' key.") + # Check that any of the keys 'content', 'file' or 'prompt' is present + if 'content' not in content and 'file' not in content and 'user_prompt' not in content: + raise ValueError(f"Dictionary item '{name}' must contain either 'content' or 'file' or 'user_prompt' key.") + # Check if 'file' key is present and its value is a string if 'file' in content and not isinstance(content['file'], str): raise ValueError(f"The 'file' value for '{name}' must be a string.") + # Check if 'permissions' key is present and its value is a string if 'permissions' in content and not isinstance(content['permissions'], str): raise ValueError(f"The 'permissions' value for '{name}' must be a string.") + # Check if 'prompt' key is present and its value is a string + if 'prompt' in content and not isinstance(content['prompt'], str): + raise ValueError(f"The 'prompt' value for '{name}' must be a string.") + # Check if 'prompt' key is present but no OpenAI API key is found + if 'prompt' in content and not openai_api_key: + raise ValueError("Using prompt property and no OpenAI API key was found. Please set it in the .env file.") elif not isinstance(content, str): raise ValueError(f"The content of '{name}' must be a string or dictionary.") logging.info("Configuration validation passed.") @@ -88,6 +154,7 @@ def create_structure(base_path, structure, dry_run=False, template_vars=None, ba file_item = FileItem({"name": name, "content": content}) file_item.apply_template_variables(template_vars) + file_item.process_prompt(dry_run) file_item.create(base_path, dry_run, backup_path, file_strategy) def main(): @@ -112,7 +179,15 @@ def main(): if backup_path and not os.path.exists(backup_path): os.makedirs(backup_path) - logging.basicConfig(level=logging_level, filename=args.log_file) + if args.base_path and not os.path.exists(args.base_path): + logging.info(f"Creating base path: {args.base_path}") + os.makedirs(args.base_path) + + logging.basicConfig( + level=logging_level, + filename=args.log_file, + format='%(levelname)s:struct:%(message)s', + ) logging.info(f"Starting to create project structure from {args.yaml_file} in {args.base_path}") logging.debug(f"YAML file path: {args.yaml_file}, Base path: {args.base_path}, Dry run: {args.dry_run}, Template vars: {template_vars}, Backup path: {backup_path}") diff --git a/tests/test_script.py b/tests/test_script.py index b522a7a..1ac402c 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -8,6 +8,12 @@ from unittest.mock import patch, MagicMock from struct_module.main import FileItem, validate_configuration, create_structure +# Mock the environment variables for OpenAI +@pytest.fixture(autouse=True) +def mock_env_vars(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-3.5-turbo") + # Test for FileItem.fetch_content @patch('struct_module.requests.get') def test_fetch_remote_content(mock_get):