Skip to main content

forgetop_core/
service.rs

1//! Runtime config service + resolver/section/health services.
2
3use 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
13/// Holds the live config and applies mutations at runtime: each change is validated
14/// against provider capabilities and persisted. Secrets go to the secret store; the
15/// config only ever references them by key.
16pub struct ConfigService {
17    store: Arc<dyn ConfigStore>,
18    secrets: Arc<dyn SecretStore>,
19    registry: Arc<ProviderRegistry>,
20    config: Mutex<ForgetopConfig>,
21    /// Bumped whenever a persisted change alters what should be fetched — connections
22    /// or section bindings, never UI preferences. The dashboard and the TUI share one
23    /// `ConfigService`, so this is how a connection added in the browser reaches the
24    /// terminal without waiting for the refresh tick.
25    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(&current, &cfg);
57            *current = cfg;
58            changed
59        };
60        // Deliberately after the lock is released, and only for changes that alter what
61        // gets fetched: a theme or sort tweak must not cost a round of network calls.
62        if fetch_changed {
63            self.data_changed.send_modify(|generation| *generation += 1);
64        }
65        Ok(())
66    }
67
68    /// Fires whenever connections or section bindings change, from anywhere in the
69    /// process. A `watch` channel coalesces bursts, so the multi-step setup flow
70    /// (add connection, then bind each section) settles into one reload rather than four.
71    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    /// Removes a single connection from the PR section (leaves others bound).
165    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    /// Removes a single connection from the work-item section (leaves others bound).
175    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    /// Drops a connection's pipeline subscription entirely (unbinds it from the section).
185    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    /// Replaces the PR section's connections with an explicit set (multi-bind).
197    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    /// Adds a connection to the Pipelines section with nothing selected — pipelines are opt-in,
249    /// chosen from the picker. An existing subscription is left exactly as it is, so re-binding
250    /// never undoes a selection.
251    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    /// Replaces a connection's repository scope. `Some(vec![])` is a real, respected state — the
272    /// user chose no repositories — and must not be confused with `None`, which means the scope
273    /// was never established and the legacy single repository still applies.
274    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    /// A connection's stored scope, exactly as stored (including the `None` / `Some([])`
286    /// distinction) — the picker needs to tell "never chosen" from "chose none".
287    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    /// Persists the saved views for a section.
340    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    /// Persists the sort for a section (`None` restores provider order).
351    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    /// Persists how the Pipelines list is grouped (`None` restores the default).
362    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    /// Replaces a connection's tracked pipeline definitions with an explicit set
369    /// (turns off auto-discovery). An empty list tracks nothing.
370    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
379/// The one-time switch to opt-in pipelines: a subscription saved as "every pipeline" was, until
380/// now, what binding a connection did on its own, so it is reset to nothing selected. Runs once —
381/// the marker keeps a later deliberate "every pipeline" from being reset. Returns whether the
382/// saved config needs rewriting (a subscription was reset).
383fn 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    // An empty list with the flag off also used to mean "everything". An explicit list was a
391    // real choice and is kept.
392    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
400/// Turns a configured connection id into a live [`ProviderConnection`].
401pub 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
429/// One pipeline connection feeding the Pipelines section, with its subscription.
430pub struct PipelineFeed {
431    pub connection: Arc<dyn ProviderConnection>,
432    pub source: Arc<dyn PipelineSource>,
433    pub subscription: PipelineSubscription,
434}
435
436/// How many runs are fetched for each pipeline definition.
437pub const RUNS_PER_DEFINITION: u32 = 10;
438
439/// How many run queries a feed has in flight at once — polite to the provider's rate limit.
440const MAX_CONCURRENT_RUN_QUERIES: usize = 4;
441
442/// The run queries for a pipeline subscription: one per definition, never one for the whole
443/// connection.
444///
445/// A connection-wide "latest N runs" is a window a busy pipeline fills on its own — twenty PR
446/// checks push every quieter pipeline's history out, and a main-line build that ran dozens of
447/// times reads as "1 run". So subscribing to everything asks each discovered definition for its
448/// own recent runs, exactly as an explicit subscription does.
449///
450/// A definition id is only unique within its repository, so each query is addressed at the
451/// repository discovery says the definition belongs to. Pipelines are opt-in, so nothing
452/// selected asks for nothing. Only when there is nothing to address —
453/// subscribed to everything and discovery came back empty — does it fall back to one
454/// connection-wide query, so a discovery hiccup still shows something.
455pub fn pipeline_run_queries(sub: &PipelineSubscription, defs: &[PipelineDefinition]) -> Vec<PipelineRunQuery> {
456    // Pipelines are opt-in: nothing selected fetches nothing.
457    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    /// Runs every query of this feed's subscription (see [`pipeline_run_queries`]), a few at a
477    /// time, and returns each query's result in order so the caller reports failures its own way.
478    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
488/// One connection feeding the aggregated Pull Requests list.
489pub struct PullRequestFeed {
490    pub connection: Arc<dyn ProviderConnection>,
491    pub source: Arc<dyn PullRequestSource>,
492}
493
494/// One connection feeding the aggregated Work Items list.
495pub struct WorkItemFeed {
496    pub connection: Arc<dyn ProviderConnection>,
497    pub source: Arc<dyn WorkItemSource>,
498}
499
500/// One connection contributing to the notification inbox. Unlike the section feeds this
501/// isn't binding-scoped — every connection with a notification feed contributes.
502pub struct NotificationFeed {
503    pub connection: Arc<dyn ProviderConnection>,
504    pub source: Arc<dyn NotificationSource>,
505}
506
507/// Resolves the live source(s) backing each section from the current bindings.
508pub 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    /// One connection feeding the PR / Work-Item list (for aggregation).
519    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    /// Every connection that exposes a notification feed (across all connections, not just
548    /// section-bound ones) — the inbox aggregates them all.
549    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    /// The first bound PR source (back-compat for single-source call sites).
563    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    /// Resolves a connection's pipeline source directly, regardless of whether it is
572    /// currently subscribed — used to discover definitions before subscribing.
573    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    /// The repositories a connection's credentials can reach, for its scope picker.
578    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
600/// Gives a freshly-added connection a starting repository scope: none of them, chosen.
601///
602/// Discovery still runs, so the repositories the credentials reach are known up front and the
603/// user picks from them ("0 of 38"); nothing is fetched until they do. Returns that discovery
604/// page, for a frontend to show the count without discovering again.
605///
606/// Best-effort and deliberately inert on anything already established — it only acts when the
607/// scope has never been set *and* no legacy single repository was supplied, so it can neither
608/// overwrite a user's choice nor change an existing connection. If discovery fails or returns
609/// nothing, the scope stays `None` and the connection behaves exactly as it did before: an
610/// empty scope is only a useful starting point when there is something to pick.
611pub 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
628/// A configured connection and whether it's currently reachable/authed.
629pub struct ConnectionHealth {
630    pub connection: Connection,
631    pub healthy: bool,
632}
633
634/// Probes each configured connection for the connections health bar.
635pub 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
660/// Whether two configs would produce the same fetches. Compares every field except
661/// `ui`, so a new data-bearing field is included by default rather than being silently
662/// forgotten — the failure mode we want is a redundant refresh, not a stale screen.
663fn 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        // None of these change what gets fetched, so none should cost a round of
773        // network calls. This is the half of the behaviour that regresses silently.
774        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        // The dashboard re-saves on every form submit, including no-op ones.
788        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        // Never set → absent, so the legacy repository is still what the factory resolves.
827        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        // "I chose none" is a real, persisted state — not the same as never having chosen.
833        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(); // duplicate ignored
848        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    /// A subscribed definition id is only unique within its repository, so each pipeline query
870    /// must be addressed at the repository discovery says the definition belongs to.
871    #[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    /// Subscribing to everything must not mean "the connection's latest 20 runs": a busy PR check
885    /// fills that window alone and a main-line pipeline with dozens of runs reads as one. Each
886    /// discovered definition gets its own query instead.
887    #[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            // Auto-discovery wins over a stale explicit list.
893            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    /// With nothing discovered there is no definition to ask about, so one connection-wide query
904    /// still shows whatever recent runs there are.
905    #[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    /// Pipelines are opt-in: a connection with nothing selected asks for no runs at all.
914    #[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    /// Binding adds the connection with nothing selected, and re-binding (the web form re-saves
922    /// every section on each edit) leaves a selection alone.
923    #[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    /// The switch to opt-in resets "every pipeline" subscriptions once, keeps explicit
947    /// selections, and never runs again — so a later "tick everything" survives restarts.
948    #[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    /// Through the real load path: the reset is saved, and a later "every pipeline" choice
969    /// survives the next launch's load.
970    #[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    /// A config with no pipelines is only marked, never rewritten on disk for it.
988    #[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}