diff options
| author | soryu <soryu@soryu.co> | 2026-01-15 03:37:44 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-15 03:37:44 +0000 |
| commit | 764bd28d08ceaef03cd4050f9568a62d77bbcfca (patch) | |
| tree | dbd83ea7d213902f2b8021acc98798b6f3545946 | |
| parent | eeafe072bc6bb81459f7d087b48fc921afe9cc11 (diff) | |
| download | soryu-764bd28d08ceaef03cd4050f9568a62d77bbcfca.tar.gz soryu-764bd28d08ceaef03cd4050f9568a62d77bbcfca.zip | |
Add repository history feature to store and suggest previously used repositories (#18)
- Add repository_history table migration with repo_type, repo_path, use_count, last_used_at
- Add RepositoryHistoryEntry model and CRUD database functions
- Create API endpoints: GET/POST/DELETE /api/v1/repository-history, GET /api/v1/repository-history/suggestions
- Update add_remote_repository and add_local_repository handlers to automatically track history
- Update frontend API with repository history types and functions
- Add Repository History section to Settings page with list of entries and delete functionality
- Add suggestions dropdown to RepositoryPanel when entering new repository URL/path
- Suggestions filter by repo type (remote vs local) and match on user input
Test plan:
- Add a remote repository to a contract - verify it appears in Settings history
- Add a local repository to a contract - verify it appears in Settings history
- Add same repository again - verify use_count increments, not duplicate
- When adding new repository, verify suggestions appear based on history
- Delete a history entry from Settings - verify it's removed
- Verify suggestions only show matching type (remote for remote, local for local)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | makima/frontend/src/components/contracts/RepositoryPanel.tsx | 69 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 74 | ||||
| -rw-r--r-- | makima/frontend/src/routes/settings.tsx | 119 | ||||
| -rw-r--r-- | makima/migrations/20250116000000_create_repository_history.sql | 31 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 39 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 166 | ||||
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 32 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/repository_history.rs | 173 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 15 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 18 |
11 files changed, 730 insertions, 7 deletions
diff --git a/makima/frontend/src/components/contracts/RepositoryPanel.tsx b/makima/frontend/src/components/contracts/RepositoryPanel.tsx index 4170cfb..e314140 100644 --- a/makima/frontend/src/components/contracts/RepositoryPanel.tsx +++ b/makima/frontend/src/components/contracts/RepositoryPanel.tsx @@ -4,8 +4,9 @@ import type { RepositorySourceType, RepositoryStatus, DaemonDirectory, + RepositoryHistoryEntry, } from "../../lib/api"; -import { getDaemonDirectories } from "../../lib/api"; +import { getDaemonDirectories, getRepositorySuggestions } from "../../lib/api"; import { DirectoryInput } from "../mesh/DirectoryInput"; interface RepositoryPanelProps { @@ -53,6 +54,9 @@ export function RepositoryPanel({ const [isPrimary, setIsPrimary] = useState(false); // Daemon directory suggestions for local repositories const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]); + // Repository history suggestions + const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]); + const [showSuggestions, setShowSuggestions] = useState(false); // Fetch daemon directories when "local" mode is selected useEffect(() => { @@ -63,6 +67,36 @@ export function RepositoryPanel({ } }, [addMode]); + // Fetch repository suggestions when mode changes to remote or local + useEffect(() => { + if (addMode === "remote" || addMode === "local") { + getRepositorySuggestions(addMode, undefined, 10) + .then((res) => { + setRepoSuggestions(res.entries); + setShowSuggestions(res.entries.length > 0); + }) + .catch(() => { + setRepoSuggestions([]); + setShowSuggestions(false); + }); + } else { + setRepoSuggestions([]); + setShowSuggestions(false); + } + }, [addMode]); + + // Apply a suggestion to the form + const applySuggestion = (suggestion: RepositoryHistoryEntry) => { + setName(suggestion.name); + if (suggestion.repositoryUrl) { + setUrl(suggestion.repositoryUrl); + } + if (suggestion.localPath) { + setPath(suggestion.localPath); + } + setShowSuggestions(false); + }; + const handleAdd = () => { if (!name.trim()) return; @@ -162,12 +196,43 @@ export function RepositoryPanel({ {/* Add repository form */} {addMode ? ( <div className="p-3 border border-[rgba(117,170,252,0.3)] space-y-3"> - <div className="flex items-center gap-2 mb-2"> + <div className="flex items-center justify-between mb-2"> <span className="font-mono text-xs text-[#75aafc] uppercase"> Add {sourceTypeLabels[addMode]} Repository </span> + {repoSuggestions.length > 0 && ( + <button + onClick={() => setShowSuggestions(!showSuggestions)} + className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors" + > + {showSuggestions ? "Hide suggestions" : `${repoSuggestions.length} suggestions`} + </button> + )} </div> + {/* Suggestions dropdown */} + {showSuggestions && repoSuggestions.length > 0 && ( + <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto"> + {repoSuggestions.map((suggestion) => ( + <button + key={suggestion.id} + onClick={() => applySuggestion(suggestion)} + className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] transition-colors border-b border-[rgba(117,170,252,0.1)] last:border-b-0" + > + <div className="flex items-center justify-between"> + <span className="text-[#9bc3ff] truncate">{suggestion.name}</span> + <span className="text-[10px] text-[#556677]"> + {suggestion.useCount}× + </span> + </div> + <div className="text-[10px] text-[#556677] truncate"> + {suggestion.repositoryUrl || suggestion.localPath} + </div> + </button> + ))} + </div> + )} + <input type="text" value={name} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 2ea1128..100a85a 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1973,3 +1973,77 @@ export async function answerQuestion( } return res.json(); } + +// ============================================================================= +// Repository History Types and API +// ============================================================================= + +export interface RepositoryHistoryEntry { + id: string; + ownerId: string; + name: string; + repositoryUrl: string | null; + localPath: string | null; + sourceType: "remote" | "local"; + useCount: number; + lastUsedAt: string; + createdAt: string; +} + +export interface RepositoryHistoryListResponse { + entries: RepositoryHistoryEntry[]; + total: number; +} + +/** + * List all repository history entries. + * Returns entries ordered by use_count DESC, last_used_at DESC. + */ +export async function listRepositoryHistory(): Promise<RepositoryHistoryListResponse> { + const res = await authFetch(`${API_BASE}/api/v1/settings/repository-history`); + if (!res.ok) { + throw new Error(`Failed to list repository history: ${res.statusText}`); + } + return res.json(); +} + +/** + * Get repository suggestions based on history. + * Optionally filter by source type and search query. + */ +export async function getRepositorySuggestions( + sourceType?: "remote" | "local", + query?: string, + limit?: number +): Promise<RepositoryHistoryListResponse> { + const params = new URLSearchParams(); + if (sourceType) params.append("source_type", sourceType); + if (query) params.append("query", query); + if (limit) params.append("limit", limit.toString()); + + const queryString = params.toString(); + const url = `${API_BASE}/api/v1/settings/repository-history/suggestions${ + queryString ? `?${queryString}` : "" + }`; + + const res = await authFetch(url); + if (!res.ok) { + throw new Error(`Failed to get repository suggestions: ${res.statusText}`); + } + return res.json(); +} + +/** + * Delete a repository history entry. + */ +export async function deleteRepositoryHistory(id: string): Promise<void> { + const res = await authFetch( + `${API_BASE}/api/v1/settings/repository-history/${id}`, + { + method: "DELETE", + } + ); + if (!res.ok) { + throw new Error(`Failed to delete repository history: ${res.statusText}`); + } +} diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx index 7ca40ba..d3f4c1b 100644 --- a/makima/frontend/src/routes/settings.tsx +++ b/makima/frontend/src/routes/settings.tsx @@ -11,9 +11,12 @@ import { changeEmail, deleteAccount, listDaemons, + listRepositoryHistory, + deleteRepositoryHistory, type ApiKeyInfo, type CreateApiKeyResponse, type Daemon, + type RepositoryHistoryEntry, } from "../lib/api"; // ============================================================================= @@ -304,9 +307,16 @@ export default function SettingsPage() { const [daemonsLoading, setDaemonsLoading] = useState(true); const [daemonsError, setDaemonsError] = useState<string | null>(null); + // Repository history state + const [repoHistory, setRepoHistory] = useState<RepositoryHistoryEntry[]>([]); + const [repoHistoryLoading, setRepoHistoryLoading] = useState(true); + const [repoHistoryError, setRepoHistoryError] = useState<string | null>(null); + const [deletingRepoId, setDeletingRepoId] = useState<string | null>(null); + useEffect(() => { loadApiKey(); loadDaemons(); + loadRepoHistory(); }, []); // Auto-refresh daemons every 30 seconds @@ -342,6 +352,30 @@ export default function SettingsPage() { } }; + const loadRepoHistory = async () => { + try { + setRepoHistoryError(null); + const response = await listRepositoryHistory(); + setRepoHistory(response.entries); + } catch (err) { + setRepoHistoryError(err instanceof Error ? err.message : "Failed to load repository history"); + } finally { + setRepoHistoryLoading(false); + } + }; + + const handleDeleteRepoHistory = async (id: string) => { + try { + setDeletingRepoId(id); + await deleteRepositoryHistory(id); + setRepoHistory((prev) => prev.filter((entry) => entry.id !== id)); + } catch (err) { + setRepoHistoryError(err instanceof Error ? err.message : "Failed to delete entry"); + } finally { + setDeletingRepoId(null); + } + }; + const handleCreate = async () => { try { setActionLoading(true); @@ -692,6 +726,91 @@ export default function SettingsPage() { </div> )} </section> + + {/* Repository History */} + <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4"> + <div className="flex items-center justify-between mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]"> + <div className="flex items-center gap-2"> + <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa]"> + Repository History + </h2> + {repoHistory.length > 0 && ( + <span className="text-[10px] font-mono text-[#556677]"> + ({repoHistory.length} saved) + </span> + )} + </div> + <button + onClick={loadRepoHistory} + disabled={repoHistoryLoading} + className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50" + title="Refresh" + > + {repoHistoryLoading ? "..." : "↻"} + </button> + </div> + <p className="text-[#7788aa] font-mono text-[10px] mb-3"> + Previously used repositories will appear as suggestions when adding repos to contracts. + </p> + + {repoHistoryError && <ErrorAlert>{repoHistoryError}</ErrorAlert>} + + {repoHistoryLoading && repoHistory.length === 0 ? ( + <p className="text-[#7788aa] font-mono text-xs">Loading...</p> + ) : repoHistory.length === 0 ? ( + <div className="text-center py-4"> + <p className="text-[#7788aa] font-mono text-xs mb-2">No repository history</p> + <p className="text-[#556677] font-mono text-[10px]"> + Add repositories to contracts to build history + </p> + </div> + ) : ( + <div className="space-y-2 max-h-[300px] overflow-y-auto"> + {repoHistory.map((entry) => ( + <div + key={entry.id} + className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] p-3" + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2 mb-1"> + <span className="font-mono text-xs text-[#9bc3ff] truncate"> + {entry.name} + </span> + <span + className={`text-[10px] font-mono uppercase px-1.5 py-0.5 border ${ + entry.sourceType === "remote" + ? "text-cyan-400 border-cyan-700/50 bg-cyan-900/20" + : "text-green-400 border-green-700/50 bg-green-900/20" + }`} + > + {entry.sourceType} + </span> + </div> + <div className="font-mono text-[10px] text-[#556677] truncate mb-1"> + {entry.repositoryUrl || entry.localPath} + </div> + <div className="flex items-center gap-3 font-mono text-[10px] text-[#7788aa]"> + <span>Used {entry.useCount}×</span> + <span> + Last: {new Date(entry.lastUsedAt).toLocaleDateString()} + </span> + </div> + </div> + <button + onClick={() => handleDeleteRepoHistory(entry.id)} + disabled={deletingRepoId === entry.id} + className="p-1 font-mono text-[10px] text-[#555] hover:text-red-400 transition-colors disabled:opacity-50" + title="Delete from history" + > + {deletingRepoId === entry.id ? "..." : "×"} + </button> + </div> + </div> + ))} + </div> + )} + </section> </div> {/* Right Column */} diff --git a/makima/migrations/20250116000000_create_repository_history.sql b/makima/migrations/20250116000000_create_repository_history.sql new file mode 100644 index 0000000..7fb6920 --- /dev/null +++ b/makima/migrations/20250116000000_create_repository_history.sql @@ -0,0 +1,31 @@ +-- Repository history table for storing and suggesting previously used repositories +-- Used to track repositories across contracts and provide suggestions when adding new repos + +CREATE TABLE IF NOT EXISTS repository_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + repository_url VARCHAR(512), -- For remote repos + local_path VARCHAR(512), -- For local repos + source_type VARCHAR(32) NOT NULL, -- 'remote' or 'local' + use_count INTEGER NOT NULL DEFAULT 1, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for efficient owner lookups +CREATE INDEX idx_repository_history_owner_id ON repository_history(owner_id); + +-- Index for filtering by source type +CREATE INDEX idx_repository_history_source_type ON repository_history(source_type); + +-- Index for ordering by use_count and last_used_at +CREATE INDEX idx_repository_history_usage ON repository_history(owner_id, use_count DESC, last_used_at DESC); + +-- Unique constraint per owner + source_type + (url or path) +-- This prevents duplicates while allowing the same URL to be tracked by different owners +CREATE UNIQUE INDEX idx_repository_history_unique_remote + ON repository_history(owner_id, repository_url) WHERE source_type = 'remote' AND repository_url IS NOT NULL; + +CREATE UNIQUE INDEX idx_repository_history_unique_local + ON repository_history(owner_id, local_path) WHERE source_type = 'local' AND local_path IS NOT NULL; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index ca12eb2..8ab3a10 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -1507,3 +1507,42 @@ pub struct DaemonWithCapacity { pub last_heartbeat_at: DateTime<Utc>, pub connected_at: DateTime<Utc>, } + +// ============================================================================ +// Repository History (for storing and suggesting previously used repositories) +// ============================================================================ + +/// Repository history entry - tracks previously used repositories for suggestions +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RepositoryHistoryEntry { + pub id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub source_type: String, + pub use_count: i32, + pub last_used_at: DateTime<Utc>, + pub created_at: DateTime<Utc>, +} + +/// Response for repository history list endpoint +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RepositoryHistoryListResponse { + pub entries: Vec<RepositoryHistoryEntry>, + pub total: i64, +} + +/// Request for getting repository suggestions +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RepositorySuggestionsQuery { + /// Filter by source type: 'remote' or 'local' + pub source_type: Option<String>, + /// Optional search query to filter by name or URL/path + pub query: Option<String>, + /// Limit results (default: 10) + pub limit: Option<i32>, +} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 7933f1e..2f28c1a 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -3005,3 +3005,169 @@ pub async fn get_daemon_task_assignment( .fetch_optional(pool) .await } + +// ============================================================================ +// Repository History Functions +// ============================================================================ + +use super::models::RepositoryHistoryEntry; + +/// List all repository history entries for an owner, ordered by use_count DESC, last_used_at DESC. +pub async fn list_repository_history_for_owner( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<RepositoryHistoryEntry>, sqlx::Error> { + sqlx::query_as::<_, RepositoryHistoryEntry>( + r#" + SELECT id, owner_id, name, repository_url, local_path, source_type, use_count, last_used_at, created_at + FROM repository_history + WHERE owner_id = $1 + ORDER BY use_count DESC, last_used_at DESC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Get repository suggestions for an owner, optionally filtered by source type and query. +pub async fn get_repository_suggestions( + pool: &PgPool, + owner_id: Uuid, + source_type: Option<&str>, + query: Option<&str>, + limit: i32, +) -> Result<Vec<RepositoryHistoryEntry>, sqlx::Error> { + // Build query dynamically based on filters + let mut sql = String::from( + r#" + SELECT id, owner_id, name, repository_url, local_path, source_type, use_count, last_used_at, created_at + FROM repository_history + WHERE owner_id = $1 + "#, + ); + + let mut param_idx = 2; + + if source_type.is_some() { + sql.push_str(&format!(" AND source_type = ${}", param_idx)); + param_idx += 1; + } + + if query.is_some() { + sql.push_str(&format!( + " AND (LOWER(name) LIKE ${} OR LOWER(COALESCE(repository_url, '')) LIKE ${} OR LOWER(COALESCE(local_path, '')) LIKE ${})", + param_idx, param_idx, param_idx + )); + param_idx += 1; + } + + sql.push_str(&format!( + " ORDER BY use_count DESC, last_used_at DESC LIMIT ${}", + param_idx + )); + + // Build and execute query with the appropriate bindings + let mut query_builder = sqlx::query_as::<_, RepositoryHistoryEntry>(&sql).bind(owner_id); + + if let Some(st) = source_type { + query_builder = query_builder.bind(st); + } + + if let Some(q) = query { + let search_pattern = format!("%{}%", q.to_lowercase()); + query_builder = query_builder.bind(search_pattern); + } + + query_builder = query_builder.bind(limit); + + query_builder.fetch_all(pool).await +} + +/// Add or update a repository history entry. +/// If an entry with the same URL (for remote) or path (for local) already exists, +/// increment use_count and update last_used_at and name. +/// Otherwise, create a new entry. +pub async fn add_or_update_repository_history( + pool: &PgPool, + owner_id: Uuid, + name: &str, + repository_url: Option<&str>, + local_path: Option<&str>, + source_type: &str, +) -> Result<RepositoryHistoryEntry, sqlx::Error> { + // Use UPSERT (INSERT ... ON CONFLICT) + if source_type == "remote" { + let url = repository_url.ok_or_else(|| { + sqlx::Error::Protocol("repository_url required for remote type".to_string()) + })?; + + sqlx::query_as::<_, RepositoryHistoryEntry>( + r#" + INSERT INTO repository_history (owner_id, name, repository_url, local_path, source_type, use_count, last_used_at) + VALUES ($1, $2, $3, NULL, $4, 1, NOW()) + ON CONFLICT (owner_id, repository_url) WHERE source_type = 'remote' AND repository_url IS NOT NULL + DO UPDATE SET + name = EXCLUDED.name, + use_count = repository_history.use_count + 1, + last_used_at = NOW() + RETURNING id, owner_id, name, repository_url, local_path, source_type, use_count, last_used_at, created_at + "#, + ) + .bind(owner_id) + .bind(name) + .bind(url) + .bind(source_type) + .fetch_one(pool) + .await + } else if source_type == "local" { + let path = local_path.ok_or_else(|| { + sqlx::Error::Protocol("local_path required for local type".to_string()) + })?; + + sqlx::query_as::<_, RepositoryHistoryEntry>( + r#" + INSERT INTO repository_history (owner_id, name, repository_url, local_path, source_type, use_count, last_used_at) + VALUES ($1, $2, NULL, $3, $4, 1, NOW()) + ON CONFLICT (owner_id, local_path) WHERE source_type = 'local' AND local_path IS NOT NULL + DO UPDATE SET + name = EXCLUDED.name, + use_count = repository_history.use_count + 1, + last_used_at = NOW() + RETURNING id, owner_id, name, repository_url, local_path, source_type, use_count, last_used_at, created_at + "#, + ) + .bind(owner_id) + .bind(name) + .bind(path) + .bind(source_type) + .fetch_one(pool) + .await + } else { + Err(sqlx::Error::Protocol(format!( + "Invalid source_type: {}", + source_type + ))) + } +} + +/// Delete a repository history entry. +/// Returns true if an entry was deleted, false if not found. +pub async fn delete_repository_history( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + DELETE FROM repository_history + WHERE id = $1 AND owner_id = $2 + "#, + ) + .bind(id) + .bind(owner_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs index a3aa00a..3ce29e1 100644 --- a/makima/src/server/handlers/contracts.rs +++ b/makima/src/server/handlers/contracts.rs @@ -631,6 +631,22 @@ pub async fn add_remote_repository( Ok(repo) => { // Update supervisor task with repository info if this is a primary repo update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; + + // Track repository in history for future suggestions + if let Err(e) = repository::add_or_update_repository_history( + pool, + auth.owner_id, + &req.name, + Some(&req.repository_url), + None, + "remote", + ) + .await + { + // Log but don't fail the request if history tracking fails + tracing::warn!("Failed to track repository in history: {}", e); + } + (StatusCode::CREATED, Json(repo)).into_response() } Err(e) => { @@ -705,6 +721,22 @@ pub async fn add_local_repository( Ok(repo) => { // Update supervisor task with repository info if this is a primary repo update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; + + // Track repository in history for future suggestions + if let Err(e) = repository::add_or_update_repository_history( + pool, + auth.owner_id, + &req.name, + None, + Some(&req.local_path), + "local", + ) + .await + { + // Log but don't fail the request if history tracking fails + tracing::warn!("Failed to track repository in history: {}", e); + } + (StatusCode::CREATED, Json(repo)).into_response() } Err(e) => { diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 0ce6c85..b5650fd 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -14,6 +14,7 @@ pub mod mesh_daemon; pub mod mesh_merge; pub mod mesh_supervisor; pub mod mesh_ws; +pub mod repository_history; pub mod templates; pub mod transcript_analysis; pub mod users; diff --git a/makima/src/server/handlers/repository_history.rs b/makima/src/server/handlers/repository_history.rs new file mode 100644 index 0000000..c788d84 --- /dev/null +++ b/makima/src/server/handlers/repository_history.rs @@ -0,0 +1,173 @@ +//! HTTP handlers for repository history management. +//! Provides endpoints for listing, suggesting, and deleting repository history entries. + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +use crate::db::models::{RepositoryHistoryListResponse, RepositorySuggestionsQuery}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +/// List all repository history entries for the authenticated user. +/// Returns entries ordered by use_count DESC, last_used_at DESC. +#[utoipa::path( + get, + path = "/api/v1/settings/repository-history", + responses( + (status = 200, description = "List of repository history entries", body = RepositoryHistoryListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Settings" +)] +pub async fn list_repository_history( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::list_repository_history_for_owner(pool, auth.owner_id).await { + Ok(entries) => { + let total = entries.len() as i64; + Json(RepositoryHistoryListResponse { entries, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list repository history: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get repository suggestions based on history. +/// Optionally filter by source_type (remote/local) and search query. +#[utoipa::path( + get, + path = "/api/v1/settings/repository-history/suggestions", + params( + ("source_type" = Option<String>, Query, description = "Filter by source type: 'remote' or 'local'"), + ("query" = Option<String>, Query, description = "Search query to filter by name or URL/path"), + ("limit" = Option<i32>, Query, description = "Limit results (default: 10)") + ), + responses( + (status = 200, description = "List of repository suggestions", body = RepositoryHistoryListResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Settings" +)] +pub async fn get_repository_suggestions( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Query(params): Query<RepositorySuggestionsQuery>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + let limit = params.limit.unwrap_or(10).min(50); // Cap at 50 for safety + + match repository::get_repository_suggestions( + pool, + auth.owner_id, + params.source_type.as_deref(), + params.query.as_deref(), + limit, + ) + .await + { + Ok(entries) => { + let total = entries.len() as i64; + Json(RepositoryHistoryListResponse { entries, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to get repository suggestions: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a repository history entry. +#[utoipa::path( + delete, + path = "/api/v1/settings/repository-history/{id}", + params( + ("id" = Uuid, Path, description = "Repository history entry ID") + ), + responses( + (status = 204, description = "Entry deleted"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Entry not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Settings" +)] +pub async fn delete_repository_history( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::delete_repository_history(pool, id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Repository history entry not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete repository history {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 27ee06c..0eba009 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contracts, file_ws, files, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contracts, file_ws, files, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -191,6 +191,19 @@ pub fn make_router(state: SharedState) -> Router { // Template endpoints .route("/templates", get(templates::list_templates)) .route("/templates/{id}", get(templates::get_template)) + // Settings endpoints + .route( + "/settings/repository-history", + get(repository_history::list_repository_history), + ) + .route( + "/settings/repository-history/suggestions", + get(repository_history::get_repository_suggestions), + ) + .route( + "/settings/repository-history/{id}", + axum::routing::delete(repository_history::delete_repository_history), + ) .with_state(state); let swagger = SwaggerUi::new("/swagger-ui") diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index c4f0f19..afa114b 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -10,15 +10,16 @@ use crate::db::models::{ Daemon, DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, File, FileListResponse, FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, - MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, SendMessageRequest, Task, - TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, - UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, + MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, + RepositoryHistoryEntry, RepositoryHistoryListResponse, RepositorySuggestionsQuery, + SendMessageRequest, Task, TaskEventListResponse, TaskListResponse, TaskSummary, + TaskWithSubtasks, TranscriptEntry, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, }; use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, contract_chat, contracts, files, listen, mesh, mesh_chat, mesh_merge, users}; +use crate::server::handlers::{api_keys, contract_chat, contracts, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -94,6 +95,10 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage contract_chat::contract_chat_handler, contract_chat::get_contract_chat_history, contract_chat::clear_contract_chat_history, + // Repository history/settings endpoints + repository_history::list_repository_history, + repository_history::get_repository_suggestions, + repository_history::delete_repository_history, ), components( schemas( @@ -166,6 +171,10 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage AddLocalRepositoryRequest, CreateManagedRepositoryRequest, ChangePhaseRequest, + // Repository history schemas + RepositoryHistoryEntry, + RepositoryHistoryListResponse, + RepositorySuggestionsQuery, ) ), tags( @@ -175,6 +184,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage (name = "Contracts", description = "Contract management with workflow phases"), (name = "API Keys", description = "API key management for programmatic access"), (name = "Users", description = "User account management"), + (name = "Settings", description = "User settings including repository history"), ) )] pub struct ApiDoc; |
