Skip to main content

devboy_executor/
factory.rs

1use devboy_core::{
2    Error, KnowledgeBaseProvider, MeetingNotesProvider, MessengerProvider, Provider, Result,
3    ToolEnricher,
4};
5
6use crate::context::{
7    ClickUpScope, ConfluenceAuthConfig, ConfluenceScope, GitHubScope, GitLabScope, JiraScope,
8    ProviderConfig, ProviderMetadata, ProxyConfig, SlackScope, TelegramScope,
9};
10
11/// Create a provider instance from a typed `ProviderConfig`.
12///
13/// Provider is created on the stack — cheap and stateless.
14/// The scope determines which project/repo/list is targeted.
15///
16/// # Unsupported scopes
17///
18/// Group, Organization, and Global scopes are not yet implemented.
19/// They will be added when cross-project queries are needed.
20pub fn create_provider(
21    config: &ProviderConfig,
22    proxy: Option<&ProxyConfig>,
23) -> Result<Box<dyn Provider>> {
24    match config {
25        ProviderConfig::GitLab {
26            base_url,
27            access_token,
28            scope,
29            ..
30        } => match scope {
31            GitLabScope::Project { id } => {
32                let client = if let Some(proxy) = proxy {
33                    devboy_gitlab::GitLabClient::with_base_url(
34                        &proxy.url,
35                        id,
36                        access_token.clone(),
37                    )
38                    .with_proxy(proxy.headers.clone())
39                } else {
40                    devboy_gitlab::GitLabClient::with_base_url(
41                        base_url,
42                        id,
43                        access_token.clone(),
44                    )
45                };
46                Ok(Box::new(client))
47            }
48            GitLabScope::Group { id } => Err(Error::ProviderUnsupported {
49                provider: "gitlab".into(),
50                operation: format!("group scope (group_id: {id}) not yet implemented"),
51            }),
52            GitLabScope::Global => Err(Error::ProviderUnsupported {
53                provider: "gitlab".into(),
54                operation: "global scope not yet implemented".into(),
55            }),
56        },
57
58        ProviderConfig::GitHub {
59            base_url,
60            access_token,
61            scope,
62            ..
63        } => match scope {
64            GitHubScope::Repository { owner, repo } => {
65                Ok(Box::new(devboy_github::GitHubClient::with_base_url(
66                    base_url,
67                    owner,
68                    repo,
69                    access_token.clone(),
70                )))
71            }
72            GitHubScope::Organization { name } => Err(Error::ProviderUnsupported {
73                provider: "github".into(),
74                operation: format!("organization scope (org: {name}) not yet implemented"),
75            }),
76            GitHubScope::Global => Err(Error::ProviderUnsupported {
77                provider: "github".into(),
78                operation: "global scope not yet implemented".into(),
79            }),
80        },
81
82        ProviderConfig::ClickUp {
83            access_token,
84            scope,
85            ..
86        } => match scope {
87            ClickUpScope::List { id, team_id } => {
88                let mut client = devboy_clickup::ClickUpClient::new(id, access_token.clone());
89                if let Some(tid) = team_id {
90                    client = client.with_team_id(tid);
91                }
92                Ok(Box::new(client))
93            }
94        },
95
96        ProviderConfig::Jira {
97            base_url,
98            access_token,
99            email,
100            scope,
101            flavor,
102            ..
103        } => match scope {
104            JiraScope::Project { key } => {
105                let mut client = if let Some(proxy) = proxy {
106                    devboy_jira::JiraClient::new(
107                        &proxy.url,
108                        key,
109                        email,
110                        access_token.clone(),
111                    )
112                    .with_proxy(proxy.headers.clone())
113                    .with_instance_url(base_url)
114                } else {
115                    devboy_jira::JiraClient::new(base_url, key, email, access_token.clone())
116                };
117                if let Some(f) = flavor {
118                    client = client.with_flavor(*f);
119                }
120                Ok(Box::new(client))
121            }
122            JiraScope::MultiProject { keys } => Err(Error::ProviderUnsupported {
123                provider: "jira".into(),
124                operation: format!(
125                    "multi-project scope ({}) not yet implemented",
126                    keys.join(", ")
127                ),
128            }),
129        },
130
131        ProviderConfig::Confluence { .. } => Err(Error::ProviderUnsupported {
132            provider: "confluence".into(),
133            operation: "Confluence is a KnowledgeBaseProvider, not a Provider. Use create_knowledge_base_provider() instead.".into(),
134        }),
135
136        ProviderConfig::Fireflies { .. } => Err(Error::ProviderUnsupported {
137            provider: "fireflies".into(),
138            operation: "Fireflies is a MeetingNotesProvider, not a Provider. Use create_meeting_notes_provider() instead.".into(),
139        }),
140
141        ProviderConfig::Slack { .. } => Err(Error::ProviderUnsupported {
142            provider: "slack".into(),
143            operation: "Slack is a MessengerProvider, not a Provider. Use create_messenger_provider() instead.".into(),
144        }),
145
146        ProviderConfig::Telegram { .. } => Err(Error::ProviderUnsupported {
147            provider: "telegram".into(),
148            operation: "Telegram is a MessengerProvider, not a Provider. Use create_messenger_provider() instead.".into(),
149        }),
150
151        ProviderConfig::Custom { name, .. } => Err(Error::ProviderNotFound(format!(
152            "custom provider '{name}' not yet supported"
153        ))),
154    }
155}
156
157pub fn create_knowledge_base_provider(
158    config: &ProviderConfig,
159    proxy: Option<&ProxyConfig>,
160) -> Result<Box<dyn KnowledgeBaseProvider>> {
161    match config {
162        ProviderConfig::Confluence {
163            base_url,
164            auth,
165            api_version,
166            scope: ConfluenceScope::Space { .. },
167            ..
168        } => {
169            let client = if let Some(proxy) = proxy {
170                devboy_confluence::ConfluenceClient::new(
171                    &proxy.url,
172                    devboy_confluence::ConfluenceAuth::None,
173                )
174                .with_api_version(api_version.as_deref())
175                .with_proxy(proxy.headers.clone())
176                // Browse links in `_links.webui` / `/pages/<id>` must
177                // point at the real Confluence host, not the proxy.
178                .with_instance_url(base_url)
179            } else {
180                devboy_confluence::ConfluenceClient::new(base_url, confluence_auth(auth))
181                    .with_api_version(api_version.as_deref())
182            };
183            Ok(Box::new(client))
184        }
185        other => Err(Error::ProviderUnsupported {
186            provider: other.provider_name().into(),
187            operation: "not a knowledge base provider".into(),
188        }),
189    }
190}
191
192/// Create the matching knowledge base enricher for a provider.
193///
194/// Knowledge-base-capable providers use a separate path from the regular
195/// issue/git repository enrichers because they implement
196/// `KnowledgeBaseProvider`, not `Provider`.
197pub fn create_knowledge_base_enricher(config: &ProviderConfig) -> Option<Box<dyn ToolEnricher>> {
198    match config {
199        ProviderConfig::Confluence { .. } => {
200            Some(Box::new(devboy_confluence::ConfluenceSchemaEnricher::new()))
201        }
202        _ => None,
203    }
204}
205
206/// Create a meeting notes provider from config.
207///
208/// Separate from `create_provider()` because meeting notes providers
209/// implement `MeetingNotesProvider`, not `Provider`.
210pub fn create_meeting_notes_provider(
211    config: &ProviderConfig,
212) -> Result<Box<dyn MeetingNotesProvider>> {
213    match config {
214        ProviderConfig::Fireflies { api_key, .. } => Ok(Box::new(
215            devboy_fireflies::FirefliesClient::new(api_key.clone()),
216        )),
217        other => Err(Error::ProviderUnsupported {
218            provider: other.provider_name().into(),
219            operation: "not a meeting notes provider".into(),
220        }),
221    }
222}
223
224pub fn create_messenger_provider(config: &ProviderConfig) -> Result<Box<dyn MessengerProvider>> {
225    match config {
226        ProviderConfig::Slack {
227            base_url,
228            access_token,
229            scope: SlackScope::Workspace { .. },
230            required_scopes,
231            ..
232        } => Ok(Box::new(
233            devboy_slack::SlackClient::new(access_token.clone())
234                .with_base_url(base_url)
235                .with_required_scopes(required_scopes.clone()),
236        )),
237        ProviderConfig::Telegram {
238            base_url,
239            access_token,
240            scope: TelegramScope::Bot { bot_username },
241            ..
242        } => Ok(Box::new(
243            devboy_telegram::TelegramClient::new(access_token.clone())
244                .with_base_url(base_url)
245                .with_bot_username(bot_username.clone()),
246        )),
247        other => Err(Error::ProviderUnsupported {
248            provider: other.provider_name().into(),
249            operation: "not a messenger provider".into(),
250        }),
251    }
252}
253
254/// Create the matching enricher for a provider.
255///
256/// Static providers (GitLab, GitHub) always return an enricher.
257/// Dynamic providers (ClickUp, Jira) require metadata — returns None if missing.
258///
259/// The metadata `data` field is deserialized to provider-specific types.
260pub fn create_enricher(
261    config: &ProviderConfig,
262    metadata: Option<&ProviderMetadata>,
263) -> Option<Box<dyn ToolEnricher>> {
264    match config {
265        ProviderConfig::GitLab { .. } => Some(Box::new(devboy_gitlab::GitLabSchemaEnricher)),
266        ProviderConfig::GitHub { .. } => Some(Box::new(devboy_github::GitHubSchemaEnricher)),
267        ProviderConfig::ClickUp { .. } => {
268            let meta = metadata?;
269            let clickup_meta: devboy_clickup::ClickUpMetadata =
270                serde_json::from_value(meta.data.clone()).ok()?;
271            Some(Box::new(devboy_clickup::ClickUpSchemaEnricher::new(
272                clickup_meta,
273            )))
274        }
275        ProviderConfig::Jira { .. } => {
276            let meta = metadata?;
277            let jira_meta: devboy_jira::JiraMetadata =
278                serde_json::from_value(meta.data.clone()).ok()?;
279            Some(Box::new(devboy_jira::JiraSchemaEnricher::new(jira_meta)))
280        }
281        ProviderConfig::Confluence { .. } => None,
282        ProviderConfig::Fireflies { .. } => {
283            Some(Box::new(devboy_fireflies::FirefliesSchemaEnricher))
284        }
285        ProviderConfig::Slack { .. } => None,
286        ProviderConfig::Telegram { .. } => None,
287        ProviderConfig::Custom { .. } => None,
288    }
289}
290
291fn confluence_auth(auth: &ConfluenceAuthConfig) -> devboy_confluence::ConfluenceAuth {
292    match auth {
293        ConfluenceAuthConfig::BearerToken { token } => {
294            devboy_confluence::ConfluenceAuth::BearerToken(token.clone())
295        }
296        ConfluenceAuthConfig::Basic { username, password } => {
297            devboy_confluence::ConfluenceAuth::Basic {
298                username: username.clone(),
299                password: password.clone(),
300            }
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::context::*;
309    use devboy_core::{IssueProvider, KnowledgeBaseProvider, MessengerProvider};
310    use httpmock::Method::GET;
311    use httpmock::MockServer;
312    use std::collections::HashMap;
313
314    #[test]
315    fn test_create_gitlab_project_provider() {
316        let config = ProviderConfig::GitLab {
317            base_url: "https://gitlab.com".into(),
318            access_token: "test-token".into(),
319            scope: GitLabScope::Project { id: "12345".into() },
320            extra: HashMap::new(),
321        };
322        let provider = create_provider(&config, None);
323        assert!(provider.is_ok());
324        assert_eq!(
325            IssueProvider::provider_name(provider.unwrap().as_ref()),
326            "gitlab"
327        );
328    }
329
330    #[test]
331    fn test_create_github_repo_provider() {
332        let config = ProviderConfig::GitHub {
333            base_url: "https://api.github.com".into(),
334            access_token: "ghp_test".into(),
335            scope: GitHubScope::Repository {
336                owner: "meteora-pro".into(),
337                repo: "devboy-tools".into(),
338            },
339            extra: HashMap::new(),
340        };
341        let provider = create_provider(&config, None);
342        assert!(provider.is_ok());
343        assert_eq!(
344            IssueProvider::provider_name(provider.unwrap().as_ref()),
345            "github"
346        );
347    }
348
349    #[test]
350    fn test_create_clickup_provider() {
351        let config = ProviderConfig::ClickUp {
352            access_token: "pk_test".into(),
353            scope: ClickUpScope::List {
354                id: "list123".into(),
355                team_id: Some("team456".into()),
356            },
357            extra: HashMap::new(),
358        };
359        let provider = create_provider(&config, None);
360        assert!(provider.is_ok());
361        assert_eq!(
362            IssueProvider::provider_name(provider.unwrap().as_ref()),
363            "clickup"
364        );
365    }
366
367    #[test]
368    fn test_create_telegram_provider() {
369        let config = ProviderConfig::Telegram {
370            base_url: "https://api.telegram.org".into(),
371            access_token: "bot-token".into(),
372            scope: TelegramScope::Bot {
373                bot_username: Some("devboy_bot".into()),
374            },
375            extra: HashMap::new(),
376        };
377        let provider = create_messenger_provider(&config);
378        assert!(provider.is_ok());
379        assert_eq!(
380            MessengerProvider::provider_name(provider.unwrap().as_ref()),
381            "telegram"
382        );
383    }
384
385    #[test]
386    fn test_create_jira_provider() {
387        let config = ProviderConfig::Jira {
388            base_url: "https://myorg.atlassian.net".into(),
389            access_token: "jira-token".into(),
390            email: "user@example.com".into(),
391            scope: JiraScope::Project { key: "PROJ".into() },
392            flavor: None,
393            extra: HashMap::new(),
394        };
395        let provider = create_provider(&config, None);
396        assert!(provider.is_ok());
397        assert_eq!(
398            IssueProvider::provider_name(provider.unwrap().as_ref()),
399            "jira"
400        );
401    }
402
403    #[test]
404    fn test_create_confluence_knowledge_base_provider() {
405        let config = ProviderConfig::Confluence {
406            base_url: "https://wiki.example.com".into(),
407            auth: ConfluenceAuthConfig::BearerToken {
408                token: "test-token".into(),
409            },
410            scope: ConfluenceScope::Space {
411                key: Some("ENG".into()),
412            },
413            api_version: Some("v1".into()),
414            extra: HashMap::new(),
415        };
416        let provider = create_knowledge_base_provider(&config, None);
417        assert!(provider.is_ok());
418        assert_eq!(
419            KnowledgeBaseProvider::provider_name(provider.unwrap().as_ref()),
420            "confluence"
421        );
422    }
423
424    #[test]
425    fn test_create_confluence_knowledge_base_enricher() {
426        let config = ProviderConfig::Confluence {
427            base_url: "https://wiki.example.com".into(),
428            auth: ConfluenceAuthConfig::BearerToken {
429                token: "test-token".into(),
430            },
431            scope: ConfluenceScope::Space {
432                key: Some("ENG".into()),
433            },
434            api_version: Some("v1".into()),
435            extra: HashMap::new(),
436        };
437        let enricher = create_knowledge_base_enricher(&config);
438        assert!(enricher.is_some());
439        assert_eq!(
440            enricher.unwrap().supported_categories(),
441            &[devboy_core::ToolCategory::KnowledgeBase]
442        );
443    }
444
445    #[test]
446    fn test_confluence_is_not_regular_provider() {
447        let config = ProviderConfig::Confluence {
448            base_url: "https://wiki.example.com".into(),
449            auth: ConfluenceAuthConfig::BearerToken {
450                token: "test-token".into(),
451            },
452            scope: ConfluenceScope::Space { key: None },
453            api_version: None,
454            extra: HashMap::new(),
455        };
456
457        let result = create_provider(&config, None);
458        assert!(matches!(
459            result,
460            Err(Error::ProviderUnsupported { provider, .. }) if provider == "confluence"
461        ));
462    }
463
464    #[test]
465    fn test_telegram_is_not_regular_provider() {
466        let config = ProviderConfig::Telegram {
467            base_url: "https://api.telegram.org".into(),
468            access_token: "bot-token".into(),
469            scope: TelegramScope::Bot {
470                bot_username: Some("devboy_bot".into()),
471            },
472            extra: HashMap::new(),
473        };
474
475        let result = create_provider(&config, None);
476        assert!(matches!(
477            result,
478            Err(Error::ProviderUnsupported { provider, .. }) if provider == "telegram"
479        ));
480    }
481
482    #[tokio::test]
483    async fn test_create_confluence_knowledge_base_provider_honors_api_version() {
484        let server = MockServer::start();
485        let mock = server.mock(|when, then| {
486            when.method(GET)
487                .path("/api/v2/space")
488                .query_param("limit", "100")
489                .query_param("type", "global,personal");
490            then.status(200)
491                .header("content-type", "application/json")
492                .body(r#"{"results":[],"start":0,"limit":100,"size":0,"_links":{}}"#);
493        });
494
495        let config = ProviderConfig::Confluence {
496            base_url: server.base_url(),
497            auth: ConfluenceAuthConfig::BearerToken {
498                token: "test-token".into(),
499            },
500            scope: ConfluenceScope::Space { key: None },
501            api_version: Some("v2".into()),
502            extra: HashMap::new(),
503        };
504
505        let provider = create_knowledge_base_provider(&config, None).unwrap();
506        let _ = provider.get_spaces().await.unwrap();
507
508        mock.assert();
509    }
510
511    #[test]
512    fn test_create_custom_provider_unsupported() {
513        let config = ProviderConfig::Custom {
514            name: "my-plugin".into(),
515            config: HashMap::new(),
516        };
517        let result = create_provider(&config, None);
518        assert!(result.is_err());
519    }
520
521    #[test]
522    fn test_gitlab_group_scope_unsupported() {
523        let config = ProviderConfig::GitLab {
524            base_url: "https://gitlab.com".into(),
525            access_token: "token".into(),
526            scope: GitLabScope::Group {
527                id: "group1".into(),
528            },
529            extra: HashMap::new(),
530        };
531        let result = create_provider(&config, None);
532        assert!(result.is_err());
533    }
534
535    #[test]
536    fn test_create_enricher_gitlab_static() {
537        let config = ProviderConfig::GitLab {
538            base_url: "https://gitlab.com".into(),
539            access_token: "token".into(),
540            scope: GitLabScope::Project { id: "123".into() },
541            extra: HashMap::new(),
542        };
543        let enricher = create_enricher(&config, None);
544        assert!(enricher.is_some());
545    }
546
547    #[test]
548    fn test_create_enricher_github_static() {
549        let config = ProviderConfig::GitHub {
550            base_url: "https://api.github.com".into(),
551            access_token: "token".into(),
552            scope: GitHubScope::Repository {
553                owner: "test".into(),
554                repo: "test".into(),
555            },
556            extra: HashMap::new(),
557        };
558        let enricher = create_enricher(&config, None);
559        assert!(enricher.is_some());
560    }
561
562    #[test]
563    fn test_create_enricher_clickup_needs_metadata() {
564        let config = ProviderConfig::ClickUp {
565            access_token: "token".into(),
566            scope: ClickUpScope::List {
567                id: "list1".into(),
568                team_id: None,
569            },
570            extra: HashMap::new(),
571        };
572        // No metadata → None
573        assert!(create_enricher(&config, None).is_none());
574
575        // With metadata → Some
576        let meta = ProviderMetadata::new(serde_json::json!({
577            "statuses": [{ "name": "To Do" }],
578            "custom_fields": []
579        }));
580        assert!(create_enricher(&config, Some(&meta)).is_some());
581    }
582
583    #[test]
584    fn test_create_enricher_jira_needs_metadata() {
585        let config = ProviderConfig::Jira {
586            base_url: "https://test.atlassian.net".into(),
587            access_token: "token".into(),
588            email: "test@test.com".into(),
589            scope: JiraScope::Project { key: "PROJ".into() },
590            flavor: None,
591            extra: HashMap::new(),
592        };
593        // No metadata → None
594        assert!(create_enricher(&config, None).is_none());
595
596        // With metadata → Some
597        let meta = ProviderMetadata::new(serde_json::json!({
598            "flavor": "cloud",
599            "projects": {
600                "PROJ": {
601                    "issue_types": [],
602                    "priorities": [],
603                    "components": [],
604                    "link_types": [],
605                    "custom_fields": []
606                }
607            }
608        }));
609        assert!(create_enricher(&config, Some(&meta)).is_some());
610    }
611
612    // --- Proxy tests ---
613
614    #[test]
615    fn test_create_gitlab_provider_with_proxy() {
616        let config = ProviderConfig::GitLab {
617            base_url: "https://gitlab.internal.com".into(),
618            access_token: "test-token".into(),
619            scope: GitLabScope::Project { id: "99".into() },
620            extra: HashMap::new(),
621        };
622        let mut headers = HashMap::new();
623        headers.insert("X-Proxy-Auth".into(), "proxy-secret".into());
624        let proxy = ProxyConfig {
625            url: "https://proxy.example.com/gitlab".into(),
626            headers,
627        };
628        let provider = create_provider(&config, Some(&proxy));
629        assert!(provider.is_ok());
630        assert_eq!(
631            IssueProvider::provider_name(provider.unwrap().as_ref()),
632            "gitlab"
633        );
634    }
635
636    #[test]
637    fn test_create_jira_provider_with_proxy() {
638        let config = ProviderConfig::Jira {
639            base_url: "https://jira.mycompany.com".into(),
640            access_token: "jira-token".into(),
641            email: "dev@mycompany.com".into(),
642            scope: JiraScope::Project { key: "DEV".into() },
643            flavor: None,
644            extra: HashMap::new(),
645        };
646        let mut headers = HashMap::new();
647        headers.insert("X-Proxy-Auth".into(), "secret".into());
648        headers.insert("X-Route".into(), "jira-dc".into());
649        let proxy = ProxyConfig {
650            url: "https://proxy.internal/jira".into(),
651            headers,
652        };
653        let provider = create_provider(&config, Some(&proxy));
654        assert!(provider.is_ok());
655        assert_eq!(
656            IssueProvider::provider_name(provider.unwrap().as_ref()),
657            "jira"
658        );
659    }
660
661    #[test]
662    fn test_create_jira_provider_with_flavor_cloud() {
663        let config = ProviderConfig::Jira {
664            base_url: "https://myorg.atlassian.net".into(),
665            access_token: "tok".into(),
666            email: "a@b.com".into(),
667            scope: JiraScope::Project { key: "CLD".into() },
668            flavor: Some(devboy_jira::JiraFlavor::Cloud),
669            extra: HashMap::new(),
670        };
671        let provider = create_provider(&config, None);
672        assert!(provider.is_ok());
673    }
674
675    #[test]
676    fn test_create_jira_provider_with_flavor_self_hosted() {
677        let config = ProviderConfig::Jira {
678            base_url: "https://jira.local".into(),
679            access_token: "tok".into(),
680            email: "a@b.com".into(),
681            scope: JiraScope::Project { key: "SH".into() },
682            flavor: Some(devboy_jira::JiraFlavor::SelfHosted),
683            extra: HashMap::new(),
684        };
685        let provider = create_provider(&config, None);
686        assert!(provider.is_ok());
687    }
688
689    #[test]
690    fn test_create_jira_provider_with_proxy_and_flavor_override() {
691        let config = ProviderConfig::Jira {
692            base_url: "https://jira.dc.mycompany.com".into(),
693            access_token: "tok".into(),
694            email: "a@b.com".into(),
695            scope: JiraScope::Project { key: "DC".into() },
696            flavor: Some(devboy_jira::JiraFlavor::SelfHosted),
697            extra: HashMap::new(),
698        };
699        let proxy = ProxyConfig {
700            url: "https://proxy.internal/jira".into(),
701            headers: HashMap::new(),
702        };
703        let provider = create_provider(&config, Some(&proxy));
704        assert!(provider.is_ok());
705    }
706
707    // --- Unsupported scope tests ---
708
709    #[test]
710    fn test_gitlab_global_scope_unsupported() {
711        let config = ProviderConfig::GitLab {
712            base_url: "https://gitlab.com".into(),
713            access_token: "tok".into(),
714            scope: GitLabScope::Global,
715            extra: HashMap::new(),
716        };
717        let result = create_provider(&config, None);
718        match result {
719            Err(e) => assert!(e.to_string().contains("global scope")),
720            Ok(_) => panic!("expected error for global scope"),
721        }
722    }
723
724    #[test]
725    fn test_github_organization_scope_unsupported() {
726        let config = ProviderConfig::GitHub {
727            base_url: "https://api.github.com".into(),
728            access_token: "tok".into(),
729            scope: GitHubScope::Organization {
730                name: "myorg".into(),
731            },
732            extra: HashMap::new(),
733        };
734        let result = create_provider(&config, None);
735        match result {
736            Err(e) => assert!(e.to_string().contains("organization scope")),
737            Ok(_) => panic!("expected error for organization scope"),
738        }
739    }
740
741    #[test]
742    fn test_github_global_scope_unsupported() {
743        let config = ProviderConfig::GitHub {
744            base_url: "https://api.github.com".into(),
745            access_token: "tok".into(),
746            scope: GitHubScope::Global,
747            extra: HashMap::new(),
748        };
749        let result = create_provider(&config, None);
750        assert!(result.is_err());
751    }
752
753    #[test]
754    fn test_jira_multi_project_scope_unsupported() {
755        let config = ProviderConfig::Jira {
756            base_url: "https://test.atlassian.net".into(),
757            access_token: "tok".into(),
758            email: "a@b.com".into(),
759            scope: JiraScope::MultiProject {
760                keys: vec!["A".into(), "B".into()],
761            },
762            flavor: None,
763            extra: HashMap::new(),
764        };
765        let result = create_provider(&config, None);
766        match result {
767            Err(e) => assert!(e.to_string().contains("multi-project")),
768            Ok(_) => panic!("expected error for multi-project scope"),
769        }
770    }
771
772    // --- ClickUp without team_id ---
773
774    #[test]
775    fn test_create_clickup_provider_without_team_id() {
776        let config = ProviderConfig::ClickUp {
777            access_token: "pk_test".into(),
778            scope: ClickUpScope::List {
779                id: "list999".into(),
780                team_id: None,
781            },
782            extra: HashMap::new(),
783        };
784        let provider = create_provider(&config, None);
785        assert!(provider.is_ok());
786        assert_eq!(
787            IssueProvider::provider_name(provider.unwrap().as_ref()),
788            "clickup"
789        );
790    }
791
792    // --- Enricher for Custom is None ---
793
794    #[test]
795    fn test_create_enricher_custom_returns_none() {
796        let config = ProviderConfig::Custom {
797            name: "custom-plugin".into(),
798            config: HashMap::new(),
799        };
800        assert!(create_enricher(&config, None).is_none());
801    }
802
803    #[test]
804    fn test_create_enricher_telegram_returns_none() {
805        let config = ProviderConfig::Telegram {
806            base_url: "https://api.telegram.org".into(),
807            access_token: "bot-token".into(),
808            scope: TelegramScope::Bot {
809                bot_username: Some("devboy_bot".into()),
810            },
811            extra: HashMap::new(),
812        };
813        assert!(create_enricher(&config, None).is_none());
814    }
815
816    #[test]
817    fn test_create_knowledge_base_enricher_non_kb_returns_none() {
818        let config = ProviderConfig::GitHub {
819            base_url: "https://api.github.com".into(),
820            access_token: "tok".into(),
821            scope: GitHubScope::Repository {
822                owner: "test".into(),
823                repo: "test".into(),
824            },
825            extra: HashMap::new(),
826        };
827        assert!(create_knowledge_base_enricher(&config).is_none());
828    }
829
830    // --- Enricher with invalid metadata ---
831
832    #[test]
833    fn test_create_enricher_clickup_invalid_metadata_returns_none() {
834        let config = ProviderConfig::ClickUp {
835            access_token: "token".into(),
836            scope: ClickUpScope::List {
837                id: "list1".into(),
838                team_id: None,
839            },
840            extra: HashMap::new(),
841        };
842        let meta = ProviderMetadata::new(serde_json::json!("invalid_data"));
843        assert!(create_enricher(&config, Some(&meta)).is_none());
844    }
845
846    #[test]
847    fn test_create_enricher_jira_invalid_metadata_returns_none() {
848        let config = ProviderConfig::Jira {
849            base_url: "https://test.atlassian.net".into(),
850            access_token: "token".into(),
851            email: "a@b.com".into(),
852            scope: JiraScope::Project { key: "X".into() },
853            flavor: None,
854            extra: HashMap::new(),
855        };
856        let meta = ProviderMetadata::new(serde_json::json!("not_valid"));
857        assert!(create_enricher(&config, Some(&meta)).is_none());
858    }
859}