diff --git a/cc-registry-v2/AGENTS.md b/cc-registry-v2/AGENTS.md index 289953aa498d..e1b4f8bda1d0 100644 --- a/cc-registry-v2/AGENTS.md +++ b/cc-registry-v2/AGENTS.md @@ -139,21 +139,24 @@ docker-compose restart backend ### Theme & Design Tokens +Registry theme is aligned with RunWhen docs (`/workspaces/docs` Starlight custom.css). Use theme tokens; do not hardcode colors. + | Token | Light | Dark | Usage | |-------|-------|------|-------| -| `primary.main` | `#5282f1` | `#5282f1` | Interactive elements, links | -| `text.primary` | `#3f3f3f` | `#e0e0e0` | Headings, body text | -| `text.secondary` | `#858484` | `#a0a0a0` | Descriptions, metadata | -| `background.paper` | `#ffffff` | `#1e1e1e` | Cards, surfaces | -| `background.default` | `#ffffff` | `#121212` | Page background | -| `divider` | `rgba(0,0,0,0.12)` | `rgba(255,255,255,0.12)` | Borders, separators | +| `primary.main` | `#2F80ED` | `#2F80ED` | Interactive elements, links, header/footer | +| `text.primary` | `#1a202c` | `#e2e8f0` | Headings, body text | +| `text.secondary` | `#4a5568` | `#94a3b8` | Descriptions, metadata | +| `background.paper` | `#ffffff` | `#1e293b` | Cards, surfaces | +| `background.default` | `#ffffff` | `#0f172a` | Page background | +| `divider` | `#e2e8f0` | `rgba(255,255,255,0.12)` | Borders, separators | | `action.hover` | — | — | Hover/zebra backgrounds | **Rules:** - Always use theme tokens — never hardcode `#fff`, `#000`, `#666`, `#ddd` -- Font family: **Raleway** (brand font, do not change) +- Font family: **Inter** (aligned with RunWhen docs; load via Google Fonts in `index.html`) - Font weight: `400` body, `500` emphasis, `600` headings/buttons — never `700` or `'bold'` - Font size grid: `0.75rem` (12px), `0.8125rem` (13px), `0.875rem` (14px), `0.9375rem` (15px), `1rem` (16px) — no sizes below 12px +- Border radius: buttons/chips `6px`, cards `8px` (docs `--rw-radius-sm` / `--rw-radius-md`) - Chip min height: `24px`, row min height: `40px` - Dark mode is supported via `ThemeContext` — test both modes @@ -221,5 +224,4 @@ All containers: `runAsNonRoot: true`, `runAsUser: 1000`, drop all capabilities. 4. Run database operations in HTTP request handlers (use Celery) 5. Show raw database IDs in the UI (use slugs) 6. Use emoji in code or docs unless explicitly requested -7. Change the font to Inter or the primary color to `#0570de` (registry has its own brand) -8. "Fix" `Home.tsx` to match the app design system — it's intentionally different +7. "Fix" `Home.tsx` to match the app design system — it's intentionally different diff --git a/cc-registry-v2/README.md b/cc-registry-v2/README.md index b25be7bb151a..e5f12fa1401e 100644 --- a/cc-registry-v2/README.md +++ b/cc-registry-v2/README.md @@ -240,6 +240,28 @@ The application requires Azure OpenAI credentials for two purposes: **See [AZURE_OPENAI_SETUP.md](AZURE_OPENAI_SETUP.md) for configuration details.** +### GitHub App (Issue Creation) + +The registry can create GitHub issues (intake requests, codebundle requests) using either a GitHub App (preferred) or a personal access token. + +**GitHub App (preferred)** -- provides short-lived installation tokens, no long-lived secrets: + +| Variable | Required | Description | +|----------|----------|-------------| +| `GITHUB_APP_ID` | Yes | Numeric App ID (Settings > General > App ID) | +| `GITHUB_APP_PRIVATE_KEY` | Yes | PEM private key -- raw PEM text or base64-encoded PEM. Generate under the App's Settings > Private keys | +| `GITHUB_APP_INSTALLATION_ID` | No | Installation ID. If omitted, the first installation returned by the API is used. Useful when the App is installed on multiple orgs | + +**Personal Access Token fallback:** + +| Variable | Required | Description | +|----------|----------|-------------| +| `GITHUB_TOKEN` | Yes | Classic or fine-grained PAT with `repo` (or Issues read/write) scope on the target repository | + +At least one of the above groups must be set, otherwise issue creation is disabled at runtime. + +Both the backend (`app/services/github_auth.py`) and the MCP server (`mcp-server/tools/github_issue.py`) use these variables. + ### Secrets Configuration All secrets are managed via Kubernetes secrets. See [k8s/secrets-example.yaml](k8s/secrets-example.yaml) for examples. diff --git a/cc-registry-v2/Taskfile.yml b/cc-registry-v2/Taskfile.yml index 096ba89cd901..78613b2490df 100644 --- a/cc-registry-v2/Taskfile.yml +++ b/cc-registry-v2/Taskfile.yml @@ -15,7 +15,7 @@ tasks: desc: Start all services cmds: - echo "🚀 Starting all services..." - - docker-compose up -d --build + - . ./az.secret && docker-compose up -d --build stop: desc: Stop all services @@ -27,7 +27,7 @@ tasks: desc: Restart all services cmds: - echo "🔄 Restarting all services..." - - docker-compose restart + - . ./az.secret && docker-compose restart # Monitoring commands logs: @@ -83,7 +83,7 @@ tasks: desc: Start only backend services (db, redis, backend) cmds: - echo "🔧 Starting backend services..." - - docker-compose up -d database redis backend + - . ./az.secret && docker-compose up -d database redis backend frontend: desc: Start only frontend (requires backend) @@ -95,14 +95,14 @@ tasks: desc: Start worker services cmds: - echo "🔄 Starting worker services..." - - docker-compose up -d worker scheduler flower + - . ./az.secret && docker-compose up -d worker scheduler flower # Development commands dev: desc: Start development environment (backend + frontend) cmds: - echo "💻 Starting development environment..." - - docker-compose up -d database redis backend frontend + - . ./az.secret && docker-compose up -d database redis backend frontend dev:logs: desc: Show logs for development services diff --git a/cc-registry-v2/backend/app/core/config.py b/cc-registry-v2/backend/app/core/config.py index 073e28160117..12a39e90e953 100644 --- a/cc-registry-v2/backend/app/core/config.py +++ b/cc-registry-v2/backend/app/core/config.py @@ -17,6 +17,14 @@ class Settings(BaseSettings): GITHUB_WEBHOOK_SECRET: str = "your_webhook_secret_here" GITHUB_OWNER: str = "runwhen-contrib" GITHUB_REPO: str = "codecollection-registry" + + # GitHub App Authentication (preferred over GITHUB_TOKEN when configured) + GITHUB_APP_ID: Optional[str] = None + GITHUB_APP_PRIVATE_KEY: Optional[str] = None + GITHUB_APP_INSTALLATION_ID: Optional[int] = None + + # Target repo for intake wizard issue creation + GITHUB_INTAKE_REPO: str = "runwhen-contrib/codecollection-registry" # Security SECRET_KEY: str = "your-secret-key-change-in-production" diff --git a/cc-registry-v2/backend/app/main.py b/cc-registry-v2/backend/app/main.py index dcd7f24c1177..2a0c2b88fcd8 100644 --- a/cc-registry-v2/backend/app/main.py +++ b/cc-registry-v2/backend/app/main.py @@ -99,7 +99,7 @@ async def health_check(): } # Include routers -from app.routers import admin, tasks, raw_data, admin_crud, task_execution_admin, versions, task_management, admin_inventory, helm_charts, mcp_chat, chat_debug, github_issues, schedule_config, analytics, vector_search +from app.routers import admin, tasks, raw_data, admin_crud, task_execution_admin, versions, task_management, admin_inventory, helm_charts, mcp_chat, chat_debug, github_issues, schedule_config, analytics, vector_search, intake app.include_router(admin.router) app.include_router(tasks.router) app.include_router(raw_data.router) @@ -115,6 +115,7 @@ async def health_check(): app.include_router(github_issues.router, prefix="/api/v1") app.include_router(analytics.router) app.include_router(vector_search.router) +app.include_router(intake.router) @app.get("/api/v1/registry/collections") async def list_collections(): diff --git a/cc-registry-v2/backend/app/routers/github_issues.py b/cc-registry-v2/backend/app/routers/github_issues.py index 510099fd259d..4c54bb272d66 100644 --- a/cc-registry-v2/backend/app/routers/github_issues.py +++ b/cc-registry-v2/backend/app/routers/github_issues.py @@ -8,6 +8,7 @@ import requests from app.core.config import settings +from app.services.github_auth import get_github_auth logger = logging.getLogger(__name__) router = APIRouter(prefix="/github", tags=["github"]) @@ -32,19 +33,18 @@ class IssueResponse(BaseModel): async def create_task_request_issue(request: TaskRequestIssue): """Create a GitHub issue requesting new tasks for the registry""" - if settings.GITHUB_TOKEN == "your_github_token_here": + gh = get_github_auth() + if not gh.is_configured: raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail="GitHub integration not configured. Please set GITHUB_TOKEN in environment variables." + detail="GitHub integration not configured. Set GITHUB_APP_ID/GITHUB_APP_PRIVATE_KEY or GITHUB_TOKEN.", ) - + try: - # Create issue title title = f"Task Request: {request.user_query}" - - # Create issue body + body_parts = [ - "## 🚀 New Task Request", + "## New Task Request", "", f"**User Query:** {request.user_query}", "", @@ -58,12 +58,10 @@ async def create_task_request_issue(request: TaskRequestIssue): f"- **Platform:** {request.platform}", f"- **Priority:** {request.priority}", ] - + if request.user_email: - body_parts.extend([ - f"- **Requested by:** {request.user_email}", - ]) - + body_parts.append(f"- **Requested by:** {request.user_email}") + body_parts.extend([ "", "### Acceptance Criteria", @@ -75,51 +73,52 @@ async def create_task_request_issue(request: TaskRequestIssue): "---", "*This issue was automatically created from the CodeCollection Registry chat interface.*" ]) - + body = "\n".join(body_parts) - - # Create GitHub issue + github_api_url = f"https://api.github.com/repos/{settings.GITHUB_OWNER}/{settings.GITHUB_REPO}/issues" - + headers = { - "Authorization": f"token {settings.GITHUB_TOKEN}", + **gh.auth_header(), "Accept": "application/vnd.github.v3+json", - "Content-Type": "application/json" + "Content-Type": "application/json", } - + issue_data = { "title": title, "body": body, - "labels": ["enhancement", "task-request", f"platform:{request.platform.lower()}", f"priority:{request.priority}"] + "labels": ["enhancement", "task-request", f"platform:{request.platform.lower()}", f"priority:{request.priority}"], } - + response = requests.post(github_api_url, json=issue_data, headers=headers) - + if response.status_code == 201: issue_info = response.json() return IssueResponse( issue_url=issue_info["html_url"], issue_number=issue_info["number"], - message=f"Successfully created GitHub issue #{issue_info['number']}" + message=f"Successfully created GitHub issue #{issue_info['number']}", ) else: logger.error(f"GitHub API error: {response.status_code} - {response.text}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create GitHub issue: {response.text}" + detail=f"GitHub API error: {response.text}", ) - + + except HTTPException: + raise except requests.RequestException as e: logger.error(f"Error creating GitHub issue: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error communicating with GitHub: {str(e)}" + detail=f"Error communicating with GitHub: {str(e)}", ) except Exception as e: logger.error(f"Unexpected error creating GitHub issue: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Unexpected error: {str(e)}" + detail=f"Unexpected error: {str(e)}", ) diff --git a/cc-registry-v2/backend/app/routers/intake.py b/cc-registry-v2/backend/app/routers/intake.py new file mode 100644 index 000000000000..3b4a70a48193 --- /dev/null +++ b/cc-registry-v2/backend/app/routers/intake.py @@ -0,0 +1,431 @@ +""" +CodeBundle Intake Wizard Router + +Guides users through a conversational flow to define CodeBundle requirements, +searches existing coverage via the MCP server, and files structured issues +to the codebundle-farm repository. +""" +import logging +from typing import Dict, List, Optional, Any +from datetime import datetime, timezone +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel +import requests + +from app.core.config import settings +from app.services.github_auth import get_github_auth +from app.services.mcp_client import get_mcp_client, MCPError + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/intake", tags=["intake"]) + +CODEBUNDLE_FARM_REPO = settings.GITHUB_INTAKE_REPO + +PLATFORMS = [ + "Kubernetes", "AWS", "Azure", "GCP", "Linux", + "Database", "Terraform", "Docker", "GitHub", "Other", +] + + +# ============================================================================= +# Request/Response Models +# ============================================================================= + +class SearchRequest(BaseModel): + """Step 1–2: User describes their need and we search for existing coverage.""" + description: str + platform: Optional[str] = None + + +class SearchMatch(BaseModel): + display_name: str + slug: str + collection_slug: str + platform: str + description: str + tasks: List[str] = [] + tags: List[str] = [] + relevance_score: float = 0.0 + source_url: str = "" + + +class ExistingRequest(BaseModel): + number: int + title: str + url: str + created_at: str + + +class SearchResponse(BaseModel): + matches: List[SearchMatch] + existing_requests: List[ExistingRequest] + suggested_platform: str + query_used: str + + +class DesignSpecDraft(BaseModel): + """Legacy: structured Design Spec. Kept for backwards compat; minimal intake uses SubmitRequest.""" + codebundle_name: str + target_collection: str = "rw-cli-codecollection" + platform: str = "" + purpose: str + tasks: List[Dict[str, str]] = [] + resource_types: List[str] = [] + env_vars: List[Dict[str, str]] = [] + secrets: List[Dict[str, str]] = [] + tools_required: List[str] = [] + related_bundles: List[str] = [] + user_description: str + coverage_notes: str = "" + + +class SubmitRequest(BaseModel): + """Minimal intake: title + description required. Search results included for the designer.""" + title: str + description: str + extra_context: Optional[str] = None + contact_email: Optional[str] = None + contact_ok: Optional[bool] = False + matches: List[SearchMatch] = [] + existing_requests: List[ExistingRequest] = [] + + +class SubmitResponse(BaseModel): + issue_url: str + issue_number: int + message: str + + +# ============================================================================= +# Endpoints +# ============================================================================= + +@router.get("/platforms") +async def get_platforms(): + """Return the list of supported platforms for the wizard.""" + return {"platforms": PLATFORMS} + + +@router.post("/search", response_model=SearchResponse) +async def search_existing_coverage(req: SearchRequest): + """ + Search for existing CodeBundles and open requests that match the + user's description. Called during wizard steps 1–3. + """ + mcp = get_mcp_client() + matches: List[SearchMatch] = [] + existing_requests: List[ExistingRequest] = [] + suggested_platform = req.platform or "" + + # Search existing CodeBundles via MCP + try: + if await mcp.is_available(): + args: Dict[str, Any] = {"query": req.description, "max_results": 8} + if req.platform: + args["platform"] = req.platform + + result = await mcp.call_tool("find_codebundle", args) + if result: + matches = _parse_codebundle_results(result) + + # Check for open requests + search_term = req.platform or req.description.split()[0] if req.description else "" + if search_term: + req_result = await mcp.call_tool("check_existing_requests", {"search_term": search_term}) + if req_result: + existing_requests = _parse_existing_requests(req_result) + except MCPError as e: + logger.warning(f"MCP search failed, continuing without results: {e}") + except Exception as e: + logger.warning(f"Unexpected error during MCP search: {e}") + + # Infer platform from description if not provided + if not suggested_platform: + suggested_platform = _infer_platform(req.description) + + return SearchResponse( + matches=matches, + existing_requests=existing_requests, + suggested_platform=suggested_platform, + query_used=req.description, + ) + + +@router.post("/generate-spec", response_model=DesignSpecDraft) +async def generate_design_spec( + description: str, + platform: str, + tasks_description: str, + resource_types: str = "", + tools: str = "", +): + """ + Generate a draft Design Spec from user-provided information. + This is a helper that pre-fills the spec structure; the frontend + lets the user refine it before submission. + """ + task_list = [t.strip() for t in tasks_description.split("\n") if t.strip()] + tasks = [{"name": _slugify_task(t), "checks": t} for t in task_list] + resources = [r.strip() for r in resource_types.split(",") if r.strip()] + tool_list = [t.strip() for t in tools.split(",") if t.strip()] + + name = _generate_bundle_name(platform, description) + + return DesignSpecDraft( + codebundle_name=name, + platform=platform, + purpose=description, + tasks=tasks, + resource_types=resources, + tools_required=tool_list, + user_description=description, + ) + + +@router.post("/submit", response_model=SubmitResponse) +async def submit_intake(req: SubmitRequest): + """ + Create a GitHub Issue in codebundle-farm. Requires only title + description. + Search results (matches, existing_requests) are included in the issue body + so the designer can see existing coverage and avoid duplication. + """ + gh = get_github_auth() + if not gh.is_configured: + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="GitHub integration not configured. Set GITHUB_APP_ID/GITHUB_APP_PRIVATE_KEY or GITHUB_TOKEN.", + ) + + title = f"[intake] {req.title[:100]}" + body = _build_minimal_issue_body(req) + platform = _infer_platform(req.description) + labels = ["intake", "needs-architect"] + if platform: + labels.append(f"platform:{platform.lower()}") + + try: + api_url = f"https://api.github.com/repos/{CODEBUNDLE_FARM_REPO}/issues" + headers = { + **gh.auth_header(), + "Accept": "application/vnd.github.v3+json", + } + issue_data = {"title": title, "body": body, "labels": labels} + + response = requests.post(api_url, json=issue_data, headers=headers) + + if response.status_code == 201: + info = response.json() + return SubmitResponse( + issue_url=info["html_url"], + issue_number=info["number"], + message=f"Created issue #{info['number']} in {CODEBUNDLE_FARM_REPO}", + ) + else: + logger.error(f"GitHub API error: {response.status_code} - {response.text}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"GitHub API error: {response.text}", + ) + except HTTPException: + raise + except requests.RequestException as e: + logger.error(f"Error creating GitHub issue: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error communicating with GitHub: {e}", + ) + except Exception as e: + logger.error(f"Unexpected error in submit_intake: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Unexpected error: {e}", + ) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def _build_minimal_issue_body(req: SubmitRequest) -> str: + """Build issue body with original request + full existing coverage details for the designer.""" + parts = [ + "## Request", + "", + f"**Title:** {req.title}", + "", + "**Description:**", + "", + "\n".join(f"> {line}" for line in req.description.splitlines()), + "", + ] + + if req.extra_context: + parts.extend([ + "**Additional context:**", + "", + req.extra_context, + "", + ]) + + # Existing CodeBundles found by search — full details for the designer + if req.matches: + parts.extend([ + "---", + "## Existing Coverage (from registry search)", + "", + "The following CodeBundles may overlap with this request. Designer: consider reusing, extending, or differentiating.", + "", + ]) + for i, m in enumerate(req.matches, 1): + score_str = f" ({int(m.relevance_score * 100)}% match)" if m.relevance_score > 0 else "" + parts.append(f"### {i}. {m.display_name}{score_str}") + parts.append("") + parts.append(f"- **Collection:** `{m.collection_slug}`") + parts.append(f"- **Platform:** {m.platform or '—'}") + parts.append(f"- **Description:** {m.description[:500]}{'…' if len(m.description) > 500 else ''}") + if m.tasks: + parts.append(f"- **Tasks:** {', '.join(m.tasks[:8])}{'…' if len(m.tasks) > 8 else ''}") + if m.tags: + parts.append(f"- **Tags:** {', '.join(m.tags[:10])}") + if m.source_url: + parts.append(f"- **Link:** {m.source_url}") + parts.append("") + + # Existing open requests — designer can consolidate + if req.existing_requests: + parts.extend([ + "---", + "## Open Requests (may overlap)", + "", + "Consider commenting on an existing issue instead of duplicating work.", + "", + ]) + for r in req.existing_requests: + parts.append(f"- [#{r.number} {r.title}]({r.url})") + parts.append("") + + if req.contact_email: + parts.append(f"**Contact:** {req.contact_email}") + parts.append("") + if req.contact_ok: + parts.append("**Contact OK:** Yes, please reach out.") + parts.append("") + + parts.extend([ + "---", + f"*Created via CodeCollection Registry intake at {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}.*", + ]) + return "\n".join(parts) + + +def _infer_platform(description: str) -> str: + """Best-effort platform detection from free text.""" + d = description.lower() + mapping = [ + (["kubernetes", "k8s", "kubectl", "pod", "deployment", "namespace", "helm"], "Kubernetes"), + (["aws", "amazon", "s3", "ec2", "lambda", "cloudwatch", "iam"], "AWS"), + (["azure", "az ", "aks", "app service", "resource group"], "Azure"), + (["gcp", "google cloud", "gke", "bigquery", "pubsub"], "GCP"), + (["terraform", "tfstate", "hcl"], "Terraform"), + (["docker", "container", "dockerfile"], "Docker"), + (["github", "gh ", "actions", "repository"], "GitHub"), + (["postgres", "mysql", "redis", "database", "sql"], "Database"), + (["linux", "ssh", "systemd", "cron"], "Linux"), + ] + for keywords, platform in mapping: + if any(kw in d for kw in keywords): + return platform + return "" + + +def _generate_bundle_name(platform: str, description: str) -> str: + """Generate a bundle name slug from platform and description.""" + prefix = platform.lower().replace(" ", "-") if platform else "generic" + words = description.lower().split()[:4] + slug = "-".join(w for w in words if len(w) > 2 and w.isalnum()) + if not slug: + slug = "healthcheck" + return f"{prefix}-{slug}" + + +def _slugify_task(description: str) -> str: + """Turn a task description into a Robot Framework task name.""" + words = description.strip().split()[:8] + return " ".join(w.capitalize() for w in words) + + +def _parse_codebundle_results(markdown: str) -> List[SearchMatch]: + """Parse the markdown output from MCP find_codebundle into structured matches.""" + matches = [] + current: Dict[str, Any] = {} + + for line in markdown.split("\n"): + line = line.strip() + if line.startswith("## ") and "**" in line: + if current.get("display_name"): + matches.append(SearchMatch(**current)) + name = line.split("**")[1] if "**" in line else line[3:] + current = { + "display_name": name.strip(), + "slug": "", + "collection_slug": "", + "platform": "", + "description": "", + "tasks": [], + "tags": [], + "relevance_score": 0.0, + "source_url": "", + } + elif line.startswith("**Collection:**"): + current["collection_slug"] = line.split("**Collection:**")[1].strip() + elif line.startswith("**Platform:**"): + current["platform"] = line.split("**Platform:**")[1].strip() + elif line.startswith("**Description:**"): + current["description"] = line.split("**Description:**")[1].strip() + elif line.startswith("**Relevance:**"): + try: + score_str = line.split("**Relevance:**")[1].strip().rstrip("%") + current["relevance_score"] = float(score_str) / 100 + except (ValueError, IndexError): + pass + elif line.startswith("**Tags:**"): + tags_str = line.split("**Tags:**")[1].strip() + current["tags"] = [t.strip() for t in tags_str.split(",") if t.strip()] + elif line.startswith("**Source:**"): + # Extract slug from source link + if "/codebundles/" in line: + slug_part = line.split("/codebundles/")[-1].rstrip(")") + current["slug"] = slug_part + current["source_url"] = line.split("(")[-1].rstrip(")") if "(" in line else "" + elif line.startswith("- ") and current.get("display_name"): + task = line[2:].strip() + if task and "tasks" in current: + current["tasks"].append(task) + + if current.get("display_name"): + matches.append(SearchMatch(**current)) + + return matches + + +def _parse_existing_requests(markdown: str) -> List[ExistingRequest]: + """Parse the markdown output from MCP check_existing_requests.""" + requests_list = [] + for line in markdown.split("\n"): + line = line.strip() + if line.startswith("- **#"): + try: + number = int(line.split("**#")[1].split("**")[0]) + title = line.split("[")[1].split("]")[0] if "[" in line else "" + url = line.split("(")[1].split(")")[0] if "(" in line else "" + created = "" + if "Created:" in line: + created = line.split("Created:")[1].strip() + requests_list.append(ExistingRequest( + number=number, title=title, url=url, created_at=created, + )) + except (IndexError, ValueError): + continue + return requests_list diff --git a/cc-registry-v2/backend/app/services/github_auth.py b/cc-registry-v2/backend/app/services/github_auth.py new file mode 100644 index 000000000000..58a8ddc8bd98 --- /dev/null +++ b/cc-registry-v2/backend/app/services/github_auth.py @@ -0,0 +1,206 @@ +""" +GitHub App authentication service. + +Generates short-lived installation access tokens from a GitHub App's +private key. Falls back to the static GITHUB_TOKEN PAT when App +credentials are not configured. +""" +import base64 +import logging +import threading +import time +from typing import Optional + +import httpx +from jose import jwt as jose_jwt + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +GITHUB_API = "https://api.github.com" + + +class GitHubAuth: + """Manages GitHub authentication via App credentials or PAT fallback.""" + + def __init__(self) -> None: + self._private_key: Optional[str] = None + self._app_id: Optional[str] = None + self._installation_id: Optional[int] = settings.GITHUB_APP_INSTALLATION_ID + self._token: Optional[str] = None + self._token_expires_at: float = 0 + self._lock = threading.Lock() + + raw_key = settings.GITHUB_APP_PRIVATE_KEY + has_app_id = bool(settings.GITHUB_APP_ID) + has_key = bool(raw_key) + has_pat = bool(settings.GITHUB_TOKEN and settings.GITHUB_TOKEN != "your_github_token_here") + + logger.info( + "GitHub auth init: GITHUB_APP_ID=%s, GITHUB_APP_PRIVATE_KEY=%s (%d chars), " + "GITHUB_APP_INSTALLATION_ID=%s, GITHUB_TOKEN=%s", + "set" if has_app_id else "MISSING", + "set" if has_key else "MISSING", + len(raw_key) if raw_key else 0, + settings.GITHUB_APP_INSTALLATION_ID or "not set (will auto-discover)", + "set" if has_pat else "MISSING/default", + ) + + if has_app_id and has_key: + self._app_id = settings.GITHUB_APP_ID + self._private_key = self._decode_key(raw_key) + if self._private_key: + logger.info("GitHub App authentication configured (app_id=%s)", self._app_id) + else: + logger.warning("GITHUB_APP_PRIVATE_KEY could not be decoded; falling back to PAT") + elif has_app_id and not has_key: + logger.warning("GITHUB_APP_ID is set but GITHUB_APP_PRIVATE_KEY is missing") + elif has_key and not has_app_id: + logger.warning("GITHUB_APP_PRIVATE_KEY is set but GITHUB_APP_ID is missing") + + if not self.is_configured: + logger.warning( + "GitHub integration NOT configured — issue creation will be disabled. " + "Set GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY, or GITHUB_TOKEN." + ) + + @property + def is_app_auth(self) -> bool: + return bool(self._app_id and self._private_key) + + @property + def is_configured(self) -> bool: + if self.is_app_auth: + return True + return bool(settings.GITHUB_TOKEN and settings.GITHUB_TOKEN != "your_github_token_here") + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def get_token(self) -> str: + """Return a valid GitHub token (installation token or PAT).""" + if self.is_app_auth: + return self._get_installation_token() + return settings.GITHUB_TOKEN + + def auth_header(self) -> dict: + """Return an Authorization header dict ready for requests.""" + token = self.get_token() + if self.is_app_auth: + return {"Authorization": f"Bearer {token}"} + return {"Authorization": f"token {token}"} + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + @staticmethod + def _decode_key(raw: str) -> Optional[str]: + """Accept either a PEM string or a base64-encoded PEM.""" + if raw.startswith("-----BEGIN"): + return raw + try: + decoded = base64.b64decode(raw).decode("utf-8") + if "-----BEGIN" in decoded: + return decoded + except Exception: + pass + logger.error("GITHUB_APP_PRIVATE_KEY is not a valid PEM or base64-encoded PEM") + return None + + def _make_jwt(self) -> str: + """Create a short-lived JWT signed with the App's private key.""" + now = int(time.time()) + payload = { + "iat": now - 60, + "exp": now + (10 * 60), + "iss": self._app_id, + } + return jose_jwt.encode(payload, self._private_key, algorithm="RS256") + + def _discover_installation_id(self, app_jwt: str) -> Optional[int]: + """Find the first installation of the App.""" + try: + resp = httpx.get( + f"{GITHUB_API}/app/installations", + headers={ + "Authorization": f"Bearer {app_jwt}", + "Accept": "application/vnd.github+json", + }, + timeout=15, + ) + if resp.status_code != 200: + logger.error("Failed to list installations: %s %s", resp.status_code, resp.text) + return None + installations = resp.json() + if not installations: + logger.error("No installations found for GitHub App %s", self._app_id) + return None + inst_id = installations[0]["id"] + logger.info("Auto-discovered GitHub App installation_id=%s", inst_id) + return inst_id + except Exception as exc: + logger.error("Error discovering installation: %s", exc) + return None + + def _request_installation_token(self, app_jwt: str, installation_id: int) -> Optional[str]: + """Exchange the JWT for an installation access token.""" + try: + resp = httpx.post( + f"{GITHUB_API}/app/installations/{installation_id}/access_tokens", + headers={ + "Authorization": f"Bearer {app_jwt}", + "Accept": "application/vnd.github+json", + }, + timeout=15, + ) + if resp.status_code != 201: + logger.error("Failed to create installation token: %s %s", resp.status_code, resp.text) + return None + data = resp.json() + self._token_expires_at = time.time() + 3500 + return data["token"] + except Exception as exc: + logger.error("Error requesting installation token: %s", exc) + return None + + def _get_installation_token(self) -> str: + """Return a cached installation token, refreshing if needed.""" + if self._token and time.time() < self._token_expires_at: + return self._token + + with self._lock: + if self._token and time.time() < self._token_expires_at: + return self._token + + app_jwt = self._make_jwt() + + if not self._installation_id: + self._installation_id = self._discover_installation_id(app_jwt) + if not self._installation_id: + raise RuntimeError("Cannot discover GitHub App installation; set GITHUB_APP_INSTALLATION_ID") + + token = self._request_installation_token(app_jwt, self._installation_id) + if not token: + raise RuntimeError("Failed to obtain GitHub App installation token") + + self._token = token + return self._token + + +# --------------------------------------------------------------------------- +# Singleton +# --------------------------------------------------------------------------- +_instance: Optional[GitHubAuth] = None +_singleton_lock = threading.Lock() + + +def get_github_auth() -> GitHubAuth: + global _instance + if _instance is None: + with _singleton_lock: + if _instance is None: + _instance = GitHubAuth() + return _instance diff --git a/cc-registry-v2/docker-compose.yml b/cc-registry-v2/docker-compose.yml index 19325929a070..b30e2db0e601 100644 --- a/cc-registry-v2/docker-compose.yml +++ b/cc-registry-v2/docker-compose.yml @@ -165,6 +165,9 @@ services: - SKIP_PREFLIGHT_CHECK=true - DISABLE_ESLINT_PLUGIN=true - NODE_OPTIONS=--max-old-space-size=4096 + # Accept any Host header (required for Remote SSH port forwarding / tunnels) + - HOST=0.0.0.0 + - DANGEROUSLY_DISABLE_HOST_CHECK=true - WDS_SOCKET_HOST=localhost - WDS_SOCKET_PORT=3000 volumes: diff --git a/cc-registry-v2/frontend/public/index.html b/cc-registry-v2/frontend/public/index.html index 52f90d3e9e3f..fc5ad06b65fa 100644 --- a/cc-registry-v2/frontend/public/index.html +++ b/cc-registry-v2/frontend/public/index.html @@ -4,12 +4,19 @@ - + + + + + + + + - RunWhen CodeCollection Registry + RunWhen Registry – Tools for AI SRE Agents