Skip to content

Commit c665c3b

Browse files
jsbattigclaude
andcommitted
feat: Implement Batch Creation for Auto-Discovery (Story #692)
Enable administrators to select multiple discovered repositories and create golden repos for all selected items in a single action. Backend additions (routes.py): - generate_unique_alias(): Extract project name from path, sanitize special chars, add numeric suffix on conflicts - _batch_create_repos(): Process repos array with partial failure handling - /admin/golden-repos/batch-create endpoint with CSRF validation Frontend additions (auto_discovery.html): - Selection bar showing count and action buttons - Batch creation confirmation modal dialog - JavaScript Map for persistent selection across pagination/search - Functions: toggleSelectAll, updateRepoSelection, updateSelectionUI, restoreSelections, clearSelection, showCreateDialog, executeBatchCreate - htmx:afterSettle event listener for selection restoration Template updates (gitlab_repos.html, github_repos.html): - Added data-platform, data-name, data-branch attributes to checkboxes - Added repo-checkbox class for cross-platform selection - Updated select-all to use toggleSelectAll function Tests (test_batch_creation.py): - 11 unit tests covering alias generation and batch creation logic - Tests for: path extraction, nested paths, conflict suffixes, special chars, case normalization, partial failures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 1216cb0 commit c665c3b

File tree

5 files changed

+628
-23
lines changed

5 files changed

+628
-23
lines changed

src/code_indexer/server/web/routes.py

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from code_indexer.server.middleware.correlation import get_correlation_id
88

9+
import json
910
import logging
1011
import os
1112
import secrets
@@ -15,7 +16,7 @@
1516

1617
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
1718
from fastapi import APIRouter, Request, Response, Form, HTTPException, status
18-
from fastapi.responses import HTMLResponse, RedirectResponse
19+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
1920
from fastapi.templating import Jinja2Templates
2021

2122
from ..auth.user_manager import UserRole
@@ -680,6 +681,113 @@ def _get_golden_repo_manager():
680681
return manager
681682

682683

684+
def generate_unique_alias(repo_name: str, golden_repo_manager) -> str:
685+
"""
686+
Generate unique alias from repository name.
687+
688+
Examples:
689+
"org/my-project" -> "my-project"
690+
"group/subgroup/project" -> "project"
691+
692+
If alias exists, add suffix: "project-2", "project-3", etc.
693+
694+
Args:
695+
repo_name: Repository name (may include path components like org/project)
696+
golden_repo_manager: GoldenRepoManager instance to check for conflicts
697+
698+
Returns:
699+
Unique alias string (lowercase, special chars replaced with dashes)
700+
"""
701+
import re
702+
703+
# Extract project name (last path component)
704+
base_alias = repo_name.split("/")[-1]
705+
706+
# Clean up: lowercase, replace special chars with dashes
707+
base_alias = re.sub(r"[^a-z0-9-]", "-", base_alias.lower())
708+
709+
# Collapse multiple dashes into one
710+
base_alias = re.sub(r"-+", "-", base_alias)
711+
712+
# Remove leading/trailing dashes
713+
base_alias = base_alias.strip("-")
714+
715+
# Handle empty result
716+
if not base_alias:
717+
base_alias = "repo"
718+
719+
# Check for conflicts with existing golden repos
720+
existing_repos = golden_repo_manager.list_golden_repos()
721+
existing_aliases = {r["alias"] for r in existing_repos}
722+
723+
if base_alias not in existing_aliases:
724+
return base_alias
725+
726+
# Add numeric suffix
727+
suffix = 2
728+
while f"{base_alias}-{suffix}" in existing_aliases:
729+
suffix += 1
730+
731+
return f"{base_alias}-{suffix}"
732+
733+
734+
def _batch_create_repos(
735+
repos: List[Dict[str, str]],
736+
submitter_username: str,
737+
golden_repo_manager,
738+
) -> Dict[str, Any]:
739+
"""
740+
Create multiple golden repositories from discovered repos.
741+
742+
Args:
743+
repos: List of repo objects with clone_url, alias, branch, platform
744+
submitter_username: Username of the admin submitting the batch
745+
golden_repo_manager: GoldenRepoManager instance
746+
747+
Returns:
748+
Dict with success, results array, and summary string
749+
"""
750+
results = []
751+
752+
for repo_data in repos:
753+
try:
754+
# Generate unique alias from repo name
755+
alias = generate_unique_alias(repo_data["alias"], golden_repo_manager)
756+
757+
# Create golden repo
758+
job_id = golden_repo_manager.add_golden_repo(
759+
repo_url=repo_data["clone_url"],
760+
alias=alias,
761+
default_branch=repo_data.get("branch", "main"),
762+
submitter_username=submitter_username,
763+
)
764+
765+
results.append(
766+
{
767+
"alias": alias,
768+
"status": "success",
769+
"job_id": job_id,
770+
}
771+
)
772+
except Exception as e:
773+
results.append(
774+
{
775+
"alias": repo_data.get("alias", "unknown"),
776+
"status": "failed",
777+
"error": str(e),
778+
}
779+
)
780+
781+
success_count = len([r for r in results if r["status"] == "success"])
782+
failed_count = len([r for r in results if r["status"] == "failed"])
783+
784+
return {
785+
"success": failed_count == 0,
786+
"results": results,
787+
"summary": f"{success_count} succeeded, {failed_count} failed",
788+
}
789+
790+
683791
def _get_golden_repos_list():
684792
"""Get list of all golden repositories with global alias, version, and index info."""
685793
try:
@@ -948,6 +1056,59 @@ async def add_golden_repo(
9481056
)
9491057

9501058

1059+
@web_router.post("/golden-repos/batch-create")
1060+
async def batch_create_golden_repos(
1061+
request: Request,
1062+
repos: str = Form(...),
1063+
csrf_token: Optional[str] = Form(None),
1064+
):
1065+
"""
1066+
Create multiple golden repositories from discovered repos.
1067+
1068+
Body params:
1069+
repos: JSON array of objects with:
1070+
- clone_url: Repository URL
1071+
- alias: Generated alias (project name)
1072+
- branch: Default branch
1073+
- platform: gitlab or github
1074+
csrf_token: CSRF token for validation
1075+
"""
1076+
session = _require_admin_session(request)
1077+
if not session:
1078+
return JSONResponse(
1079+
{"success": False, "error": "Authentication required"},
1080+
status_code=401,
1081+
)
1082+
1083+
# Validate CSRF token
1084+
if not validate_login_csrf_token(request, csrf_token):
1085+
return JSONResponse(
1086+
{"success": False, "error": "Invalid CSRF token"},
1087+
status_code=403,
1088+
)
1089+
1090+
# Parse JSON repos array
1091+
try:
1092+
repo_list = json.loads(repos)
1093+
except json.JSONDecodeError as e:
1094+
return JSONResponse(
1095+
{"success": False, "error": f"Invalid JSON: {e}"},
1096+
status_code=400,
1097+
)
1098+
1099+
if not isinstance(repo_list, list):
1100+
return JSONResponse(
1101+
{"success": False, "error": "repos must be a JSON array"},
1102+
status_code=400,
1103+
)
1104+
1105+
# Process batch creation
1106+
manager = _get_golden_repo_manager()
1107+
results = _batch_create_repos(repo_list, session.username, manager)
1108+
1109+
return JSONResponse(results)
1110+
1111+
9511112
@web_router.post("/golden-repos/{alias}/delete", response_class=HTMLResponse)
9521113
async def delete_golden_repo(
9531114
request: Request,

0 commit comments

Comments
 (0)