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