Skip to main content

forgetop_core/
service.rs

1//! Runtime config service + resolver/section/health services.
2
3use 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
14/// Holds the live config and applies mutations at runtime: each change is validated
15/// against provider capabilities and persisted. Secrets go to the secret store; the
16/// config only ever references them by key.
17pub struct ConfigService {
18    store: Arc<dyn ConfigStore>,
19    secrets: Arc<dyn SecretStore>,
20    registry: Arc<ProviderRegistry>,
21    config: Mutex<ForgetopConfig>,
22    /// Bumped whenever a persisted change alters what should be fetched — connections
23    /// or section bindings, never UI preferences. The dashboard and the TUI share one
24    /// `ConfigService`, so this is how a connection added in the browser reaches the
25    /// terminal without waiting for the refresh tick.
26    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(&current, &cfg);
58            *current = cfg;
59            changed
60        };
61        // Deliberately after the lock is released, and only for changes that alter what
62        // gets fetched: a theme or sort tweak must not cost a round of network calls.
63        if fetch_changed {
64            self.data_changed.send_modify(|generation| *generation += 1);
65        }
66        Ok(())
67    }
68
69    /// Fires whenever connections or section bindings change, from anywhere in the
70    /// process. A `watch` channel coalesces bursts, so the multi-step setup flow
71    /// (add connection, then bind each section) settles into one reload rather than four.
72    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    /// Removes a single connection from the PR section (leaves others bound).
166    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    /// Removes a single connection from the work-item section (leaves others bound).
176    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    /// Drops a connection's pipeline subscription entirely (unbinds it from the section).
186    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    /// Replaces the PR section's connections with an explicit set (multi-bind).
198    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    /// Adds a connection to the Pipelines section with nothing selected — pipelines are opt-in,
250    /// chosen from the picker. An existing subscription is left exactly as it is, so re-binding
251    /// never undoes a selection.
252    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    /// Replaces a connection's repository scope. `Some(vec![])` is a real, respected state — the
273    /// user chose no repositories — and must not be confused with `None`, which means the scope
274    /// was never established and the legacy single repository still applies.
275    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    /// A connection's stored scope, exactly as stored (including the `None` / `Some([])`
287    /// distinction) — the picker needs to tell "never chosen" from "chose none".
288    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    /// Persists the saved views for a section.
341    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    /// Persists the sort for a section (`None` restores provider order).
352    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    /// Persists how the Pipelines list is grouped (`None` restores the default).
363    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    /// Replaces a connection's tracked pipeline definitions with an explicit set
370    /// (turns off auto-discovery). An empty list tracks nothing.
371    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
380/// The one-time switch to opt-in pipelines: a subscription saved as "every pipeline" was, until
381/// now, what binding a connection did on its own, so it is reset to nothing selected. Runs once —
382/// the marker keeps a later deliberate "every pipeline" from being reset. Returns whether the
383/// saved config needs rewriting (a subscription was reset).
384fn 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    // An empty list with the flag off also used to mean "everything". An explicit list was a
392    // real choice and is kept.
393    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
401/// Turns a configured connection id into a live [`ProviderConnection`].
402pub struct ConnectionResolver {
403    config: Arc<ConfigService>,
404    registry: Arc<ProviderRegistry>,
405    secrets: Arc<dyn SecretStore>,
406    /// connection id → the live connection last created for it. Reused while the connection's
407    /// configuration and credential are unchanged: a provider client keeps caches (GitHub's
408    /// signed-in login and the full fetch behind each search hit) that only pay off if the client
409    /// outlives one call. Created afresh per call, every reload paid for every search hit again —
410    /// enough to exhaust GitHub's hourly rate limit on a busy repository.
411    live: Mutex<HashMap<String, LiveConnection>>,
412}
413
414/// A created connection with what it was created from, so a change to either recreates it.
415struct LiveConnection {
416    connection: Connection,
417    /// A fingerprint of the credential, not the credential: the client holds that already.
418    secret: u64,
419    live: Arc<dyn ProviderConnection>,
420}
421
422/// A change-detection fingerprint of a credential (not a secure hash — the client holds the
423/// credential itself; this only says whether it is the one the client was built with).
424fn 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        // The lock is taken before the snapshot and held across no `.await`: two resolves racing
438        // a configuration change then build from the same view of it, and the later one cannot
439        // put a client built from the older configuration over a newer one.
440        let mut live = self.live.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
441        let cfg = self.config.snapshot();
442        // A connection that is gone takes its client with it — the credential it was built with
443        // has no business staying in memory — whichever connection this call is about.
444        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
468/// One pipeline connection feeding the Pipelines section, with its subscription.
469pub struct PipelineFeed {
470    pub connection: Arc<dyn ProviderConnection>,
471    pub source: Arc<dyn PipelineSource>,
472    pub subscription: PipelineSubscription,
473}
474
475/// How many runs are fetched for each pipeline definition.
476pub const RUNS_PER_DEFINITION: u32 = 10;
477
478/// How many run queries a feed has in flight at once — polite to the provider's rate limit.
479const MAX_CONCURRENT_RUN_QUERIES: usize = 4;
480
481/// The run queries for a pipeline subscription: one per definition, never one for the whole
482/// connection.
483///
484/// A connection-wide "latest N runs" is a window a busy pipeline fills on its own — twenty PR
485/// checks push every quieter pipeline's history out, and a main-line build that ran dozens of
486/// times reads as "1 run". So subscribing to everything asks each discovered definition for its
487/// own recent runs, exactly as an explicit subscription does.
488///
489/// A definition id is only unique within its repository, so each query is addressed at the
490/// repository discovery says the definition belongs to. Pipelines are opt-in, so nothing
491/// selected asks for nothing. Only when there is nothing to address —
492/// subscribed to everything and discovery came back empty — does it fall back to one
493/// connection-wide query, so a discovery hiccup still shows something.
494pub fn pipeline_run_queries(sub: &PipelineSubscription, defs: &[PipelineDefinition]) -> Vec<PipelineRunQuery> {
495    // Pipelines are opt-in: nothing selected fetches nothing.
496    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    /// Runs every query of this feed's subscription (see [`pipeline_run_queries`]), a few at a
516    /// time, and returns each query's result in order so the caller reports failures its own way.
517    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
527/// One connection feeding the aggregated Pull Requests list.
528pub struct PullRequestFeed {
529    pub connection: Arc<dyn ProviderConnection>,
530    pub source: Arc<dyn PullRequestSource>,
531}
532
533/// One connection feeding the aggregated Work Items list.
534pub struct WorkItemFeed {
535    pub connection: Arc<dyn ProviderConnection>,
536    pub source: Arc<dyn WorkItemSource>,
537}
538
539/// One connection contributing to the notification inbox. Unlike the section feeds this
540/// isn't binding-scoped — every connection with a notification feed contributes.
541pub struct NotificationFeed {
542    pub connection: Arc<dyn ProviderConnection>,
543    pub source: Arc<dyn NotificationSource>,
544}
545
546/// Resolves the live source(s) backing each section from the current bindings.
547pub 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    /// One connection feeding the PR / Work-Item list (for aggregation).
558    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    /// Every connection that exposes a notification feed (across all connections, not just
587    /// section-bound ones) — the inbox aggregates them all.
588    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    /// The first bound PR source (back-compat for single-source call sites).
602    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    /// Resolves a connection's pipeline source directly, regardless of whether it is
611    /// currently subscribed — used to discover definitions before subscribing.
612    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    /// The repositories a connection's credentials can reach, for its scope picker.
617    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
639/// Gives a freshly-added connection a starting repository scope: none of them, chosen.
640///
641/// Discovery still runs, so the repositories the credentials reach are known up front and the
642/// user picks from them ("0 of 38"); nothing is fetched until they do. Returns that discovery
643/// page, for a frontend to show the count without discovering again.
644///
645/// Best-effort and deliberately inert on anything already established — it only acts when the
646/// scope has never been set *and* no legacy single repository was supplied, so it can neither
647/// overwrite a user's choice nor change an existing connection. If discovery fails or returns
648/// nothing, the scope stays `None` and the connection behaves exactly as it did before: an
649/// empty scope is only a useful starting point when there is something to pick.
650pub 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
667/// A configured connection and whether it's currently reachable/authed.
668pub struct ConnectionHealth {
669    pub connection: Connection,
670    pub healthy: bool,
671}
672
673/// Probes each configured connection for the connections health bar.
674pub 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
699/// Whether two configs would produce the same fetches. Compares every field except
700/// `ui`, so a new data-bearing field is included by default rather than being silently
701/// forgotten — the failure mode we want is a redundant refresh, not a stale screen.
702fn 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        /// How many connections this factory has built.
734        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    /// The registry, and how many GitHub connections it has built so far.
750    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    /// A resolver over a fresh config, with the count of GitHub connections built.
768    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        // A new token: the old client would keep sending the old one.
792        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        // A new scope: the client fetches from the repositories it was built with.
798        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        // Unchanged since: reused.
806        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        // Nothing resolves a removed id again (feeds iterate the connections that exist), so the
820        // pruning has to ride on whichever resolve comes next.
821        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        // None of these change what gets fetched, so none should cost a round of
884        // network calls. This is the half of the behaviour that regresses silently.
885        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        // The dashboard re-saves on every form submit, including no-op ones.
899        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        // Never set → absent, so the legacy repository is still what the factory resolves.
938        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        // "I chose none" is a real, persisted state — not the same as never having chosen.
944        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(); // duplicate ignored
959        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    /// A subscribed definition id is only unique within its repository, so each pipeline query
981    /// must be addressed at the repository discovery says the definition belongs to.
982    #[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    /// Subscribing to everything must not mean "the connection's latest 20 runs": a busy PR check
996    /// fills that window alone and a main-line pipeline with dozens of runs reads as one. Each
997    /// discovered definition gets its own query instead.
998    #[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            // Auto-discovery wins over a stale explicit list.
1004            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    /// With nothing discovered there is no definition to ask about, so one connection-wide query
1015    /// still shows whatever recent runs there are.
1016    #[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    /// Pipelines are opt-in: a connection with nothing selected asks for no runs at all.
1025    #[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    /// Binding adds the connection with nothing selected, and re-binding (the web form re-saves
1033    /// every section on each edit) leaves a selection alone.
1034    #[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    /// The switch to opt-in resets "every pipeline" subscriptions once, keeps explicit
1058    /// selections, and never runs again — so a later "tick everything" survives restarts.
1059    #[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    /// Through the real load path: the reset is saved, and a later "every pipeline" choice
1080    /// survives the next launch's load.
1081    #[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    /// A config with no pipelines is only marked, never rewritten on disk for it.
1099    #[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}