mirror of
https://github.com/MODSetter/SurfSense.git
synced 2025-09-02 02:29:08 +00:00
Merge pull request #44 from Adamsmith6300/select-github-repos
Select/edit GitHub repos when editing github connector
This commit is contained in:
commit
73623aa37e
14 changed files with 1099 additions and 168 deletions
|
@ -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).
|
||||
"""
|
||||
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.future import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from typing import List, Dict, Any
|
||||
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.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.connectors.github_connector import GitHubConnector
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import logging
|
||||
|
||||
|
@ -28,6 +29,34 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
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)
|
||||
async def create_search_source_connector(
|
||||
connector: SearchSourceConnectorCreate,
|
||||
|
@ -76,6 +105,7 @@ async def create_search_source_connector(
|
|||
await session.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create search source connector: {str(e)}")
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
|
@ -130,54 +160,84 @@ async def update_search_source_connector(
|
|||
):
|
||||
"""
|
||||
Update a search source connector.
|
||||
|
||||
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.
|
||||
Handles partial updates, including merging changes into the 'config' field.
|
||||
"""
|
||||
try:
|
||||
db_connector = await check_ownership(session, SearchSourceConnector, connector_id, user)
|
||||
|
||||
# If connector type is being changed, check if one of that type already exists
|
||||
if connector_update.connector_type != db_connector.connector_type:
|
||||
# Convert the sparse update data (only fields present in request) to a dict
|
||||
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(
|
||||
select(SearchSourceConnector)
|
||||
.filter(
|
||||
SearchSourceConnector.user_id == user.id,
|
||||
SearchSourceConnector.connector_type == connector_update.connector_type,
|
||||
SearchSourceConnector.connector_type == value,
|
||||
SearchSourceConnector.id != connector_id
|
||||
)
|
||||
)
|
||||
existing_connector = result.scalars().first()
|
||||
|
||||
if existing_connector:
|
||||
raise HTTPException(
|
||||
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)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_connector, key, value)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
await session.refresh(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:
|
||||
await session.rollback()
|
||||
# This might occur if connector_type constraint is violated somehow after the check
|
||||
raise HTTPException(
|
||||
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:
|
||||
await session.rollback()
|
||||
logger.error(f"Failed to update search source connector {connector_id}: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to update search source connector: {str(e)}"
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
from datetime import datetime
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional
|
||||
from pydantic import BaseModel, field_validator
|
||||
from .base import IDModel, TimestampModel
|
||||
from app.db import SearchSourceConnectorType
|
||||
from fastapi import HTTPException
|
||||
|
||||
class SearchSourceConnectorBase(BaseModel):
|
||||
name: str
|
||||
connector_type: SearchSourceConnectorType
|
||||
is_indexable: bool
|
||||
last_indexed_at: datetime | None
|
||||
last_indexed_at: Optional[datetime] = None
|
||||
config: Dict[str, Any]
|
||||
|
||||
@field_validator('config')
|
||||
|
@ -59,8 +58,8 @@ class SearchSourceConnectorBase(BaseModel):
|
|||
raise ValueError("NOTION_INTEGRATION_TOKEN cannot be empty")
|
||||
|
||||
elif connector_type == SearchSourceConnectorType.GITHUB_CONNECTOR:
|
||||
# For GITHUB_CONNECTOR, only allow GITHUB_PAT
|
||||
allowed_keys = ["GITHUB_PAT"]
|
||||
# For GITHUB_CONNECTOR, only allow GITHUB_PAT and repo_full_names
|
||||
allowed_keys = ["GITHUB_PAT", "repo_full_names"]
|
||||
if set(config.keys()) != set(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"):
|
||||
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:
|
||||
# For LINEAR_CONNECTOR, only allow LINEAR_API_KEY
|
||||
allowed_keys = ["LINEAR_API_KEY"]
|
||||
|
@ -83,8 +86,12 @@ class SearchSourceConnectorBase(BaseModel):
|
|||
class SearchSourceConnectorCreate(SearchSourceConnectorBase):
|
||||
pass
|
||||
|
||||
class SearchSourceConnectorUpdate(SearchSourceConnectorBase):
|
||||
pass
|
||||
class SearchSourceConnectorUpdate(BaseModel):
|
||||
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):
|
||||
user_id: uuid.UUID
|
||||
|
|
|
@ -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.exc import SQLAlchemyError
|
||||
from sqlalchemy.future import select
|
||||
|
@ -639,24 +639,26 @@ async def index_github_repos(
|
|||
if not 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")
|
||||
repo_full_names_to_index = connector.config.get("repo_full_names")
|
||||
|
||||
if not github_pat:
|
||||
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
|
||||
try:
|
||||
github_client = GitHubConnector(token=github_pat)
|
||||
except ValueError as e:
|
||||
return 0, f"Failed to initialize GitHub client: {str(e)}"
|
||||
|
||||
# 4. Get list of accessible repositories
|
||||
repositories = github_client.get_user_repositories()
|
||||
if not repositories:
|
||||
logger.info("No accessible GitHub repositories found for the provided token.")
|
||||
return 0, "No accessible GitHub repositories found."
|
||||
|
||||
logger.info(f"Found {len(repositories)} repositories to potentially index.")
|
||||
# 4. Validate selected repositories
|
||||
# For simplicity, we'll proceed with the list provided.
|
||||
# If a repo is inaccessible, get_repository_files will likely fail gracefully later.
|
||||
logger.info(f"Starting indexing for {len(repo_full_names_to_index)} selected repositories.")
|
||||
|
||||
# 5. Get existing documents for this search space and connector type to prevent duplicates
|
||||
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")}
|
||||
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
|
||||
for repo_info in repositories:
|
||||
repo_full_name = repo_info.get("full_name")
|
||||
if not repo_full_name:
|
||||
logger.warning(f"Skipping repository with missing full_name: {repo_info.get('name')}")
|
||||
# 6. Iterate through selected repositories and index files
|
||||
for repo_full_name in repo_full_names_to_index:
|
||||
if not repo_full_name or not isinstance(repo_full_name, str):
|
||||
logger.warning(f"Skipping invalid repository entry: {repo_full_name}")
|
||||
continue
|
||||
|
||||
logger.info(f"Processing repository: {repo_full_name}")
|
||||
|
|
|
@ -206,7 +206,7 @@ export default function ConnectorsPage() {
|
|||
<Button
|
||||
variant="outline"
|
||||
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" />
|
||||
<span className="sr-only">Edit</span>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -70,7 +70,7 @@ export default function EditConnectorPage() {
|
|||
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
console.log("connector", connector);
|
||||
// Initialize the form
|
||||
const form = useForm<ApiConnectorFormValues>({
|
||||
resolver: zodResolver(apiConnectorFormSchema),
|
||||
|
|
|
@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
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
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
@ -42,9 +42,10 @@ import {
|
|||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
// Define the form schema with Zod for GitHub
|
||||
const githubConnectorFormSchema = z.object({
|
||||
// Define the form schema with Zod for GitHub PAT entry step
|
||||
const githubPatFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
|
@ -58,61 +59,144 @@ const githubConnectorFormSchema = z.object({
|
|||
});
|
||||
|
||||
// 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() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors(); // Assuming this hook exists
|
||||
const [step, setStep] = useState<'enter_pat' | 'select_repos'>('enter_pat');
|
||||
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 form = useForm<GithubConnectorFormValues>({
|
||||
resolver: zodResolver(githubConnectorFormSchema),
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form for PAT entry
|
||||
const form = useForm<GithubPatFormValues>({
|
||||
resolver: zodResolver(githubPatFormSchema),
|
||||
defaultValues: {
|
||||
name: "GitHub Connector",
|
||||
name: connectorName,
|
||||
github_pat: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: GithubConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
// Function to fetch repositories using the new backend endpoint
|
||||
const fetchRepositories = async (values: GithubPatFormValues) => {
|
||||
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 {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
name: connectorName, // Use the stored name
|
||||
connector_type: "GITHUB_CONNECTOR",
|
||||
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
|
||||
last_indexed_at: null, // New connector hasn't been indexed
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("GitHub connector created successfully!");
|
||||
|
||||
// Navigate back to connectors management page (or the add page)
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) { // Added type check for error
|
||||
} catch (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. Please check the PAT and permissions.";
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to create GitHub connector.";
|
||||
toast.error(errorMessage);
|
||||
} 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 (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
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" />
|
||||
Back to Add Connectors
|
||||
{step === 'select_repos' ? "Back to PAT Entry" : "Back to Add Connectors"}
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
|
@ -129,30 +213,38 @@ export default function GithubConnectorPage() {
|
|||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<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>
|
||||
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>
|
||||
</CardHeader>
|
||||
|
||||
<Form {...form}>
|
||||
{step === 'enter_pat' && (
|
||||
<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
|
||||
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch repositories. 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"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
GitHub Developer Settings
|
||||
</a>.
|
||||
</a>. The PAT will be used to fetch repositories and then stored securely to enable indexing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
@ -184,7 +276,7 @@ export default function GithubConnectorPage() {
|
|||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your GitHub PAT will be encrypted and stored securely. Ensure it has the necessary 'repo' scopes.
|
||||
Enter your GitHub PAT here to fetch your repositories. It will be stored encrypted later.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -194,32 +286,101 @@ export default function GithubConnectorPage() {
|
|||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
disabled={isFetchingRepos}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
{isFetchingRepos ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
Fetching Repositories...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect GitHub
|
||||
</>
|
||||
"Fetch Repositories"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</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">
|
||||
<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">
|
||||
<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>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>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
@ -237,27 +398,20 @@ export default function GithubConnectorPage() {
|
|||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<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>
|
||||
<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>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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="create_pat">
|
||||
<AccordionTrigger className="text-lg font-medium">Step 1: Create a GitHub PAT</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<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>
|
||||
|
||||
<AccordionTrigger className="text-lg font-medium">Step 1: Generate GitHub PAT</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
<AccordionContent className="space-y-4">
|
||||
<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>Optionally, give the connector a custom name.</li>
|
||||
<li>Click the <strong>Connect GitHub</strong> button.</li>
|
||||
<li>Navigate to the "Connect GitHub" tab.</li>
|
||||
<li>Enter a name for your connector.</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>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
34
surfsense_web/components/editConnector/types.ts
Normal file
34
surfsense_web/components/editConnector/types.ts
Normal 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>;
|
240
surfsense_web/hooks/useConnectorEditPage.ts
Normal file
240
surfsense_web/hooks/useConnectorEditPage.ts
Normal 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,
|
||||
};
|
||||
}
|
12
surfsense_web/lib/connectors/utils.ts
Normal file
12
surfsense_web/lib/connectors/utils.ts
Normal 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;
|
||||
};
|
Loading…
Add table
Reference in a new issue