Skip to main content

lean_ctx/tools/
ctx_provider.rs

1use crate::core::consolidation;
2use crate::core::providers::cache as provider_cache;
3use crate::core::providers::config::GitLabConfig;
4use crate::core::providers::provider_trait::ProviderParams;
5use crate::core::providers::registry::global_registry;
6use crate::core::providers::{ProviderResult, gitlab};
7use crate::server::tool_trait::ToolContext;
8
9pub fn handle(args: &serde_json::Map<String, serde_json::Value>, ctx: &ToolContext) -> String {
10    let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
11
12    match action {
13        // -- Discovery & Management --
14        "discover" | "list" => handle_discover(ctx),
15        "status" => handle_status(ctx),
16        "refresh" => handle_refresh(args, ctx),
17        "configure" => handle_configure(args, ctx),
18
19        // -- Registry-based routing (provider_id + resource) --
20        "query" => handle_registry_query(args, ctx),
21
22        // -- MCP Bridge convenience actions --
23        "mcp_resources" => handle_mcp_resources(args, ctx),
24
25        // -- Legacy GitLab actions (backward-compatible) --
26        "gitlab_issues" => handle_gitlab_issues(args),
27        "gitlab_issue" => handle_gitlab_issue(args),
28        "gitlab_mrs" => handle_gitlab_mrs(args),
29        "gitlab_pipelines" => handle_gitlab_pipelines(args),
30
31        _ => {
32            let available = "discover, list, status, refresh, configure, query, mcp_resources, \
33                 gitlab_issues, gitlab_issue, gitlab_mrs, gitlab_pipelines";
34            format!("Unknown action: {action}. Available: {available}")
35        }
36    }
37}
38
39// ---------------------------------------------------------------------------
40// Discovery
41// ---------------------------------------------------------------------------
42
43fn handle_discover(ctx: &ToolContext) -> String {
44    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
45        &ctx.project_root,
46    )));
47    let infos = global_registry().discover();
48    if infos.is_empty() {
49        return "No providers registered. Set GITHUB_TOKEN or GITLAB_TOKEN.".to_string();
50    }
51
52    let mut out = format!("Registered providers ({}):\n", infos.len());
53    for info in &infos {
54        let status = if info.available {
55            "ready"
56        } else {
57            "unavailable"
58        };
59        out.push_str(&format!(
60            "  {} ({}) [{}] actions: {}\n",
61            info.id,
62            info.display_name,
63            status,
64            info.actions.join(", "),
65        ));
66    }
67    out
68}
69
70// ---------------------------------------------------------------------------
71// Status — provider health + cache metrics
72// ---------------------------------------------------------------------------
73
74fn handle_status(ctx: &ToolContext) -> String {
75    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
76        &ctx.project_root,
77    )));
78
79    let infos = global_registry().discover();
80    let metrics = provider_cache::cache_metrics();
81
82    let mut out = String::new();
83
84    // Provider health
85    out.push_str(&format!("Provider Status ({} registered):\n", infos.len()));
86    for info in &infos {
87        let status = if info.available { "✓" } else { "✗" };
88        let auth = if info.requires_auth { " (auth)" } else { "" };
89        out.push_str(&format!(
90            "  {status} {} — {} [ttl:{}s]{auth}\n",
91            info.id, info.display_name, info.cache_ttl_secs,
92        ));
93    }
94
95    // Cache metrics
96    out.push_str(&format!(
97        "\nCache: {} entries, {:.0}% hit rate ({} hits / {} misses)\n",
98        metrics.total_entries,
99        metrics.total_hit_rate() * 100.0,
100        metrics.total_hits,
101        metrics.total_misses,
102    ));
103
104    if !metrics.provider_stats.is_empty() {
105        out.push_str("Per-provider:\n");
106        for ps in &metrics.provider_stats {
107            let last = ps
108                .last_fetch
109                .and_then(|t| t.elapsed().ok())
110                .map_or_else(|| "never".into(), |d| format!("{}s ago", d.as_secs()));
111            out.push_str(&format!(
112                "  {} — {} cached, {:.0}% hit rate, last fetch: {}\n",
113                ps.provider_id,
114                ps.entry_count,
115                ps.hit_rate() * 100.0,
116                last,
117            ));
118        }
119    }
120
121    out
122}
123
124// ---------------------------------------------------------------------------
125// Refresh — invalidate cache + re-fetch + re-consolidate
126// ---------------------------------------------------------------------------
127
128fn handle_refresh(args: &serde_json::Map<String, serde_json::Value>, ctx: &ToolContext) -> String {
129    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
130        &ctx.project_root,
131    )));
132
133    let provider_id = args.get("provider").and_then(|v| v.as_str());
134    let resource = args.get("resource").and_then(|v| v.as_str());
135
136    // Invalidate cache
137    let invalidated = if let Some(pid) = provider_id {
138        let count = provider_cache::invalidate_provider(pid);
139        format!("Invalidated {count} cached entries for '{pid}'")
140    } else {
141        let count = provider_cache::invalidate_all();
142        format!("Invalidated {count} cached entries (all providers)")
143    };
144
145    let mut out = format!("{invalidated}\n");
146
147    // Re-fetch if provider + resource specified
148    if let (Some(pid), Some(res)) = (provider_id, resource) {
149        let params = ProviderParams {
150            state: args.get("state").and_then(|v| v.as_str()).map(String::from),
151            limit: args
152                .get("limit")
153                .and_then(serde_json::Value::as_u64)
154                .map(|n| n as usize),
155            ..Default::default()
156        };
157
158        match global_registry().execute_as_chunks(pid, res, &params) {
159            Ok(chunks) => {
160                consolidate_to_session(&chunks, ctx);
161                out.push_str(&format!(
162                    "Re-fetched {pid}/{res}: {} items, consolidated to BM25+Graph+Knowledge\n",
163                    chunks.len()
164                ));
165            }
166            Err(e) => out.push_str(&format!("Re-fetch failed: {e}\n")),
167        }
168    } else if let Some(pid) = provider_id {
169        // Refresh all actions for a single provider
170        let registry = global_registry();
171        match registry.get(pid) {
172            Some(provider) => {
173                let mut total = 0;
174                for action in provider.supported_actions() {
175                    let params = ProviderParams {
176                        limit: Some(20),
177                        ..Default::default()
178                    };
179                    match registry.execute_as_chunks(pid, action, &params) {
180                        Ok(chunks) => {
181                            consolidate_to_session(&chunks, ctx);
182                            total += chunks.len();
183                        }
184                        Err(e) => {
185                            tracing::debug!("[ctx_provider] refresh {pid}/{action} failed: {e}");
186                        }
187                    }
188                }
189                out.push_str(&format!(
190                    "Re-fetched all actions for '{pid}': {total} items consolidated\n"
191                ));
192            }
193            _ => {
194                out.push_str(&format!("Provider '{pid}' not found\n"));
195            }
196        }
197    } else {
198        out.push_str("Specify provider= to also re-fetch data after cache invalidation\n");
199    }
200
201    out
202}
203
204// ---------------------------------------------------------------------------
205// Configure — show config paths + available config providers
206// ---------------------------------------------------------------------------
207
208fn handle_configure(
209    args: &serde_json::Map<String, serde_json::Value>,
210    ctx: &ToolContext,
211) -> String {
212    let sub = args
213        .get("resource")
214        .and_then(|v| v.as_str())
215        .unwrap_or("show");
216
217    match sub {
218        "paths" => {
219            let mut out = String::from("Provider config locations (checked in order):\n");
220            out.push_str("  Single-file (providers.toml):\n");
221            if let Some(config_dir) = dirs::config_dir() {
222                let p = config_dir.join("lean-ctx").join("providers.toml");
223                let exists = if p.exists() { " ✓" } else { "" };
224                out.push_str(&format!("    {}{exists}\n", p.display()));
225            }
226            if let Some(home) = dirs::home_dir() {
227                let p = home.join(".lean-ctx").join("providers.toml");
228                let exists = if p.exists() { " ✓" } else { "" };
229                out.push_str(&format!("    {}{exists}\n", p.display()));
230            }
231            let p = std::path::Path::new(&ctx.project_root)
232                .join(".lean-ctx")
233                .join("providers.toml");
234            let exists = if p.exists() { " ✓" } else { "" };
235            out.push_str(&format!("    {}{exists}\n", p.display()));
236
237            out.push_str("  Per-file (one provider per file):\n");
238            if let Some(config_dir) = dirs::config_dir() {
239                let p = config_dir.join("lean-ctx").join("providers");
240                let exists = if p.exists() { " ✓" } else { "" };
241                out.push_str(&format!("    {}/{exists}\n", p.display()));
242            }
243            let p = std::path::Path::new(&ctx.project_root)
244                .join(".lean-ctx")
245                .join("providers");
246            let exists = if p.exists() { " ✓" } else { "" };
247            out.push_str(&format!("    {}/{exists}\n", p.display()));
248
249            out.push_str("\nEnvironment variables:\n");
250            for (var, label) in [
251                ("GITHUB_TOKEN", "GitHub"),
252                ("GITLAB_TOKEN", "GitLab"),
253                ("JIRA_URL", "Jira"),
254                ("DATABASE_URL", "PostgreSQL"),
255            ] {
256                let set = if std::env::var(var).is_ok() {
257                    "✓ set"
258                } else {
259                    "✗ not set"
260                };
261                out.push_str(&format!("  {var} ({label}): {set}\n"));
262            }
263            out
264        }
265        "template" => String::from(
266            r#"# providers.toml — drop in ~/.config/lean-ctx/ or .lean-ctx/
267# Each [[providers]] entry registers a custom REST API as a context source.
268
269[[providers]]
270id = "linear"
271name = "Linear"
272base_url = "https://api.linear.app"
273cache_ttl_secs = 120
274
275[providers.auth]
276type = "bearer"
277token_env = "LINEAR_API_KEY"
278
279[providers.resources.issues]
280method = "POST"
281path = "/graphql"
282
283[providers.resources.issues.response]
284root = "data.issues.nodes"
285
286[providers.resources.issues.response.mapping]
287id = "id"
288title = "title"
289body = "description"
290state = "state.name"
291labels = "labels.nodes[].name"
292
293# --- Built-in providers (env vars only) ---
294# GitHub: set GITHUB_TOKEN
295# GitLab: set GITLAB_TOKEN
296# Jira:   set JIRA_URL + JIRA_EMAIL + JIRA_TOKEN
297# Postgres: set DATABASE_URL or PGDATABASE
298"#,
299        ),
300        _ => {
301            let cfg = crate::core::config::Config::load();
302            let mut out = String::from("Provider configuration:\n");
303            out.push_str(&format!("  enabled: {}\n", cfg.providers.enabled));
304            out.push_str(&format!("  auto_index: {}\n", cfg.providers.auto_index));
305            out.push_str(&format!(
306                "  github.enabled: {}\n",
307                cfg.providers.github.enabled
308            ));
309            out.push_str(&format!(
310                "  gitlab.enabled: {}\n",
311                cfg.providers.gitlab.enabled
312            ));
313
314            if !cfg.providers.mcp_bridges.is_empty() {
315                out.push_str(&format!(
316                    "  mcp_bridges: {} configured\n",
317                    cfg.providers.mcp_bridges.len()
318                ));
319            }
320
321            let discovered = crate::core::providers::config_provider::discovery::discover_configs(
322                Some(std::path::Path::new(&ctx.project_root)),
323            );
324            if !discovered.is_empty() {
325                out.push_str(&format!(
326                    "  config providers: {} discovered\n",
327                    discovered.len()
328                ));
329                for d in &discovered {
330                    out.push_str(&format!(
331                        "    {} — {}\n",
332                        d.config.id,
333                        d.source_path.display()
334                    ));
335                }
336            }
337
338            out.push_str(
339                "\nUse resource=\"paths\" to see config file locations.\n\
340                 Use resource=\"template\" to get a providers.toml template.\n",
341            );
342            out
343        }
344    }
345}
346
347// ---------------------------------------------------------------------------
348// MCP Bridge convenience: list resources from a specific MCP bridge
349// ---------------------------------------------------------------------------
350
351fn handle_mcp_resources(
352    args: &serde_json::Map<String, serde_json::Value>,
353    ctx: &ToolContext,
354) -> String {
355    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
356        &ctx.project_root,
357    )));
358
359    let Some(provider_id) = args.get("provider").and_then(|v| v.as_str()) else {
360        let registry = global_registry();
361        let mcp_providers: Vec<_> = registry
362            .discover()
363            .into_iter()
364            .filter(|p| p.id.starts_with("mcp:"))
365            .collect();
366
367        if mcp_providers.is_empty() {
368            return "No MCP bridges configured. Add [providers.mcp_bridges] to config.toml."
369                .to_string();
370        }
371
372        let mut out = format!("Available MCP bridges ({}):\n", mcp_providers.len());
373        for p in &mcp_providers {
374            let status = if p.available { "ready" } else { "unavailable" };
375            out.push_str(&format!("  {} ({}) [{}]\n", p.id, p.display_name, status));
376        }
377        out.push_str("\nUse provider=\"mcp:<name>\" to list resources from a specific bridge.");
378        return out;
379    };
380
381    let provider_id = if provider_id.starts_with("mcp:") {
382        provider_id.to_string()
383    } else {
384        format!("mcp:{provider_id}")
385    };
386
387    let params = ProviderParams {
388        limit: args
389            .get("limit")
390            .and_then(serde_json::Value::as_u64)
391            .map(|n| n as usize),
392        ..Default::default()
393    };
394
395    match global_registry().execute(&provider_id, "resources", &params) {
396        Ok(result) => format_result(&result),
397        Err(e) => format!("Error: {e}"),
398    }
399}
400
401// ---------------------------------------------------------------------------
402// Registry-based query (new unified interface)
403// ---------------------------------------------------------------------------
404
405fn handle_registry_query(
406    args: &serde_json::Map<String, serde_json::Value>,
407    ctx: &ToolContext,
408) -> String {
409    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
410        &ctx.project_root,
411    )));
412
413    let Some(provider_id) = args.get("provider").and_then(|v| v.as_str()) else {
414        return "Error: 'provider' is required for action=query".to_string();
415    };
416    let Some(resource) = args.get("resource").and_then(|v| v.as_str()) else {
417        return "Error: 'resource' is required for action=query".to_string();
418    };
419
420    let params = ProviderParams {
421        project: args
422            .get("project")
423            .and_then(|v| v.as_str())
424            .map(String::from),
425        state: args.get("state").and_then(|v| v.as_str()).map(String::from),
426        limit: args
427            .get("limit")
428            .and_then(serde_json::Value::as_u64)
429            .map(|n| n as usize),
430        query: args.get("query").and_then(|v| v.as_str()).map(String::from),
431        id: args.get("id").and_then(|v| v.as_str()).map(String::from),
432    };
433
434    let mode = args
435        .get("mode")
436        .and_then(|v| v.as_str())
437        .unwrap_or("compact");
438
439    match mode {
440        "chunks" => handle_registry_chunks(provider_id, resource, &params, ctx),
441        _ => handle_registry_compact(provider_id, resource, &params, ctx),
442    }
443}
444
445fn handle_registry_compact(
446    provider_id: &str,
447    resource: &str,
448    params: &ProviderParams,
449    ctx: &ToolContext,
450) -> String {
451    match global_registry().execute_as_chunks(provider_id, resource, params) {
452        Ok(chunks) => {
453            consolidate_to_session(&chunks, ctx);
454            let result = global_registry().execute(provider_id, resource, params);
455            match result {
456                Ok(r) => format_result(&r),
457                Err(_) => format_chunks_compact(&chunks, provider_id, resource),
458            }
459        }
460        Err(e) => format!("Error: {e}"),
461    }
462}
463
464fn handle_registry_chunks(
465    provider_id: &str,
466    resource: &str,
467    params: &ProviderParams,
468    ctx: &ToolContext,
469) -> String {
470    match global_registry().execute_as_chunks(provider_id, resource, params) {
471        Ok(chunks) => {
472            consolidate_to_session(&chunks, ctx);
473            let mut out = format!(
474                "{} content chunks from {provider_id}/{resource}:\n",
475                chunks.len()
476            );
477            for c in &chunks {
478                let refs = if c.references.is_empty() {
479                    String::new()
480                } else {
481                    format!(" refs:[{}]", c.references.join(","))
482                };
483                out.push_str(&format!(
484                    "  {} {:?} ({}tok){}\n",
485                    c.file_path, c.kind, c.token_count, refs
486                ));
487            }
488            out
489        }
490        Err(e) => format!("Error: {e}"),
491    }
492}
493
494/// Consolidate provider chunks into ALL long-term stores:
495///   1. Session cache (fast re-reads at ~13 tokens)
496///   2. BM25 index (searchable via ctx_semantic_search)
497///   3. Graph index (cross-source edges for ctx_read hints)
498///   4. Knowledge (extracted facts for ctx_knowledge)
499///
500/// Cache writes happen synchronously (fast). BM25/Graph/Knowledge
501/// writes happen in a background thread to avoid blocking the tool
502/// response — the "hippocampal sleep replay" pattern.
503fn consolidate_to_session(chunks: &[crate::core::content_chunk::ContentChunk], ctx: &ToolContext) {
504    if chunks.is_empty() {
505        return;
506    }
507
508    // #8 Immune × workspace-trust coupling: in an UNTRUSTED workspace, apply the
509    // strict immune screen to provider data before consolidation, dropping
510    // command/exfiltration and obfuscated payloads that the baseline screen
511    // (inside `consolidate`) intentionally tolerates for trusted contexts.
512    let trusted = crate::core::workspace_trust::is_trusted(std::path::Path::new(&ctx.project_root));
513    let strict_screened: Vec<crate::core::content_chunk::ContentChunk>;
514    let chunks: &[crate::core::content_chunk::ContentChunk] = if trusted {
515        chunks
516    } else {
517        strict_screened = chunks
518            .iter()
519            .filter(|c| {
520                if c.is_external()
521                    && let Some(reason) = crate::core::immune_detector::screen_strict(&c.content)
522                {
523                    tracing::warn!(
524                        target: "immune",
525                        "untrusted workspace: quarantined {} ({reason})",
526                        c.file_path
527                    );
528                    crate::core::introspect::tick("immune_detector");
529                    return false;
530                }
531                true
532            })
533            .cloned()
534            .collect();
535        &strict_screened
536    };
537
538    let artifacts = consolidation::consolidate(chunks);
539    if artifacts.is_empty() {
540        return;
541    }
542
543    // Phase 1: Session cache (synchronous, fast)
544    if let Some(cache_lock) = ctx.cache.as_ref()
545        && let Ok(mut cache) = cache_lock.try_write()
546    {
547        for entry in &artifacts.cache_entries {
548            cache.store(&entry.uri, &entry.content);
549        }
550    }
551
552    let external_count = artifacts
553        .bm25_chunks
554        .iter()
555        .filter(|c| c.is_external())
556        .count();
557    let edge_count = artifacts.edges.len();
558    let fact_count = artifacts.facts.len();
559    let cache_count = artifacts.cache_entries.len();
560
561    tracing::debug!(
562        "[ctx_provider] consolidated {} chunks → {} edges, {} facts, {} cached",
563        external_count,
564        edge_count,
565        fact_count,
566        cache_count,
567    );
568
569    // Phase 2: Deep indexing (background thread — BM25, Graph, Knowledge)
570    let cfg = crate::core::config::Config::load();
571    if !cfg.providers.auto_index {
572        return;
573    }
574
575    let project_root = ctx.project_root.clone();
576    std::thread::spawn(move || {
577        apply_artifacts_to_stores(&artifacts, &project_root);
578    });
579}
580
581/// Apply consolidation artifacts to BM25, Graph, and Knowledge stores.
582/// Called from a background thread after provider queries.
583pub fn apply_artifacts_to_stores(
584    artifacts: &consolidation::ConsolidationArtifacts,
585    project_root: &str,
586) {
587    let root_path = std::path::Path::new(project_root);
588
589    // BM25: load existing index, ingest provider chunks, save
590    if !artifacts.bm25_chunks.is_empty() {
591        let mut index = crate::core::bm25_index::BM25Index::load_or_build(root_path);
592        let ingested = index.ingest_content_chunks(artifacts.bm25_chunks.clone());
593        if ingested > 0 {
594            if let Err(e) = index.save(root_path) {
595                tracing::warn!("[ctx_provider] BM25 save failed: {e}");
596            } else {
597                tracing::info!("[ctx_provider] indexed {ingested} provider chunks into BM25");
598            }
599        }
600    }
601
602    // Cross-source edges → PropertyGraph (#682/#696): the property graph is the
603    // single authoritative store for the `ctx_read` cross-source hints. The
604    // legacy JSON graph_index write was removed with the graph_index teardown —
605    // reads go through the GraphProvider facade (PG), so a second JSON copy is
606    // pure redundant work.
607    if !artifacts.edges.is_empty() {
608        match crate::core::property_graph::CodeGraph::open(project_root) {
609            Ok(pg) => {
610                let mut added = 0usize;
611                for edge in &artifacts.edges {
612                    if pg
613                        .upsert_cross_source_edge(&edge.from, &edge.to, &edge.kind, edge.weight)
614                        .is_ok()
615                    {
616                        added += 1;
617                    }
618                }
619                tracing::info!("[ctx_provider] wrote {added} cross-source edges to property graph");
620            }
621            Err(e) => tracing::warn!("[ctx_provider] property graph open failed: {e}"),
622        }
623    }
624
625    // Knowledge: load or create, remember extracted facts, save
626    if !artifacts.facts.is_empty() {
627        let policy = crate::core::memory_policy::MemoryPolicy::default();
628        let mut knowledge = crate::core::knowledge::ProjectKnowledge::load(project_root)
629            .unwrap_or_else(|| crate::core::knowledge::ProjectKnowledge::new(project_root));
630
631        let session_id = format!("provider-ingest-{}", chrono::Utc::now().timestamp());
632        for fact in &artifacts.facts {
633            knowledge.remember(
634                &fact.category,
635                &fact.key,
636                &fact.value,
637                &session_id,
638                fact.confidence,
639                &policy,
640            );
641        }
642
643        if let Err(e) = knowledge.save() {
644            tracing::warn!("[ctx_provider] knowledge save failed: {e}");
645        } else {
646            tracing::info!(
647                "[ctx_provider] remembered {} facts from provider data",
648                artifacts.facts.len()
649            );
650        }
651    }
652}
653
654fn format_chunks_compact(
655    chunks: &[crate::core::content_chunk::ContentChunk],
656    provider_id: &str,
657    resource: &str,
658) -> String {
659    let mut out = format!("{} results from {provider_id}/{resource}:\n", chunks.len());
660    for c in chunks {
661        out.push_str(&format!(
662            "  #{} {}\n",
663            c.file_path.rsplit('/').next().unwrap_or("?"),
664            c.symbol_name
665        ));
666    }
667    out
668}
669
670// ---------------------------------------------------------------------------
671// Legacy GitLab handlers (unchanged)
672// ---------------------------------------------------------------------------
673
674fn handle_gitlab_issues(args: &serde_json::Map<String, serde_json::Value>) -> String {
675    let config = match GitLabConfig::from_env() {
676        Ok(c) => c,
677        Err(e) => return format!("Error: {e}"),
678    };
679    let state = args.get("state").and_then(|v| v.as_str());
680    let labels = args.get("labels").and_then(|v| v.as_str());
681    let limit = args
682        .get("limit")
683        .and_then(serde_json::Value::as_u64)
684        .map(|n| n as usize);
685
686    match gitlab::list_issues(&config, state, labels, limit) {
687        Ok(result) => format_result(&result),
688        Err(e) => format!("Error: {e}"),
689    }
690}
691
692fn handle_gitlab_issue(args: &serde_json::Map<String, serde_json::Value>) -> String {
693    let config = match GitLabConfig::from_env() {
694        Ok(c) => c,
695        Err(e) => return format!("Error: {e}"),
696    };
697    let iid = args
698        .get("iid")
699        .and_then(serde_json::Value::as_u64)
700        .unwrap_or(0);
701    if iid == 0 {
702        return "Error: iid is required for gitlab_issue".to_string();
703    }
704
705    match gitlab::show_issue(&config, iid) {
706        Ok(result) => format_result(&result),
707        Err(e) => format!("Error: {e}"),
708    }
709}
710
711fn handle_gitlab_mrs(args: &serde_json::Map<String, serde_json::Value>) -> String {
712    let config = match GitLabConfig::from_env() {
713        Ok(c) => c,
714        Err(e) => return format!("Error: {e}"),
715    };
716    let state = args.get("state").and_then(|v| v.as_str());
717    let limit = args
718        .get("limit")
719        .and_then(serde_json::Value::as_u64)
720        .map(|n| n as usize);
721
722    match gitlab::list_mrs(&config, state, limit) {
723        Ok(result) => format_result(&result),
724        Err(e) => format!("Error: {e}"),
725    }
726}
727
728fn handle_gitlab_pipelines(args: &serde_json::Map<String, serde_json::Value>) -> String {
729    let config = match GitLabConfig::from_env() {
730        Ok(c) => c,
731        Err(e) => return format!("Error: {e}"),
732    };
733    let status = args.get("status").and_then(|v| v.as_str());
734    let limit = args
735        .get("limit")
736        .and_then(serde_json::Value::as_u64)
737        .map(|n| n as usize);
738
739    match gitlab::list_pipelines(&config, status, limit) {
740        Ok(result) => format_result(&result),
741        Err(e) => format!("Error: {e}"),
742    }
743}
744
745fn format_result(result: &ProviderResult) -> String {
746    crate::core::redaction::redact_text_if_enabled(&result.format_compact())
747}