summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-15 03:37:44 +0000
committerGitHub <noreply@github.com>2026-01-15 03:37:44 +0000
commit764bd28d08ceaef03cd4050f9568a62d77bbcfca (patch)
treedbd83ea7d213902f2b8021acc98798b6f3545946
parenteeafe072bc6bb81459f7d087b48fc921afe9cc11 (diff)
downloadsoryu-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.tsx69
-rw-r--r--makima/frontend/src/lib/api.ts74
-rw-r--r--makima/frontend/src/routes/settings.tsx119
-rw-r--r--makima/migrations/20250116000000_create_repository_history.sql31
-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
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;