Merge pull request #44 from Adamsmith6300/select-github-repos

Select/edit GitHub repos when editing github connector
This commit is contained in:
Rohan Verma 2025-04-17 17:27:01 -07:00 committed by GitHub
commit 73623aa37e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1099 additions and 168 deletions

View file

@ -9,17 +9,18 @@ POST /search-source-connectors/{connector_id}/index - Index content from a conne
Note: Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, NOTION_CONNECTOR, GITHUB_CONNECTOR, LINEAR_CONNECTOR). Note: Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, NOTION_CONNECTOR, GITHUB_CONNECTOR, LINEAR_CONNECTOR).
""" """
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks, Body
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from typing import List, Dict, Any from typing import List, Dict, Any
from app.db import get_async_session, User, SearchSourceConnector, SearchSourceConnectorType, SearchSpace, async_session_maker from app.db import get_async_session, User, SearchSourceConnector, SearchSourceConnectorType, SearchSpace, async_session_maker
from app.schemas import SearchSourceConnectorCreate, SearchSourceConnectorUpdate, SearchSourceConnectorRead from app.schemas import SearchSourceConnectorCreate, SearchSourceConnectorUpdate, SearchSourceConnectorRead, SearchSourceConnectorBase
from app.users import current_active_user from app.users import current_active_user
from app.utils.check_ownership import check_ownership from app.utils.check_ownership import check_ownership
from pydantic import ValidationError from pydantic import BaseModel, Field, ValidationError
from app.tasks.connectors_indexing_tasks import index_slack_messages, index_notion_pages, index_github_repos, index_linear_issues from app.tasks.connectors_indexing_tasks import index_slack_messages, index_notion_pages, index_github_repos, index_linear_issues
from app.connectors.github_connector import GitHubConnector
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import logging import logging
@ -28,6 +29,34 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# Use Pydantic's BaseModel here
class GitHubPATRequest(BaseModel):
github_pat: str = Field(..., description="GitHub Personal Access Token")
# --- New Endpoint to list GitHub Repositories ---
@router.post("/github/repositories/", response_model=List[Dict[str, Any]])
async def list_github_repositories(
pat_request: GitHubPATRequest,
user: User = Depends(current_active_user) # Ensure the user is logged in
):
"""
Fetches a list of repositories accessible by the provided GitHub PAT.
The PAT is used for this request only and is not stored.
"""
try:
# Initialize GitHubConnector with the provided PAT
github_client = GitHubConnector(token=pat_request.github_pat)
# Fetch repositories
repositories = github_client.get_user_repositories()
return repositories
except ValueError as e:
# Handle invalid token error specifically
logger.error(f"GitHub PAT validation failed for user {user.id}: {str(e)}")
raise HTTPException(status_code=400, detail=f"Invalid GitHub PAT: {str(e)}")
except Exception as e:
logger.error(f"Failed to fetch GitHub repositories for user {user.id}: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to fetch GitHub repositories.")
@router.post("/search-source-connectors/", response_model=SearchSourceConnectorRead) @router.post("/search-source-connectors/", response_model=SearchSourceConnectorRead)
async def create_search_source_connector( async def create_search_source_connector(
connector: SearchSourceConnectorCreate, connector: SearchSourceConnectorCreate,
@ -76,6 +105,7 @@ async def create_search_source_connector(
await session.rollback() await session.rollback()
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to create search source connector: {str(e)}")
await session.rollback() await session.rollback()
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
@ -130,54 +160,84 @@ async def update_search_source_connector(
): ):
""" """
Update a search source connector. Update a search source connector.
Handles partial updates, including merging changes into the 'config' field.
Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, etc.).
The config must contain the appropriate keys for the connector type.
""" """
try: db_connector = await check_ownership(session, SearchSourceConnector, connector_id, user)
db_connector = await check_ownership(session, SearchSourceConnector, connector_id, user)
# If connector type is being changed, check if one of that type already exists # Convert the sparse update data (only fields present in request) to a dict
if connector_update.connector_type != db_connector.connector_type: update_data = connector_update.model_dump(exclude_unset=True)
# Special handling for 'config' field
if "config" in update_data:
incoming_config = update_data["config"] # Config data from the request
existing_config = db_connector.config if db_connector.config else {} # Current config from DB
# Merge incoming config into existing config
# This preserves existing keys (like GITHUB_PAT) if they are not in the incoming data
merged_config = existing_config.copy()
merged_config.update(incoming_config)
# -- Validation after merging --
# Validate the *merged* config based on the connector type
# We need the connector type - use the one from the update if provided, else the existing one
current_connector_type = connector_update.connector_type if connector_update.connector_type is not None else db_connector.connector_type
try:
# We can reuse the base validator by creating a temporary base model instance
# Note: This assumes 'name' and 'is_indexable' are not crucial for config validation itself
temp_data_for_validation = {
"name": db_connector.name, # Use existing name
"connector_type": current_connector_type,
"is_indexable": db_connector.is_indexable, # Use existing value
"last_indexed_at": db_connector.last_indexed_at, # Not used by validator
"config": merged_config
}
SearchSourceConnectorBase.model_validate(temp_data_for_validation)
except ValidationError as e:
# Raise specific validation error for the merged config
raise HTTPException(
status_code=422,
detail=f"Validation error for merged config: {str(e)}"
)
# If validation passes, update the main update_data dict with the merged config
update_data["config"] = merged_config
# Apply all updates (including the potentially merged config)
for key, value in update_data.items():
# Prevent changing connector_type if it causes a duplicate (check moved here)
if key == "connector_type" and value != db_connector.connector_type:
result = await session.execute( result = await session.execute(
select(SearchSourceConnector) select(SearchSourceConnector)
.filter( .filter(
SearchSourceConnector.user_id == user.id, SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type == connector_update.connector_type, SearchSourceConnector.connector_type == value,
SearchSourceConnector.id != connector_id SearchSourceConnector.id != connector_id
) )
) )
existing_connector = result.scalars().first() existing_connector = result.scalars().first()
if existing_connector: if existing_connector:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=f"A connector with type {connector_update.connector_type} already exists. Each user can have only one connector of each type." detail=f"A connector with type {value} already exists. Each user can have only one connector of each type."
) )
update_data = connector_update.model_dump(exclude_unset=True) setattr(db_connector, key, value)
for key, value in update_data.items():
setattr(db_connector, key, value) try:
await session.commit() await session.commit()
await session.refresh(db_connector) await session.refresh(db_connector)
return db_connector return db_connector
except ValidationError as e:
await session.rollback()
raise HTTPException(
status_code=422,
detail=f"Validation error: {str(e)}"
)
except IntegrityError as e: except IntegrityError as e:
await session.rollback() await session.rollback()
# This might occur if connector_type constraint is violated somehow after the check
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=f"Integrity error: A connector with this type already exists. {str(e)}" detail=f"Database integrity error during update: {str(e)}"
) )
except HTTPException:
await session.rollback()
raise
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
logger.error(f"Failed to update search source connector {connector_id}: {e}", exc_info=True)
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Failed to update search source connector: {str(e)}" detail=f"Failed to update search source connector: {str(e)}"

View file

@ -1,16 +1,15 @@
from datetime import datetime from datetime import datetime
import uuid import uuid
from typing import Dict, Any from typing import Dict, Any, Optional
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from .base import IDModel, TimestampModel from .base import IDModel, TimestampModel
from app.db import SearchSourceConnectorType from app.db import SearchSourceConnectorType
from fastapi import HTTPException
class SearchSourceConnectorBase(BaseModel): class SearchSourceConnectorBase(BaseModel):
name: str name: str
connector_type: SearchSourceConnectorType connector_type: SearchSourceConnectorType
is_indexable: bool is_indexable: bool
last_indexed_at: datetime | None last_indexed_at: Optional[datetime] = None
config: Dict[str, Any] config: Dict[str, Any]
@field_validator('config') @field_validator('config')
@ -59,8 +58,8 @@ class SearchSourceConnectorBase(BaseModel):
raise ValueError("NOTION_INTEGRATION_TOKEN cannot be empty") raise ValueError("NOTION_INTEGRATION_TOKEN cannot be empty")
elif connector_type == SearchSourceConnectorType.GITHUB_CONNECTOR: elif connector_type == SearchSourceConnectorType.GITHUB_CONNECTOR:
# For GITHUB_CONNECTOR, only allow GITHUB_PAT # For GITHUB_CONNECTOR, only allow GITHUB_PAT and repo_full_names
allowed_keys = ["GITHUB_PAT"] allowed_keys = ["GITHUB_PAT", "repo_full_names"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For GITHUB_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(f"For GITHUB_CONNECTOR connector type, config must only contain these keys: {allowed_keys}")
@ -68,6 +67,10 @@ class SearchSourceConnectorBase(BaseModel):
if not config.get("GITHUB_PAT"): if not config.get("GITHUB_PAT"):
raise ValueError("GITHUB_PAT cannot be empty") raise ValueError("GITHUB_PAT cannot be empty")
# Ensure the repo_full_names is present and is a non-empty list
repo_full_names = config.get("repo_full_names")
if not isinstance(repo_full_names, list) or not repo_full_names:
raise ValueError("repo_full_names must be a non-empty list of strings")
elif connector_type == SearchSourceConnectorType.LINEAR_CONNECTOR: elif connector_type == SearchSourceConnectorType.LINEAR_CONNECTOR:
# For LINEAR_CONNECTOR, only allow LINEAR_API_KEY # For LINEAR_CONNECTOR, only allow LINEAR_API_KEY
allowed_keys = ["LINEAR_API_KEY"] allowed_keys = ["LINEAR_API_KEY"]
@ -83,8 +86,12 @@ class SearchSourceConnectorBase(BaseModel):
class SearchSourceConnectorCreate(SearchSourceConnectorBase): class SearchSourceConnectorCreate(SearchSourceConnectorBase):
pass pass
class SearchSourceConnectorUpdate(SearchSourceConnectorBase): class SearchSourceConnectorUpdate(BaseModel):
pass name: Optional[str] = None
connector_type: Optional[SearchSourceConnectorType] = None
is_indexable: Optional[bool] = None
last_indexed_at: Optional[datetime] = None
config: Optional[Dict[str, Any]] = None
class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampModel): class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampModel):
user_id: uuid.UUID user_id: uuid.UUID

View file

@ -1,4 +1,4 @@
from typing import Optional, List, Dict, Any, Tuple from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.future import select from sqlalchemy.future import select
@ -639,24 +639,26 @@ async def index_github_repos(
if not connector: if not connector:
return 0, f"Connector with ID {connector_id} not found or is not a GitHub connector" return 0, f"Connector with ID {connector_id} not found or is not a GitHub connector"
# 2. Get the GitHub PAT from the connector config # 2. Get the GitHub PAT and selected repositories from the connector config
github_pat = connector.config.get("GITHUB_PAT") github_pat = connector.config.get("GITHUB_PAT")
repo_full_names_to_index = connector.config.get("repo_full_names")
if not github_pat: if not github_pat:
return 0, "GitHub Personal Access Token (PAT) not found in connector config" return 0, "GitHub Personal Access Token (PAT) not found in connector config"
if not repo_full_names_to_index or not isinstance(repo_full_names_to_index, list):
return 0, "'repo_full_names' not found or is not a list in connector config"
# 3. Initialize GitHub connector client # 3. Initialize GitHub connector client
try: try:
github_client = GitHubConnector(token=github_pat) github_client = GitHubConnector(token=github_pat)
except ValueError as e: except ValueError as e:
return 0, f"Failed to initialize GitHub client: {str(e)}" return 0, f"Failed to initialize GitHub client: {str(e)}"
# 4. Get list of accessible repositories # 4. Validate selected repositories
repositories = github_client.get_user_repositories() # For simplicity, we'll proceed with the list provided.
if not repositories: # If a repo is inaccessible, get_repository_files will likely fail gracefully later.
logger.info("No accessible GitHub repositories found for the provided token.") logger.info(f"Starting indexing for {len(repo_full_names_to_index)} selected repositories.")
return 0, "No accessible GitHub repositories found."
logger.info(f"Found {len(repositories)} repositories to potentially index.")
# 5. Get existing documents for this search space and connector type to prevent duplicates # 5. Get existing documents for this search space and connector type to prevent duplicates
existing_docs_result = await session.execute( existing_docs_result = await session.execute(
@ -671,11 +673,10 @@ async def index_github_repos(
existing_docs_lookup = {doc.document_metadata.get("full_path"): doc for doc in existing_docs if doc.document_metadata.get("full_path")} existing_docs_lookup = {doc.document_metadata.get("full_path"): doc for doc in existing_docs if doc.document_metadata.get("full_path")}
logger.info(f"Found {len(existing_docs_lookup)} existing GitHub documents in database for search space {search_space_id}") logger.info(f"Found {len(existing_docs_lookup)} existing GitHub documents in database for search space {search_space_id}")
# 6. Iterate through repositories and index files # 6. Iterate through selected repositories and index files
for repo_info in repositories: for repo_full_name in repo_full_names_to_index:
repo_full_name = repo_info.get("full_name") if not repo_full_name or not isinstance(repo_full_name, str):
if not repo_full_name: logger.warning(f"Skipping invalid repository entry: {repo_full_name}")
logger.warning(f"Skipping repository with missing full_name: {repo_info.get('name')}")
continue continue
logger.info(f"Processing repository: {repo_full_name}") logger.info(f"Processing repository: {repo_full_name}")

View file

@ -206,7 +206,7 @@ export default function ConnectorsPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}`)} onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`)}
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
<span className="sr-only">Edit</span> <span className="sr-only">Edit</span>

View file

@ -0,0 +1,176 @@
"use client";
import React, { useEffect } from 'react';
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { toast } from "sonner";
import { ArrowLeft, Check, Loader2, Github } from "lucide-react";
import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
// Import Utils, Types, Hook, and Components
import { getConnectorTypeDisplay } from '@/lib/connectors/utils';
import { useConnectorEditPage } from '@/hooks/useConnectorEditPage';
import { EditConnectorLoadingSkeleton } from "@/components/editConnector/EditConnectorLoadingSkeleton";
import { EditConnectorNameForm } from "@/components/editConnector/EditConnectorNameForm";
import { EditGitHubConnectorConfig } from "@/components/editConnector/EditGitHubConnectorConfig";
import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenForm";
export default function EditConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
// Ensure connectorId is parsed safely
const connectorIdParam = params.connector_id as string;
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
// Use the custom hook to manage state and logic
const {
connectorsLoading,
connector,
isSaving,
editForm,
patForm, // Needed for GitHub child component
handleSaveChanges,
// GitHub specific props for the child component
editMode,
setEditMode, // Pass down if needed by GitHub component
originalPat,
currentSelectedRepos,
fetchedRepos,
setFetchedRepos,
newSelectedRepos,
setNewSelectedRepos,
isFetchingRepos,
handleFetchRepositories,
handleRepoSelectionChange,
} = useConnectorEditPage(connectorId, searchSpaceId);
// Redirect if connectorId is not a valid number after parsing
useEffect(() => {
if (isNaN(connectorId)) {
toast.error("Invalid Connector ID.");
router.push(`/dashboard/${searchSpaceId}/connectors`);
}
}, [connectorId, router, searchSpaceId]);
// Loading State
if (connectorsLoading || !connector) {
// Handle NaN case before showing skeleton
if (isNaN(connectorId)) return null;
return <EditConnectorLoadingSkeleton />;
}
// Main Render using data/handlers from the hook
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button variant="ghost" className="mb-6" onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Connectors
</Button>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }}>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<Github className="h-6 w-6" /> {/* TODO: Dynamic icon */}
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
</CardTitle>
<CardDescription>Modify connector name and configuration.</CardDescription>
</CardHeader>
<Form {...editForm}>
{/* Pass hook's handleSaveChanges */}
<form onSubmit={editForm.handleSubmit(handleSaveChanges)} className="space-y-6">
<CardContent className="space-y-6">
{/* Pass form control from hook */}
<EditConnectorNameForm control={editForm.control} />
<hr />
<h3 className="text-lg font-semibold">Configuration</h3>
{/* == GitHub == */}
{connector.connector_type === 'GITHUB_CONNECTOR' && (
<EditGitHubConnectorConfig
// Pass relevant state and handlers from hook
editMode={editMode}
setEditMode={setEditMode} // Pass setter if child manages mode
originalPat={originalPat}
currentSelectedRepos={currentSelectedRepos}
fetchedRepos={fetchedRepos}
newSelectedRepos={newSelectedRepos}
isFetchingRepos={isFetchingRepos}
patForm={patForm}
handleFetchRepositories={handleFetchRepositories}
handleRepoSelectionChange={handleRepoSelectionChange}
setNewSelectedRepos={setNewSelectedRepos}
setFetchedRepos={setFetchedRepos}
/>
)}
{/* == Slack == */}
{connector.connector_type === 'SLACK_CONNECTOR' && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="SLACK_BOT_TOKEN"
fieldLabel="Slack Bot Token"
fieldDescription="Update the Slack Bot Token if needed."
placeholder="Begins with xoxb-..."
/>
)}
{/* == Notion == */}
{connector.connector_type === 'NOTION_CONNECTOR' && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="NOTION_INTEGRATION_TOKEN"
fieldLabel="Notion Integration Token"
fieldDescription="Update the Notion Integration Token if needed."
placeholder="Begins with secret_..."
/>
)}
{/* == Serper == */}
{connector.connector_type === 'SERPER_API' && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="SERPER_API_KEY"
fieldLabel="Serper API Key"
fieldDescription="Update the Serper API Key if needed."
/>
)}
{/* == Tavily == */}
{connector.connector_type === 'TAVILY_API' && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="TAVILY_API_KEY"
fieldLabel="Tavily API Key"
fieldDescription="Update the Tavily API Key if needed."
/>
)}
{/* == Linear == */}
{connector.connector_type === 'LINEAR_CONNECTOR' && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="LINEAR_API_KEY"
fieldLabel="Linear API Key"
fieldDescription="Update your Linear API Key if needed."
placeholder="Begins with lin_api_..."
/>
)}
</CardContent>
<CardFooter className="border-t pt-6">
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
Save Changes
</Button>
</CardFooter>
</form>
</Form>
</Card>
</motion.div>
</div>
);
}

View file

@ -70,7 +70,7 @@ export default function EditConnectorPage() {
const [connector, setConnector] = useState<SearchSourceConnector | null>(null); const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
console.log("connector", connector);
// Initialize the form // Initialize the form
const form = useForm<ApiConnectorFormValues>({ const form = useForm<ApiConnectorFormValues>({
resolver: zodResolver(apiConnectorFormSchema), resolver: zodResolver(apiConnectorFormSchema),

View file

@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
import { toast } from "sonner"; import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2, Github } from "lucide-react"; import { ArrowLeft, Check, Info, Loader2, Github, CircleAlert, ListChecks } from "lucide-react";
// Assuming useSearchSourceConnectors hook exists and works similarly // Assuming useSearchSourceConnectors hook exists and works similarly
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
@ -42,9 +42,10 @@ import {
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Checkbox } from "@/components/ui/checkbox";
// Define the form schema with Zod for GitHub // Define the form schema with Zod for GitHub PAT entry step
const githubConnectorFormSchema = z.object({ const githubPatFormSchema = z.object({
name: z.string().min(3, { name: z.string().min(3, {
message: "Connector name must be at least 3 characters.", message: "Connector name must be at least 3 characters.",
}), }),
@ -58,61 +59,144 @@ const githubConnectorFormSchema = z.object({
}); });
// Define the type for the form values // Define the type for the form values
type GithubConnectorFormValues = z.infer<typeof githubConnectorFormSchema>; type GithubPatFormValues = z.infer<typeof githubPatFormSchema>;
// Type for fetched GitHub repositories
interface GithubRepo {
id: number;
name: string;
full_name: string;
private: boolean;
url: string;
description: string | null;
last_updated: string | null;
}
export default function GithubConnectorPage() { export default function GithubConnectorPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false); const [step, setStep] = useState<'enter_pat' | 'select_repos'>('enter_pat');
const { createConnector } = useSearchSourceConnectors(); // Assuming this hook exists const [isFetchingRepos, setIsFetchingRepos] = useState(false);
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
const [repositories, setRepositories] = useState<GithubRepo[]>([]);
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
// Initialize the form const { createConnector } = useSearchSourceConnectors();
const form = useForm<GithubConnectorFormValues>({
resolver: zodResolver(githubConnectorFormSchema), // Initialize the form for PAT entry
const form = useForm<GithubPatFormValues>({
resolver: zodResolver(githubPatFormSchema),
defaultValues: { defaultValues: {
name: "GitHub Connector", name: connectorName,
github_pat: "", github_pat: "",
}, },
}); });
// Handle form submission // Function to fetch repositories using the new backend endpoint
const onSubmit = async (values: GithubConnectorFormValues) => { const fetchRepositories = async (values: GithubPatFormValues) => {
setIsSubmitting(true); setIsFetchingRepos(true);
setConnectorName(values.name); // Store the name
setValidatedPat(values.github_pat); // Store the PAT temporarily
try {
const token = localStorage.getItem('surfsense_bearer_token');
if (!token) {
throw new Error('No authentication token found');
}
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ github_pat: values.github_pat })
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
}
const data: GithubRepo[] = await response.json();
setRepositories(data);
setStep('select_repos'); // Move to the next step
toast.success(`Found ${data.length} repositories.`);
} catch (error) {
console.error("Error fetching GitHub repositories:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to fetch repositories. Please check the PAT and try again.";
toast.error(errorMessage);
} finally {
setIsFetchingRepos(false);
}
};
// Handle final connector creation
const handleCreateConnector = async () => {
if (selectedRepos.length === 0) {
toast.warning("Please select at least one repository to index.");
return;
}
setIsCreatingConnector(true);
try { try {
await createConnector({ await createConnector({
name: values.name, name: connectorName, // Use the stored name
connector_type: "GITHUB_CONNECTOR", connector_type: "GITHUB_CONNECTOR",
config: { config: {
GITHUB_PAT: values.github_pat, GITHUB_PAT: validatedPat, // Use the stored validated PAT
repo_full_names: selectedRepos, // Add the selected repo names
}, },
is_indexable: true, // GitHub connector is indexable is_indexable: true,
last_indexed_at: null, // New connector hasn't been indexed last_indexed_at: null,
}); });
toast.success("GitHub connector created successfully!"); toast.success("GitHub connector created successfully!");
// Navigate back to connectors management page (or the add page)
router.push(`/dashboard/${searchSpaceId}/connectors`); router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) { // Added type check for error } catch (error) {
console.error("Error creating GitHub connector:", error); console.error("Error creating GitHub connector:", error);
// Display specific backend error message if available const errorMessage = error instanceof Error ? error.message : "Failed to create GitHub connector.";
const errorMessage = error instanceof Error ? error.message : "Failed to create GitHub connector. Please check the PAT and permissions.";
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
setIsSubmitting(false); setIsCreatingConnector(false);
} }
}; };
// Handle checkbox changes
const handleRepoSelection = (repoFullName: string, checked: boolean) => {
setSelectedRepos(prev =>
checked
? [...prev, repoFullName]
: prev.filter(name => name !== repoFullName)
);
};
return ( return (
<div className="container mx-auto py-8 max-w-3xl"> <div className="container mx-auto py-8 max-w-3xl">
<Button <Button
variant="ghost" variant="ghost"
className="mb-6" className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)} onClick={() => {
if (step === 'select_repos') {
// Go back to PAT entry, clear sensitive/fetched data
setStep('enter_pat');
setRepositories([]);
setSelectedRepos([]);
setValidatedPat("");
// Reset form PAT field, keep name
form.reset({ name: connectorName, github_pat: "" });
} else {
router.push(`/dashboard/${searchSpaceId}/connectors/add`);
}
}}
> >
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Back to Add Connectors {step === 'select_repos' ? "Back to PAT Entry" : "Back to Add Connectors"}
</Button> </Button>
<motion.div <motion.div
@ -129,97 +213,174 @@ export default function GithubConnectorPage() {
<TabsContent value="connect"> <TabsContent value="connect">
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl font-bold flex items-center gap-2"><Github className="h-6 w-6" /> Connect GitHub Account</CardTitle> <CardTitle className="text-2xl font-bold flex items-center gap-2">
{step === 'enter_pat' ? <Github className="h-6 w-6" /> : <ListChecks className="h-6 w-6" />}
{step === 'enter_pat' ? "Connect GitHub Account" : "Select Repositories to Index"}
</CardTitle>
<CardDescription> <CardDescription>
Integrate with GitHub using a Personal Access Token (PAT) to search and retrieve information from accessible repositories. This connector can index your code and documentation. {step === 'enter_pat'
? "Provide a name and GitHub Personal Access Token (PAT) to fetch accessible repositories."
: `Select which repositories you want SurfSense to index for search. Found ${repositories.length} repositories accessible via your PAT.`
}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
<AlertDescription>
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to use this connector. You can create one from your
<a
href="https://github.com/settings/personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4 ml-1"
>
GitHub Developer Settings
</a>.
</AlertDescription>
</Alert>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> {step === 'enter_pat' && (
<FormField <CardContent>
control={form.control} <Alert className="mb-6 bg-muted">
name="name" <Info className="h-4 w-4" />
render={({ field }) => ( <AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
<FormItem> <AlertDescription>
<FormLabel>Connector Name</FormLabel> You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch repositories. You can create one from your{' '}
<FormControl> <a
<Input placeholder="My GitHub Connector" {...field} /> href="https://github.com/settings/personal-access-tokens"
</FormControl> target="_blank"
<FormDescription> rel="noopener noreferrer"
A friendly name to identify this GitHub connection. className="font-medium underline underline-offset-4"
</FormDescription> >
<FormMessage /> GitHub Developer Settings
</FormItem> </a>. The PAT will be used to fetch repositories and then stored securely to enable indexing.
)} </AlertDescription>
/> </Alert>
<FormField <form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
control={form.control} <FormField
name="github_pat" control={form.control}
render={({ field }) => ( name="name"
<FormItem> render={({ field }) => (
<FormLabel>GitHub Personal Access Token (PAT)</FormLabel> <FormItem>
<FormControl> <FormLabel>Connector Name</FormLabel>
<Input <FormControl>
type="password" <Input placeholder="My GitHub Connector" {...field} />
placeholder="ghp_... or github_pat_..." </FormControl>
{...field} <FormDescription>
/> A friendly name to identify this GitHub connection.
</FormControl> </FormDescription>
<FormDescription> <FormMessage />
Your GitHub PAT will be encrypted and stored securely. Ensure it has the necessary 'repo' scopes. </FormItem>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect GitHub
</>
)} )}
</Button> />
</div>
</form> <FormField
</Form> control={form.control}
</CardContent> name="github_pat"
render={({ field }) => (
<FormItem>
<FormLabel>GitHub Personal Access Token (PAT)</FormLabel>
<FormControl>
<Input
type="password"
placeholder="ghp_... or github_pat_..."
{...field}
/>
</FormControl>
<FormDescription>
Enter your GitHub PAT here to fetch your repositories. It will be stored encrypted later.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={isFetchingRepos}
className="w-full sm:w-auto"
>
{isFetchingRepos ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Fetching Repositories...
</>
) : (
"Fetch Repositories"
)}
</Button>
</div>
</form>
</CardContent>
)}
{step === 'select_repos' && (
<CardContent>
{repositories.length === 0 ? (
<Alert variant="destructive">
<CircleAlert className="h-4 w-4" />
<AlertTitle>No Repositories Found</AlertTitle>
<AlertDescription>
No repositories were found or accessible with the provided PAT. Please check the token and its permissions, then go back and try again.
</AlertDescription>
</Alert>
) : (
<div className="space-y-4">
<FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel>
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
{repositories.map((repo) => (
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
<Checkbox
id={`repo-${repo.id}`}
checked={selectedRepos.includes(repo.full_name)}
onCheckedChange={(checked) => handleRepoSelection(repo.full_name, !!checked)}
/>
<label
htmlFor={`repo-${repo.id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{repo.full_name} {repo.private && "(Private)"}
</label>
</div>
))}
</div>
<FormDescription>
Select the repositories you wish to index. Only checked repositories will be processed.
</FormDescription>
<div className="flex justify-between items-center pt-4">
<Button
variant="outline"
onClick={() => {
setStep('enter_pat');
setRepositories([]);
setSelectedRepos([]);
setValidatedPat("");
form.reset({ name: connectorName, github_pat: "" });
}}
>
Back
</Button>
<Button
onClick={handleCreateConnector}
disabled={isCreatingConnector || selectedRepos.length === 0}
className="w-full sm:w-auto"
>
{isCreatingConnector ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Connector...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Create Connector
</>
)}
</Button>
</div>
</div>
)}
</CardContent>
)}
</Form>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4"> <CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with GitHub integration:</h4> <h4 className="text-sm font-medium">What you get with GitHub integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground"> <ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through code and documentation in your repositories</li> <li>Search through code and documentation in your selected repositories</li>
<li>Access READMEs, Markdown files, and common code files</li> <li>Access READMEs, Markdown files, and common code files</li>
<li>Connect your project knowledge directly to your search space</li> <li>Connect your project knowledge directly to your search space</li>
<li>Index your repositories for enhanced search capabilities</li> <li>Index your selected repositories for enhanced search capabilities</li>
</ul> </ul>
</CardFooter> </CardFooter>
</Card> </Card>
@ -237,27 +398,20 @@ export default function GithubConnectorPage() {
<div> <div>
<h3 className="text-xl font-semibold mb-2">How it works</h3> <h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the GitHub API. It fetches information about repositories accessible to the token and indexes relevant files (code, markdown, text). The GitHub connector uses a Personal Access Token (PAT) to authenticate with the GitHub API. First, it fetches a list of repositories accessible to the token. You then select which repositories you want to index. The connector indexes relevant files (code, markdown, text) from only the selected repositories.
</p> </p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground"> <ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>The connector indexes files based on common code and documentation extensions.</li> <li>The connector indexes files based on common code and documentation extensions.</li>
<li>Large files (over 1MB) are skipped during indexing.</li> <li>Large files (over 1MB) are skipped during indexing.</li>
<li>Only selected repositories are indexed.</li>
<li>Indexing runs periodically (check connector settings for frequency) to keep content up-to-date.</li> <li>Indexing runs periodically (check connector settings for frequency) to keep content up-to-date.</li>
</ul> </ul>
</div> </div>
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
<AccordionItem value="create_pat"> <AccordionItem value="create_pat">
<AccordionTrigger className="text-lg font-medium">Step 1: Create a GitHub PAT</AccordionTrigger> <AccordionTrigger className="text-lg font-medium">Step 1: Generate GitHub PAT</AccordionTrigger>
<AccordionContent className="space-y-4"> <AccordionContent>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Token Security</AlertTitle>
<AlertDescription>
Treat your PAT like a password. Store it securely and consider using fine-grained tokens if possible.
</AlertDescription>
</Alert>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h4 className="font-medium mb-2">Generating a Token:</h4> <h4 className="font-medium mb-2">Generating a Token:</h4>
@ -280,9 +434,13 @@ export default function GithubConnectorPage() {
<AccordionTrigger className="text-lg font-medium">Step 2: Connect in SurfSense</AccordionTrigger> <AccordionTrigger className="text-lg font-medium">Step 2: Connect in SurfSense</AccordionTrigger>
<AccordionContent className="space-y-4"> <AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3"> <ol className="list-decimal pl-5 space-y-3">
<li>Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)" field on the "Connect GitHub" tab.</li> <li>Navigate to the "Connect GitHub" tab.</li>
<li>Optionally, give the connector a custom name.</li> <li>Enter a name for your connector.</li>
<li>Click the <strong>Connect GitHub</strong> button.</li> <li>Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)" field.</li>
<li>Click <strong>Fetch Repositories</strong>.</li>
<li>If the PAT is valid, you'll see a list of your accessible repositories.</li>
<li>Select the repositories you want SurfSense to index using the checkboxes.</li>
<li>Click the <strong>Create Connector</strong> button.</li>
<li>If the connection is successful, you will be redirected and can start indexing from the Connectors page.</li> <li>If the connection is successful, you will be redirected and can start indexing from the Connectors page.</li>
</ol> </ol>
</AccordionContent> </AccordionContent>

View file

@ -0,0 +1,21 @@
import React from 'react';
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
export function EditConnectorLoadingSkeleton() {
return (
<div className="container mx-auto py-8 max-w-3xl">
<Skeleton className="h-8 w-48 mb-6" />
<Card className="border-2 border-border">
<CardHeader>
<Skeleton className="h-7 w-3/4 mb-2" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,25 @@
import React from 'react';
import { Control } from 'react-hook-form';
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
// Assuming EditConnectorFormValues is defined elsewhere or passed as generic
interface EditConnectorNameFormProps {
control: Control<any>; // Use Control<EditConnectorFormValues> if type is available
}
export function EditConnectorNameForm({ control }: EditConnectorNameFormProps) {
return (
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}

View file

@ -0,0 +1,160 @@
import React from 'react';
import { UseFormReturn } from 'react-hook-form';
import { FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { Edit, KeyRound, Loader2, CircleAlert } from 'lucide-react';
// Types needed from parent
interface GithubRepo {
id: number;
name: string;
full_name: string;
private: boolean;
url: string;
description: string | null;
last_updated: string | null;
}
type GithubPatFormValues = { github_pat: string; };
type EditMode = 'viewing' | 'editing_repos';
interface EditGitHubConnectorConfigProps {
// State from parent
editMode: EditMode;
originalPat: string;
currentSelectedRepos: string[];
fetchedRepos: GithubRepo[] | null;
newSelectedRepos: string[];
isFetchingRepos: boolean;
// Forms from parent
patForm: UseFormReturn<GithubPatFormValues>;
// Handlers from parent
setEditMode: (mode: EditMode) => void;
handleFetchRepositories: (values: GithubPatFormValues) => Promise<void>;
handleRepoSelectionChange: (repoFullName: string, checked: boolean) => void;
setNewSelectedRepos: React.Dispatch<React.SetStateAction<string[]>>;
setFetchedRepos: React.Dispatch<React.SetStateAction<GithubRepo[] | null>>;
}
export function EditGitHubConnectorConfig({
editMode,
originalPat,
currentSelectedRepos,
fetchedRepos,
newSelectedRepos,
isFetchingRepos,
patForm,
setEditMode,
handleFetchRepositories,
handleRepoSelectionChange,
setNewSelectedRepos,
setFetchedRepos
}: EditGitHubConnectorConfigProps) {
return (
<div className="space-y-4">
<h4 className="font-medium text-muted-foreground">Repository Selection & Access</h4>
{/* Viewing Mode */}
{editMode === 'viewing' && (
<div className="space-y-3 p-4 border rounded-md bg-muted/50">
<FormLabel>Currently Indexed Repositories:</FormLabel>
{currentSelectedRepos.length > 0 ? (
<ul className="list-disc pl-5 text-sm">
{currentSelectedRepos.map(repo => <li key={repo}>{repo}</li>)}
</ul>
) : (
<p className="text-sm text-muted-foreground">(No repositories currently selected)</p>
)}
<Button type="button" variant="outline" size="sm" onClick={() => setEditMode('editing_repos')}>
<Edit className="mr-2 h-4 w-4" /> Change Selection / Update PAT
</Button>
<FormDescription>To change repo selections or update the PAT, click above.</FormDescription>
</div>
)}
{/* Editing Mode */}
{editMode === 'editing_repos' && (
<div className="space-y-4 p-4 border rounded-md">
{/* PAT Input */}
<div className="flex items-end gap-4 p-4 border rounded-md bg-muted/90">
<FormField
control={patForm.control}
name="github_pat"
render={({ field }) => (
<FormItem className="flex-grow">
<FormLabel className="flex items-center gap-1"><KeyRound className="h-4 w-4" /> GitHub PAT</FormLabel>
<FormControl><Input type="password" placeholder="ghp_... or github_pat_..." {...field} /></FormControl>
<FormDescription>Enter PAT to fetch/update repos or if you need to update the stored token.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
disabled={isFetchingRepos}
size="sm"
onClick={async () => {
const isValid = await patForm.trigger('github_pat');
if (isValid) {
handleFetchRepositories(patForm.getValues());
}
}}
>
{isFetchingRepos ? <Loader2 className="h-4 w-4 animate-spin" /> : "Fetch Repositories"}
</Button>
</div>
{/* Repo List */}
{isFetchingRepos && <Skeleton className="h-40 w-full" />}
{!isFetchingRepos && fetchedRepos !== null && (
fetchedRepos.length === 0 ? (
<Alert variant="destructive">
<CircleAlert className="h-4 w-4" />
<AlertTitle>No Repositories Found</AlertTitle>
<AlertDescription>Check PAT & permissions.</AlertDescription>
</Alert>
) : (
<div className="space-y-2">
<FormLabel>Select Repositories to Index ({newSelectedRepos.length} selected):</FormLabel>
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
{fetchedRepos.map((repo) => (
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
<Checkbox
id={`repo-${repo.id}`}
checked={newSelectedRepos.includes(repo.full_name)}
onCheckedChange={(checked) => handleRepoSelectionChange(repo.full_name, !!checked)}
/>
<label
htmlFor={`repo-${repo.id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{repo.full_name} {repo.private && "(Private)"}
</label>
</div>
))}
</div>
</div>
)
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setEditMode('viewing');
setFetchedRepos(null);
setNewSelectedRepos(currentSelectedRepos);
patForm.reset({ github_pat: originalPat }); // Reset PAT form on cancel
}}
>
Cancel Repo Change
</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,37 @@
import React from 'react';
import { Control } from 'react-hook-form';
import { FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { KeyRound } from 'lucide-react';
// Assuming EditConnectorFormValues is defined elsewhere or passed as generic
interface EditSimpleTokenFormProps {
control: Control<any>;
fieldName: string; // e.g., "SLACK_BOT_TOKEN"
fieldLabel: string; // e.g., "Slack Bot Token"
fieldDescription: string;
placeholder?: string;
}
export function EditSimpleTokenForm({
control,
fieldName,
fieldLabel,
fieldDescription,
placeholder
}: EditSimpleTokenFormProps) {
return (
<FormField
control={control}
name={fieldName}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-1"><KeyRound className="h-4 w-4" /> {fieldLabel}</FormLabel>
<FormControl><Input type="password" placeholder={placeholder} {...field} /></FormControl>
<FormDescription>{fieldDescription}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
}

View file

@ -0,0 +1,34 @@
import * as z from "zod";
// Types
export interface GithubRepo {
id: number;
name: string;
full_name: string;
private: boolean;
url: string;
description: string | null;
last_updated: string | null;
}
export type EditMode = 'viewing' | 'editing_repos';
// Schemas
export const githubPatSchema = z.object({
github_pat: z.string()
.min(20, { message: "GitHub Personal Access Token seems too short." })
.refine(pat => pat.startsWith('ghp_') || pat.startsWith('github_pat_'), {
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
}),
});
export type GithubPatFormValues = z.infer<typeof githubPatSchema>;
export const editConnectorSchema = z.object({
name: z.string().min(3, { message: "Connector name must be at least 3 characters." }),
SLACK_BOT_TOKEN: z.string().optional(),
NOTION_INTEGRATION_TOKEN: z.string().optional(),
SERPER_API_KEY: z.string().optional(),
TAVILY_API_KEY: z.string().optional(),
LINEAR_API_KEY: z.string().optional(),
});
export type EditConnectorFormValues = z.infer<typeof editConnectorSchema>;

View file

@ -0,0 +1,240 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { useSearchSourceConnectors, SearchSourceConnector } from '@/hooks/useSearchSourceConnectors';
import {
GithubRepo,
EditMode,
githubPatSchema,
editConnectorSchema,
GithubPatFormValues,
EditConnectorFormValues
} from '@/components/editConnector/types';
export function useConnectorEditPage(connectorId: number, searchSpaceId: string) {
const router = useRouter();
const { connectors, updateConnector, isLoading: connectorsLoading } = useSearchSourceConnectors();
// State managed by the hook
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
const [originalConfig, setOriginalConfig] = useState<Record<string, any> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [currentSelectedRepos, setCurrentSelectedRepos] = useState<string[]>([]);
const [originalPat, setOriginalPat] = useState<string>("");
const [editMode, setEditMode] = useState<EditMode>('viewing');
const [fetchedRepos, setFetchedRepos] = useState<GithubRepo[] | null>(null);
const [newSelectedRepos, setNewSelectedRepos] = useState<string[]>([]);
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
// Forms managed by the hook
const patForm = useForm<GithubPatFormValues>({
resolver: zodResolver(githubPatSchema),
defaultValues: { github_pat: "" },
});
const editForm = useForm<EditConnectorFormValues>({
resolver: zodResolver(editConnectorSchema),
defaultValues: {
name: "",
SLACK_BOT_TOKEN: "",
NOTION_INTEGRATION_TOKEN: "",
SERPER_API_KEY: "",
TAVILY_API_KEY: "",
LINEAR_API_KEY: ""
},
});
// Effect to load initial data
useEffect(() => {
if (!connectorsLoading && connectors.length > 0 && !connector) {
const currentConnector = connectors.find(c => c.id === connectorId);
if (currentConnector) {
setConnector(currentConnector);
const config = currentConnector.config || {};
setOriginalConfig(config);
editForm.reset({
name: currentConnector.name,
SLACK_BOT_TOKEN: config.SLACK_BOT_TOKEN || "",
NOTION_INTEGRATION_TOKEN: config.NOTION_INTEGRATION_TOKEN || "",
SERPER_API_KEY: config.SERPER_API_KEY || "",
TAVILY_API_KEY: config.TAVILY_API_KEY || "",
LINEAR_API_KEY: config.LINEAR_API_KEY || ""
});
if (currentConnector.connector_type === 'GITHUB_CONNECTOR') {
const savedRepos = config.repo_full_names || [];
const savedPat = config.GITHUB_PAT || "";
setCurrentSelectedRepos(savedRepos);
setNewSelectedRepos(savedRepos);
setOriginalPat(savedPat);
patForm.reset({ github_pat: savedPat });
setEditMode('viewing');
}
} else {
toast.error("Connector not found.");
router.push(`/dashboard/${searchSpaceId}/connectors`);
}
}
}, [connectorId, connectors, connectorsLoading, router, searchSpaceId, connector, editForm, patForm]);
// Handlers managed by the hook
const handleFetchRepositories = useCallback(async (values: GithubPatFormValues) => {
setIsFetchingRepos(true);
setFetchedRepos(null);
try {
const token = localStorage.getItem('surfsense_bearer_token');
if (!token) throw new Error('No auth token');
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
{ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ github_pat: values.github_pat }) }
);
if (!response.ok) { const err = await response.json(); throw new Error(err.detail || 'Fetch failed'); }
const data: GithubRepo[] = await response.json();
setFetchedRepos(data);
setNewSelectedRepos(currentSelectedRepos);
toast.success(`Found ${data.length} repos.`);
} catch (error) {
console.error("Error fetching GitHub repositories:", error);
toast.error(error instanceof Error ? error.message : "Failed to fetch repositories.");
} finally { setIsFetchingRepos(false); }
}, [currentSelectedRepos]); // Added dependency
const handleRepoSelectionChange = useCallback((repoFullName: string, checked: boolean) => {
setNewSelectedRepos(prev => checked ? [...prev, repoFullName] : prev.filter(name => name !== repoFullName));
}, []);
const handleSaveChanges = useCallback(async (formData: EditConnectorFormValues) => {
if (!connector || !originalConfig) return;
setIsSaving(true);
const updatePayload: Partial<SearchSourceConnector> = {};
let configChanged = false;
let newConfig: Record<string, any> | null = null;
if (formData.name !== connector.name) {
updatePayload.name = formData.name;
}
switch (connector.connector_type) {
case 'GITHUB_CONNECTOR':
const currentPatInForm = patForm.getValues('github_pat');
const patChanged = currentPatInForm !== originalPat;
const initialRepoSet = new Set(currentSelectedRepos);
const newRepoSet = new Set(newSelectedRepos);
const reposChanged = initialRepoSet.size !== newRepoSet.size || ![...initialRepoSet].every(repo => newRepoSet.has(repo));
if (patChanged || (editMode === 'editing_repos' && reposChanged && fetchedRepos !== null)) {
if (!currentPatInForm || !(currentPatInForm.startsWith('ghp_') || currentPatInForm.startsWith('github_pat_'))) {
toast.error("Invalid GitHub PAT format. Cannot save."); setIsSaving(false); return;
}
newConfig = { GITHUB_PAT: currentPatInForm, repo_full_names: newSelectedRepos };
if (reposChanged && newSelectedRepos.length === 0) { toast.warning("Warning: No repositories selected."); }
}
break;
case 'SLACK_CONNECTOR':
if (formData.SLACK_BOT_TOKEN !== originalConfig.SLACK_BOT_TOKEN) {
if (!formData.SLACK_BOT_TOKEN) { toast.error("Slack Token empty."); setIsSaving(false); return; }
newConfig = { SLACK_BOT_TOKEN: formData.SLACK_BOT_TOKEN };
}
break;
case 'NOTION_CONNECTOR':
if (formData.NOTION_INTEGRATION_TOKEN !== originalConfig.NOTION_INTEGRATION_TOKEN) {
if (!formData.NOTION_INTEGRATION_TOKEN) { toast.error("Notion Token empty."); setIsSaving(false); return; }
newConfig = { NOTION_INTEGRATION_TOKEN: formData.NOTION_INTEGRATION_TOKEN };
}
break;
case 'SERPER_API':
if (formData.SERPER_API_KEY !== originalConfig.SERPER_API_KEY) {
if (!formData.SERPER_API_KEY) { toast.error("Serper Key empty."); setIsSaving(false); return; }
newConfig = { SERPER_API_KEY: formData.SERPER_API_KEY };
}
break;
case 'TAVILY_API':
if (formData.TAVILY_API_KEY !== originalConfig.TAVILY_API_KEY) {
if (!formData.TAVILY_API_KEY) { toast.error("Tavily Key empty."); setIsSaving(false); return; }
newConfig = { TAVILY_API_KEY: formData.TAVILY_API_KEY };
}
break;
case 'LINEAR_CONNECTOR':
if (formData.LINEAR_API_KEY !== originalConfig.LINEAR_API_KEY) {
if (!formData.LINEAR_API_KEY) {
toast.error("Linear API Key cannot be empty.");
setIsSaving(false);
return;
}
newConfig = { LINEAR_API_KEY: formData.LINEAR_API_KEY };
}
break;
}
if (newConfig !== null) {
updatePayload.config = newConfig;
configChanged = true;
}
if (Object.keys(updatePayload).length === 0) {
toast.info("No changes detected.");
setIsSaving(false);
if (connector.connector_type === 'GITHUB_CONNECTOR') { setEditMode('viewing'); patForm.reset({ github_pat: originalPat }); }
return;
}
try {
await updateConnector(connectorId, updatePayload);
toast.success("Connector updated!");
const newlySavedConfig = updatePayload.config || originalConfig;
setOriginalConfig(newlySavedConfig);
if (updatePayload.name) {
setConnector(prev => prev ? { ...prev, name: updatePayload.name!, config: newlySavedConfig } : null);
}
if (configChanged) {
if (connector.connector_type === 'GITHUB_CONNECTOR') {
const savedGitHubConfig = newlySavedConfig as { GITHUB_PAT?: string; repo_full_names?: string[] };
setCurrentSelectedRepos(savedGitHubConfig.repo_full_names || []);
setOriginalPat(savedGitHubConfig.GITHUB_PAT || "");
setNewSelectedRepos(savedGitHubConfig.repo_full_names || []);
patForm.reset({ github_pat: savedGitHubConfig.GITHUB_PAT || "" });
} else if(connector.connector_type === 'SLACK_CONNECTOR') {
editForm.setValue('SLACK_BOT_TOKEN', newlySavedConfig.SLACK_BOT_TOKEN || "");
} else if(connector.connector_type === 'NOTION_CONNECTOR') {
editForm.setValue('NOTION_INTEGRATION_TOKEN', newlySavedConfig.NOTION_INTEGRATION_TOKEN || "");
} else if(connector.connector_type === 'SERPER_API') {
editForm.setValue('SERPER_API_KEY', newlySavedConfig.SERPER_API_KEY || "");
} else if(connector.connector_type === 'TAVILY_API') {
editForm.setValue('TAVILY_API_KEY', newlySavedConfig.TAVILY_API_KEY || "");
} else if(connector.connector_type === 'LINEAR_CONNECTOR') {
editForm.setValue('LINEAR_API_KEY', newlySavedConfig.LINEAR_API_KEY || "");
}
}
if (connector.connector_type === 'GITHUB_CONNECTOR') {
setEditMode('viewing');
setFetchedRepos(null);
}
// Resetting simple form values is handled by useEffect if connector state updates
} catch (error) {
console.error("Error updating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to update connector.");
} finally { setIsSaving(false); }
}, [connector, originalConfig, updateConnector, connectorId, patForm, originalPat, currentSelectedRepos, newSelectedRepos, editMode, fetchedRepos, editForm]); // Added editForm to dependencies
// Return values needed by the component
return {
connectorsLoading,
connector,
isSaving,
editForm,
patForm,
handleSaveChanges,
// GitHub specific props
editMode,
setEditMode,
originalPat,
currentSelectedRepos,
fetchedRepos,
setFetchedRepos,
newSelectedRepos,
setNewSelectedRepos,
isFetchingRepos,
handleFetchRepositories,
handleRepoSelectionChange,
};
}

View file

@ -0,0 +1,12 @@
// Helper function to get connector type display name
export const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = {
"SERPER_API": "Serper API",
"TAVILY_API": "Tavily API",
"SLACK_CONNECTOR": "Slack",
"NOTION_CONNECTOR": "Notion",
"GITHUB_CONNECTOR": "GitHub",
"LINEAR_CONNECTOR": "Linear",
};
return typeMap[type] || type;
};