1use std::sync::{Arc, Mutex};
4
5use tokio::sync::watch;
6
7use crate::config::*;
8use crate::domain::Section;
9use crate::error::{Error, Result};
10use crate::provider::*;
11use crate::secret::SecretStore;
12
13pub struct ConfigService {
17 store: Arc<dyn ConfigStore>,
18 secrets: Arc<dyn SecretStore>,
19 registry: Arc<ProviderRegistry>,
20 config: Mutex<ForgetopConfig>,
21 data_changed: watch::Sender<u64>,
26}
27
28impl ConfigService {
29 pub fn new(store: Arc<dyn ConfigStore>, secrets: Arc<dyn SecretStore>, registry: Arc<ProviderRegistry>) -> Self {
30 Self {
31 store,
32 secrets,
33 registry,
34 config: Mutex::new(ForgetopConfig::default()),
35 data_changed: watch::channel(0).0,
36 }
37 }
38
39 pub async fn load(&self) -> Result<()> {
40 let cfg = self.store.load().await?;
41 *self.config.lock().unwrap() = cfg;
42 Ok(())
43 }
44
45 pub fn snapshot(&self) -> ForgetopConfig {
46 self.config.lock().unwrap().clone()
47 }
48
49 async fn persist(&self, cfg: ForgetopConfig) -> Result<()> {
50 self.store.save(&cfg).await?;
51 let fetch_changed = {
52 let mut current = self.config.lock().unwrap();
53 let changed = !same_fetch_inputs(¤t, &cfg);
54 *current = cfg;
55 changed
56 };
57 if fetch_changed {
60 self.data_changed.send_modify(|generation| *generation += 1);
61 }
62 Ok(())
63 }
64
65 pub fn subscribe_data_changed(&self) -> watch::Receiver<u64> {
69 self.data_changed.subscribe()
70 }
71
72 fn ensure_supports(&self, cfg: &ForgetopConfig, connection_id: &str, section: Section) -> Result<()> {
73 let conn = cfg
74 .find_connection(connection_id)
75 .ok_or_else(|| Error::Config(format!("unknown connection '{connection_id}'")))?;
76 let caps = self
77 .registry
78 .describe(conn.provider_type)
79 .ok_or_else(|| Error::Config(format!("provider '{}' not registered", conn.provider_type.as_str())))?;
80 if !caps.supports(section) {
81 return Err(Error::Config(format!(
82 "connection '{}' ({}) does not support {section:?}",
83 conn.display_name,
84 conn.provider_type.as_str()
85 )));
86 }
87 Ok(())
88 }
89
90 pub async fn add_or_update_connection(&self, mut connection: Connection, secret: Option<String>) -> Result<()> {
91 let cred = connection.credential_ref.clone().unwrap_or_else(|| connection.id.clone());
92 connection.credential_ref = Some(cred.clone());
93
94 if let Some(secret) = secret {
95 if !self.secrets.is_writable() {
96 return Err(Error::Config(
97 "secret store is read-only; provide the PAT via environment variable instead".into(),
98 ));
99 }
100 self.secrets.set(&cred, &secret)?;
101 }
102
103 let mut cfg = self.snapshot();
104 cfg.connections.retain(|c| c.id != connection.id);
105 cfg.connections.push(connection);
106 self.persist(cfg).await
107 }
108
109 pub async fn remove_connection(&self, connection_id: &str) -> Result<()> {
110 let mut cfg = self.snapshot();
111 if let Some(existing) = cfg.find_connection(connection_id).cloned() {
112 if let (Some(cred), true) = (existing.credential_ref.as_ref(), self.secrets.is_writable()) {
113 let _ = self.secrets.delete(cred);
114 }
115 }
116 cfg.connections.retain(|c| c.id != connection_id);
117 if let Some(b) = &mut cfg.pull_requests {
118 b.connection_ids.retain(|c| c != connection_id);
119 b.connection_id = b.connection_id.take().filter(|c| c != connection_id);
120 if b.ids().is_empty() {
121 cfg.pull_requests = None;
122 }
123 }
124 if let Some(b) = &mut cfg.work_items {
125 b.connection_ids.retain(|c| c != connection_id);
126 b.connection_id = b.connection_id.take().filter(|c| c != connection_id);
127 if b.ids().is_empty() {
128 cfg.work_items = None;
129 }
130 }
131 if let Some(p) = &mut cfg.pipelines {
132 p.subscriptions.retain(|s| s.connection_id != connection_id);
133 }
134 self.persist(cfg).await
135 }
136
137 pub async fn bind_pull_requests(&self, connection_id: &str) -> Result<()> {
138 let mut cfg = self.snapshot();
139 self.ensure_supports(&cfg, connection_id, Section::PullRequests)?;
140 let b = cfg.pull_requests.get_or_insert_with(PullRequestBinding::default);
141 let mut ids = b.ids();
142 if !ids.contains(&connection_id.to_string()) {
143 ids.push(connection_id.into());
144 }
145 *b = PullRequestBinding { connection_ids: ids, connection_id: None };
146 self.persist(cfg).await
147 }
148
149 pub async fn bind_work_items(&self, connection_id: &str) -> Result<()> {
150 let mut cfg = self.snapshot();
151 self.ensure_supports(&cfg, connection_id, Section::WorkItems)?;
152 let b = cfg.work_items.get_or_insert_with(WorkItemBinding::default);
153 let mut ids = b.ids();
154 if !ids.contains(&connection_id.to_string()) {
155 ids.push(connection_id.into());
156 }
157 *b = WorkItemBinding { connection_ids: ids, connection_id: None };
158 self.persist(cfg).await
159 }
160
161 pub async fn unbind_pull_requests(&self, connection_id: &str) -> Result<()> {
163 let mut cfg = self.snapshot();
164 if let Some(b) = &mut cfg.pull_requests {
165 let ids: Vec<String> = b.ids().into_iter().filter(|c| c != connection_id).collect();
166 cfg.pull_requests = (!ids.is_empty()).then_some(PullRequestBinding { connection_ids: ids, connection_id: None });
167 }
168 self.persist(cfg).await
169 }
170
171 pub async fn unbind_work_items(&self, connection_id: &str) -> Result<()> {
173 let mut cfg = self.snapshot();
174 if let Some(b) = &mut cfg.work_items {
175 let ids: Vec<String> = b.ids().into_iter().filter(|c| c != connection_id).collect();
176 cfg.work_items = (!ids.is_empty()).then_some(WorkItemBinding { connection_ids: ids, connection_id: None });
177 }
178 self.persist(cfg).await
179 }
180
181 pub async fn unbind_pipelines(&self, connection_id: &str) -> Result<()> {
183 let mut cfg = self.snapshot();
184 if let Some(p) = &mut cfg.pipelines {
185 p.subscriptions.retain(|s| s.connection_id != connection_id);
186 if p.subscriptions.is_empty() {
187 cfg.pipelines = None;
188 }
189 }
190 self.persist(cfg).await
191 }
192
193 pub async fn set_pull_request_connections(&self, connection_ids: Vec<String>) -> Result<()> {
195 let mut cfg = self.snapshot();
196 cfg.pull_requests = Some(PullRequestBinding { connection_ids, connection_id: None });
197 self.persist(cfg).await
198 }
199
200 pub async fn set_work_item_connections(&self, connection_ids: Vec<String>) -> Result<()> {
201 let mut cfg = self.snapshot();
202 cfg.work_items = Some(WorkItemBinding { connection_ids, connection_id: None });
203 self.persist(cfg).await
204 }
205
206 pub async fn unbind_section(&self, section: Section) -> Result<()> {
207 let mut cfg = self.snapshot();
208 match section {
209 Section::PullRequests => cfg.pull_requests = None,
210 Section::WorkItems => cfg.work_items = None,
211 Section::Pipelines => cfg.pipelines = None,
212 }
213 self.persist(cfg).await
214 }
215
216 async fn mutate_subscription(&self, connection_id: &str, f: impl FnOnce(&mut PipelineSubscription)) -> Result<()> {
217 let mut cfg = self.snapshot();
218 self.ensure_supports(&cfg, connection_id, Section::Pipelines)?;
219 let binding = cfg.pipelines.get_or_insert_with(PipelineBinding::default);
220 let sub = match binding.subscriptions.iter_mut().find(|s| s.connection_id == connection_id) {
221 Some(existing) => existing,
222 None => {
223 binding.subscriptions.push(PipelineSubscription {
224 connection_id: connection_id.into(),
225 definition_ids: Vec::new(),
226 auto_discover_all: false,
227 });
228 binding.subscriptions.last_mut().unwrap()
229 }
230 };
231 f(sub);
232 self.persist(cfg).await
233 }
234
235 pub async fn subscribe_pipeline(&self, connection_id: &str, definition_id: &str) -> Result<()> {
236 let definition_id = definition_id.to_string();
237 self.mutate_subscription(connection_id, move |s| {
238 if !s.definition_ids.contains(&definition_id) {
239 s.definition_ids.push(definition_id);
240 }
241 })
242 .await
243 }
244
245 pub async fn set_pipeline_auto_discover(&self, connection_id: &str, auto_discover_all: bool) -> Result<()> {
246 self.mutate_subscription(connection_id, move |s| s.auto_discover_all = auto_discover_all).await
247 }
248
249 pub async fn unsubscribe_pipeline(&self, connection_id: &str, definition_id: &str) -> Result<()> {
250 self.mutate_subscription(connection_id, move |s| s.definition_ids.retain(|d| d != definition_id)).await
251 }
252
253 pub async fn remove_pipeline_connection(&self, connection_id: &str) -> Result<()> {
254 let mut cfg = self.snapshot();
255 if let Some(p) = &mut cfg.pipelines {
256 p.subscriptions.retain(|s| s.connection_id != connection_id);
257 }
258 self.persist(cfg).await
259 }
260
261 pub async fn set_repo_scope(&self, connection_id: &str, scope: Option<Vec<String>>) -> Result<()> {
265 let mut cfg = self.snapshot();
266 let conn = cfg
267 .connections
268 .iter_mut()
269 .find(|c| c.id == connection_id)
270 .ok_or_else(|| Error::Config(format!("unknown connection '{connection_id}'")))?;
271 conn.repo_scope = scope;
272 self.persist(cfg).await
273 }
274
275 pub fn repo_scope(&self, connection_id: &str) -> Option<Vec<String>> {
278 self.snapshot().find_connection(connection_id).and_then(|c| c.repo_scope.clone())
279 }
280
281 pub async fn set_theme(&self, theme: Option<String>) -> Result<()> {
282 let mut cfg = self.snapshot();
283 cfg.ui.theme = theme;
284 self.persist(cfg).await
285 }
286
287 pub async fn set_hidden_sections(&self, hidden: Vec<Section>) -> Result<()> {
288 let mut cfg = self.snapshot();
289 cfg.ui.hidden_sections = hidden;
290 self.persist(cfg).await
291 }
292
293 pub async fn set_preview_hidden(&self, hidden: Vec<Section>) -> Result<()> {
294 let mut cfg = self.snapshot();
295 cfg.ui.preview_hidden = hidden;
296 self.persist(cfg).await
297 }
298
299 pub async fn set_hidden_work_item_states(&self, hidden: Vec<String>) -> Result<()> {
300 let mut cfg = self.snapshot();
301 cfg.ui.hidden_work_item_states = hidden;
302 self.persist(cfg).await
303 }
304
305 pub async fn set_dismissed_launchpad_items(&self, dismissed: Vec<String>) -> Result<()> {
306 let mut cfg = self.snapshot();
307 cfg.ui.dismissed_launchpad_items = dismissed;
308 self.persist(cfg).await
309 }
310
311 pub async fn set_dismissed_notifications(&self, dismissed: Vec<String>) -> Result<()> {
312 let mut cfg = self.snapshot();
313 cfg.ui.dismissed_notifications = dismissed;
314 self.persist(cfg).await
315 }
316
317 pub async fn set_notifications(&self, prefs: NotificationPrefs) -> Result<()> {
318 let mut cfg = self.snapshot();
319 cfg.ui.notifications = prefs;
320 self.persist(cfg).await
321 }
322
323 pub async fn set_views(&self, section: Section, views: Vec<SavedView>) -> Result<()> {
325 let mut cfg = self.snapshot();
326 match section {
327 Section::PullRequests => cfg.ui.pr_views = views,
328 Section::WorkItems => cfg.ui.work_item_views = views,
329 Section::Pipelines => cfg.ui.pipeline_views = views,
330 }
331 self.persist(cfg).await
332 }
333
334 pub async fn set_sort(&self, section: Section, sort: Option<SortPref>) -> Result<()> {
336 let mut cfg = self.snapshot();
337 match section {
338 Section::PullRequests => cfg.ui.pr_sort = sort,
339 Section::WorkItems => cfg.ui.work_item_sort = sort,
340 Section::Pipelines => cfg.ui.pipeline_sort = sort,
341 }
342 self.persist(cfg).await
343 }
344
345 pub async fn set_pipeline_group(&self, group: Option<String>) -> Result<()> {
347 let mut cfg = self.snapshot();
348 cfg.ui.pipeline_group = group;
349 self.persist(cfg).await
350 }
351
352 pub async fn set_pipeline_definitions(&self, connection_id: &str, definition_ids: Vec<String>) -> Result<()> {
355 self.mutate_subscription(connection_id, move |s| {
356 s.auto_discover_all = false;
357 s.definition_ids = definition_ids;
358 })
359 .await
360 }
361}
362
363pub struct ConnectionResolver {
365 config: Arc<ConfigService>,
366 registry: Arc<ProviderRegistry>,
367 secrets: Arc<dyn SecretStore>,
368}
369
370impl ConnectionResolver {
371 pub fn new(config: Arc<ConfigService>, registry: Arc<ProviderRegistry>, secrets: Arc<dyn SecretStore>) -> Self {
372 Self { config, registry, secrets }
373 }
374
375 pub async fn resolve(&self, connection_id: &str) -> Result<Option<Arc<dyn ProviderConnection>>> {
376 let cfg = self.config.snapshot();
377 let conn = match cfg.find_connection(connection_id) {
378 Some(c) => c.clone(),
379 None => return Ok(None),
380 };
381 if !self.registry.supports(conn.provider_type) {
382 return Ok(None);
383 }
384 let secret = match &conn.credential_ref {
385 Some(r) => self.secrets.get(r)?,
386 None => None,
387 };
388 Ok(Some(self.registry.create(&conn, secret)?))
389 }
390}
391
392pub struct PipelineFeed {
394 pub connection: Arc<dyn ProviderConnection>,
395 pub source: Arc<dyn PipelineSource>,
396 pub subscription: PipelineSubscription,
397}
398
399pub struct PullRequestFeed {
401 pub connection: Arc<dyn ProviderConnection>,
402 pub source: Arc<dyn PullRequestSource>,
403}
404
405pub struct WorkItemFeed {
407 pub connection: Arc<dyn ProviderConnection>,
408 pub source: Arc<dyn WorkItemSource>,
409}
410
411pub struct NotificationFeed {
414 pub connection: Arc<dyn ProviderConnection>,
415 pub source: Arc<dyn NotificationSource>,
416}
417
418pub struct SectionService {
420 config: Arc<ConfigService>,
421 resolver: Arc<ConnectionResolver>,
422}
423
424impl SectionService {
425 pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
426 Self { config, resolver }
427 }
428
429 pub async fn pull_request_feeds(&self) -> Result<Vec<PullRequestFeed>> {
431 let cfg = self.config.snapshot();
432 let Some(binding) = cfg.pull_requests else { return Ok(Vec::new()) };
433 let mut feeds = Vec::new();
434 for id in binding.ids() {
435 if let Some(conn) = self.resolver.resolve(&id).await? {
436 if let Some(source) = conn.pull_requests() {
437 feeds.push(PullRequestFeed { connection: conn, source });
438 }
439 }
440 }
441 Ok(feeds)
442 }
443
444 pub async fn work_item_feeds(&self) -> Result<Vec<WorkItemFeed>> {
445 let cfg = self.config.snapshot();
446 let Some(binding) = cfg.work_items else { return Ok(Vec::new()) };
447 let mut feeds = Vec::new();
448 for id in binding.ids() {
449 if let Some(conn) = self.resolver.resolve(&id).await? {
450 if let Some(source) = conn.work_items() {
451 feeds.push(WorkItemFeed { connection: conn, source });
452 }
453 }
454 }
455 Ok(feeds)
456 }
457
458 pub async fn notification_feeds(&self) -> Result<Vec<NotificationFeed>> {
461 let cfg = self.config.snapshot();
462 let mut feeds = Vec::new();
463 for c in &cfg.connections {
464 if let Some(conn) = self.resolver.resolve(&c.id).await? {
465 if let Some(source) = conn.notifications() {
466 feeds.push(NotificationFeed { connection: conn, source });
467 }
468 }
469 }
470 Ok(feeds)
471 }
472
473 pub async fn pull_request_source(&self) -> Result<Option<Arc<dyn PullRequestSource>>> {
475 Ok(self.pull_request_feeds().await?.into_iter().next().map(|f| f.source))
476 }
477
478 pub async fn work_item_source(&self) -> Result<Option<Arc<dyn WorkItemSource>>> {
479 Ok(self.work_item_feeds().await?.into_iter().next().map(|f| f.source))
480 }
481
482 pub async fn pipeline_source_for(&self, connection_id: &str) -> Result<Option<Arc<dyn PipelineSource>>> {
485 Ok(self.resolver.resolve(connection_id).await?.and_then(|c| c.pipelines()))
486 }
487
488 pub async fn discover_repositories(&self, connection_id: &str) -> Result<RepositoryPage> {
490 match self.resolver.resolve(connection_id).await? {
491 Some(conn) => conn.discover_repositories().await,
492 None => Ok(RepositoryPage::default()),
493 }
494 }
495
496 pub async fn pipeline_feeds(&self) -> Result<Vec<PipelineFeed>> {
497 let cfg = self.config.snapshot();
498 let Some(binding) = cfg.pipelines else { return Ok(Vec::new()) };
499 let mut feeds = Vec::new();
500 for sub in binding.subscriptions {
501 if let Some(conn) = self.resolver.resolve(&sub.connection_id).await? {
502 if let Some(source) = conn.pipelines() {
503 feeds.push(PipelineFeed { connection: conn, source, subscription: sub });
504 }
505 }
506 }
507 Ok(feeds)
508 }
509}
510
511pub const DEFAULT_REPO_SCOPE_SIZE: usize = 5;
515
516pub async fn seed_default_repo_scope(
524 config: &ConfigService,
525 sections: &SectionService,
526 connection_id: &str,
527) -> Result<Option<Vec<String>>> {
528 let Some(conn) = config.snapshot().find_connection(connection_id).cloned() else { return Ok(None) };
529 if conn.repo_scope.is_some() || conn.repository.is_some() {
530 return Ok(None);
531 }
532 let page = sections.discover_repositories(connection_id).await.unwrap_or_default();
533 if page.repositories.is_empty() {
534 return Ok(None);
535 }
536 let scope: Vec<String> = page.repositories.into_iter().take(DEFAULT_REPO_SCOPE_SIZE).collect();
537 config.set_repo_scope(connection_id, Some(scope.clone())).await?;
538 Ok(Some(scope))
539}
540
541pub struct ConnectionHealth {
543 pub connection: Connection,
544 pub healthy: bool,
545}
546
547pub struct ConnectionHealthService {
549 config: Arc<ConfigService>,
550 resolver: Arc<ConnectionResolver>,
551}
552
553impl ConnectionHealthService {
554 pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
555 Self { config, resolver }
556 }
557
558 pub async fn check_all(&self) -> Vec<ConnectionHealth> {
559 let cfg = self.config.snapshot();
560 let mut out = Vec::new();
561 for connection in cfg.connections {
562 let healthy = match self.resolver.resolve(&connection.id).await {
563 Ok(Some(live)) => live.check().await,
564 _ => false,
565 };
566 out.push(ConnectionHealth { connection, healthy });
567 }
568 out
569 }
570}
571
572
573fn same_fetch_inputs(a: &ForgetopConfig, b: &ForgetopConfig) -> bool {
577 a.connections == b.connections
578 && a.pull_requests == b.pull_requests
579 && a.work_items == b.work_items
580 && a.pipelines == b.pipelines
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use crate::domain::ProviderType;
587 use crate::secret::InMemorySecretStore;
588 use async_trait::async_trait;
589
590 struct FakeConn {
591 caps: Capabilities,
592 }
593 #[async_trait]
594 impl ProviderConnection for FakeConn {
595 fn connection_id(&self) -> &str { "fake" }
596 fn provider_type(&self) -> ProviderType { ProviderType::GitHub }
597 fn display_name(&self) -> &str { "Fake" }
598 fn capabilities(&self) -> &Capabilities { &self.caps }
599 fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> { None }
600 fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> { None }
601 fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> { None }
602 async fn check(&self) -> bool { true }
603 }
604 struct FakeFactory {
605 provider: ProviderType,
606 caps: Capabilities,
607 }
608 impl ProviderFactory for FakeFactory {
609 fn provider_type(&self) -> ProviderType { self.provider }
610 fn describe_capabilities(&self) -> Capabilities { self.caps.clone() }
611 fn create(&self, _c: &Connection, _s: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
612 Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
613 }
614 }
615
616 fn registry() -> Arc<ProviderRegistry> {
617 Arc::new(ProviderRegistry::new(vec![
618 Arc::new(FakeFactory {
619 provider: ProviderType::GitHub,
620 caps: Capabilities { supports_pull_requests: true, supports_pipelines: true, ..Default::default() },
621 }),
622 Arc::new(FakeFactory {
623 provider: ProviderType::Linear,
624 caps: Capabilities { supports_work_items: true, ..Default::default() },
625 }),
626 ]))
627 }
628
629 fn conn(id: &str, provider: ProviderType) -> Connection {
630 Connection {
631 id: id.into(),
632 provider_type: provider,
633 display_name: id.into(),
634 base_url: None,
635 organization: None,
636 project: None,
637 repository: None,
638 username: None,
639 credential_ref: None,
640 repo_scope: None,
641 }
642 }
643
644 fn service() -> (Arc<ConfigService>, Arc<InMemorySecretStore>) {
645 let secrets = Arc::new(InMemorySecretStore::default());
646 let svc = Arc::new(ConfigService::new(
647 Arc::new(InMemoryConfigStore::default()),
648 secrets.clone(),
649 registry(),
650 ));
651 (svc, secrets)
652 }
653
654 #[tokio::test]
655 async fn add_connection_persists_and_stores_secret() {
656 let (svc, secrets) = service();
657 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
658 assert_eq!(svc.snapshot().connections.len(), 1);
659 assert_eq!(secrets.get("gh-1").unwrap().as_deref(), Some("pat"));
660 }
661
662 #[tokio::test]
663 async fn connection_and_binding_changes_notify_subscribers() {
664 let (svc, _) = service();
665 let mut rx = svc.subscribe_data_changed();
666
667 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
668 assert!(rx.has_changed().unwrap(), "adding a connection must wake the TUI");
669 rx.borrow_and_update();
670
671 svc.bind_pull_requests("gh-1").await.unwrap();
672 assert!(rx.has_changed().unwrap(), "binding a section must wake the TUI");
673 rx.borrow_and_update();
674
675 svc.remove_connection("gh-1").await.unwrap();
676 assert!(rx.has_changed().unwrap(), "removing a connection must wake the TUI");
677 }
678
679 #[tokio::test]
680 async fn ui_preferences_do_not_notify_subscribers() {
681 let (svc, _) = service();
682 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
683 let rx = svc.subscribe_data_changed();
684
685 svc.set_theme(Some("slate".into())).await.unwrap();
688 svc.set_hidden_sections(vec![Section::Pipelines]).await.unwrap();
689 svc.set_notifications(NotificationPrefs::default()).await.unwrap();
690
691 assert!(!rx.has_changed().unwrap(), "a UI preference must not trigger a refetch");
692 }
693
694 #[tokio::test]
695 async fn rewriting_an_identical_connection_does_not_notify() {
696 let (svc, _) = service();
697 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
698 let rx = svc.subscribe_data_changed();
699
700 let mut same = conn("gh-1", ProviderType::GitHub);
702 same.credential_ref = Some("gh-1".into());
703 svc.add_or_update_connection(same, None).await.unwrap();
704
705 assert!(!rx.has_changed().unwrap(), "an unchanged rewrite must not trigger a refetch");
706 }
707
708 #[tokio::test]
709 async fn bind_validates_capability() {
710 let (svc, _) = service();
711 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
712 svc.add_or_update_connection(conn("lin-1", ProviderType::Linear), None).await.unwrap();
713 svc.bind_pull_requests("gh-1").await.unwrap();
714 assert_eq!(svc.snapshot().pull_requests.unwrap().ids(), vec!["gh-1".to_string()]);
715 assert!(svc.bind_pull_requests("lin-1").await.is_err());
716 }
717
718 #[tokio::test]
719 async fn remove_connection_cascades() {
720 let (svc, secrets) = service();
721 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
722 svc.bind_pull_requests("gh-1").await.unwrap();
723 svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
724 svc.remove_connection("gh-1").await.unwrap();
725 let cfg = svc.snapshot();
726 assert!(cfg.connections.is_empty());
727 assert!(cfg.pull_requests.is_none());
728 assert!(cfg.pipelines.unwrap().subscriptions.is_empty());
729 assert_eq!(secrets.get("gh-1").unwrap(), None);
730 }
731
732 #[tokio::test]
733 async fn repo_scope_persists_including_the_empty_choice() {
734 let (svc, _) = service();
735 let mut c = conn("gh-1", ProviderType::GitHub);
736 c.repository = Some("acme/pay".into());
737 svc.add_or_update_connection(c, None).await.unwrap();
738
739 assert_eq!(svc.repo_scope("gh-1"), None);
741
742 svc.set_repo_scope("gh-1", Some(vec!["acme/pay".into(), "acme/ledger".into()])).await.unwrap();
743 assert_eq!(svc.repo_scope("gh-1").unwrap().len(), 2);
744
745 svc.set_repo_scope("gh-1", Some(vec![])).await.unwrap();
747 assert_eq!(svc.repo_scope("gh-1"), Some(vec![]));
748 svc.load().await.unwrap();
749 assert_eq!(svc.repo_scope("gh-1"), Some(vec![]), "an emptied scope survives a reload");
750
751 assert!(svc.set_repo_scope("nope", None).await.is_err());
752 }
753
754 #[tokio::test]
755 async fn pipeline_subscribe_unsubscribe() {
756 let (svc, _) = service();
757 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
758 svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
759 svc.subscribe_pipeline("gh-1", "release").await.unwrap();
760 svc.subscribe_pipeline("gh-1", "ci").await.unwrap(); assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 2);
762 svc.unsubscribe_pipeline("gh-1", "ci").await.unwrap();
763 assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 1);
764 }
765
766 #[tokio::test]
767 async fn set_pipeline_definitions_replaces_and_disables_auto() {
768 let (svc, _) = service();
769 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
770 svc.set_pipeline_auto_discover("gh-1", true).await.unwrap();
771 svc.set_pipeline_definitions("gh-1", vec!["ci".into(), "release".into()]).await.unwrap();
772 let cfg = svc.snapshot();
773 let sub = &cfg.pipelines.unwrap().subscriptions[0];
774 assert!(!sub.auto_discover_all);
775 assert_eq!(sub.definition_ids, vec!["ci".to_string(), "release".to_string()]);
776 }
777}