radicle_feed/
feed.rs

1use radicle::cob::object::Storage;
2use radicle::cob::stream::{CobRange, CobStream, Stream};
3use radicle::cob::TypeName;
4use radicle::node::AliasStore;
5use radicle::profile::{Aliases, Profile};
6use radicle::storage::git::paths;
7use radicle::{issue, patch};
8use serde::{Deserialize, Serialize};
9use snafu::ResultExt;
10
11use crate::entry::{OperationEntry, TimelineEntry};
12use crate::radicle_extra::cob::CobInfo;
13use crate::storage::FeedStorage;
14
15pub struct FeedProcessor<S: FeedStorage> {
16    storage: S,
17    profile: Profile,
18}
19
20impl<S: FeedStorage> FeedProcessor<S> {
21    pub fn new(storage: S, profile: Profile) -> Result<Self, S::Error> {
22        Ok(Self { storage, profile })
23    }
24
25    pub async fn process_repository(
26        &mut self,
27        repo: &radicle::storage::RepositoryInfo,
28    ) -> Result<(), snafu::Whatever> {
29        let aliases = self.profile.aliases();
30        let profile_storage = self.profile.storage.clone();
31
32        tracing::info!("Processing repository: {}", repo.rid);
33
34        let path = paths::repository(&profile_storage, &repo.rid);
35        let repo_handle = radicle::storage::git::Repository::open(path.clone(), repo.rid)
36            .whatever_context("Failed to open readonly repo")?;
37        let git2_repo = radicle::git::raw::Repository::open(path)
38            .whatever_context("Failed to open git2 repo")?;
39
40        let patches = radicle::patch::Patches::open(&repo_handle)
41            .whatever_context("Failed opening patches")?;
42        let patch_ids = patches
43            .as_ref()
44            .types(&radicle::patch::TYPENAME)
45            .whatever_context("Unable to load patch cob ids")?;
46        for patch in patch_ids.into_keys() {
47            let Some(stored_patch) = patches.get(&patch).ok().flatten() else {
48                continue;
49            };
50            if stored_patch.title().is_empty() {
51                continue;
52            }
53            self.process_cob::<patch::Action>(
54                CobInfo {
55                    id: patch,
56                    title: stored_patch.title().to_string(),
57                    status: stored_patch.state().to_string(),
58                },
59                &patch::TYPENAME,
60                &git2_repo,
61                &repo.rid,
62                &aliases,
63            )
64            .await
65            .whatever_context("Failed processing patches")?;
66        }
67
68        let issues =
69            radicle::issue::Issues::open(&repo_handle).whatever_context("Failed opening issues")?;
70        let issue_ids = issues
71            .as_ref()
72            .types(&issue::TYPENAME)
73            .whatever_context("Unable to load issue cob ids")?;
74        for issue in issue_ids.into_keys() {
75            let Some(stored_issue) = issues.get(&issue).ok().flatten() else {
76                continue;
77            };
78            if stored_issue.title().is_empty() {
79                continue;
80            }
81            self.process_cob::<issue::Action>(
82                CobInfo {
83                    id: issue,
84                    title: stored_issue.title().to_string(),
85                    status: stored_issue.state().to_string(),
86                },
87                &issue::TYPENAME,
88                &git2_repo,
89                &repo.rid,
90                &aliases,
91            )
92            .await
93            .whatever_context("Failed processing issues")?;
94        }
95
96        Ok(())
97    }
98
99    pub async fn process_cob<A>(
100        &mut self,
101        CobInfo { id, title, status }: CobInfo,
102        typename: &TypeName,
103        git2_repo: &radicle::git::raw::Repository,
104        rid: &radicle::prelude::RepoId,
105        aliases: &Aliases,
106    ) -> Result<(), snafu::Whatever>
107    where
108        A: Serialize + for<'de> Deserialize<'de>,
109    {
110        let last_operation_id = self
111            .storage
112            .get_last_processed_operation(rid, &id, typename)
113            .await
114            .whatever_context("Unable to get last processed operation")?;
115
116        if let Some(since_id) = last_operation_id {
117            tracing::debug!("{id} Last processed operation ID: {}", since_id);
118        } else {
119            tracing::debug!("{id} No previous operations found (first time processing)");
120        }
121
122        let stream = Stream::<A>::new(git2_repo, CobRange::new(typename, &id), typename.clone());
123
124        let stream_entries = if let Some(since_id) = last_operation_id {
125            let since_entries: Vec<_> = stream
126                .since(since_id.into())
127                .whatever_context("Unable to create since cob stream")?
128                .filter_map(|s| s.ok())
129                .filter(|entry| entry.id() != since_id.into()) // Filter out the already processed operation
130                .collect();
131            tracing::debug!(
132                "{id} Found {} new operations since last processed",
133                since_entries.len()
134            );
135
136            since_entries
137        } else {
138            let all_entries: Vec<_> = stream
139                .all()
140                .whatever_context("Unable to create all cob stream")?
141                .filter_map(|s| s.ok())
142                .collect();
143            tracing::debug!("{id} Found {} total operations", all_entries.len());
144
145            all_entries
146        };
147
148        if stream_entries.is_empty() {
149            tracing::debug!("{id} No new operations to process");
150            return Ok(());
151        }
152
153        let mut last_processed_id = last_operation_id;
154        let mut new_operations = Vec::new();
155
156        for stream_entry in stream_entries {
157            tracing::debug!("Processing operation: {}", stream_entry.id());
158
159            if self
160                .storage
161                .operation_exists(&stream_entry.id())
162                .await
163                .whatever_context("Unable to query for existing operation")?
164            {
165                tracing::debug!("Operation already exists in storage, skipping");
166                continue;
167            }
168
169            new_operations.push(OperationEntry {
170                operation_id: stream_entry.id(),
171                cob_id: id,
172                rid: *rid,
173                timestamp: stream_entry.timestamp,
174                actions: stream_entry
175                    .actions
176                    .iter()
177                    .filter_map(|action| serde_json::to_string(&action).ok())
178                    .collect::<Vec<_>>(),
179                author: stream_entry.author,
180                author_alias: aliases.alias(&stream_entry.author),
181                typename: typename.clone(),
182            });
183
184            last_processed_id = Some(stream_entry.id().into());
185        }
186
187        self.storage
188            .insert_timeline_entry(&TimelineEntry {
189                repo: rid.to_owned(),
190                node: self.profile.id().to_owned(),
191                cob_id: id.to_owned(),
192                cob_title: title,
193                cob_status: status,
194                typename: typename.to_owned(),
195                last_operation_id: last_processed_id.map(Into::into),
196                operations: new_operations
197                    .clone()
198                    .into_iter()
199                    .map(|s| s.operation_id.to_string())
200                    .collect::<Vec<_>>(),
201            })
202            .await
203            .whatever_context("Insert timeline entry failed")?;
204
205        if !new_operations.is_empty() {
206            tracing::debug!(
207                "Inserting {} new operations into storage",
208                new_operations.len()
209            );
210            self.storage
211                .insert_batch(&new_operations)
212                .await
213                .whatever_context("Failed insert batch")?;
214        } else {
215            tracing::debug!("{id} No new operations found");
216        }
217
218        Ok(())
219    }
220
221    /// Get storage statistics
222    pub async fn get_stats(&self) -> Result<crate::storage::StorageStats, S::Error> {
223        self.storage.get_stats().await
224    }
225}
226
227/// Statistics about the processing operation
228#[derive(Debug, Default)]
229pub struct ProcessingStats {
230    pub repositories_processed: usize,
231    pub operations_found: usize,
232    pub operations_stored: usize,
233}
234
235impl std::fmt::Display for ProcessingStats {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        writeln!(f, "=== Processing Statistics ===")?;
238        writeln!(f, "Repositories processed: {}", self.repositories_processed)?;
239        writeln!(f, "Operations found: {}", self.operations_found)?;
240        writeln!(f, "Operations stored: {}", self.operations_stored)?;
241        Ok(())
242    }
243}