Skip to content

Commit 5dd044c

Browse files
committed
feat(playground): add GET /index/{job_id} status endpoint (#125)
- Poll endpoint for job status (queued/cloning/processing/completed/failed) - Returns progress with percent_complete during processing - Returns repo_id on completion for search access - Returns error details on failure - Handles partial indexing info - 7 new tests for status endpoint Checkpoint 3 complete. 176 tests passing.
1 parent 1e1ae25 commit 5dd044c

2 files changed

Lines changed: 326 additions & 1 deletion

File tree

backend/routes/playground.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,3 +864,161 @@ async def start_anonymous_indexing(
864864
)
865865

866866
return response_data
867+
868+
869+
# =============================================================================
870+
# GET /playground/index/{job_id} - Check indexing job status (#126)
871+
# =============================================================================
872+
873+
@router.get(
874+
"/index/{job_id}",
875+
summary="Check indexing job status",
876+
description="Poll this endpoint to check the status of an indexing job.",
877+
responses={
878+
200: {
879+
"description": "Job status",
880+
"content": {
881+
"application/json": {
882+
"examples": {
883+
"queued": {
884+
"value": {
885+
"job_id": "idx_abc123",
886+
"status": "queued",
887+
"message": "Job is queued for processing"
888+
}
889+
},
890+
"processing": {
891+
"value": {
892+
"job_id": "idx_abc123",
893+
"status": "processing",
894+
"progress": {
895+
"files_processed": 50,
896+
"files_total": 100,
897+
"functions_found": 250,
898+
"percent_complete": 50
899+
}
900+
}
901+
},
902+
"completed": {
903+
"value": {
904+
"job_id": "idx_abc123",
905+
"status": "completed",
906+
"repo_id": "anon_idx_abc123",
907+
"stats": {
908+
"files_indexed": 100,
909+
"functions_found": 500,
910+
"time_taken_seconds": 45.2
911+
}
912+
}
913+
},
914+
"failed": {
915+
"value": {
916+
"job_id": "idx_abc123",
917+
"status": "failed",
918+
"error": "clone_failed",
919+
"error_message": "Repository not found"
920+
}
921+
}
922+
}
923+
}
924+
}
925+
},
926+
404: {"description": "Job not found or expired"}
927+
}
928+
)
929+
async def get_indexing_status(
930+
job_id: str,
931+
req: Request
932+
):
933+
"""
934+
Check the status of an anonymous indexing job.
935+
936+
Poll this endpoint after starting an indexing job to track progress.
937+
Jobs expire after 1 hour.
938+
939+
Status values:
940+
- queued: Job is waiting to start
941+
- cloning: Repository is being cloned
942+
- processing: Files are being indexed
943+
- completed: Indexing finished successfully
944+
- failed: Indexing failed (check error field)
945+
"""
946+
# Validate job_id format
947+
if not job_id or not job_id.startswith("idx_"):
948+
raise HTTPException(
949+
status_code=400,
950+
detail={
951+
"error": "invalid_job_id",
952+
"message": "Invalid job ID format"
953+
}
954+
)
955+
956+
# Get job from Redis
957+
job_manager = AnonymousIndexingJob(redis_client)
958+
job = job_manager.get_job(job_id)
959+
960+
if not job:
961+
raise HTTPException(
962+
status_code=404,
963+
detail={
964+
"error": "job_not_found",
965+
"message": "Job not found or has expired. Jobs expire after 1 hour."
966+
}
967+
)
968+
969+
# Build response based on status
970+
status = job.get("status", "unknown")
971+
response = {
972+
"job_id": job_id,
973+
"status": status,
974+
"created_at": job.get("created_at"),
975+
"updated_at": job.get("updated_at"),
976+
}
977+
978+
# Add repo info
979+
response["repository"] = {
980+
"owner": job.get("owner"),
981+
"name": job.get("repo_name"),
982+
"branch": job.get("branch"),
983+
"github_url": job.get("github_url"),
984+
}
985+
986+
# Add partial info if applicable
987+
if job.get("is_partial"):
988+
response["partial"] = True
989+
response["max_files"] = job.get("max_files")
990+
991+
# Status-specific fields
992+
if status == "queued":
993+
response["message"] = "Job is queued for processing"
994+
995+
elif status == "cloning":
996+
response["message"] = "Cloning repository..."
997+
998+
elif status == "processing":
999+
response["message"] = "Indexing files..."
1000+
if job.get("progress"):
1001+
progress = job["progress"]
1002+
files_processed = progress.get("files_processed", 0)
1003+
files_total = progress.get("files_total", 1)
1004+
percent = round((files_processed / files_total) * 100) if files_total > 0 else 0
1005+
response["progress"] = {
1006+
"files_processed": files_processed,
1007+
"files_total": files_total,
1008+
"functions_found": progress.get("functions_found", 0),
1009+
"percent_complete": percent,
1010+
"current_file": progress.get("current_file")
1011+
}
1012+
1013+
elif status == "completed":
1014+
response["message"] = "Indexing completed successfully"
1015+
response["repo_id"] = job.get("repo_id")
1016+
if job.get("stats"):
1017+
response["stats"] = job["stats"]
1018+
1019+
elif status == "failed":
1020+
response["message"] = job.get("error_message", "Indexing failed")
1021+
response["error"] = job.get("error", "unknown_error")
1022+
response["error_message"] = job.get("error_message")
1023+
1024+
return response

backend/tests/test_anonymous_indexing.py

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
Note: These tests rely on conftest.py for Pinecone/OpenAI/Redis mocking.
66
"""
77
import pytest
8-
from unittest.mock import AsyncMock, patch, MagicMock
8+
from unittest.mock import patch, MagicMock
99
from datetime import datetime, timezone, timedelta
1010
import json
1111

@@ -512,3 +512,170 @@ def test_expired_repo_allows_new_indexing(
512512

513513
assert response.status_code == 202
514514
assert response.json()["job_id"] == "idx_new123456"
515+
516+
517+
# =============================================================================
518+
# STATUS ENDPOINT TESTS (GET /playground/index/{job_id})
519+
# =============================================================================
520+
521+
class TestStatusEndpoint:
522+
"""Tests for GET /playground/index/{job_id} status endpoint."""
523+
524+
@pytest.fixture
525+
def client(self):
526+
"""Create test client."""
527+
from fastapi.testclient import TestClient
528+
from main import app
529+
return TestClient(app)
530+
531+
def test_invalid_job_id_format_returns_400(self, client):
532+
"""Invalid job ID format returns 400."""
533+
response = client.get("/api/v1/playground/index/invalid_format")
534+
assert response.status_code == 400
535+
assert response.json()["detail"]["error"] == "invalid_job_id"
536+
537+
def test_job_not_found_returns_404(self, client):
538+
"""Non-existent job returns 404."""
539+
response = client.get("/api/v1/playground/index/idx_nonexistent123")
540+
assert response.status_code == 404
541+
assert response.json()["detail"]["error"] == "job_not_found"
542+
543+
@patch('routes.playground.AnonymousIndexingJob')
544+
def test_queued_job_returns_status(self, mock_job_class, client):
545+
"""Queued job returns correct status."""
546+
mock_job_manager = MagicMock()
547+
mock_job_manager.get_job.return_value = {
548+
"job_id": "idx_test123456",
549+
"status": "queued",
550+
"owner": "user",
551+
"repo_name": "repo",
552+
"branch": "main",
553+
"github_url": "https://github.com/user/repo",
554+
"created_at": "2024-01-01T00:00:00Z",
555+
"updated_at": "2024-01-01T00:00:00Z",
556+
}
557+
mock_job_class.return_value = mock_job_manager
558+
559+
response = client.get("/api/v1/playground/index/idx_test123456")
560+
561+
assert response.status_code == 200
562+
data = response.json()
563+
assert data["status"] == "queued"
564+
assert data["message"] == "Job is queued for processing"
565+
566+
@patch('routes.playground.AnonymousIndexingJob')
567+
def test_processing_job_returns_progress(self, mock_job_class, client):
568+
"""Processing job returns progress info."""
569+
mock_job_manager = MagicMock()
570+
mock_job_manager.get_job.return_value = {
571+
"job_id": "idx_test123456",
572+
"status": "processing",
573+
"owner": "user",
574+
"repo_name": "repo",
575+
"branch": "main",
576+
"github_url": "https://github.com/user/repo",
577+
"created_at": "2024-01-01T00:00:00Z",
578+
"updated_at": "2024-01-01T00:00:01Z",
579+
"progress": {
580+
"files_processed": 50,
581+
"files_total": 100,
582+
"functions_found": 250,
583+
"current_file": "src/index.ts"
584+
}
585+
}
586+
mock_job_class.return_value = mock_job_manager
587+
588+
response = client.get("/api/v1/playground/index/idx_test123456")
589+
590+
assert response.status_code == 200
591+
data = response.json()
592+
assert data["status"] == "processing"
593+
assert data["progress"]["files_processed"] == 50
594+
assert data["progress"]["percent_complete"] == 50
595+
596+
@patch('routes.playground.AnonymousIndexingJob')
597+
def test_completed_job_returns_repo_id(self, mock_job_class, client):
598+
"""Completed job returns repo_id and stats."""
599+
mock_job_manager = MagicMock()
600+
mock_job_manager.get_job.return_value = {
601+
"job_id": "idx_test123456",
602+
"status": "completed",
603+
"owner": "user",
604+
"repo_name": "repo",
605+
"branch": "main",
606+
"github_url": "https://github.com/user/repo",
607+
"repo_id": "anon_idx_test123456",
608+
"created_at": "2024-01-01T00:00:00Z",
609+
"updated_at": "2024-01-01T00:01:00Z",
610+
"stats": {
611+
"files_indexed": 100,
612+
"functions_found": 500,
613+
"time_taken_seconds": 45.2
614+
}
615+
}
616+
mock_job_class.return_value = mock_job_manager
617+
618+
response = client.get("/api/v1/playground/index/idx_test123456")
619+
620+
assert response.status_code == 200
621+
data = response.json()
622+
assert data["status"] == "completed"
623+
assert data["repo_id"] == "anon_idx_test123456"
624+
assert data["stats"]["files_indexed"] == 100
625+
626+
@patch('routes.playground.AnonymousIndexingJob')
627+
def test_failed_job_returns_error(self, mock_job_class, client):
628+
"""Failed job returns error details."""
629+
mock_job_manager = MagicMock()
630+
mock_job_manager.get_job.return_value = {
631+
"job_id": "idx_test123456",
632+
"status": "failed",
633+
"owner": "user",
634+
"repo_name": "repo",
635+
"branch": "main",
636+
"github_url": "https://github.com/user/repo",
637+
"error": "clone_failed",
638+
"error_message": "Repository not found or access denied",
639+
"created_at": "2024-01-01T00:00:00Z",
640+
"updated_at": "2024-01-01T00:00:30Z",
641+
}
642+
mock_job_class.return_value = mock_job_manager
643+
644+
response = client.get("/api/v1/playground/index/idx_test123456")
645+
646+
assert response.status_code == 200
647+
data = response.json()
648+
assert data["status"] == "failed"
649+
assert data["error"] == "clone_failed"
650+
assert "not found" in data["error_message"].lower()
651+
652+
@patch('routes.playground.AnonymousIndexingJob')
653+
def test_partial_job_includes_partial_info(self, mock_job_class, client):
654+
"""Partial indexing job includes partial flag."""
655+
mock_job_manager = MagicMock()
656+
mock_job_manager.get_job.return_value = {
657+
"job_id": "idx_test123456",
658+
"status": "processing",
659+
"owner": "user",
660+
"repo_name": "large-repo",
661+
"branch": "main",
662+
"github_url": "https://github.com/user/large-repo",
663+
"is_partial": True,
664+
"max_files": 200,
665+
"file_count": 500,
666+
"created_at": "2024-01-01T00:00:00Z",
667+
"updated_at": "2024-01-01T00:00:10Z",
668+
"progress": {
669+
"files_processed": 100,
670+
"files_total": 200,
671+
"functions_found": 400
672+
}
673+
}
674+
mock_job_class.return_value = mock_job_manager
675+
676+
response = client.get("/api/v1/playground/index/idx_test123456")
677+
678+
assert response.status_code == 200
679+
data = response.json()
680+
assert data["partial"] is True
681+
assert data["max_files"] == 200

0 commit comments

Comments
 (0)