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_notifications(&self, prefs: NotificationPrefs) -> Result<()> {
312 let mut cfg = self.snapshot();
313 cfg.ui.notifications = prefs;
314 self.persist(cfg).await
315 }
316
317 pub async fn set_views(&self, section: Section, views: Vec<SavedView>) -> Result<()> {
319 let mut cfg = self.snapshot();
320 match section {
321 Section::PullRequests => cfg.ui.pr_views = views,
322 Section::WorkItems => cfg.ui.work_item_views = views,
323 Section::Pipelines => cfg.ui.pipeline_views = views,
324 }
325 self.persist(cfg).await
326 }
327
328 pub async fn set_sort(&self, section: Section, sort: Option<SortPref>) -> Result<()> {
330 let mut cfg = self.snapshot();
331 match section {
332 Section::PullRequests => cfg.ui.pr_sort = sort,
333 Section::WorkItems => cfg.ui.work_item_sort = sort,
334 Section::Pipelines => cfg.ui.pipeline_sort = sort,
335 }
336 self.persist(cfg).await
337 }
338
339 pub async fn set_pipeline_group(&self, group: Option<String>) -> Result<()> {
341 let mut cfg = self.snapshot();
342 cfg.ui.pipeline_group = group;
343 self.persist(cfg).await
344 }
345
346 pub async fn set_pipeline_definitions(&self, connection_id: &str, definition_ids: Vec<String>) -> Result<()> {
349 self.mutate_subscription(connection_id, move |s| {
350 s.auto_discover_all = false;
351 s.definition_ids = definition_ids;
352 })
353 .await
354 }
355}
356
357pub struct ConnectionResolver {
359 config: Arc<ConfigService>,
360 registry: Arc<ProviderRegistry>,
361 secrets: Arc<dyn SecretStore>,
362}
363
364impl ConnectionResolver {
365 pub fn new(config: Arc<ConfigService>, registry: Arc<ProviderRegistry>, secrets: Arc<dyn SecretStore>) -> Self {
366 Self { config, registry, secrets }
367 }
368
369 pub async fn resolve(&self, connection_id: &str) -> Result<Option<Arc<dyn ProviderConnection>>> {
370 let cfg = self.config.snapshot();
371 let conn = match cfg.find_connection(connection_id) {
372 Some(c) => c.clone(),
373 None => return Ok(None),
374 };
375 if !self.registry.supports(conn.provider_type) {
376 return Ok(None);
377 }
378 let secret = match &conn.credential_ref {
379 Some(r) => self.secrets.get(r)?,
380 None => None,
381 };
382 Ok(Some(self.registry.create(&conn, secret)?))
383 }
384}
385
386pub struct PipelineFeed {
388 pub connection: Arc<dyn ProviderConnection>,
389 pub source: Arc<dyn PipelineSource>,
390 pub subscription: PipelineSubscription,
391}
392
393pub struct PullRequestFeed {
395 pub connection: Arc<dyn ProviderConnection>,
396 pub source: Arc<dyn PullRequestSource>,
397}
398
399pub struct WorkItemFeed {
401 pub connection: Arc<dyn ProviderConnection>,
402 pub source: Arc<dyn WorkItemSource>,
403}
404
405pub struct NotificationFeed {
408 pub connection: Arc<dyn ProviderConnection>,
409 pub source: Arc<dyn NotificationSource>,
410}
411
412pub struct SectionService {
414 config: Arc<ConfigService>,
415 resolver: Arc<ConnectionResolver>,
416}
417
418impl SectionService {
419 pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
420 Self { config, resolver }
421 }
422
423 pub async fn pull_request_feeds(&self) -> Result<Vec<PullRequestFeed>> {
425 let cfg = self.config.snapshot();
426 let Some(binding) = cfg.pull_requests else { return Ok(Vec::new()) };
427 let mut feeds = Vec::new();
428 for id in binding.ids() {
429 if let Some(conn) = self.resolver.resolve(&id).await? {
430 if let Some(source) = conn.pull_requests() {
431 feeds.push(PullRequestFeed { connection: conn, source });
432 }
433 }
434 }
435 Ok(feeds)
436 }
437
438 pub async fn work_item_feeds(&self) -> Result<Vec<WorkItemFeed>> {
439 let cfg = self.config.snapshot();
440 let Some(binding) = cfg.work_items else { return Ok(Vec::new()) };
441 let mut feeds = Vec::new();
442 for id in binding.ids() {
443 if let Some(conn) = self.resolver.resolve(&id).await? {
444 if let Some(source) = conn.work_items() {
445 feeds.push(WorkItemFeed { connection: conn, source });
446 }
447 }
448 }
449 Ok(feeds)
450 }
451
452 pub async fn notification_feeds(&self) -> Result<Vec<NotificationFeed>> {
455 let cfg = self.config.snapshot();
456 let mut feeds = Vec::new();
457 for c in &cfg.connections {
458 if let Some(conn) = self.resolver.resolve(&c.id).await? {
459 if let Some(source) = conn.notifications() {
460 feeds.push(NotificationFeed { connection: conn, source });
461 }
462 }
463 }
464 Ok(feeds)
465 }
466
467 pub async fn pull_request_source(&self) -> Result<Option<Arc<dyn PullRequestSource>>> {
469 Ok(self.pull_request_feeds().await?.into_iter().next().map(|f| f.source))
470 }
471
472 pub async fn work_item_source(&self) -> Result<Option<Arc<dyn WorkItemSource>>> {
473 Ok(self.work_item_feeds().await?.into_iter().next().map(|f| f.source))
474 }
475
476 pub async fn pipeline_source_for(&self, connection_id: &str) -> Result<Option<Arc<dyn PipelineSource>>> {
479 Ok(self.resolver.resolve(connection_id).await?.and_then(|c| c.pipelines()))
480 }
481
482 pub async fn discover_repositories(&self, connection_id: &str) -> Result<RepositoryPage> {
484 match self.resolver.resolve(connection_id).await? {
485 Some(conn) => conn.discover_repositories().await,
486 None => Ok(RepositoryPage::default()),
487 }
488 }
489
490 pub async fn pipeline_feeds(&self) -> Result<Vec<PipelineFeed>> {
491 let cfg = self.config.snapshot();
492 let Some(binding) = cfg.pipelines else { return Ok(Vec::new()) };
493 let mut feeds = Vec::new();
494 for sub in binding.subscriptions {
495 if let Some(conn) = self.resolver.resolve(&sub.connection_id).await? {
496 if let Some(source) = conn.pipelines() {
497 feeds.push(PipelineFeed { connection: conn, source, subscription: sub });
498 }
499 }
500 }
501 Ok(feeds)
502 }
503}
504
505pub const DEFAULT_REPO_SCOPE_SIZE: usize = 5;
509
510pub async fn seed_default_repo_scope(
518 config: &ConfigService,
519 sections: &SectionService,
520 connection_id: &str,
521) -> Result<Option<Vec<String>>> {
522 let Some(conn) = config.snapshot().find_connection(connection_id).cloned() else { return Ok(None) };
523 if conn.repo_scope.is_some() || conn.repository.is_some() {
524 return Ok(None);
525 }
526 let page = sections.discover_repositories(connection_id).await.unwrap_or_default();
527 if page.repositories.is_empty() {
528 return Ok(None);
529 }
530 let scope: Vec<String> = page.repositories.into_iter().take(DEFAULT_REPO_SCOPE_SIZE).collect();
531 config.set_repo_scope(connection_id, Some(scope.clone())).await?;
532 Ok(Some(scope))
533}
534
535pub struct ConnectionHealth {
537 pub connection: Connection,
538 pub healthy: bool,
539}
540
541pub struct ConnectionHealthService {
543 config: Arc<ConfigService>,
544 resolver: Arc<ConnectionResolver>,
545}
546
547impl ConnectionHealthService {
548 pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
549 Self { config, resolver }
550 }
551
552 pub async fn check_all(&self) -> Vec<ConnectionHealth> {
553 let cfg = self.config.snapshot();
554 let mut out = Vec::new();
555 for connection in cfg.connections {
556 let healthy = match self.resolver.resolve(&connection.id).await {
557 Ok(Some(live)) => live.check().await,
558 _ => false,
559 };
560 out.push(ConnectionHealth { connection, healthy });
561 }
562 out
563 }
564}
565
566
567fn same_fetch_inputs(a: &ForgetopConfig, b: &ForgetopConfig) -> bool {
571 a.connections == b.connections
572 && a.pull_requests == b.pull_requests
573 && a.work_items == b.work_items
574 && a.pipelines == b.pipelines
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use crate::domain::ProviderType;
581 use crate::secret::InMemorySecretStore;
582 use async_trait::async_trait;
583
584 struct FakeConn {
585 caps: Capabilities,
586 }
587 #[async_trait]
588 impl ProviderConnection for FakeConn {
589 fn connection_id(&self) -> &str { "fake" }
590 fn provider_type(&self) -> ProviderType { ProviderType::GitHub }
591 fn display_name(&self) -> &str { "Fake" }
592 fn capabilities(&self) -> &Capabilities { &self.caps }
593 fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> { None }
594 fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> { None }
595 fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> { None }
596 async fn check(&self) -> bool { true }
597 }
598 struct FakeFactory {
599 provider: ProviderType,
600 caps: Capabilities,
601 }
602 impl ProviderFactory for FakeFactory {
603 fn provider_type(&self) -> ProviderType { self.provider }
604 fn describe_capabilities(&self) -> Capabilities { self.caps.clone() }
605 fn create(&self, _c: &Connection, _s: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
606 Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
607 }
608 }
609
610 fn registry() -> Arc<ProviderRegistry> {
611 Arc::new(ProviderRegistry::new(vec![
612 Arc::new(FakeFactory {
613 provider: ProviderType::GitHub,
614 caps: Capabilities { supports_pull_requests: true, supports_pipelines: true, ..Default::default() },
615 }),
616 Arc::new(FakeFactory {
617 provider: ProviderType::Linear,
618 caps: Capabilities { supports_work_items: true, ..Default::default() },
619 }),
620 ]))
621 }
622
623 fn conn(id: &str, provider: ProviderType) -> Connection {
624 Connection {
625 id: id.into(),
626 provider_type: provider,
627 display_name: id.into(),
628 base_url: None,
629 organization: None,
630 project: None,
631 repository: None,
632 username: None,
633 credential_ref: None,
634 repo_scope: None,
635 }
636 }
637
638 fn service() -> (Arc<ConfigService>, Arc<InMemorySecretStore>) {
639 let secrets = Arc::new(InMemorySecretStore::default());
640 let svc = Arc::new(ConfigService::new(
641 Arc::new(InMemoryConfigStore::default()),
642 secrets.clone(),
643 registry(),
644 ));
645 (svc, secrets)
646 }
647
648 #[tokio::test]
649 async fn add_connection_persists_and_stores_secret() {
650 let (svc, secrets) = service();
651 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
652 assert_eq!(svc.snapshot().connections.len(), 1);
653 assert_eq!(secrets.get("gh-1").unwrap().as_deref(), Some("pat"));
654 }
655
656 #[tokio::test]
657 async fn connection_and_binding_changes_notify_subscribers() {
658 let (svc, _) = service();
659 let mut rx = svc.subscribe_data_changed();
660
661 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
662 assert!(rx.has_changed().unwrap(), "adding a connection must wake the TUI");
663 rx.borrow_and_update();
664
665 svc.bind_pull_requests("gh-1").await.unwrap();
666 assert!(rx.has_changed().unwrap(), "binding a section must wake the TUI");
667 rx.borrow_and_update();
668
669 svc.remove_connection("gh-1").await.unwrap();
670 assert!(rx.has_changed().unwrap(), "removing a connection must wake the TUI");
671 }
672
673 #[tokio::test]
674 async fn ui_preferences_do_not_notify_subscribers() {
675 let (svc, _) = service();
676 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
677 let rx = svc.subscribe_data_changed();
678
679 svc.set_theme(Some("slate".into())).await.unwrap();
682 svc.set_hidden_sections(vec![Section::Pipelines]).await.unwrap();
683 svc.set_notifications(NotificationPrefs::default()).await.unwrap();
684
685 assert!(!rx.has_changed().unwrap(), "a UI preference must not trigger a refetch");
686 }
687
688 #[tokio::test]
689 async fn rewriting_an_identical_connection_does_not_notify() {
690 let (svc, _) = service();
691 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
692 let rx = svc.subscribe_data_changed();
693
694 let mut same = conn("gh-1", ProviderType::GitHub);
696 same.credential_ref = Some("gh-1".into());
697 svc.add_or_update_connection(same, None).await.unwrap();
698
699 assert!(!rx.has_changed().unwrap(), "an unchanged rewrite must not trigger a refetch");
700 }
701
702 #[tokio::test]
703 async fn bind_validates_capability() {
704 let (svc, _) = service();
705 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
706 svc.add_or_update_connection(conn("lin-1", ProviderType::Linear), None).await.unwrap();
707 svc.bind_pull_requests("gh-1").await.unwrap();
708 assert_eq!(svc.snapshot().pull_requests.unwrap().ids(), vec!["gh-1".to_string()]);
709 assert!(svc.bind_pull_requests("lin-1").await.is_err());
710 }
711
712 #[tokio::test]
713 async fn remove_connection_cascades() {
714 let (svc, secrets) = service();
715 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
716 svc.bind_pull_requests("gh-1").await.unwrap();
717 svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
718 svc.remove_connection("gh-1").await.unwrap();
719 let cfg = svc.snapshot();
720 assert!(cfg.connections.is_empty());
721 assert!(cfg.pull_requests.is_none());
722 assert!(cfg.pipelines.unwrap().subscriptions.is_empty());
723 assert_eq!(secrets.get("gh-1").unwrap(), None);
724 }
725
726 #[tokio::test]
727 async fn repo_scope_persists_including_the_empty_choice() {
728 let (svc, _) = service();
729 let mut c = conn("gh-1", ProviderType::GitHub);
730 c.repository = Some("acme/pay".into());
731 svc.add_or_update_connection(c, None).await.unwrap();
732
733 assert_eq!(svc.repo_scope("gh-1"), None);
735
736 svc.set_repo_scope("gh-1", Some(vec!["acme/pay".into(), "acme/ledger".into()])).await.unwrap();
737 assert_eq!(svc.repo_scope("gh-1").unwrap().len(), 2);
738
739 svc.set_repo_scope("gh-1", Some(vec![])).await.unwrap();
741 assert_eq!(svc.repo_scope("gh-1"), Some(vec![]));
742 svc.load().await.unwrap();
743 assert_eq!(svc.repo_scope("gh-1"), Some(vec![]), "an emptied scope survives a reload");
744
745 assert!(svc.set_repo_scope("nope", None).await.is_err());
746 }
747
748 #[tokio::test]
749 async fn pipeline_subscribe_unsubscribe() {
750 let (svc, _) = service();
751 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
752 svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
753 svc.subscribe_pipeline("gh-1", "release").await.unwrap();
754 svc.subscribe_pipeline("gh-1", "ci").await.unwrap(); assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 2);
756 svc.unsubscribe_pipeline("gh-1", "ci").await.unwrap();
757 assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 1);
758 }
759
760 #[tokio::test]
761 async fn set_pipeline_definitions_replaces_and_disables_auto() {
762 let (svc, _) = service();
763 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
764 svc.set_pipeline_auto_discover("gh-1", true).await.unwrap();
765 svc.set_pipeline_definitions("gh-1", vec!["ci".into(), "release".into()]).await.unwrap();
766 let cfg = svc.snapshot();
767 let sub = &cfg.pipelines.unwrap().subscriptions[0];
768 assert!(!sub.auto_discover_all);
769 assert_eq!(sub.definition_ids, vec!["ci".to_string(), "release".to_string()]);
770 }
771}