1#![cfg(feature = "client")]
40
41use std::{
42 collections::{BTreeMap, HashSet},
43 fs,
44 path::{Path, PathBuf},
45};
46
47use anyhow::{Context, Result};
48use api::heddle::api::v1alpha1::{
49 PathSymbolRef, ReviewKind as ProtoReviewKind, ReviewScope as ProtoReviewScope, review_scope,
50};
51use objects::fs_atomic::write_file_atomic;
52use objects::object::{
53 ReviewKind, ReviewScope, ReviewSignature, ReviewSignaturesBlob, StateAttachmentBody, StateId,
54};
55use objects::store::ObjectStore;
56use repo::{HistoryQuery, Repository, StateAttachmentKind};
57use serde::{Deserialize, Serialize};
58use wire::ProtocolError;
59
60use crate::client::HostedGrpcClient;
61
62const REVIEW_SCAN_LIMIT: usize = 50;
64
65#[derive(Debug, Default, Serialize, Deserialize)]
66struct HostedReviewMirror {
67 #[serde(default)]
68 repos: BTreeMap<String, RepoReviewMirror>,
69}
70
71#[derive(Debug, Default, Serialize, Deserialize)]
72struct RepoReviewMirror {
73 #[serde(default)]
75 synced: Vec<String>,
76 #[serde(default)]
79 rejected: Vec<String>,
80}
81
82fn synced_key(state_id: &StateId, signature_hex: &str) -> String {
83 format!("{}#{signature_hex}", state_id.to_string_full())
84}
85
86fn mirror_path(heddle_dir: &Path) -> PathBuf {
87 heddle_dir
88 .join("collaboration")
89 .join("hosted-review-mirror.json")
90}
91
92fn load_mirror(heddle_dir: &Path) -> Result<HostedReviewMirror> {
93 match fs::read(mirror_path(heddle_dir)) {
94 Ok(bytes) => serde_json::from_slice(&bytes).context("decode hosted review mirror map"),
95 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
96 Ok(HostedReviewMirror::default())
97 }
98 Err(error) => Err(error).context("read hosted review mirror map"),
99 }
100}
101
102fn save_mirror(heddle_dir: &Path, mirror: &HostedReviewMirror) -> Result<()> {
103 let path = mirror_path(heddle_dir);
104 if let Some(parent) = path.parent() {
105 fs::create_dir_all(parent).context("create collaboration dir")?;
106 }
107 let bytes = serde_json::to_vec_pretty(mirror).context("encode hosted review mirror map")?;
108 write_file_atomic(&path, &bytes).context("write hosted review mirror map")?;
109 Ok(())
110}
111
112fn kind_to_proto(kind: ReviewKind) -> ProtoReviewKind {
113 match kind {
114 ReviewKind::Read => ProtoReviewKind::Read,
115 ReviewKind::AgentPreview => ProtoReviewKind::AgentPreview,
116 ReviewKind::AgentCoReview => ProtoReviewKind::AgentCoReview,
117 }
118}
119
120fn scope_to_proto(scope: &ReviewScope) -> ProtoReviewScope {
121 let inner = match scope {
122 ReviewScope::WholeChange => review_scope::Scope::WholeChange(review_scope::WholeChange {}),
123 ReviewScope::Symbols(symbols) => review_scope::Scope::Symbols(review_scope::SymbolList {
124 symbols: symbols
125 .iter()
126 .map(|anchor| PathSymbolRef {
127 file: anchor.file.clone(),
128 symbol: anchor.symbol.clone(),
129 })
130 .collect(),
131 }),
132 };
133 ProtoReviewScope { scope: Some(inner) }
134}
135
136fn is_permanent(error: &ProtocolError) -> bool {
140 matches!(
141 error,
142 ProtocolError::InvalidState(_) | ProtocolError::AuthorizationFailed(_)
143 )
144}
145
146enum ForwardOutcome {
147 Installed,
148 Permanent(String),
149 Transient(String),
150}
151
152fn read_signatures(repo: &Repository, state_id: &StateId) -> Result<Vec<ReviewSignature>> {
154 let Some(attachment) =
155 repo.latest_state_attachment(state_id, StateAttachmentKind::ReviewSignatures)?
156 else {
157 return Ok(Vec::new());
158 };
159 let StateAttachmentBody::ReviewSignatures(hash) = attachment.body else {
160 return Ok(Vec::new());
161 };
162 let Some(blob) = repo.store().get_blob(&hash)? else {
163 return Ok(Vec::new());
164 };
165 let decoded = ReviewSignaturesBlob::decode(blob.content())
166 .map_err(|error| anyhow::anyhow!("decode review signatures blob: {error}"))?;
167 Ok(decoded.signatures)
168}
169
170pub async fn push_review_signatures(
172 repo: &Repository,
173 client: &mut HostedGrpcClient,
174 repo_path: &str,
175) -> Result<usize> {
176 let Some(head) = repo.head().context("resolve repository head")? else {
177 return Ok(0);
178 };
179
180 let principal = match repo.get_principal() {
184 Ok(principal) => principal,
185 Err(error) => {
186 eprintln!(
187 "{} review sync skipped: could not resolve the local principal ({error}); \
188 set one with `heddle init --principal-name <name> --principal-email <email>`",
189 crate::cli::style::warn_marker(),
190 );
191 return Ok(0);
192 }
193 };
194
195 let states = repo
196 .query_history(&HistoryQuery::new(Some(head)).with_limit(REVIEW_SCAN_LIMIT))
197 .context("walk history for review signatures")?;
198
199 let heddle_dir = repo.heddle_dir().to_path_buf();
200 let mut mirror = load_mirror(&heddle_dir)?;
201 let skip: HashSet<String> = mirror
202 .repos
203 .get(repo_path)
204 .map(|repo_mirror| {
205 repo_mirror
206 .synced
207 .iter()
208 .chain(repo_mirror.rejected.iter())
209 .cloned()
210 .collect()
211 })
212 .unwrap_or_default();
213
214 let mut synced = 0usize;
215 for state in states {
216 let signatures = match read_signatures(repo, &state.state_id) {
217 Ok(signatures) => signatures,
218 Err(error) => {
219 eprintln!(
220 "{} hosted review {}: {error:#}",
221 crate::cli::style::warn_marker(),
222 state.state_id.short()
223 );
224 continue;
225 }
226 };
227 for signature in signatures {
228 if signature.actor.name != principal.name || signature.actor.email != principal.email {
229 continue;
230 }
231 let key = synced_key(&state.state_id, &signature.signature);
232 if skip.contains(&key) {
233 continue;
234 }
235 match forward_signature(client, repo_path, &state.state_id, &signature).await {
236 ForwardOutcome::Installed => {
237 mirror
238 .repos
239 .entry(repo_path.to_string())
240 .or_default()
241 .synced
242 .push(key);
243 save_mirror(&heddle_dir, &mirror)?;
244 synced += 1;
245 }
246 ForwardOutcome::Permanent(message) => {
247 mirror
249 .repos
250 .entry(repo_path.to_string())
251 .or_default()
252 .rejected
253 .push(key);
254 save_mirror(&heddle_dir, &mirror)?;
255 eprintln!(
256 "{} hosted review {}: permanently rejected, will not retry: {message}",
257 crate::cli::style::warn_marker(),
258 state.state_id.short()
259 );
260 }
261 ForwardOutcome::Transient(message) => {
262 eprintln!(
263 "{} hosted review {}: {message} (will retry on next push)",
264 crate::cli::style::warn_marker(),
265 state.state_id.short()
266 );
267 }
268 }
269 }
270 }
271 Ok(synced)
272}
273
274async fn forward_signature(
275 client: &mut HostedGrpcClient,
276 repo_path: &str,
277 state_id: &StateId,
278 signature: &ReviewSignature,
279) -> ForwardOutcome {
280 let public_key = match hex::decode(&signature.public_key) {
282 Ok(bytes) => bytes,
283 Err(error) => return ForwardOutcome::Permanent(format!("public_key is not hex: {error}")),
284 };
285 let signature_bytes = match hex::decode(&signature.signature) {
286 Ok(bytes) => bytes,
287 Err(error) => return ForwardOutcome::Permanent(format!("signature is not hex: {error}")),
288 };
289 match client
290 .sign_state(
291 repo_path,
292 state_id,
293 kind_to_proto(signature.kind),
294 scope_to_proto(&signature.scope),
295 signature.justification.as_deref().unwrap_or_default(),
296 &signature.algorithm,
297 public_key,
298 signature_bytes,
299 signature.signed_at,
300 sign_op_id(repo_path, state_id, &signature.signature),
301 )
302 .await
303 {
304 Ok(_) | Err(ProtocolError::AlreadyExists(_)) => ForwardOutcome::Installed,
307 Err(error) if is_permanent(&error) => ForwardOutcome::Permanent(error.to_string()),
308 Err(error) => ForwardOutcome::Transient(error.to_string()),
309 }
310}
311
312const OP_NAMESPACE: uuid::Uuid = uuid::Uuid::from_u128(0x6865_6464_6c65_7276_775f_7379_6e63_0001);
313
314fn sign_op_id(repo_path: &str, state_id: &StateId, signature_hex: &str) -> String {
315 uuid::Uuid::new_v5(
316 &OP_NAMESPACE,
317 format!(
318 "sign:{repo_path}:{}:{signature_hex}",
319 state_id.to_string_full()
320 )
321 .as_bytes(),
322 )
323 .to_string()
324}
325
326#[cfg(test)]
327mod tests {
328 use objects::object::SymbolAnchor;
329
330 use super::*;
331
332 #[test]
333 fn whole_change_scope_maps_to_proto() {
334 let proto = scope_to_proto(&ReviewScope::WholeChange);
335 assert!(matches!(
336 proto.scope,
337 Some(review_scope::Scope::WholeChange(_))
338 ));
339 }
340
341 #[test]
342 fn symbol_scope_maps_to_proto() {
343 let proto = scope_to_proto(&ReviewScope::Symbols(vec![SymbolAnchor::new("a.rs", "foo")]));
344 match proto.scope {
345 Some(review_scope::Scope::Symbols(list)) => {
346 assert_eq!(list.symbols.len(), 1);
347 assert_eq!(list.symbols[0].file, "a.rs");
348 assert_eq!(list.symbols[0].symbol, "foo");
349 }
350 other => panic!("expected symbols scope, got {other:?}"),
351 }
352 }
353
354 #[test]
355 fn synced_key_is_state_scoped() {
356 let a = StateId::from_bytes([1; 32]);
357 let b = StateId::from_bytes([2; 32]);
358 assert_ne!(synced_key(&a, "abad1dea"), synced_key(&b, "abad1dea"));
359 assert_eq!(synced_key(&a, "abad1dea"), synced_key(&a, "abad1dea"));
360 }
361
362 #[test]
363 fn kind_maps_to_proto() {
364 assert_eq!(kind_to_proto(ReviewKind::Read), ProtoReviewKind::Read);
365 assert_eq!(
366 kind_to_proto(ReviewKind::AgentPreview),
367 ProtoReviewKind::AgentPreview
368 );
369 assert_eq!(
370 kind_to_proto(ReviewKind::AgentCoReview),
371 ProtoReviewKind::AgentCoReview
372 );
373 }
374
375 #[test]
378 fn permanent_vs_transient_classification() {
379 assert!(is_permanent(&ProtocolError::InvalidState("bad sig".into())));
380 assert!(is_permanent(&ProtocolError::AuthorizationFailed(
381 "key not owned".into()
382 )));
383 assert!(!is_permanent(&ProtocolError::ObjectNotFound(
384 "state not on server yet".into()
385 )));
386 assert!(!is_permanent(&ProtocolError::Remote("unavailable".into())));
387 }
388}