summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/db/models.rs39
-rw-r--r--makima/src/db/repository.rs166
-rw-r--r--makima/src/server/handlers/contracts.rs32
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/handlers/repository_history.rs173
-rw-r--r--makima/src/server/mod.rs15
-rw-r--r--makima/src/server/openapi.rs18
7 files changed, 439 insertions, 5 deletions
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;