1use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6use tokio::sync::watch;
7
8use crate::config::*;
9use crate::domain::{PipelineDefinition, PipelineRun, Section};
10use crate::error::{Error, Result};
11use crate::provider::*;
12use crate::secret::SecretStore;
13
14pub struct ConfigService {
18 store: Arc<dyn ConfigStore>,
19 secrets: Arc<dyn SecretStore>,
20 registry: Arc<ProviderRegistry>,
21 config: Mutex<ForgetopConfig>,
22 data_changed: watch::Sender<u64>,
27}
28
29impl ConfigService {
30 pub fn new(store: Arc<dyn ConfigStore>, secrets: Arc<dyn SecretStore>, registry: Arc<ProviderRegistry>) -> Self {
31 Self {
32 store,
33 secrets,
34 registry,
35 config: Mutex::new(ForgetopConfig::default()),
36 data_changed: watch::channel(0).0,
37 }
38 }
39
40 pub async fn load(&self) -> Result<()> {
41 let mut cfg = self.store.load().await?;
42 if migrate_pipelines_opt_in(&mut cfg) {
43 self.store.save(&cfg).await?;
44 }
45 *self.config.lock().unwrap() = cfg;
46 Ok(())
47 }
48
49 pub fn snapshot(&self) -> ForgetopConfig {
50 self.config.lock().unwrap().clone()
51 }
52
53 async fn persist(&self, cfg: ForgetopConfig) -> Result<()> {
54 self.store.save(&cfg).await?;
55 let fetch_changed = {
56 let mut current = self.config.lock().unwrap();
57 let changed = !same_fetch_inputs(¤t, &cfg);
58 *current = cfg;
59 changed
60 };
61 if fetch_changed {
64 self.data_changed.send_modify(|generation| *generation += 1);
65 }
66 Ok(())
67 }
68
69 pub fn subscribe_data_changed(&self) -> watch::Receiver<u64> {
73 self.data_changed.subscribe()
74 }
75
76 fn ensure_supports(&self, cfg: &ForgetopConfig, connection_id: &str, section: Section) -> Result<()> {
77 let conn = cfg
78 .find_connection(connection_id)
79 .ok_or_else(|| Error::Config(format!("unknown connection '{connection_id}'")))?;
80 let caps = self
81 .registry
82 .describe(conn.provider_type)
83 .ok_or_else(|| Error::Config(format!("provider '{}' not registered", conn.provider_type.as_str())))?;
84 if !caps.supports(section) {
85 return Err(Error::Config(format!(
86 "connection '{}' ({}) does not support {section:?}",
87 conn.display_name,
88 conn.provider_type.as_str()
89 )));
90 }
91 Ok(())
92 }
93
94 pub async fn add_or_update_connection(&self, mut connection: Connection, secret: Option<String>) -> Result<()> {
95 let cred = connection.credential_ref.clone().unwrap_or_else(|| connection.id.clone());
96 connection.credential_ref = Some(cred.clone());
97
98 if let Some(secret) = secret {
99 if !self.secrets.is_writable() {
100 return Err(Error::Config(
101 "secret store is read-only; provide the PAT via environment variable instead".into(),
102 ));
103 }
104 self.secrets.set(&cred, &secret)?;
105 }
106
107 let mut cfg = self.snapshot();
108 cfg.connections.retain(|c| c.id != connection.id);
109 cfg.connections.push(connection);
110 self.persist(cfg).await
111 }
112
113 pub async fn remove_connection(&self, connection_id: &str) -> Result<()> {
114 let mut cfg = self.snapshot();
115 if let Some(existing) = cfg.find_connection(connection_id).cloned() {
116 if let (Some(cred), true) = (existing.credential_ref.as_ref(), self.secrets.is_writable()) {
117 let _ = self.secrets.delete(cred);
118 }
119 }
120 cfg.connections.retain(|c| c.id != connection_id);
121 if let Some(b) = &mut cfg.pull_requests {
122 b.connection_ids.retain(|c| c != connection_id);
123 b.connection_id = b.connection_id.take().filter(|c| c != connection_id);
124 if b.ids().is_empty() {
125 cfg.pull_requests = None;
126 }
127 }
128 if let Some(b) = &mut cfg.work_items {
129 b.connection_ids.retain(|c| c != connection_id);
130 b.connection_id = b.connection_id.take().filter(|c| c != connection_id);
131 if b.ids().is_empty() {
132 cfg.work_items = None;
133 }
134 }
135 if let Some(p) = &mut cfg.pipelines {
136 p.subscriptions.retain(|s| s.connection_id != connection_id);
137 }
138 self.persist(cfg).await
139 }
140
141 pub async fn bind_pull_requests(&self, connection_id: &str) -> Result<()> {
142 let mut cfg = self.snapshot();
143 self.ensure_supports(&cfg, connection_id, Section::PullRequests)?;
144 let b = cfg.pull_requests.get_or_insert_with(PullRequestBinding::default);
145 let mut ids = b.ids();
146 if !ids.contains(&connection_id.to_string()) {
147 ids.push(connection_id.into());
148 }
149 *b = PullRequestBinding { connection_ids: ids, connection_id: None };
150 self.persist(cfg).await
151 }
152
153 pub async fn bind_work_items(&self, connection_id: &str) -> Result<()> {
154 let mut cfg = self.snapshot();
155 self.ensure_supports(&cfg, connection_id, Section::WorkItems)?;
156 let b = cfg.work_items.get_or_insert_with(WorkItemBinding::default);
157 let mut ids = b.ids();
158 if !ids.contains(&connection_id.to_string()) {
159 ids.push(connection_id.into());
160 }
161 *b = WorkItemBinding { connection_ids: ids, connection_id: None };
162 self.persist(cfg).await
163 }
164
165 pub async fn unbind_pull_requests(&self, connection_id: &str) -> Result<()> {
167 let mut cfg = self.snapshot();
168 if let Some(b) = &mut cfg.pull_requests {
169 let ids: Vec<String> = b.ids().into_iter().filter(|c| c != connection_id).collect();
170 cfg.pull_requests = (!ids.is_empty()).then_some(PullRequestBinding { connection_ids: ids, connection_id: None });
171 }
172 self.persist(cfg).await
173 }
174
175 pub async fn unbind_work_items(&self, connection_id: &str) -> Result<()> {
177 let mut cfg = self.snapshot();
178 if let Some(b) = &mut cfg.work_items {
179 let ids: Vec<String> = b.ids().into_iter().filter(|c| c != connection_id).collect();
180 cfg.work_items = (!ids.is_empty()).then_some(WorkItemBinding { connection_ids: ids, connection_id: None });
181 }
182 self.persist(cfg).await
183 }
184
185 pub async fn unbind_pipelines(&self, connection_id: &str) -> Result<()> {
187 let mut cfg = self.snapshot();
188 if let Some(p) = &mut cfg.pipelines {
189 p.subscriptions.retain(|s| s.connection_id != connection_id);
190 if p.subscriptions.is_empty() {
191 cfg.pipelines = None;
192 }
193 }
194 self.persist(cfg).await
195 }
196
197 pub async fn set_pull_request_connections(&self, connection_ids: Vec<String>) -> Result<()> {
199 let mut cfg = self.snapshot();
200 cfg.pull_requests = Some(PullRequestBinding { connection_ids, connection_id: None });
201 self.persist(cfg).await
202 }
203
204 pub async fn set_work_item_connections(&self, connection_ids: Vec<String>) -> Result<()> {
205 let mut cfg = self.snapshot();
206 cfg.work_items = Some(WorkItemBinding { connection_ids, connection_id: None });
207 self.persist(cfg).await
208 }
209
210 pub async fn unbind_section(&self, section: Section) -> Result<()> {
211 let mut cfg = self.snapshot();
212 match section {
213 Section::PullRequests => cfg.pull_requests = None,
214 Section::WorkItems => cfg.work_items = None,
215 Section::Pipelines => cfg.pipelines = None,
216 }
217 self.persist(cfg).await
218 }
219
220 async fn mutate_subscription(&self, connection_id: &str, f: impl FnOnce(&mut PipelineSubscription)) -> Result<()> {
221 let mut cfg = self.snapshot();
222 self.ensure_supports(&cfg, connection_id, Section::Pipelines)?;
223 let binding = cfg.pipelines.get_or_insert_with(PipelineBinding::default);
224 let sub = match binding.subscriptions.iter_mut().find(|s| s.connection_id == connection_id) {
225 Some(existing) => existing,
226 None => {
227 binding.subscriptions.push(PipelineSubscription {
228 connection_id: connection_id.into(),
229 definition_ids: Vec::new(),
230 auto_discover_all: false,
231 });
232 binding.subscriptions.last_mut().unwrap()
233 }
234 };
235 f(sub);
236 self.persist(cfg).await
237 }
238
239 pub async fn subscribe_pipeline(&self, connection_id: &str, definition_id: &str) -> Result<()> {
240 let definition_id = definition_id.to_string();
241 self.mutate_subscription(connection_id, move |s| {
242 if !s.definition_ids.contains(&definition_id) {
243 s.definition_ids.push(definition_id);
244 }
245 })
246 .await
247 }
248
249 pub async fn bind_pipelines(&self, connection_id: &str) -> Result<()> {
253 self.mutate_subscription(connection_id, |_| {}).await
254 }
255
256 pub async fn set_pipeline_auto_discover(&self, connection_id: &str, auto_discover_all: bool) -> Result<()> {
257 self.mutate_subscription(connection_id, move |s| s.auto_discover_all = auto_discover_all).await
258 }
259
260 pub async fn unsubscribe_pipeline(&self, connection_id: &str, definition_id: &str) -> Result<()> {
261 self.mutate_subscription(connection_id, move |s| s.definition_ids.retain(|d| d != definition_id)).await
262 }
263
264 pub async fn remove_pipeline_connection(&self, connection_id: &str) -> Result<()> {
265 let mut cfg = self.snapshot();
266 if let Some(p) = &mut cfg.pipelines {
267 p.subscriptions.retain(|s| s.connection_id != connection_id);
268 }
269 self.persist(cfg).await
270 }
271
272 pub async fn set_repo_scope(&self, connection_id: &str, scope: Option<Vec<String>>) -> Result<()> {
276 let mut cfg = self.snapshot();
277 let conn = cfg
278 .connections
279 .iter_mut()
280 .find(|c| c.id == connection_id)
281 .ok_or_else(|| Error::Config(format!("unknown connection '{connection_id}'")))?;
282 conn.repo_scope = scope;
283 self.persist(cfg).await
284 }
285
286 pub fn repo_scope(&self, connection_id: &str) -> Option<Vec<String>> {
289 self.snapshot().find_connection(connection_id).and_then(|c| c.repo_scope.clone())
290 }
291
292 pub async fn set_theme(&self, theme: Option<String>) -> Result<()> {
293 let mut cfg = self.snapshot();
294 cfg.ui.theme = theme;
295 self.persist(cfg).await
296 }
297
298 pub async fn set_hidden_sections(&self, hidden: Vec<Section>) -> Result<()> {
299 let mut cfg = self.snapshot();
300 cfg.ui.hidden_sections = hidden;
301 self.persist(cfg).await
302 }
303
304 pub async fn set_preview_hidden(&self, hidden: Vec<Section>) -> Result<()> {
305 let mut cfg = self.snapshot();
306 cfg.ui.preview_hidden = Some(hidden);
307 self.persist(cfg).await
308 }
309
310 pub async fn set_hidden_work_item_states(&self, hidden: Vec<String>) -> Result<()> {
311 let mut cfg = self.snapshot();
312 cfg.ui.hidden_work_item_states = hidden;
313 self.persist(cfg).await
314 }
315
316 pub async fn set_hidden_columns(&self, hidden: [Vec<String>; 3]) -> Result<()> {
317 let mut cfg = self.snapshot();
318 cfg.ui.hidden_columns = Some(hidden);
319 self.persist(cfg).await
320 }
321
322 pub async fn set_dismissed_launchpad_items(&self, dismissed: Vec<String>) -> Result<()> {
323 let mut cfg = self.snapshot();
324 cfg.ui.dismissed_launchpad_items = dismissed;
325 self.persist(cfg).await
326 }
327
328 pub async fn set_dismissed_notifications(&self, dismissed: Vec<String>) -> Result<()> {
329 let mut cfg = self.snapshot();
330 cfg.ui.dismissed_notifications = dismissed;
331 self.persist(cfg).await
332 }
333
334 pub async fn set_notifications(&self, prefs: NotificationPrefs) -> Result<()> {
335 let mut cfg = self.snapshot();
336 cfg.ui.notifications = prefs;
337 self.persist(cfg).await
338 }
339
340 pub async fn set_views(&self, section: Section, views: Vec<SavedView>) -> Result<()> {
342 let mut cfg = self.snapshot();
343 match section {
344 Section::PullRequests => cfg.ui.pr_views = views,
345 Section::WorkItems => cfg.ui.work_item_views = views,
346 Section::Pipelines => cfg.ui.pipeline_views = views,
347 }
348 self.persist(cfg).await
349 }
350
351 pub async fn set_sort(&self, section: Section, sort: Option<SortPref>) -> Result<()> {
353 let mut cfg = self.snapshot();
354 match section {
355 Section::PullRequests => cfg.ui.pr_sort = sort,
356 Section::WorkItems => cfg.ui.work_item_sort = sort,
357 Section::Pipelines => cfg.ui.pipeline_sort = sort,
358 }
359 self.persist(cfg).await
360 }
361
362 pub async fn set_pipeline_group(&self, group: Option<String>) -> Result<()> {
364 let mut cfg = self.snapshot();
365 cfg.ui.pipeline_group = group;
366 self.persist(cfg).await
367 }
368
369 pub async fn set_pipeline_definitions(&self, connection_id: &str, definition_ids: Vec<String>) -> Result<()> {
372 self.mutate_subscription(connection_id, move |s| {
373 s.auto_discover_all = false;
374 s.definition_ids = definition_ids;
375 })
376 .await
377 }
378}
379
380fn migrate_pipelines_opt_in(cfg: &mut ForgetopConfig) -> bool {
385 if cfg.pipelines_opt_in {
386 return false;
387 }
388 cfg.pipelines_opt_in = true;
389 let Some(binding) = &mut cfg.pipelines else { return false };
390 let mut changed = false;
391 for sub in binding.subscriptions.iter_mut().filter(|s| s.auto_discover_all || s.definition_ids.is_empty()) {
394 sub.auto_discover_all = false;
395 sub.definition_ids.clear();
396 changed = true;
397 }
398 changed
399}
400
401pub struct ConnectionResolver {
403 config: Arc<ConfigService>,
404 registry: Arc<ProviderRegistry>,
405 secrets: Arc<dyn SecretStore>,
406 live: Mutex<HashMap<String, LiveConnection>>,
412}
413
414struct LiveConnection {
416 connection: Connection,
417 secret: u64,
419 live: Arc<dyn ProviderConnection>,
420}
421
422fn secret_fingerprint(secret: Option<&str>) -> u64 {
425 use std::hash::{Hash, Hasher};
426 let mut hasher = std::collections::hash_map::DefaultHasher::new();
427 secret.hash(&mut hasher);
428 hasher.finish()
429}
430
431impl ConnectionResolver {
432 pub fn new(config: Arc<ConfigService>, registry: Arc<ProviderRegistry>, secrets: Arc<dyn SecretStore>) -> Self {
433 Self { config, registry, secrets, live: Mutex::new(HashMap::new()) }
434 }
435
436 pub async fn resolve(&self, connection_id: &str) -> Result<Option<Arc<dyn ProviderConnection>>> {
437 let mut live = self.live.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
441 let cfg = self.config.snapshot();
442 live.retain(|id, _| cfg.find_connection(id).is_some());
445 let conn = match cfg.find_connection(connection_id) {
446 Some(c) => c.clone(),
447 None => return Ok(None),
448 };
449 if !self.registry.supports(conn.provider_type) {
450 return Ok(None);
451 }
452 let secret = match &conn.credential_ref {
453 Some(r) => self.secrets.get(r)?,
454 None => None,
455 };
456 let fingerprint = secret_fingerprint(secret.as_deref());
457 if let Some(entry) = live.get(connection_id) {
458 if entry.connection == conn && entry.secret == fingerprint {
459 return Ok(Some(entry.live.clone()));
460 }
461 }
462 let created = self.registry.create(&conn, secret)?;
463 live.insert(connection_id.to_string(), LiveConnection { connection: conn, secret: fingerprint, live: created.clone() });
464 Ok(Some(created))
465 }
466}
467
468pub struct PipelineFeed {
470 pub connection: Arc<dyn ProviderConnection>,
471 pub source: Arc<dyn PipelineSource>,
472 pub subscription: PipelineSubscription,
473}
474
475pub const RUNS_PER_DEFINITION: u32 = 10;
477
478const MAX_CONCURRENT_RUN_QUERIES: usize = 4;
480
481pub fn pipeline_run_queries(sub: &PipelineSubscription, defs: &[PipelineDefinition]) -> Vec<PipelineRunQuery> {
495 if !sub.auto_discover_all && sub.definition_ids.is_empty() {
497 return Vec::new();
498 }
499 let all = sub.auto_discover_all;
500 let ids: Vec<&String> = if all { defs.iter().map(|d| &d.id).collect() } else { sub.definition_ids.iter().collect() };
501 if ids.is_empty() {
502 return vec![PipelineRunQuery { definition_id: None, repository: None, branch: None, limit: Some(20) }];
503 }
504 ids.into_iter()
505 .map(|id| PipelineRunQuery {
506 repository: defs.iter().find(|d| &d.id == id).and_then(|d| d.repository.clone()),
507 definition_id: Some(id.clone()),
508 branch: None,
509 limit: Some(RUNS_PER_DEFINITION),
510 })
511 .collect()
512}
513
514impl PipelineFeed {
515 pub async fn list_runs(&self, defs: &[PipelineDefinition]) -> Vec<Result<Vec<PipelineRun>>> {
518 let queries = pipeline_run_queries(&self.subscription, defs);
519 let mut out = Vec::with_capacity(queries.len());
520 for chunk in queries.chunks(MAX_CONCURRENT_RUN_QUERIES) {
521 out.extend(futures_util::future::join_all(chunk.iter().map(|q| self.source.list_runs(q))).await);
522 }
523 out
524 }
525}
526
527pub struct PullRequestFeed {
529 pub connection: Arc<dyn ProviderConnection>,
530 pub source: Arc<dyn PullRequestSource>,
531}
532
533pub struct WorkItemFeed {
535 pub connection: Arc<dyn ProviderConnection>,
536 pub source: Arc<dyn WorkItemSource>,
537}
538
539pub struct NotificationFeed {
542 pub connection: Arc<dyn ProviderConnection>,
543 pub source: Arc<dyn NotificationSource>,
544}
545
546pub struct SectionService {
548 config: Arc<ConfigService>,
549 resolver: Arc<ConnectionResolver>,
550}
551
552impl SectionService {
553 pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
554 Self { config, resolver }
555 }
556
557 pub async fn pull_request_feeds(&self) -> Result<Vec<PullRequestFeed>> {
559 let cfg = self.config.snapshot();
560 let Some(binding) = cfg.pull_requests else { return Ok(Vec::new()) };
561 let mut feeds = Vec::new();
562 for id in binding.ids() {
563 if let Some(conn) = self.resolver.resolve(&id).await? {
564 if let Some(source) = conn.pull_requests() {
565 feeds.push(PullRequestFeed { connection: conn, source });
566 }
567 }
568 }
569 Ok(feeds)
570 }
571
572 pub async fn work_item_feeds(&self) -> Result<Vec<WorkItemFeed>> {
573 let cfg = self.config.snapshot();
574 let Some(binding) = cfg.work_items else { return Ok(Vec::new()) };
575 let mut feeds = Vec::new();
576 for id in binding.ids() {
577 if let Some(conn) = self.resolver.resolve(&id).await? {
578 if let Some(source) = conn.work_items() {
579 feeds.push(WorkItemFeed { connection: conn, source });
580 }
581 }
582 }
583 Ok(feeds)
584 }
585
586 pub async fn notification_feeds(&self) -> Result<Vec<NotificationFeed>> {
589 let cfg = self.config.snapshot();
590 let mut feeds = Vec::new();
591 for c in &cfg.connections {
592 if let Some(conn) = self.resolver.resolve(&c.id).await? {
593 if let Some(source) = conn.notifications() {
594 feeds.push(NotificationFeed { connection: conn, source });
595 }
596 }
597 }
598 Ok(feeds)
599 }
600
601 pub async fn pull_request_source(&self) -> Result<Option<Arc<dyn PullRequestSource>>> {
603 Ok(self.pull_request_feeds().await?.into_iter().next().map(|f| f.source))
604 }
605
606 pub async fn work_item_source(&self) -> Result<Option<Arc<dyn WorkItemSource>>> {
607 Ok(self.work_item_feeds().await?.into_iter().next().map(|f| f.source))
608 }
609
610 pub async fn pipeline_source_for(&self, connection_id: &str) -> Result<Option<Arc<dyn PipelineSource>>> {
613 Ok(self.resolver.resolve(connection_id).await?.and_then(|c| c.pipelines()))
614 }
615
616 pub async fn discover_repositories(&self, connection_id: &str) -> Result<RepositoryPage> {
618 match self.resolver.resolve(connection_id).await? {
619 Some(conn) => conn.discover_repositories().await,
620 None => Ok(RepositoryPage::default()),
621 }
622 }
623
624 pub async fn pipeline_feeds(&self) -> Result<Vec<PipelineFeed>> {
625 let cfg = self.config.snapshot();
626 let Some(binding) = cfg.pipelines else { return Ok(Vec::new()) };
627 let mut feeds = Vec::new();
628 for sub in binding.subscriptions {
629 if let Some(conn) = self.resolver.resolve(&sub.connection_id).await? {
630 if let Some(source) = conn.pipelines() {
631 feeds.push(PipelineFeed { connection: conn, source, subscription: sub });
632 }
633 }
634 }
635 Ok(feeds)
636 }
637}
638
639pub async fn seed_default_repo_scope(
651 config: &ConfigService,
652 sections: &SectionService,
653 connection_id: &str,
654) -> Result<Option<RepositoryPage>> {
655 let Some(conn) = config.snapshot().find_connection(connection_id).cloned() else { return Ok(None) };
656 if conn.repo_scope.is_some() || conn.repository.is_some() {
657 return Ok(None);
658 }
659 let page = sections.discover_repositories(connection_id).await.unwrap_or_default();
660 if page.repositories.is_empty() {
661 return Ok(None);
662 }
663 config.set_repo_scope(connection_id, Some(Vec::new())).await?;
664 Ok(Some(page))
665}
666
667pub struct ConnectionHealth {
669 pub connection: Connection,
670 pub healthy: bool,
671}
672
673pub struct ConnectionHealthService {
675 config: Arc<ConfigService>,
676 resolver: Arc<ConnectionResolver>,
677}
678
679impl ConnectionHealthService {
680 pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
681 Self { config, resolver }
682 }
683
684 pub async fn check_all(&self) -> Vec<ConnectionHealth> {
685 let cfg = self.config.snapshot();
686 let mut out = Vec::new();
687 for connection in cfg.connections {
688 let healthy = match self.resolver.resolve(&connection.id).await {
689 Ok(Some(live)) => live.check().await,
690 _ => false,
691 };
692 out.push(ConnectionHealth { connection, healthy });
693 }
694 out
695 }
696}
697
698
699fn same_fetch_inputs(a: &ForgetopConfig, b: &ForgetopConfig) -> bool {
703 a.connections == b.connections
704 && a.pull_requests == b.pull_requests
705 && a.work_items == b.work_items
706 && a.pipelines == b.pipelines
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use crate::domain::ProviderType;
713 use crate::secret::InMemorySecretStore;
714 use async_trait::async_trait;
715
716 struct FakeConn {
717 caps: Capabilities,
718 }
719 #[async_trait]
720 impl ProviderConnection for FakeConn {
721 fn connection_id(&self) -> &str { "fake" }
722 fn provider_type(&self) -> ProviderType { ProviderType::GitHub }
723 fn display_name(&self) -> &str { "Fake" }
724 fn capabilities(&self) -> &Capabilities { &self.caps }
725 fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> { None }
726 fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> { None }
727 fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> { None }
728 async fn check(&self) -> bool { true }
729 }
730 struct FakeFactory {
731 provider: ProviderType,
732 caps: Capabilities,
733 created: Arc<std::sync::atomic::AtomicUsize>,
735 }
736 impl ProviderFactory for FakeFactory {
737 fn provider_type(&self) -> ProviderType { self.provider }
738 fn describe_capabilities(&self) -> Capabilities { self.caps.clone() }
739 fn create(&self, _c: &Connection, _s: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
740 self.created.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
741 Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
742 }
743 }
744
745 fn registry() -> Arc<ProviderRegistry> {
746 counting_registry().0
747 }
748
749 fn counting_registry() -> (Arc<ProviderRegistry>, Arc<std::sync::atomic::AtomicUsize>) {
751 let created = Arc::new(std::sync::atomic::AtomicUsize::new(0));
752 let registry = Arc::new(ProviderRegistry::new(vec![
753 Arc::new(FakeFactory {
754 provider: ProviderType::GitHub,
755 caps: Capabilities { supports_pull_requests: true, supports_pipelines: true, ..Default::default() },
756 created: created.clone(),
757 }),
758 Arc::new(FakeFactory {
759 provider: ProviderType::Linear,
760 caps: Capabilities { supports_work_items: true, ..Default::default() },
761 created: Arc::default(),
762 }),
763 ]));
764 (registry, created)
765 }
766
767 fn resolver() -> (ConnectionResolver, Arc<ConfigService>, Arc<std::sync::atomic::AtomicUsize>) {
769 let (registry, created) = counting_registry();
770 let secrets = Arc::new(InMemorySecretStore::default());
771 let svc = Arc::new(ConfigService::new(Arc::new(InMemoryConfigStore::default()), secrets.clone(), registry.clone()));
772 (ConnectionResolver::new(svc.clone(), registry, secrets), svc, created)
773 }
774
775 #[tokio::test]
776 async fn resolving_a_connection_again_reuses_the_live_one() {
777 let (resolver, svc, created) = resolver();
778 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
779 let first = resolver.resolve("gh-1").await.unwrap().expect("resolved");
780 let again = resolver.resolve("gh-1").await.unwrap().expect("resolved");
781 assert!(Arc::ptr_eq(&first, &again), "the same live connection, caches and all");
782 assert_eq!(created.load(std::sync::atomic::Ordering::SeqCst), 1, "built once");
783 }
784
785 #[tokio::test]
786 async fn a_changed_credential_or_configuration_recreates_the_connection() {
787 let (resolver, svc, created) = resolver();
788 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
789 let first = resolver.resolve("gh-1").await.unwrap().unwrap();
790
791 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat-2".into())).await.unwrap();
793 let rekeyed = resolver.resolve("gh-1").await.unwrap().unwrap();
794 assert!(!Arc::ptr_eq(&first, &rekeyed), "rebuilt with the new credential");
795 assert_eq!(created.load(std::sync::atomic::Ordering::SeqCst), 2);
796
797 let mut rescoped = conn("gh-1", ProviderType::GitHub);
799 rescoped.repo_scope = Some(vec!["acme/pay".into()]);
800 svc.add_or_update_connection(rescoped, None).await.unwrap();
801 let widened = resolver.resolve("gh-1").await.unwrap().unwrap();
802 assert!(!Arc::ptr_eq(&rekeyed, &widened), "rebuilt with the new scope");
803 assert_eq!(created.load(std::sync::atomic::Ordering::SeqCst), 3);
804
805 let same = resolver.resolve("gh-1").await.unwrap().unwrap();
807 assert!(Arc::ptr_eq(&widened, &same));
808 assert_eq!(created.load(std::sync::atomic::Ordering::SeqCst), 3);
809 }
810
811 #[tokio::test]
812 async fn a_removed_connection_is_let_go_on_the_next_resolve_of_any_connection() {
813 let (resolver, svc, _) = resolver();
814 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
815 svc.add_or_update_connection(conn("gh-2", ProviderType::GitHub), Some("pat".into())).await.unwrap();
816 resolver.resolve("gh-1").await.unwrap().expect("resolved");
817 resolver.resolve("gh-2").await.unwrap().expect("resolved");
818 svc.remove_connection("gh-1").await.unwrap();
819 resolver.resolve("gh-2").await.unwrap().expect("still there");
822 let live: Vec<String> = resolver.live.lock().unwrap().keys().cloned().collect();
823 assert_eq!(live, vec!["gh-2".to_string()], "the removed connection's client is gone");
824 assert!(resolver.resolve("gh-1").await.unwrap().is_none());
825 }
826
827 fn conn(id: &str, provider: ProviderType) -> Connection {
828 Connection {
829 id: id.into(),
830 provider_type: provider,
831 display_name: id.into(),
832 base_url: None,
833 organization: None,
834 project: None,
835 repository: None,
836 username: None,
837 credential_ref: None,
838 repo_scope: None,
839 }
840 }
841
842 fn service() -> (Arc<ConfigService>, Arc<InMemorySecretStore>) {
843 let secrets = Arc::new(InMemorySecretStore::default());
844 let svc = Arc::new(ConfigService::new(
845 Arc::new(InMemoryConfigStore::default()),
846 secrets.clone(),
847 registry(),
848 ));
849 (svc, secrets)
850 }
851
852 #[tokio::test]
853 async fn add_connection_persists_and_stores_secret() {
854 let (svc, secrets) = service();
855 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
856 assert_eq!(svc.snapshot().connections.len(), 1);
857 assert_eq!(secrets.get("gh-1").unwrap().as_deref(), Some("pat"));
858 }
859
860 #[tokio::test]
861 async fn connection_and_binding_changes_notify_subscribers() {
862 let (svc, _) = service();
863 let mut rx = svc.subscribe_data_changed();
864
865 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
866 assert!(rx.has_changed().unwrap(), "adding a connection must wake the TUI");
867 rx.borrow_and_update();
868
869 svc.bind_pull_requests("gh-1").await.unwrap();
870 assert!(rx.has_changed().unwrap(), "binding a section must wake the TUI");
871 rx.borrow_and_update();
872
873 svc.remove_connection("gh-1").await.unwrap();
874 assert!(rx.has_changed().unwrap(), "removing a connection must wake the TUI");
875 }
876
877 #[tokio::test]
878 async fn ui_preferences_do_not_notify_subscribers() {
879 let (svc, _) = service();
880 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
881 let rx = svc.subscribe_data_changed();
882
883 svc.set_theme(Some("slate".into())).await.unwrap();
886 svc.set_hidden_sections(vec![Section::Pipelines]).await.unwrap();
887 svc.set_notifications(NotificationPrefs::default()).await.unwrap();
888
889 assert!(!rx.has_changed().unwrap(), "a UI preference must not trigger a refetch");
890 }
891
892 #[tokio::test]
893 async fn rewriting_an_identical_connection_does_not_notify() {
894 let (svc, _) = service();
895 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
896 let rx = svc.subscribe_data_changed();
897
898 let mut same = conn("gh-1", ProviderType::GitHub);
900 same.credential_ref = Some("gh-1".into());
901 svc.add_or_update_connection(same, None).await.unwrap();
902
903 assert!(!rx.has_changed().unwrap(), "an unchanged rewrite must not trigger a refetch");
904 }
905
906 #[tokio::test]
907 async fn bind_validates_capability() {
908 let (svc, _) = service();
909 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
910 svc.add_or_update_connection(conn("lin-1", ProviderType::Linear), None).await.unwrap();
911 svc.bind_pull_requests("gh-1").await.unwrap();
912 assert_eq!(svc.snapshot().pull_requests.unwrap().ids(), vec!["gh-1".to_string()]);
913 assert!(svc.bind_pull_requests("lin-1").await.is_err());
914 }
915
916 #[tokio::test]
917 async fn remove_connection_cascades() {
918 let (svc, secrets) = service();
919 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
920 svc.bind_pull_requests("gh-1").await.unwrap();
921 svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
922 svc.remove_connection("gh-1").await.unwrap();
923 let cfg = svc.snapshot();
924 assert!(cfg.connections.is_empty());
925 assert!(cfg.pull_requests.is_none());
926 assert!(cfg.pipelines.unwrap().subscriptions.is_empty());
927 assert_eq!(secrets.get("gh-1").unwrap(), None);
928 }
929
930 #[tokio::test]
931 async fn repo_scope_persists_including_the_empty_choice() {
932 let (svc, _) = service();
933 let mut c = conn("gh-1", ProviderType::GitHub);
934 c.repository = Some("acme/pay".into());
935 svc.add_or_update_connection(c, None).await.unwrap();
936
937 assert_eq!(svc.repo_scope("gh-1"), None);
939
940 svc.set_repo_scope("gh-1", Some(vec!["acme/pay".into(), "acme/ledger".into()])).await.unwrap();
941 assert_eq!(svc.repo_scope("gh-1").unwrap().len(), 2);
942
943 svc.set_repo_scope("gh-1", Some(vec![])).await.unwrap();
945 assert_eq!(svc.repo_scope("gh-1"), Some(vec![]));
946 svc.load().await.unwrap();
947 assert_eq!(svc.repo_scope("gh-1"), Some(vec![]), "an emptied scope survives a reload");
948
949 assert!(svc.set_repo_scope("nope", None).await.is_err());
950 }
951
952 #[tokio::test]
953 async fn pipeline_subscribe_unsubscribe() {
954 let (svc, _) = service();
955 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
956 svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
957 svc.subscribe_pipeline("gh-1", "release").await.unwrap();
958 svc.subscribe_pipeline("gh-1", "ci").await.unwrap(); assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 2);
960 svc.unsubscribe_pipeline("gh-1", "ci").await.unwrap();
961 assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 1);
962 }
963
964 #[tokio::test]
965 async fn set_pipeline_definitions_replaces_and_disables_auto() {
966 let (svc, _) = service();
967 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
968 svc.set_pipeline_auto_discover("gh-1", true).await.unwrap();
969 svc.set_pipeline_definitions("gh-1", vec!["ci".into(), "release".into()]).await.unwrap();
970 let cfg = svc.snapshot();
971 let sub = &cfg.pipelines.unwrap().subscriptions[0];
972 assert!(!sub.auto_discover_all);
973 assert_eq!(sub.definition_ids, vec!["ci".to_string(), "release".to_string()]);
974 }
975
976 fn def(repo: &str, id: &str) -> PipelineDefinition {
977 PipelineDefinition { repository: Some(repo.into()), id: id.into(), name: id.into(), path: None, url: None }
978 }
979
980 #[test]
983 fn pipeline_queries_are_addressed_at_the_definition_s_own_repository() {
984 let defs = vec![def("acme/pay", "ci"), def("acme/web", "release")];
985 let sub = PipelineSubscription {
986 connection_id: "gh".into(),
987 definition_ids: vec!["ci".into(), "release".into()],
988 auto_discover_all: false,
989 };
990 let queries = pipeline_run_queries(&sub, &defs);
991 assert_eq!(queries[0].repository.as_deref(), Some("acme/pay"));
992 assert_eq!(queries[1].repository.as_deref(), Some("acme/web"));
993 }
994
995 #[test]
999 fn subscribing_to_everything_queries_every_definition_on_its_own() {
1000 let defs = vec![def("proj/app", "pr-check"), def("proj/app", "mainline"), def("proj/web", "deploy")];
1001 for sub in [
1002 PipelineSubscription { connection_id: "ado".into(), definition_ids: vec![], auto_discover_all: true },
1003 PipelineSubscription { connection_id: "ado".into(), definition_ids: vec!["pr-check".into()], auto_discover_all: true },
1005 ] {
1006 let queries = pipeline_run_queries(&sub, &defs);
1007 let ids: Vec<_> = queries.iter().map(|q| q.definition_id.as_deref()).collect();
1008 assert_eq!(ids, vec![Some("pr-check"), Some("mainline"), Some("deploy")]);
1009 assert!(queries.iter().all(|q| q.limit == Some(RUNS_PER_DEFINITION)));
1010 assert_eq!(queries[2].repository.as_deref(), Some("proj/web"));
1011 }
1012 }
1013
1014 #[test]
1017 fn an_empty_discovery_falls_back_to_one_connection_wide_query() {
1018 let sub = PipelineSubscription { connection_id: "ado".into(), definition_ids: vec![], auto_discover_all: true };
1019 let queries = pipeline_run_queries(&sub, &[]);
1020 assert_eq!(queries.len(), 1);
1021 assert_eq!(queries[0].definition_id, None);
1022 }
1023
1024 #[test]
1026 fn nothing_selected_fetches_nothing() {
1027 let sub = PipelineSubscription { connection_id: "ado".into(), definition_ids: vec![], auto_discover_all: false };
1028 assert!(pipeline_run_queries(&sub, &[def("proj/app", "ci")]).is_empty());
1029 assert!(pipeline_run_queries(&sub, &[]).is_empty());
1030 }
1031
1032 #[tokio::test]
1035 async fn bind_pipelines_selects_nothing_and_keeps_an_existing_selection() {
1036 let (svc, _) = service();
1037 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
1038 svc.bind_pipelines("gh-1").await.unwrap();
1039 let sub = svc.snapshot().pipelines.unwrap().subscriptions[0].clone();
1040 assert!(!sub.auto_discover_all && sub.definition_ids.is_empty());
1041
1042 svc.set_pipeline_definitions("gh-1", vec!["ci".into()]).await.unwrap();
1043 svc.bind_pipelines("gh-1").await.unwrap();
1044 let subs = svc.snapshot().pipelines.unwrap().subscriptions;
1045 assert_eq!(subs.len(), 1);
1046 assert_eq!(subs[0].definition_ids, vec!["ci".to_string()]);
1047 }
1048
1049 fn sub(id: &str, ids: &[&str], all: bool) -> PipelineSubscription {
1050 PipelineSubscription {
1051 connection_id: id.into(),
1052 definition_ids: ids.iter().map(|s| s.to_string()).collect(),
1053 auto_discover_all: all,
1054 }
1055 }
1056
1057 #[test]
1060 fn opt_in_migration_resets_every_pipeline_once() {
1061 let mut cfg = ForgetopConfig {
1062 pipelines: Some(PipelineBinding {
1063 subscriptions: vec![sub("ado", &[], true), sub("gh", &[], false), sub("gl", &["ci"], false)],
1064 }),
1065 ..Default::default()
1066 };
1067 assert!(migrate_pipelines_opt_in(&mut cfg));
1068 assert!(cfg.pipelines_opt_in);
1069 let subs = &cfg.pipelines.as_ref().unwrap().subscriptions;
1070 assert_eq!(subs[0], sub("ado", &[], false));
1071 assert_eq!(subs[1], sub("gh", &[], false));
1072 assert_eq!(subs[2], sub("gl", &["ci"], false), "an explicit selection is kept");
1073
1074 cfg.pipelines.as_mut().unwrap().subscriptions[0].auto_discover_all = true;
1075 assert!(!migrate_pipelines_opt_in(&mut cfg), "runs once");
1076 assert!(cfg.pipelines.unwrap().subscriptions[0].auto_discover_all);
1077 }
1078
1079 #[tokio::test]
1082 async fn opt_in_reset_is_saved_and_not_repeated_on_the_next_load() {
1083 let store = Arc::new(InMemoryConfigStore::default());
1084 let svc = ConfigService::new(store.clone(), Arc::new(InMemorySecretStore::default()), registry());
1085 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
1086 svc.set_pipeline_auto_discover("gh-1", true).await.unwrap();
1087
1088 svc.load().await.unwrap();
1089 let saved = store.load().await.unwrap();
1090 assert!(saved.pipelines_opt_in);
1091 assert!(!saved.pipelines.unwrap().subscriptions[0].auto_discover_all, "reset on disk");
1092
1093 svc.set_pipeline_auto_discover("gh-1", true).await.unwrap();
1094 svc.load().await.unwrap();
1095 assert!(svc.snapshot().pipelines.unwrap().subscriptions[0].auto_discover_all, "kept after restart");
1096 }
1097
1098 #[test]
1100 fn opt_in_migration_without_pipelines_needs_no_save() {
1101 let mut cfg = ForgetopConfig::default();
1102 assert!(!migrate_pipelines_opt_in(&mut cfg));
1103 assert!(cfg.pipelines_opt_in);
1104 }
1105}