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()) .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 pub async fn get_stats(&self) -> Result<crate::storage::StorageStats, S::Error> {
223 self.storage.get_stats().await
224 }
225}
226
227#[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}