Skip to content

feat(docker-compose): add Docker Compose file generation #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions content.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,17 @@ def generator_section():
return Section(
Form(
Group(
Input(type="text", name="repo_url", placeholder="Paste your Github repo URL, or select a repo to get started", cls="form-input", list="repo-list"),
Input(type="text",
name="repo_url",
placeholder="Paste your Github repo URL, or select a repo to get started",
cls="form-input",
list="repo-list"),
Datalist(
Option(value="https://github.com/devcontainers/templates"),
Option(value="https://github.com/JetBrains/devcontainers-examples"),
Option(value="https://github.com/devcontainers/cli"),
id="repo-list"
),
Option(value="https://github.com/devcontainers/templates"),
Option(value="https://github.com/JetBrains/devcontainers-examples"),
Option(value="https://github.com/devcontainers/cli"),
id="repo-list"
),
Button(
Div(
Img(src="assets/icons/magic-wand.svg", cls="svg-icon"),
Expand All @@ -38,7 +42,17 @@ def generator_section():
hx_post="/generate",
hx_target="#result",
hx_indicator="#generate-button"
)
),
cls="input-group"
),
Group(
Label(
Input(type="checkbox",
name="with_docker_compose",
role="switch"),
"With Docker Compose"
),
cls="toggle-group"
),
Div(id="url-error", cls="error-message"),
id="generate-form",
Expand Down
21 changes: 20 additions & 1 deletion css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -296,4 +296,23 @@ H2 {
width: 24px;
height: 24px;
margin-right: 10px;
}
}

.error {
background-color: #f0f0f0;
color: red;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin: 10px 0;
}

.error h2 {
color: red;
margin-bottom: 10px;
}

.error p {
color: red;
margin: 0;
}
136 changes: 110 additions & 26 deletions helpers/devcontainer_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
from schemas import DevContainerModel
from supabase_client import supabase
from models import DevContainer
import yaml


import logging
import tiktoken


def truncate_context(context, max_tokens=120000):
logging.info(f"Starting truncate_context with max_tokens={max_tokens}")
logging.debug(f"Initial context length: {len(context)} characters")
Expand All @@ -36,21 +38,29 @@ def truncate_context(context, max_tokens=120000):
logging.debug(f"Structure end position: {structure_end}")
logging.debug(f"Languages end position: {languages_end}")

important_content = context[:languages_end] + "<<END_SECTION: Repository Languages >>\n\n"
remaining_content = context[languages_end + len("<<END_SECTION: Repository Languages >>\n\n"):]
important_content = (
context[:languages_end] + "<<END_SECTION: Repository Languages >>\n\n"
)
remaining_content = context[
languages_end + len("<<END_SECTION: Repository Languages >>\n\n") :
]

important_tokens = encoding.encode(important_content)
logging.debug(f"Important content token count: {len(important_tokens)}")

if len(important_tokens) > max_tokens:
logging.warning("Important content alone exceeds max_tokens. Truncating important content.")
logging.warning(
"Important content alone exceeds max_tokens. Truncating important content."
)
important_content = encoding.decode(important_tokens[:max_tokens])
return important_content

remaining_tokens = max_tokens - len(important_tokens)
logging.info(f"Tokens available for remaining content: {remaining_tokens}")

truncated_remaining = encoding.decode(encoding.encode(remaining_content)[:remaining_tokens])
truncated_remaining = encoding.decode(
encoding.encode(remaining_content)[:remaining_tokens]
)

final_context = important_content + truncated_remaining
final_tokens = encoding.encode(final_context)
Expand All @@ -60,65 +70,85 @@ def truncate_context(context, max_tokens=120000):

return final_context

def generate_devcontainer_json(instructor_client, repo_url, repo_context, devcontainer_url=None, max_retries=2, regenerate=False):

def generate_devcontainer_json(
instructor_client,
repo_url,
repo_context,
devcontainer_url=None,
max_retries=2,
regenerate=False,
):
existing_devcontainer = None
existing_docker_compose = None
if "<<EXISTING_DEVCONTAINER>>" in repo_context:
logging.info("Existing devcontainer.json found in the repository.")
existing_devcontainer = (
repo_context.split("<<EXISTING_DEVCONTAINER>>")[1]
.split("<<END_EXISTING_DEVCONTAINER>>")[0]
.strip()
)
if not regenerate and devcontainer_url:
logging.info(f"Using existing devcontainer.json from URL: {devcontainer_url}")
return existing_devcontainer, devcontainer_url
if "<<EXISTING_DOCKER_COMPOSE>>" in repo_context:
logging.info("Existing docker-compose.yml found in the repository.")
existing_docker_compose = (
repo_context.split("<<EXISTING_DOCKER_COMPOSE>>")[1]
.split("<<END_EXISTING_DOCKER_COMPOSE>>")[0]
.strip()
)
if not regenerate and devcontainer_url:
logging.info(f"Using existing devcontainer.json from URL: {devcontainer_url}")
return existing_devcontainer, existing_docker_compose, devcontainer_url

logging.info("Generating devcontainer.json...")
logging.info("Generating devcontainer.json and docker-compose.yml...")

# Truncate the context to fit within token limits
truncated_context = truncate_context(repo_context, max_tokens=126000)

template_data = {
"repo_url": repo_url,
"repo_context": truncated_context,
"existing_devcontainer": existing_devcontainer
"existing_devcontainer": existing_devcontainer,
"existing_docker_compose": existing_docker_compose
}

prompt = process_template("prompts/devcontainer.jinja", template_data)
prompt = process_template("prompts/devcontainer_docker_compose.jinja", template_data)

for attempt in range(max_retries + 1):
try:
logging.debug(f"Attempt {attempt + 1} to generate devcontainer.json")
logging.debug(f"Attempt {attempt + 1} to generate devcontainer.json and docker-compose.yml")
response = instructor_client.chat.completions.create(
model=os.getenv("MODEL"),
response_model=DevContainerModel,
messages=[
{"role": "system", "content": "You are a helpful assistant that generates devcontainer.json files."},
{"role": "system", "content": "You are a helpful assistant that generates devcontainer.json and docker-compose.yml files."},
{"role": "user", "content": prompt},
],
)
devcontainer_json = json.dumps(response.dict(exclude_none=True), indent=2)
devcontainer_json = json.dumps(response.dict(exclude={'docker_compose'}, exclude_none=True), indent=2)
docker_compose_yml = yaml.dump(response.docker_compose.dict(exclude_none=True), sort_keys=False) if response.docker_compose else None

if validate_devcontainer_json(devcontainer_json):
logging.info("Successfully generated and validated devcontainer.json")
if existing_devcontainer and not regenerate:
return existing_devcontainer, devcontainer_url
if validate_devcontainer_json(devcontainer_json) and (docker_compose_yml is None or validate_docker_compose_yml(docker_compose_yml)):
logging.info("Successfully generated and validated devcontainer.json and docker-compose.yml")
if existing_devcontainer and existing_docker_compose and not regenerate:
return existing_devcontainer, existing_docker_compose, devcontainer_url
else:
return devcontainer_json, None # Return None as URL for generated content
return devcontainer_json, docker_compose_yml, None
else:
logging.warning(f"Generated JSON failed validation on attempt {attempt + 1}")
logging.warning(f"Generated files failed validation on attempt {attempt + 1}")
if attempt == max_retries:
raise ValueError("Failed to generate valid devcontainer.json after maximum retries")
raise ValueError("Failed to generate valid files after maximum retries")
except Exception as e:
logging.error(f"Error on attempt {attempt + 1}: {str(e)}")
if attempt == max_retries:
raise

raise ValueError("Failed to generate valid devcontainer.json after maximum retries")
raise ValueError("Failed to generate valid files after maximum retries")


def validate_devcontainer_json(devcontainer_json):
logging.info("Validating devcontainer.json...")
schema_path = os.path.join(os.path.dirname(__file__), "..", "schemas", "devContainer.base.schema.json")
schema_path = os.path.join(
os.path.dirname(__file__), "..", "schemas", "devContainer.base.schema.json"
)
with open(schema_path, "r") as schema_file:
schema = json.load(schema_file)
try:
Expand All @@ -130,10 +160,64 @@ def validate_devcontainer_json(devcontainer_json):
logging.error(f"Validation failed: {e}")
return False


def validate_docker_compose_yml(docker_compose_yml):
"""
Validates the docker-compose.yml content.
Returns True if valid, False otherwise.
"""
logging.info("Validating docker-compose.yml...")
try:
# Parse the YAML to check for syntax errors
parsed_yaml = yaml.safe_load(docker_compose_yml)

# Basic structure validation
if not isinstance(parsed_yaml, dict):
logging.error("Docker Compose file must be a dictionary")
return False

# Check for required version field
if 'version' not in parsed_yaml:
logging.error("Docker Compose file must specify a version")
return False

# Check for services section
if 'services' not in parsed_yaml:
logging.error("Docker Compose file must have a services section")
return False

if not isinstance(parsed_yaml['services'], dict):
logging.error("Services section must be a dictionary")
return False

# Validate each service
for service_name, service in parsed_yaml['services'].items():
if not isinstance(service, dict):
logging.error(f"Service {service_name} configuration must be a dictionary")
return False

# Check for at least one of image or build
if 'image' not in service and 'build' not in service:
logging.error(f"Service {service_name} must specify either image or build")
return False

logging.info("Docker Compose YAML validation successful")
return True

except yaml.YAMLError as e:
logging.error(f"Docker Compose YAML validation failed: {e}")
return False
except Exception as e:
logging.error(f"Unexpected error during Docker Compose validation: {e}")
return False


def save_devcontainer(new_devcontainer):
try:
result = supabase.table("devcontainers").insert(new_devcontainer.dict()).execute()
result = (
supabase.table("devcontainers").insert(new_devcontainer.dict()).execute()
)
return result.data[0] if result.data else None
except Exception as e:
logging.error(f"Error saving devcontainer to Supabase: {str(e)}")
raise
raise
7 changes: 7 additions & 0 deletions js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ document.addEventListener('DOMContentLoaded', function() {
} else {
console.log('Generate button not found');
}

const dockerComposeToggle = document.querySelector('input[name="with_docker_compose"]');
if (dockerComposeToggle) {
dockerComposeToggle.addEventListener('change', function() {
console.log('Docker Compose toggle:', this.checked);
});
}
});

// Remove the initializeButtons function and related code since we're not using it anymore
Expand Down
Loading