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