Skip to content
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
6 changes: 3 additions & 3 deletions content.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def hero_section():
Div(
H1("AI-Powered Dev Environment Setup", cls="text-3xl font-bold text-center"),
H2("From GitHub to Ready-to-Code in Seconds"),
P("Paste your GitHub URL and get a custom devcontainer.json to simplify your development.", cls="text-lg text-center mt-4"),
P("Paste a GitHub URL or describe a project to get a custom devcontainer.json.", cls="text-lg text-center mt-4"),
cls="container mx-auto px-4 py-16"
),
cls="bg-gray-100",
Expand All @@ -19,7 +19,7 @@ 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 a GitHub repo URL or describe a project", cls="form-input", list="repo-list"),
Datalist(
Option(value="https://github.com/devcontainers/templates"),
Option(value="https://github.com/JetBrains/devcontainers-examples"),
Expand Down Expand Up @@ -258,4 +258,4 @@ def manifesto_page():
),
footer_section()
)
)
)
5 changes: 3 additions & 2 deletions helpers/devcontainer_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ def generate_devcontainer_json(instructor_client, repo_url, repo_context, devcon
template_data = {
"repo_url": repo_url,
"repo_context": truncated_context,
"existing_devcontainer": existing_devcontainer
"existing_devcontainer": existing_devcontainer,
"context_source": "a project description" if "<<SECTION: Project Description >>" in repo_context else "a GitHub repository",
}

prompt = process_template("prompts/devcontainer.jinja", template_data)
Expand Down Expand Up @@ -136,4 +137,4 @@ def save_devcontainer(new_devcontainer):
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
38 changes: 38 additions & 0 deletions helpers/natural_language_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import hashlib
import re


GITHUB_REPO_URL_PATTERN = re.compile(r"^https?://github\.com/[\w-]+/[\w.-]+/?$")
URL_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)


def normalize_project_description(description: str) -> str:
lines = [" ".join(line.split()) for line in description.strip().splitlines()]
return "\n".join(line for line in lines if line)


def is_project_description(value: str) -> bool:
description = normalize_project_description(value)
if len(description) < 10:
return False
if GITHUB_REPO_URL_PATTERN.match(description):
return False
return URL_PATTERN.match(description) is None


def build_project_description_context(description: str) -> str:
description = normalize_project_description(description)
if not is_project_description(description):
raise ValueError("Please enter a GitHub repository URL or describe the project you want to generate.")

return (
"<<SECTION: Project Description >>\n"
f"{description}\n"
"<<END_SECTION: Project Description >>"
)


def project_description_cache_key(description: str) -> str:
description = normalize_project_description(description)
digest = hashlib.sha256(description.encode("utf-8")).hexdigest()
return f"description:{digest}"
15 changes: 11 additions & 4 deletions js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,19 +123,27 @@ function isValidGithubUrl(url) {
return pattern.test(url);
}

function looksLikeUrl(value) {
return /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
}

function isValidProjectDescription(value) {
return value.trim().length >= 10 && !isValidGithubUrl(value.trim()) && !looksLikeUrl(value.trim());
}

function validateRepoUrl() {
const input = document.querySelector('input[name="repo_url"]');
const errorDiv = document.getElementById('url-error');
const generateButton = document.getElementById('generate-button');

if (input.value.trim() === '') {
errorDiv.textContent = 'Please enter a GitHub repository URL.';
errorDiv.textContent = 'Please enter a GitHub repository URL or project description.';
generateButton.disabled = true;
return false;
}

if (!isValidGithubUrl(input.value)) {
errorDiv.textContent = 'Please enter a valid GitHub repository URL.';
if (!isValidGithubUrl(input.value) && !isValidProjectDescription(input.value)) {
errorDiv.textContent = 'Please enter a GitHub repository URL or describe the project in plain text.';
generateButton.disabled = true;
return false;
}
Expand Down Expand Up @@ -164,4 +172,3 @@ document.addEventListener('DOMContentLoaded', function() {
initializeButtons();
setupObserver();
});

36 changes: 23 additions & 13 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import logging
import os
import json
from urllib.parse import quote_plus
from datetime import datetime
from fasthtml.common import *
from dotenv import load_dotenv
from supabase_client import supabase

from helpers.openai_helpers import setup_azure_openai, setup_instructor
from helpers.github_helpers import fetch_repo_context, check_url_exists
from helpers.github_helpers import fetch_repo_context, check_url_exists, is_valid_github_url
from helpers.natural_language_helpers import build_project_description_context, project_description_cache_key
from helpers.devcontainer_helpers import generate_devcontainer_json, validate_devcontainer_json
from helpers.token_helpers import count_tokens, truncate_to_token_limit
from models import DevContainer
Expand Down Expand Up @@ -105,19 +107,27 @@ async def get():
async def post(repo_url: str, regenerate: bool = False):
logging.info(f"Generating devcontainer.json for: {repo_url}")

# Normalize the repo_url by stripping trailing slashes
repo_url = repo_url.rstrip('/')

try:
exists, existing_record = check_url_exists(repo_url)
project_input = repo_url.strip()
if is_valid_github_url(project_input):
repo_url = project_input.rstrip('/')
cache_key = repo_url
repo_context, existing_devcontainer, devcontainer_url = fetch_repo_context(repo_url)
logging.info(f"Fetched repo context. Existing devcontainer: {'Yes' if existing_devcontainer else 'No'}")
logging.info(f"Devcontainer URL: {devcontainer_url}")
else:
repo_url = project_input
cache_key = project_description_cache_key(project_input)
repo_context = build_project_description_context(project_input)
existing_devcontainer = None
devcontainer_url = None
logging.info("Using natural language project description as generation context.")

exists, existing_record = check_url_exists(cache_key)
logging.info(f"URL check result: exists={exists}, existing_record={existing_record}")

repo_context, existing_devcontainer, devcontainer_url = fetch_repo_context(repo_url)
logging.info(f"Fetched repo context. Existing devcontainer: {'Yes' if existing_devcontainer else 'No'}")
logging.info(f"Devcontainer URL: {devcontainer_url}")

if exists and not regenerate:
logging.info(f"URL already exists in database. Returning existing devcontainer_json for: {repo_url}")
logging.info(f"Input already exists in database. Returning existing devcontainer_json for: {cache_key}")
devcontainer_json = existing_record['devcontainer_json']
generated = existing_record['generated']
source = "database"
Expand All @@ -143,7 +153,7 @@ async def post(repo_url: str, regenerate: bool = False):
embedding_json = None

new_devcontainer = DevContainer(
url=repo_url,
url=cache_key,
devcontainer_json=devcontainer_json,
devcontainer_url=devcontainer_url,
repo_context=repo_context,
Expand Down Expand Up @@ -176,7 +186,7 @@ async def post(repo_url: str, regenerate: bool = False):
Button(
Img(cls="w-4 h-4", src="assets/icons/regenerate.svg", alt="Regenerate"),
cls="icon-button regenerate-button",
hx_post=f"/generate?regenerate=true&repo_url={repo_url}",
hx_post=f"/generate?regenerate=true&repo_url={quote_plus(repo_url)}",
hx_target="#result",
hx_indicator="#action-text",
title="Regenerate",
Expand Down Expand Up @@ -207,4 +217,4 @@ async def get(fname:str, ext:str):

if __name__ == "__main__":
logging.info("Starting FastHTML app...")
serve()
serve()
4 changes: 2 additions & 2 deletions prompts/devcontainer.jinja
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Given the following context from a GitHub repository:
Given the following context from {{ context_source }}:

{{ repo_context }}

Expand Down Expand Up @@ -78,4 +78,4 @@ Here's an example of a devcontainer.json:

}
```
Your goal is to deliver the most logical, secure, efficient, and well-documented devcontainer.json file.
Your goal is to deliver the most logical, secure, efficient, and well-documented devcontainer.json file.
43 changes: 43 additions & 0 deletions tests/test_natural_language_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import hashlib
import unittest

from helpers.natural_language_helpers import (
build_project_description_context,
is_project_description,
normalize_project_description,
project_description_cache_key,
)


class NaturalLanguageHelpersTest(unittest.TestCase):
def test_normalize_project_description_collapses_line_spacing(self):
description = " Python FastAPI service \n\n with Postgres and Redis "

self.assertEqual(
normalize_project_description(description),
"Python FastAPI service\nwith Postgres and Redis",
)

def test_detects_descriptions_without_accepting_urls(self):
self.assertTrue(is_project_description("Node.js API with Redis and background workers"))
self.assertFalse(is_project_description("https://github.com/daytonaio/daytona"))
self.assertFalse(is_project_description("https://example.com/not-a-github-repo"))
self.assertFalse(is_project_description("tiny"))

def test_build_project_description_context_uses_expected_sections(self):
context = build_project_description_context("Ruby on Rails app with PostgreSQL")

self.assertIn("<<SECTION: Project Description >>", context)
self.assertIn("Ruby on Rails app with PostgreSQL", context)
self.assertIn("<<END_SECTION: Project Description >>", context)

def test_project_description_cache_key_is_stable_for_normalized_text(self):
description = "Python FastAPI service\nwith Redis"
normalized = "Python FastAPI service\nwith Redis"
expected = hashlib.sha256(normalized.encode("utf-8")).hexdigest()

self.assertEqual(project_description_cache_key(description), f"description:{expected}")


if __name__ == "__main__":
unittest.main()