1use std::sync::{Arc, Mutex};
4
5use tokio::sync::watch;
6
7use crate::config::*;
8use crate::domain::{PipelineDefinition, PipelineRun, 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 mut cfg = self.store.load().await?;
41 if migrate_pipelines_opt_in(&mut cfg) {
42 self.store.save(&cfg).await?;
43 }
44 *self.config.lock().unwrap() = cfg;
45 Ok(())
46 }
47
48 pub fn snapshot(&self) -> ForgetopConfig {
49 self.config.lock().unwrap().clone()
50 }
51
52 async fn persist(&self, cfg: ForgetopConfig) -> Result<()> {
53 self.store.save(&cfg).await?;
54 let fetch_changed = {
55 let mut current = self.config.lock().unwrap();
56 let changed = !same_fetch_inputs(¤t, &cfg);
57 *current = cfg;
58 changed
59 };
60 if fetch_changed {
63 self.data_changed.send_modify(|generation| *generation += 1);
64 }
65 Ok(())
66 }
67
68 pub fn subscribe_data_changed(&self) -> watch::Receiver<u64> {
72 self.data_changed.subscribe()
73 }
74
75 fn ensure_supports(&self, cfg: &ForgetopConfig, connection_id: &str, section: Section) -> Result<()> {
76 let conn = cfg
77 .find_connection(connection_id)
78 .ok_or_else(|| Error::Config(format!("unknown connection '{connection_id}'")))?;
79 let caps = self
80 .registry
81 .describe(conn.provider_type)
82 .ok_or_else(|| Error::Config(format!("provider '{}' not registered", conn.provider_type.as_str())))?;
83 if !caps.supports(section) {
84 return Err(Error::Config(format!(
85 "connection '{}' ({}) does not support {section:?}",
86 conn.display_name,
87 conn.provider_type.as_str()
88 )));
89 }
90 Ok(())
91 }
92
93 pub async fn add_or_update_connection(&self, mut connection: Connection, secret: Option<String>) -> Result<()> {
94 let cred = connection.credential_ref.clone().unwrap_or_else(|| connection.id.clone());
95 connection.credential_ref = Some(cred.clone());
96
97 if let Some(secret) = secret {
98 if !self.secrets.is_writable() {
99 return Err(Error::Config(
100 "secret store is read-only; provide the PAT via environment variable instead".into(),
101 ));
102 }
103 self.secrets.set(&cred, &secret)?;
104 }
105
106 let mut cfg = self.snapshot();
107 cfg.connections.retain(|c| c.id != connection.id);
108 cfg.connections.push(connection);
109 self.persist(cfg).await
110 }
111
112 pub async fn remove_connection(&self, connection_id: &str) -> Result<()> {
113 let mut cfg = self.snapshot();
114 if let Some(existing) = cfg.find_connection(connection_id).cloned() {
115 if let (Some(cred), true) = (existing.credential_ref.as_ref(), self.secrets.is_writable()) {
116 let _ = self.secrets.delete(cred);
117 }
118 }
119 cfg.connections.retain(|c| c.id != connection_id);
120 if let Some(b) = &mut cfg.pull_requests {
121 b.connection_ids.retain(|c| c != connection_id);
122 b.connection_id = b.connection_id.take().filter(|c| c != connection_id);
123 if b.ids().is_empty() {
124 cfg.pull_requests = None;
125 }
126 }
127 if let Some(b) = &mut cfg.work_items {
128 b.connection_ids.retain(|c| c != connection_id);
129 b.connection_id = b.connection_id.take().filter(|c| c != connection_id);
130 if b.ids().is_empty() {
131 cfg.work_items = None;
132 }
133 }
134 if let Some(p) = &mut cfg.pipelines {
135 p.subscriptions.retain(|s| s.connection_id != connection_id);
136 }
137 self.persist(cfg).await
138 }
139
140 pub async fn bind_pull_requests(&self, connection_id: &str) -> Result<()> {
141 let mut cfg = self.snapshot();
142 self.ensure_supports(&cfg, connection_id, Section::PullRequests)?;
143 let b = cfg.pull_requests.get_or_insert_with(PullRequestBinding::default);
144 let mut ids = b.ids();
145 if !ids.contains(&connection_id.to_string()) {
146 ids.push(connection_id.into());
147 }
148 *b = PullRequestBinding { connection_ids: ids, connection_id: None };
149 self.persist(cfg).await
150 }
151
152 pub async fn bind_work_items(&self, connection_id: &str) -> Result<()> {
153 let mut cfg = self.snapshot();
154 self.ensure_supports(&cfg, connection_id, Section::WorkItems)?;
155 let b = cfg.work_items.get_or_insert_with(WorkItemBinding::default);
156 let mut ids = b.ids();
157 if !ids.contains(&connection_id.to_string()) {
158 ids.push(connection_id.into());
159 }
160 *b = WorkItemBinding { connection_ids: ids, connection_id: None };
161 self.persist(cfg).await
162 }
163
164 pub async fn unbind_pull_requests(&self, connection_id: &str) -> Result<()> {
166 let mut cfg = self.snapshot();
167 if let Some(b) = &mut cfg.pull_requests {
168 let ids: Vec<String> = b.ids().into_iter().filter(|c| c != connection_id).collect();
169 cfg.pull_requests = (!ids.is_empty()).then_some(PullRequestBinding { connection_ids: ids, connection_id: None });
170 }
171 self.persist(cfg).await
172 }
173
174 pub async fn unbind_work_items(&self, connection_id: &str) -> Result<()> {
176 let mut cfg = self.snapshot();
177 if let Some(b) = &mut cfg.work_items {
178 let ids: Vec<String> = b.ids().into_iter().filter(|c| c != connection_id).collect();
179 cfg.work_items = (!ids.is_empty()).then_some(WorkItemBinding { connection_ids: ids, connection_id: None });
180 }
181 self.persist(cfg).await
182 }
183
184 pub async fn unbind_pipelines(&self, connection_id: &str) -> Result<()> {
186 let mut cfg = self.snapshot();
187 if let Some(p) = &mut cfg.pipelines {
188 p.subscriptions.retain(|s| s.connection_id != connection_id);
189 if p.subscriptions.is_empty() {
190 cfg.pipelines = None;
191 }
192 }
193 self.persist(cfg).await
194 }
195
196 pub async fn set_pull_request_connections(&self, connection_ids: Vec<String>) -> Result<()> {
198 let mut cfg = self.snapshot();
199 cfg.pull_requests = Some(PullRequestBinding { connection_ids, connection_id: None });
200 self.persist(cfg).await
201 }
202
203 pub async fn set_work_item_connections(&self, connection_ids: Vec<String>) -> Result<()> {
204 let mut cfg = self.snapshot();
205 cfg.work_items = Some(WorkItemBinding { connection_ids, connection_id: None });
206 self.persist(cfg).await
207 }
208
209 pub async fn unbind_section(&self, section: Section) -> Result<()> {
210 let mut cfg = self.snapshot();
211 match section {
212 Section::PullRequests => cfg.pull_requests = None,
213 Section::WorkItems => cfg.work_items = None,
214 Section::Pipelines => cfg.pipelines = None,
215 }
216 self.persist(cfg).await
217 }
218
219 async fn mutate_subscription(&self, connection_id: &str, f: impl FnOnce(&mut PipelineSubscription)) -> Result<()> {
220 let mut cfg = self.snapshot();
221 self.ensure_supports(&cfg, connection_id, Section::Pipelines)?;
222 let binding = cfg.pipelines.get_or_insert_with(PipelineBinding::default);
223 let sub = match binding.subscriptions.iter_mut().find(|s| s.connection_id == connection_id) {
224 Some(existing) => existing,
225 None => {
226 binding.subscriptions.push(PipelineSubscription {
227 connection_id: connection_id.into(),
228 definition_ids: Vec::new(),
229 auto_discover_all: false,
230 });
231 binding.subscriptions.last_mut().unwrap()
232 }
233 };
234 f(sub);
235 self.persist(cfg).await
236 }
237
238 pub async fn subscribe_pipeline(&self, connection_id: &str, definition_id: &str) -> Result<()> {
239 let definition_id = definition_id.to_string();
240 self.mutate_subscription(connection_id, move |s| {
241 if !s.definition_ids.contains(&definition_id) {
242 s.definition_ids.push(definition_id);
243 }
244 })
245 .await
246 }
247
248 pub async fn bind_pipelines(&self, connection_id: &str) -> Result<()> {
252 self.mutate_subscription(connection_id, |_| {}).await
253 }
254
255 pub async fn set_pipeline_auto_discover(&self, connection_id: &str, auto_discover_all: bool) -> Result<()> {
256 self.mutate_subscription(connection_id, move |s| s.auto_discover_all = auto_discover_all).await
257 }
258
259 pub async fn unsubscribe_pipeline(&self, connection_id: &str, definition_id: &str) -> Result<()> {
260 self.mutate_subscription(connection_id, move |s| s.definition_ids.retain(|d| d != definition_id)).await
261 }
262
263 pub async fn remove_pipeline_connection(&self, connection_id: &str) -> Result<()> {
264 let mut cfg = self.snapshot();
265 if let Some(p) = &mut cfg.pipelines {
266 p.subscriptions.retain(|s| s.connection_id != connection_id);
267 }
268 self.persist(cfg).await
269 }
270
271 pub async fn set_repo_scope(&self, connection_id: &str, scope: Option<Vec<String>>) -> Result<()> {
275 let mut cfg = self.snapshot();
276 let conn = cfg
277 .connections
278 .iter_mut()
279 .find(|c| c.id == connection_id)
280 .ok_or_else(|| Error::Config(format!("unknown connection '{connection_id}'")))?;
281 conn.repo_scope = scope;
282 self.persist(cfg).await
283 }
284
285 pub fn repo_scope(&self, connection_id: &str) -> Option<Vec<String>> {
288 self.snapshot().find_connection(connection_id).and_then(|c| c.repo_scope.clone())
289 }
290
291 pub async fn set_theme(&self, theme: Option<String>) -> Result<()> {
292 let mut cfg = self.snapshot();
293 cfg.ui.theme = theme;
294 self.persist(cfg).await
295 }
296
297 pub async fn set_hidden_sections(&self, hidden: Vec<Section>) -> Result<()> {
298 let mut cfg = self.snapshot();
299 cfg.ui.hidden_sections = hidden;
300 self.persist(cfg).await
301 }
302
303 pub async fn set_preview_hidden(&self, hidden: Vec<Section>) -> Result<()> {
304 let mut cfg = self.snapshot();
305 cfg.ui.preview_hidden = Some(hidden);
306 self.persist(cfg).await
307 }
308
309 pub async fn set_hidden_work_item_states(&self, hidden: Vec<String>) -> Result<()> {
310 let mut cfg = self.snapshot();
311 cfg.ui.hidden_work_item_states = hidden;
312 self.persist(cfg).await
313 }
314
315 pub async fn set_hidden_columns(&self, hidden: [Vec<String>; 3]) -> Result<()> {
316 let mut cfg = self.snapshot();
317 cfg.ui.hidden_columns = Some(hidden);
318 self.persist(cfg).await
319 }
320
321 pub async fn set_dismissed_launchpad_items(&self, dismissed: Vec<String>) -> Result<()> {
322 let mut cfg = self.snapshot();
323 cfg.ui.dismissed_launchpad_items = dismissed;
324 self.persist(cfg).await
325 }
326
327 pub async fn set_dismissed_notifications(&self, dismissed: Vec<String>) -> Result<()> {
328 let mut cfg = self.snapshot();
329 cfg.ui.dismissed_notifications = dismissed;
330 self.persist(cfg).await
331 }
332
333 pub async fn set_notifications(&self, prefs: NotificationPrefs) -> Result<()> {
334 let mut cfg = self.snapshot();
335 cfg.ui.notifications = prefs;
336 self.persist(cfg).await
337 }
338
339 pub async fn set_views(&self, section: Section, views: Vec<SavedView>) -> Result<()> {
341 let mut cfg = self.snapshot();
342 match section {
343 Section::PullRequests => cfg.ui.pr_views = views,
344 Section::WorkItems => cfg.ui.work_item_views = views,
345 Section::Pipelines => cfg.ui.pipeline_views = views,
346 }
347 self.persist(cfg).await
348 }
349
350 pub async fn set_sort(&self, section: Section, sort: Option<SortPref>) -> Result<()> {
352 let mut cfg = self.snapshot();
353 match section {
354 Section::PullRequests => cfg.ui.pr_sort = sort,
355 Section::WorkItems => cfg.ui.work_item_sort = sort,
356 Section::Pipelines => cfg.ui.pipeline_sort = sort,
357 }
358 self.persist(cfg).await
359 }
360
361 pub async fn set_pipeline_group(&self, group: Option<String>) -> Result<()> {
363 let mut cfg = self.snapshot();
364 cfg.ui.pipeline_group = group;
365 self.persist(cfg).await
366 }
367
368 pub async fn set_pipeline_definitions(&self, connection_id: &str, definition_ids: Vec<String>) -> Result<()> {
371 self.mutate_subscription(connection_id, move |s| {
372 s.auto_discover_all = false;
373 s.definition_ids = definition_ids;
374 })
375 .await
376 }
377}
378
379fn migrate_pipelines_opt_in(cfg: &mut ForgetopConfig) -> bool {
384 if cfg.pipelines_opt_in {
385 return false;
386 }
387 cfg.pipelines_opt_in = true;
388 let Some(binding) = &mut cfg.pipelines else { return false };
389 let mut changed = false;
390 for sub in binding.subscriptions.iter_mut().filter(|s| s.auto_discover_all || s.definition_ids.is_empty()) {
393 sub.auto_discover_all = false;
394 sub.definition_ids.clear();
395 changed = true;
396 }
397 changed
398}
399
400pub struct ConnectionResolver {
402 config: Arc<ConfigService>,
403 registry: Arc<ProviderRegistry>,
404 secrets: Arc<dyn SecretStore>,
405}
406
407impl ConnectionResolver {
408 pub fn new(config: Arc<ConfigService>, registry: Arc<ProviderRegistry>, secrets: Arc<dyn SecretStore>) -> Self {
409 Self { config, registry, secrets }
410 }
411
412 pub async fn resolve(&self, connection_id: &str) -> Result<Option<Arc<dyn ProviderConnection>>> {
413 let cfg = self.config.snapshot();
414 let conn = match cfg.find_connection(connection_id) {
415 Some(c) => c.clone(),
416 None => return Ok(None),
417 };
418 if !self.registry.supports(conn.provider_type) {
419 return Ok(None);
420 }
421 let secret = match &conn.credential_ref {
422 Some(r) => self.secrets.get(r)?,
423 None => None,
424 };
425 Ok(Some(self.registry.create(&conn, secret)?))
426 }
427}
428
429pub struct PipelineFeed {
431 pub connection: Arc<dyn ProviderConnection>,
432 pub source: Arc<dyn PipelineSource>,
433 pub subscription: PipelineSubscription,
434}
435
436pub const RUNS_PER_DEFINITION: u32 = 10;
438
439const MAX_CONCURRENT_RUN_QUERIES: usize = 4;
441
442pub fn pipeline_run_queries(sub: &PipelineSubscription, defs: &[PipelineDefinition]) -> Vec<PipelineRunQuery> {
456 if !sub.auto_discover_all && sub.definition_ids.is_empty() {
458 return Vec::new();
459 }
460 let all = sub.auto_discover_all;
461 let ids: Vec<&String> = if all { defs.iter().map(|d| &d.id).collect() } else { sub.definition_ids.iter().collect() };
462 if ids.is_empty() {
463 return vec![PipelineRunQuery { definition_id: None, repository: None, branch: None, limit: Some(20) }];
464 }
465 ids.into_iter()
466 .map(|id| PipelineRunQuery {
467 repository: defs.iter().find(|d| &d.id == id).and_then(|d| d.repository.clone()),
468 definition_id: Some(id.clone()),
469 branch: None,
470 limit: Some(RUNS_PER_DEFINITION),
471 })
472 .collect()
473}
474
475impl PipelineFeed {
476 pub async fn list_runs(&self, defs: &[PipelineDefinition]) -> Vec<Result<Vec<PipelineRun>>> {
479 let queries = pipeline_run_queries(&self.subscription, defs);
480 let mut out = Vec::with_capacity(queries.len());
481 for chunk in queries.chunks(MAX_CONCURRENT_RUN_QUERIES) {
482 out.extend(futures_util::future::join_all(chunk.iter().map(|q| self.source.list_runs(q))).await);
483 }
484 out
485 }
486}
487
488pub struct PullRequestFeed {
490 pub connection: Arc<dyn ProviderConnection>,
491 pub source: Arc<dyn PullRequestSource>,
492}
493
494pub struct WorkItemFeed {
496 pub connection: Arc<dyn ProviderConnection>,
497 pub source: Arc<dyn WorkItemSource>,
498}
499
500pub struct NotificationFeed {
503 pub connection: Arc<dyn ProviderConnection>,
504 pub source: Arc<dyn NotificationSource>,
505}
506
507pub struct SectionService {
509 config: Arc<ConfigService>,
510 resolver: Arc<ConnectionResolver>,
511}
512
513impl SectionService {
514 pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
515 Self { config, resolver }
516 }
517
518 pub async fn pull_request_feeds(&self) -> Result<Vec<PullRequestFeed>> {
520 let cfg = self.config.snapshot();
521 let Some(binding) = cfg.pull_requests else { return Ok(Vec::new()) };
522 let mut feeds = Vec::new();
523 for id in binding.ids() {
524 if let Some(conn) = self.resolver.resolve(&id).await? {
525 if let Some(source) = conn.pull_requests() {
526 feeds.push(PullRequestFeed { connection: conn, source });
527 }
528 }
529 }
530 Ok(feeds)
531 }
532
533 pub async fn work_item_feeds(&self) -> Result<Vec<WorkItemFeed>> {
534 let cfg = self.config.snapshot();
535 let Some(binding) = cfg.work_items else { return Ok(Vec::new()) };
536 let mut feeds = Vec::new();
537 for id in binding.ids() {
538 if let Some(conn) = self.resolver.resolve(&id).await? {
539 if let Some(source) = conn.work_items() {
540 feeds.push(WorkItemFeed { connection: conn, source });
541 }
542 }
543 }
544 Ok(feeds)
545 }
546
547 pub async fn notification_feeds(&self) -> Result<Vec<NotificationFeed>> {
550 let cfg = self.config.snapshot();
551 let mut feeds = Vec::new();
552 for c in &cfg.connections {
553 if let Some(conn) = self.resolver.resolve(&c.id).await? {
554 if let Some(source) = conn.notifications() {
555 feeds.push(NotificationFeed { connection: conn, source });
556 }
557 }
558 }
559 Ok(feeds)
560 }
561
562 pub async fn pull_request_source(&self) -> Result<Option<Arc<dyn PullRequestSource>>> {
564 Ok(self.pull_request_feeds().await?.into_iter().next().map(|f| f.source))
565 }
566
567 pub async fn work_item_source(&self) -> Result<Option<Arc<dyn WorkItemSource>>> {
568 Ok(self.work_item_feeds().await?.into_iter().next().map(|f| f.source))
569 }
570
571 pub async fn pipeline_source_for(&self, connection_id: &str) -> Result<Option<Arc<dyn PipelineSource>>> {
574 Ok(self.resolver.resolve(connection_id).await?.and_then(|c| c.pipelines()))
575 }
576
577 pub async fn discover_repositories(&self, connection_id: &str) -> Result<RepositoryPage> {
579 match self.resolver.resolve(connection_id).await? {
580 Some(conn) => conn.discover_repositories().await,
581 None => Ok(RepositoryPage::default()),
582 }
583 }
584
585 pub async fn pipeline_feeds(&self) -> Result<Vec<PipelineFeed>> {
586 let cfg = self.config.snapshot();
587 let Some(binding) = cfg.pipelines else { return Ok(Vec::new()) };
588 let mut feeds = Vec::new();
589 for sub in binding.subscriptions {
590 if let Some(conn) = self.resolver.resolve(&sub.connection_id).await? {
591 if let Some(source) = conn.pipelines() {
592 feeds.push(PipelineFeed { connection: conn, source, subscription: sub });
593 }
594 }
595 }
596 Ok(feeds)
597 }
598}
599
600pub async fn seed_default_repo_scope(
612 config: &ConfigService,
613 sections: &SectionService,
614 connection_id: &str,
615) -> Result<Option<RepositoryPage>> {
616 let Some(conn) = config.snapshot().find_connection(connection_id).cloned() else { return Ok(None) };
617 if conn.repo_scope.is_some() || conn.repository.is_some() {
618 return Ok(None);
619 }
620 let page = sections.discover_repositories(connection_id).await.unwrap_or_default();
621 if page.repositories.is_empty() {
622 return Ok(None);
623 }
624 config.set_repo_scope(connection_id, Some(Vec::new())).await?;
625 Ok(Some(page))
626}
627
628pub struct ConnectionHealth {
630 pub connection: Connection,
631 pub healthy: bool,
632}
633
634pub struct ConnectionHealthService {
636 config: Arc<ConfigService>,
637 resolver: Arc<ConnectionResolver>,
638}
639
640impl ConnectionHealthService {
641 pub fn new(config: Arc<ConfigService>, resolver: Arc<ConnectionResolver>) -> Self {
642 Self { config, resolver }
643 }
644
645 pub async fn check_all(&self) -> Vec<ConnectionHealth> {
646 let cfg = self.config.snapshot();
647 let mut out = Vec::new();
648 for connection in cfg.connections {
649 let healthy = match self.resolver.resolve(&connection.id).await {
650 Ok(Some(live)) => live.check().await,
651 _ => false,
652 };
653 out.push(ConnectionHealth { connection, healthy });
654 }
655 out
656 }
657}
658
659
660fn same_fetch_inputs(a: &ForgetopConfig, b: &ForgetopConfig) -> bool {
664 a.connections == b.connections
665 && a.pull_requests == b.pull_requests
666 && a.work_items == b.work_items
667 && a.pipelines == b.pipelines
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use crate::domain::ProviderType;
674 use crate::secret::InMemorySecretStore;
675 use async_trait::async_trait;
676
677 struct FakeConn {
678 caps: Capabilities,
679 }
680 #[async_trait]
681 impl ProviderConnection for FakeConn {
682 fn connection_id(&self) -> &str { "fake" }
683 fn provider_type(&self) -> ProviderType { ProviderType::GitHub }
684 fn display_name(&self) -> &str { "Fake" }
685 fn capabilities(&self) -> &Capabilities { &self.caps }
686 fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> { None }
687 fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> { None }
688 fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> { None }
689 async fn check(&self) -> bool { true }
690 }
691 struct FakeFactory {
692 provider: ProviderType,
693 caps: Capabilities,
694 }
695 impl ProviderFactory for FakeFactory {
696 fn provider_type(&self) -> ProviderType { self.provider }
697 fn describe_capabilities(&self) -> Capabilities { self.caps.clone() }
698 fn create(&self, _c: &Connection, _s: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
699 Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
700 }
701 }
702
703 fn registry() -> Arc<ProviderRegistry> {
704 Arc::new(ProviderRegistry::new(vec![
705 Arc::new(FakeFactory {
706 provider: ProviderType::GitHub,
707 caps: Capabilities { supports_pull_requests: true, supports_pipelines: true, ..Default::default() },
708 }),
709 Arc::new(FakeFactory {
710 provider: ProviderType::Linear,
711 caps: Capabilities { supports_work_items: true, ..Default::default() },
712 }),
713 ]))
714 }
715
716 fn conn(id: &str, provider: ProviderType) -> Connection {
717 Connection {
718 id: id.into(),
719 provider_type: provider,
720 display_name: id.into(),
721 base_url: None,
722 organization: None,
723 project: None,
724 repository: None,
725 username: None,
726 credential_ref: None,
727 repo_scope: None,
728 }
729 }
730
731 fn service() -> (Arc<ConfigService>, Arc<InMemorySecretStore>) {
732 let secrets = Arc::new(InMemorySecretStore::default());
733 let svc = Arc::new(ConfigService::new(
734 Arc::new(InMemoryConfigStore::default()),
735 secrets.clone(),
736 registry(),
737 ));
738 (svc, secrets)
739 }
740
741 #[tokio::test]
742 async fn add_connection_persists_and_stores_secret() {
743 let (svc, secrets) = service();
744 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
745 assert_eq!(svc.snapshot().connections.len(), 1);
746 assert_eq!(secrets.get("gh-1").unwrap().as_deref(), Some("pat"));
747 }
748
749 #[tokio::test]
750 async fn connection_and_binding_changes_notify_subscribers() {
751 let (svc, _) = service();
752 let mut rx = svc.subscribe_data_changed();
753
754 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
755 assert!(rx.has_changed().unwrap(), "adding a connection must wake the TUI");
756 rx.borrow_and_update();
757
758 svc.bind_pull_requests("gh-1").await.unwrap();
759 assert!(rx.has_changed().unwrap(), "binding a section must wake the TUI");
760 rx.borrow_and_update();
761
762 svc.remove_connection("gh-1").await.unwrap();
763 assert!(rx.has_changed().unwrap(), "removing a connection must wake the TUI");
764 }
765
766 #[tokio::test]
767 async fn ui_preferences_do_not_notify_subscribers() {
768 let (svc, _) = service();
769 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
770 let rx = svc.subscribe_data_changed();
771
772 svc.set_theme(Some("slate".into())).await.unwrap();
775 svc.set_hidden_sections(vec![Section::Pipelines]).await.unwrap();
776 svc.set_notifications(NotificationPrefs::default()).await.unwrap();
777
778 assert!(!rx.has_changed().unwrap(), "a UI preference must not trigger a refetch");
779 }
780
781 #[tokio::test]
782 async fn rewriting_an_identical_connection_does_not_notify() {
783 let (svc, _) = service();
784 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
785 let rx = svc.subscribe_data_changed();
786
787 let mut same = conn("gh-1", ProviderType::GitHub);
789 same.credential_ref = Some("gh-1".into());
790 svc.add_or_update_connection(same, None).await.unwrap();
791
792 assert!(!rx.has_changed().unwrap(), "an unchanged rewrite must not trigger a refetch");
793 }
794
795 #[tokio::test]
796 async fn bind_validates_capability() {
797 let (svc, _) = service();
798 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
799 svc.add_or_update_connection(conn("lin-1", ProviderType::Linear), None).await.unwrap();
800 svc.bind_pull_requests("gh-1").await.unwrap();
801 assert_eq!(svc.snapshot().pull_requests.unwrap().ids(), vec!["gh-1".to_string()]);
802 assert!(svc.bind_pull_requests("lin-1").await.is_err());
803 }
804
805 #[tokio::test]
806 async fn remove_connection_cascades() {
807 let (svc, secrets) = service();
808 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), Some("pat".into())).await.unwrap();
809 svc.bind_pull_requests("gh-1").await.unwrap();
810 svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
811 svc.remove_connection("gh-1").await.unwrap();
812 let cfg = svc.snapshot();
813 assert!(cfg.connections.is_empty());
814 assert!(cfg.pull_requests.is_none());
815 assert!(cfg.pipelines.unwrap().subscriptions.is_empty());
816 assert_eq!(secrets.get("gh-1").unwrap(), None);
817 }
818
819 #[tokio::test]
820 async fn repo_scope_persists_including_the_empty_choice() {
821 let (svc, _) = service();
822 let mut c = conn("gh-1", ProviderType::GitHub);
823 c.repository = Some("acme/pay".into());
824 svc.add_or_update_connection(c, None).await.unwrap();
825
826 assert_eq!(svc.repo_scope("gh-1"), None);
828
829 svc.set_repo_scope("gh-1", Some(vec!["acme/pay".into(), "acme/ledger".into()])).await.unwrap();
830 assert_eq!(svc.repo_scope("gh-1").unwrap().len(), 2);
831
832 svc.set_repo_scope("gh-1", Some(vec![])).await.unwrap();
834 assert_eq!(svc.repo_scope("gh-1"), Some(vec![]));
835 svc.load().await.unwrap();
836 assert_eq!(svc.repo_scope("gh-1"), Some(vec![]), "an emptied scope survives a reload");
837
838 assert!(svc.set_repo_scope("nope", None).await.is_err());
839 }
840
841 #[tokio::test]
842 async fn pipeline_subscribe_unsubscribe() {
843 let (svc, _) = service();
844 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
845 svc.subscribe_pipeline("gh-1", "ci").await.unwrap();
846 svc.subscribe_pipeline("gh-1", "release").await.unwrap();
847 svc.subscribe_pipeline("gh-1", "ci").await.unwrap(); assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 2);
849 svc.unsubscribe_pipeline("gh-1", "ci").await.unwrap();
850 assert_eq!(svc.snapshot().pipelines.unwrap().subscriptions[0].definition_ids.len(), 1);
851 }
852
853 #[tokio::test]
854 async fn set_pipeline_definitions_replaces_and_disables_auto() {
855 let (svc, _) = service();
856 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
857 svc.set_pipeline_auto_discover("gh-1", true).await.unwrap();
858 svc.set_pipeline_definitions("gh-1", vec!["ci".into(), "release".into()]).await.unwrap();
859 let cfg = svc.snapshot();
860 let sub = &cfg.pipelines.unwrap().subscriptions[0];
861 assert!(!sub.auto_discover_all);
862 assert_eq!(sub.definition_ids, vec!["ci".to_string(), "release".to_string()]);
863 }
864
865 fn def(repo: &str, id: &str) -> PipelineDefinition {
866 PipelineDefinition { repository: Some(repo.into()), id: id.into(), name: id.into(), path: None, url: None }
867 }
868
869 #[test]
872 fn pipeline_queries_are_addressed_at_the_definition_s_own_repository() {
873 let defs = vec![def("acme/pay", "ci"), def("acme/web", "release")];
874 let sub = PipelineSubscription {
875 connection_id: "gh".into(),
876 definition_ids: vec!["ci".into(), "release".into()],
877 auto_discover_all: false,
878 };
879 let queries = pipeline_run_queries(&sub, &defs);
880 assert_eq!(queries[0].repository.as_deref(), Some("acme/pay"));
881 assert_eq!(queries[1].repository.as_deref(), Some("acme/web"));
882 }
883
884 #[test]
888 fn subscribing_to_everything_queries_every_definition_on_its_own() {
889 let defs = vec![def("proj/app", "pr-check"), def("proj/app", "mainline"), def("proj/web", "deploy")];
890 for sub in [
891 PipelineSubscription { connection_id: "ado".into(), definition_ids: vec![], auto_discover_all: true },
892 PipelineSubscription { connection_id: "ado".into(), definition_ids: vec!["pr-check".into()], auto_discover_all: true },
894 ] {
895 let queries = pipeline_run_queries(&sub, &defs);
896 let ids: Vec<_> = queries.iter().map(|q| q.definition_id.as_deref()).collect();
897 assert_eq!(ids, vec![Some("pr-check"), Some("mainline"), Some("deploy")]);
898 assert!(queries.iter().all(|q| q.limit == Some(RUNS_PER_DEFINITION)));
899 assert_eq!(queries[2].repository.as_deref(), Some("proj/web"));
900 }
901 }
902
903 #[test]
906 fn an_empty_discovery_falls_back_to_one_connection_wide_query() {
907 let sub = PipelineSubscription { connection_id: "ado".into(), definition_ids: vec![], auto_discover_all: true };
908 let queries = pipeline_run_queries(&sub, &[]);
909 assert_eq!(queries.len(), 1);
910 assert_eq!(queries[0].definition_id, None);
911 }
912
913 #[test]
915 fn nothing_selected_fetches_nothing() {
916 let sub = PipelineSubscription { connection_id: "ado".into(), definition_ids: vec![], auto_discover_all: false };
917 assert!(pipeline_run_queries(&sub, &[def("proj/app", "ci")]).is_empty());
918 assert!(pipeline_run_queries(&sub, &[]).is_empty());
919 }
920
921 #[tokio::test]
924 async fn bind_pipelines_selects_nothing_and_keeps_an_existing_selection() {
925 let (svc, _) = service();
926 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
927 svc.bind_pipelines("gh-1").await.unwrap();
928 let sub = svc.snapshot().pipelines.unwrap().subscriptions[0].clone();
929 assert!(!sub.auto_discover_all && sub.definition_ids.is_empty());
930
931 svc.set_pipeline_definitions("gh-1", vec!["ci".into()]).await.unwrap();
932 svc.bind_pipelines("gh-1").await.unwrap();
933 let subs = svc.snapshot().pipelines.unwrap().subscriptions;
934 assert_eq!(subs.len(), 1);
935 assert_eq!(subs[0].definition_ids, vec!["ci".to_string()]);
936 }
937
938 fn sub(id: &str, ids: &[&str], all: bool) -> PipelineSubscription {
939 PipelineSubscription {
940 connection_id: id.into(),
941 definition_ids: ids.iter().map(|s| s.to_string()).collect(),
942 auto_discover_all: all,
943 }
944 }
945
946 #[test]
949 fn opt_in_migration_resets_every_pipeline_once() {
950 let mut cfg = ForgetopConfig {
951 pipelines: Some(PipelineBinding {
952 subscriptions: vec![sub("ado", &[], true), sub("gh", &[], false), sub("gl", &["ci"], false)],
953 }),
954 ..Default::default()
955 };
956 assert!(migrate_pipelines_opt_in(&mut cfg));
957 assert!(cfg.pipelines_opt_in);
958 let subs = &cfg.pipelines.as_ref().unwrap().subscriptions;
959 assert_eq!(subs[0], sub("ado", &[], false));
960 assert_eq!(subs[1], sub("gh", &[], false));
961 assert_eq!(subs[2], sub("gl", &["ci"], false), "an explicit selection is kept");
962
963 cfg.pipelines.as_mut().unwrap().subscriptions[0].auto_discover_all = true;
964 assert!(!migrate_pipelines_opt_in(&mut cfg), "runs once");
965 assert!(cfg.pipelines.unwrap().subscriptions[0].auto_discover_all);
966 }
967
968 #[tokio::test]
971 async fn opt_in_reset_is_saved_and_not_repeated_on_the_next_load() {
972 let store = Arc::new(InMemoryConfigStore::default());
973 let svc = ConfigService::new(store.clone(), Arc::new(InMemorySecretStore::default()), registry());
974 svc.add_or_update_connection(conn("gh-1", ProviderType::GitHub), None).await.unwrap();
975 svc.set_pipeline_auto_discover("gh-1", true).await.unwrap();
976
977 svc.load().await.unwrap();
978 let saved = store.load().await.unwrap();
979 assert!(saved.pipelines_opt_in);
980 assert!(!saved.pipelines.unwrap().subscriptions[0].auto_discover_all, "reset on disk");
981
982 svc.set_pipeline_auto_discover("gh-1", true).await.unwrap();
983 svc.load().await.unwrap();
984 assert!(svc.snapshot().pipelines.unwrap().subscriptions[0].auto_discover_all, "kept after restart");
985 }
986
987 #[test]
989 fn opt_in_migration_without_pipelines_needs_no_save() {
990 let mut cfg = ForgetopConfig::default();
991 assert!(!migrate_pipelines_opt_in(&mut cfg));
992 assert!(cfg.pipelines_opt_in);
993 }
994}