1use ::state_review::{
12 PathSymbol, ReadingOrderPartition, SymbolKind, build_review_payload_partition,
13};
14use crypto::verify_payload_signature;
15use grpc::heddle::v1::{
16 AnchoredDiscussion, GetReviewPayloadRequest, GetReviewProgressRequest,
17 GetReviewProgressResponse, ListSignaturesRequest, ListSignaturesResponse, MergeRequirement,
18 PathSymbolRef as ProtoPathSymbolRef, ReadingOrderPartition as ProtoReadingOrderPartition,
19 RecordCheckAckRequest, RecordCheckAckResponse, RecordVerdictRequest, RecordVerdictResponse,
20 ReviewPayload, ReviewScope as ProtoReviewScope, ReviewSignature as ProtoReviewSignature,
21 ReviewSummary, RiskSignal as ProtoRiskSignal, SignStateRequest, SignStateResponse,
22 SignalAnchor as ProtoSignalAnchor, SigningFooter,
23 state_review_service_server::StateReviewService,
24};
25use objects::{
26 lock::RepositoryLockExt,
27 object::{
28 Blob, ChangeId, DiffKind, Discussion, DiscussionResolution, DiscussionsBlob, ReviewKind,
29 ReviewScope, ReviewSignature, ReviewSignaturesBlob, RiskSignalBlob, State, SymbolAnchor,
30 signing_payload,
31 },
32 store::ObjectStore,
33 worktree::diff_blobs,
34};
35use prost::Message;
36use repo::Repository;
37use tonic::{Request, Response, Status};
38
39use super::{GrpcLocalService, to_status, with_idempotency};
40
41const SIGN_TIMESTAMP_SKEW_SECS: i64 = 5 * 60;
45
46#[derive(Clone)]
48pub struct LocalStateReviewService {
49 inner: GrpcLocalService,
50}
51
52impl LocalStateReviewService {
53 pub fn new(inner: GrpcLocalService) -> Self {
54 Self { inner }
55 }
56}
57
58#[tonic::async_trait]
59impl StateReviewService for LocalStateReviewService {
60 async fn get_review_payload(
61 &self,
62 request: Request<GetReviewPayloadRequest>,
63 ) -> Result<Response<ReviewPayload>, Status> {
64 let req = request.into_inner();
65 let change_id = parse_change_id(&req.state_id)?;
66 let repo = self.inner.repo();
67 let state = repo
68 .store()
69 .get_state(&change_id)
70 .map_err(to_status)?
71 .ok_or_else(|| {
72 Status::not_found(format!("state {} not found", change_id.to_string_full()))
73 })?;
74
75 let diff_summary = compute_state_diff_summary(repo, &state).map_err(to_status)?;
81
82 let summary = ReviewSummary {
83 headline: state.intent.clone().unwrap_or_default(),
84 files_changed: diff_summary.files_changed,
85 added_lines: diff_summary.added_lines,
86 removed_lines: diff_summary.removed_lines,
87 in_budget_signal_count: 0,
88 hidden_signal_count: 0,
89 };
90
91 let agent_narrative = if state.attribution.agent.is_some() {
92 state.intent.clone().unwrap_or_default()
93 } else {
94 String::new()
95 };
96
97 let mut all_signals: Vec<ProtoRiskSignal> = Vec::new();
101 if req.include_all_signals
102 && let Some(hash) = state.risk_signals
103 && let Some(blob) = repo.store().get_blob(&hash).map_err(to_status)?
104 {
105 let decoded = RiskSignalBlob::decode(blob.content())
106 .map_err(|err| Status::internal(format!("decode risk signals: {err}")))?;
107 all_signals = decoded
108 .signals
109 .into_iter()
110 .map(|s| risk_signal_to_proto(s, "visible"))
111 .collect();
112 }
113
114 let mut in_budget_signals: Vec<ProtoRiskSignal> = Vec::new();
123 let summary_kind = match (
124 diff_summary.added_files,
125 diff_summary.modified_files,
126 diff_summary.deleted_files,
127 ) {
128 (a, 0, 0) if a > 0 => "diff_summary.added_only",
129 (0, m, 0) if m > 0 => "diff_summary.modified_only",
130 (0, 0, d) if d > 0 => "diff_summary.deleted_only",
131 (0, 0, 0) => "diff_summary.empty",
132 _ => "diff_summary.mixed",
133 };
134 let summary_reason = format!(
135 "{} files changed (+{}/-{}, {} added, {} modified, {} deleted)",
136 diff_summary.files_changed,
137 diff_summary.added_lines,
138 diff_summary.removed_lines,
139 diff_summary.added_files,
140 diff_summary.modified_files,
141 diff_summary.deleted_files,
142 );
143 const MAX_DIFF_SIGNAL_ANCHORS: usize = 32;
148 if diff_summary.changed_paths.is_empty() {
149 in_budget_signals.push(ProtoRiskSignal {
150 kind: summary_kind.to_string(),
151 anchor: Some(ProtoSignalAnchor {
152 file: String::new(),
153 symbol: String::new(),
154 start_line: 0,
155 end_line: 0,
156 }),
157 reason: summary_reason.clone(),
158 producer_module: "review_show.diff_summary".to_string(),
159 producer_version: 1,
160 computed_at: None,
161 visibility: "visible".to_string(),
162 });
163 } else {
164 for (idx, path_kind) in diff_summary
165 .changed_paths
166 .iter()
167 .take(MAX_DIFF_SIGNAL_ANCHORS)
168 .enumerate()
169 {
170 let reason = if idx == 0 {
171 summary_reason.clone()
172 } else {
173 format!("{} ({})", path_kind.path, path_kind.kind_str())
174 };
175 in_budget_signals.push(ProtoRiskSignal {
176 kind: summary_kind.to_string(),
177 anchor: Some(ProtoSignalAnchor {
178 file: path_kind.path.clone(),
179 symbol: String::new(),
180 start_line: 0,
181 end_line: 0,
182 }),
183 reason,
184 producer_module: "review_show.diff_summary".to_string(),
185 producer_version: 1,
186 computed_at: None,
187 visibility: "visible".to_string(),
188 });
189 }
190 }
191
192 let symbols = changed_files_as_symbols(repo, &state, &diff_summary.changed_paths)
196 .map_err(to_status)?;
197 let partition = build_review_payload_partition(&symbols);
198
199 let discussions = match state.discussions {
202 Some(hash) => {
203 let blob = repo
204 .store()
205 .get_blob(&hash)
206 .map_err(to_status)?
207 .ok_or_else(|| {
208 Status::internal(format!(
209 "discussions blob {} referenced by state {} is missing",
210 hash,
211 state.change_id.to_string_full()
212 ))
213 })?;
214 let decoded = DiscussionsBlob::decode(blob.content())
215 .map_err(|err| Status::internal(format!("decode discussions: {err}")))?;
216 decoded
217 .discussions
218 .iter()
219 .map(discussion_to_anchored_proto)
220 .collect()
221 }
222 None => Vec::<AnchoredDiscussion>::new(),
223 };
224
225 let mut summary = summary;
226 summary.in_budget_signal_count = in_budget_signals.len() as u32;
227 summary.hidden_signal_count =
228 all_signals.len().saturating_sub(in_budget_signals.len()) as u32;
229
230 let payload = ReviewPayload {
231 state_id: req.state_id.clone(),
232 summary: Some(summary),
233 agent_narrative,
234 partition: Some(partition_to_proto(partition)),
235 in_budget_signals,
236 all_signals,
237 tick_budget: 3,
238 discussions,
239 merge_requirements: Vec::<MergeRequirement>::new(),
242 signing_footer: Some(SigningFooter {
243 available_kinds: vec![
244 grpc::heddle::v1::ReviewKind::Read as i32,
245 grpc::heddle::v1::ReviewKind::AgentPreview as i32,
246 grpc::heddle::v1::ReviewKind::AgentCoReview as i32,
247 ],
248 }),
249 };
250
251 Ok(Response::new(payload))
252 }
253
254 async fn sign_state(
255 &self,
256 request: Request<SignStateRequest>,
257 ) -> Result<Response<SignStateResponse>, Status> {
258 let req = request.into_inner();
259 let req_bytes = req.encode_to_vec();
260 let client_operation_id = req.client_operation_id.clone();
261 let inner = self.inner.clone();
262
263 let response = with_idempotency(
264 &self.inner,
265 &client_operation_id,
266 "state_review.sign_state",
267 &req_bytes,
268 move || {
269 let inner = inner.clone();
270 async move { execute_sign_state(&inner, req).await }
271 },
272 )
273 .await?;
274
275 Ok(Response::new(response))
276 }
277
278 async fn list_signatures(
279 &self,
280 request: Request<ListSignaturesRequest>,
281 ) -> Result<Response<ListSignaturesResponse>, Status> {
282 let req = request.into_inner();
283 let change_id = parse_change_id(&req.state_id)?;
284 let repo = self.inner.repo();
285 let state = repo
286 .store()
287 .get_state(&change_id)
288 .map_err(to_status)?
289 .ok_or_else(|| {
290 Status::not_found(format!("state {} not found", change_id.to_string_full()))
291 })?;
292
293 let signatures = match state.review_signatures {
294 Some(hash) => {
295 let blob = repo
296 .store()
297 .get_blob(&hash)
298 .map_err(to_status)?
299 .ok_or_else(|| {
300 Status::internal(format!(
301 "review signatures blob {} missing from object store",
302 hash
303 ))
304 })?;
305 let decoded = ReviewSignaturesBlob::decode(blob.content())
306 .map_err(|err| Status::internal(format!("decode review signatures: {err}")))?;
307 decoded
308 .signatures
309 .into_iter()
310 .enumerate()
311 .map(|(idx, sig)| review_signature_to_proto(sig, synthetic_signature_id(idx)))
312 .collect()
313 }
314 None => Vec::new(),
315 };
316
317 Ok(Response::new(ListSignaturesResponse { signatures }))
318 }
319
320 async fn record_verdict(
325 &self,
326 _request: Request<RecordVerdictRequest>,
327 ) -> Result<Response<RecordVerdictResponse>, Status> {
328 Err(Status::unimplemented(
329 "RecordVerdict is not available in local mode (weft#481)",
330 ))
331 }
332
333 async fn record_check_ack(
334 &self,
335 _request: Request<RecordCheckAckRequest>,
336 ) -> Result<Response<RecordCheckAckResponse>, Status> {
337 Err(Status::unimplemented(
338 "RecordCheckAck is not available in local mode (weft#482)",
339 ))
340 }
341
342 async fn get_review_progress(
343 &self,
344 _request: Request<GetReviewProgressRequest>,
345 ) -> Result<Response<GetReviewProgressResponse>, Status> {
346 Err(Status::unimplemented(
347 "GetReviewProgress is not available in local mode (weft#482)",
348 ))
349 }
350}
351
352async fn execute_sign_state(
355 inner: &GrpcLocalService,
356 req: SignStateRequest,
357) -> Result<SignStateResponse, Status> {
358 let kind = match grpc::heddle::v1::ReviewKind::try_from(req.kind)
360 .map_err(|_| Status::invalid_argument(format!("unknown review kind tag {}", req.kind)))?
361 {
362 grpc::heddle::v1::ReviewKind::Read => ReviewKind::Read,
363 grpc::heddle::v1::ReviewKind::AgentPreview => ReviewKind::AgentPreview,
364 grpc::heddle::v1::ReviewKind::AgentCoReview => ReviewKind::AgentCoReview,
365 grpc::heddle::v1::ReviewKind::Unspecified => {
366 return Err(Status::invalid_argument("review kind is required"));
367 }
368 };
369
370 let change_id = parse_change_id(&req.state_id)?;
372 let repo = inner.repo();
373
374 let scope = match req.scope.as_ref() {
376 Some(s) => proto_scope_to_object(s)?,
377 None => ReviewScope::WholeChange,
378 };
379
380 let actor = repo
388 .get_principal()
389 .map_err(|err| Status::internal(format!("resolve caller principal: {err}")))?;
390 let justification = if req.justification.is_empty() {
391 None
392 } else {
393 Some(req.justification.clone())
394 };
395
396 let now = chrono::Utc::now().timestamp();
397 let signed_at = req.signed_at.as_ref().map(|t| t.seconds).unwrap_or(0);
398 if signed_at == 0 {
399 return Err(Status::invalid_argument(
400 "signed_at is required and must match the timestamp the client signed over",
401 ));
402 }
403 if (signed_at - now).abs() > SIGN_TIMESTAMP_SKEW_SECS {
404 return Err(Status::invalid_argument(format!(
405 "signed_at={signed_at} is too far from server time={now} (max skew {SIGN_TIMESTAMP_SKEW_SECS}s)"
406 )));
407 }
408
409 let new_sig = ReviewSignature {
410 actor,
411 kind,
412 scope: scope.clone(),
413 justification: justification.clone(),
414 signed_at,
415 algorithm: req.algorithm.clone(),
416 public_key: hex::encode(&req.public_key),
417 signature: hex::encode(&req.signature),
418 };
419 new_sig
420 .validate()
421 .map_err(|err| Status::invalid_argument(format!("invalid review signature: {err}")))?;
422
423 let public_key_bytes = req.public_key.clone();
424 let signature_bytes = req.signature.clone();
425 let payload = signing_payload(change_id, kind, &scope, signed_at, justification.as_deref());
426 verify_payload_signature(
427 &payload,
428 &req.algorithm,
429 &public_key_bytes,
430 &signature_bytes,
431 )
432 .map_err(|err| {
433 Status::invalid_argument(format!(
434 "review signature failed verification ({}): {err}",
435 req.algorithm
436 ))
437 })?;
438
439 let _lock = repo
445 .locker()
446 .write()
447 .map_err(|err| Status::internal(err.to_string()))?;
448 let state = repo
449 .store()
450 .get_state(&change_id)
451 .map_err(to_status)?
452 .ok_or_else(|| {
453 Status::not_found(format!("state {} not found", change_id.to_string_full()))
454 })?;
455 let mut blob = match state.review_signatures {
456 Some(hash) => {
457 let raw = repo
458 .store()
459 .get_blob(&hash)
460 .map_err(to_status)?
461 .ok_or_else(|| {
462 Status::internal(format!(
463 "existing review signatures blob {} missing from object store",
464 hash
465 ))
466 })?;
467 ReviewSignaturesBlob::decode(raw.content())
468 .map_err(|err| Status::internal(format!("decode review signatures: {err}")))?
469 }
470 None => ReviewSignaturesBlob::new(Vec::new()),
471 };
472 blob.signatures.push(new_sig);
473 let new_index = blob.signatures.len() - 1;
474
475 let bytes = blob
477 .encode()
478 .map_err(|err| Status::internal(format!("encode review signatures: {err}")))?;
479 let content_hash = repo
480 .store()
481 .put_blob(&Blob::new(bytes))
482 .map_err(to_status)?;
483
484 let new_state = state.with_review_signatures(content_hash);
486 repo.store().put_state(&new_state).map_err(to_status)?;
487
488 Ok(SignStateResponse {
489 signature_id: synthetic_signature_id(new_index),
490 state_id: req.state_id,
491 })
492}
493
494fn synthetic_signature_id(index: usize) -> String {
498 format!("rs-{index}")
499}
500
501fn parse_change_id(s: &[u8]) -> Result<ChangeId, Status> {
502 ChangeId::try_from_slice(s)
503 .map_err(|err| Status::invalid_argument(format!("invalid state_id: {err}")))
504}
505
506fn proto_scope_to_object(scope: &ProtoReviewScope) -> Result<ReviewScope, Status> {
507 use grpc::heddle::v1::review_scope::Scope;
508 match scope.scope.as_ref() {
509 None | Some(Scope::WholeChange(_)) => Ok(ReviewScope::WholeChange),
510 Some(Scope::Symbols(list)) => {
511 if list.symbols.is_empty() {
512 return Err(Status::invalid_argument(
513 "symbols scope requires at least one symbol anchor",
514 ));
515 }
516 let symbols = list
517 .symbols
518 .iter()
519 .map(|s| SymbolAnchor::new(s.file.clone(), s.symbol.clone()))
520 .collect();
521 Ok(ReviewScope::Symbols(symbols))
522 }
523 }
524}
525
526fn object_scope_to_proto(scope: &ReviewScope) -> ProtoReviewScope {
527 use grpc::heddle::v1::review_scope::{Scope, SymbolList, WholeChange};
528 let inner = match scope {
529 ReviewScope::WholeChange => Scope::WholeChange(WholeChange {}),
530 ReviewScope::Symbols(symbols) => Scope::Symbols(SymbolList {
531 symbols: symbols
532 .iter()
533 .map(|s| ProtoPathSymbolRef {
534 file: s.file.clone(),
535 symbol: s.symbol.clone(),
536 })
537 .collect(),
538 }),
539 };
540 ProtoReviewScope { scope: Some(inner) }
541}
542
543fn review_signature_to_proto(sig: ReviewSignature, signature_id: String) -> ProtoReviewSignature {
544 ProtoReviewSignature {
545 signature_id,
546 actor_name: sig.actor.name.clone(),
547 actor_email: sig.actor.email.clone(),
548 kind: review_kind_to_proto(sig.kind) as i32,
549 scope: Some(object_scope_to_proto(&sig.scope)),
550 justification: sig.justification.unwrap_or_default(),
551 signed_at: Some(prost_types::Timestamp {
552 seconds: sig.signed_at,
553 nanos: 0,
554 }),
555 algorithm: sig.algorithm,
556 public_key: hex::decode(&sig.public_key).unwrap_or_default(),
557 signature: hex::decode(&sig.signature).unwrap_or_default(),
558 verdict: grpc::heddle::v1::Verdict::Unspecified as i32,
561 reason: String::new(),
562 }
563}
564
565fn review_kind_to_proto(kind: ReviewKind) -> grpc::heddle::v1::ReviewKind {
566 match kind {
567 ReviewKind::Read => grpc::heddle::v1::ReviewKind::Read,
568 ReviewKind::AgentPreview => grpc::heddle::v1::ReviewKind::AgentPreview,
569 ReviewKind::AgentCoReview => grpc::heddle::v1::ReviewKind::AgentCoReview,
570 }
571}
572
573fn risk_signal_to_proto(sig: objects::object::RiskSignal, visibility: &str) -> ProtoRiskSignal {
574 let (start_line, end_line) = sig.anchor.line_range.unwrap_or((0, 0));
575 ProtoRiskSignal {
576 kind: sig.kind.as_str().to_string(),
577 anchor: Some(ProtoSignalAnchor {
578 file: sig.anchor.file,
579 symbol: sig.anchor.symbol.unwrap_or_default(),
580 start_line,
581 end_line,
582 }),
583 reason: sig.reason,
584 producer_module: sig.producer.module,
585 producer_version: sig.producer.version,
586 computed_at: Some(prost_types::Timestamp {
587 seconds: sig.computed_at,
588 nanos: 0,
589 }),
590 visibility: visibility.to_string(),
591 }
592}
593
594fn changed_files_as_symbols(
605 repo: &Repository,
606 state: &State,
607 changed_paths: &[ChangedPath],
608) -> objects::error::Result<Vec<PathSymbol>> {
609 let new_tree = match repo.store().get_tree(&state.tree)? {
610 Some(t) => t,
611 None => return Ok(Vec::new()),
612 };
613 let new_files = collect_files(repo, &new_tree, "")?;
614
615 let mut out: Vec<PathSymbol> = Vec::new();
616 for path_kind in changed_paths {
617 let path = &path_kind.path;
618 #[cfg_attr(not(feature = "semantic"), allow(unused_mut))]
619 let mut emitted_any = false;
620 if let Some(hash) = new_files.get(path) {
621 #[cfg(feature = "semantic")]
622 {
623 if let Some(blob) = repo.store().get_blob(hash)? {
624 emitted_any = extract_file_symbols(path, blob.content(), &mut out);
625 }
626 }
627 #[cfg(not(feature = "semantic"))]
628 {
629 let _ = hash;
630 }
631 }
632 if !emitted_any {
633 out.push(PathSymbol {
634 file: path.clone(),
635 symbol: path.clone(),
636 kind: SymbolKind::Other,
637 });
638 }
639 }
640 Ok(out)
641}
642
643#[cfg(feature = "semantic")]
644fn extract_file_symbols(path: &str, source: &[u8], out: &mut Vec<PathSymbol>) -> bool {
645 use ::semantic::symbol_resolver::{Definition, DefinitionKind, extract_definitions};
646 let definitions: Vec<Definition> = match extract_definitions(source, std::path::Path::new(path))
647 {
648 Ok(defs) => defs,
649 Err(_) => return false,
650 };
651 if definitions.is_empty() {
652 return false;
653 }
654 for d in definitions {
655 let kind = match d.kind {
656 DefinitionKind::Type => SymbolKind::Type,
657 DefinitionKind::Trait => SymbolKind::Trait,
658 DefinitionKind::Class => SymbolKind::Class,
659 DefinitionKind::Interface => SymbolKind::Interface,
660 DefinitionKind::TypeAlias => SymbolKind::TypeAlias,
661 DefinitionKind::EnumDef => SymbolKind::EnumDef,
662 DefinitionKind::ConstDecl => SymbolKind::ConstDecl,
663 DefinitionKind::Module => SymbolKind::Module,
664 DefinitionKind::Function => SymbolKind::Function,
665 DefinitionKind::Other => SymbolKind::Other,
666 };
667 let symbol = match d.parent_name.as_deref() {
668 Some(parent) if !parent.is_empty() => format!("{parent}::{}", d.name),
669 _ => d.name,
670 };
671 out.push(PathSymbol {
672 file: path.to_string(),
673 symbol,
674 kind,
675 });
676 }
677 true
678}
679
680fn collect_files(
681 repo: &Repository,
682 tree: &objects::object::Tree,
683 prefix: &str,
684) -> objects::error::Result<std::collections::HashMap<String, objects::object::ContentHash>> {
685 let mut out = std::collections::HashMap::new();
686 for entry in tree.entries() {
687 let path = if prefix.is_empty() {
688 entry.name().to_string()
689 } else {
690 format!("{prefix}/{}", entry.name())
691 };
692 if entry.is_tree() {
693 if let Some(hash) = entry.tree_hash()
694 && let Some(subtree) = repo.store().get_tree(&hash)?
695 {
696 let sub = collect_files(repo, &subtree, &path)?;
697 out.extend(sub);
698 }
699 } else if let Some(hash) = entry.content_hash() {
700 out.insert(path, hash);
701 }
702 }
703 Ok(out)
704}
705
706fn partition_to_proto(p: ReadingOrderPartition) -> ProtoReadingOrderPartition {
707 ProtoReadingOrderPartition {
708 structural: p.structural.iter().map(path_symbol_to_proto).collect(),
709 consequence: p.consequence.iter().map(path_symbol_to_proto).collect(),
710 tests_and_docs: p.tests_and_docs.iter().map(path_symbol_to_proto).collect(),
711 }
712}
713
714fn path_symbol_to_proto(p: &PathSymbol) -> ProtoPathSymbolRef {
715 ProtoPathSymbolRef {
716 file: p.file.clone(),
717 symbol: p.symbol.clone(),
718 }
719}
720
721fn discussion_to_anchored_proto(d: &Discussion) -> AnchoredDiscussion {
722 AnchoredDiscussion {
723 id: d.id.clone(),
724 anchor: Some(ProtoPathSymbolRef {
725 file: d.anchor.file.clone(),
726 symbol: d.anchor.symbol.clone(),
727 }),
728 opened_against_state: d.opened_against_state.as_bytes().to_vec(),
729 opened_at: Some(prost_types::Timestamp {
730 seconds: d.opened_at,
731 nanos: 0,
732 }),
733 turns: d
734 .turns
735 .iter()
736 .map(|t| grpc::heddle::v1::DiscussionTurn {
737 author_name: t.author.name.clone(),
738 author_email: t.author.email.clone(),
739 body: t.body.clone(),
740 posted_at: Some(prost_types::Timestamp {
741 seconds: t.posted_at,
742 nanos: 0,
743 }),
744 })
745 .collect(),
746 resolution: Some(discussion_resolution_to_proto(&d.resolution)),
747 body_changed_since_open: d.body_changed_since_open,
748 orphaned: d.orphaned,
749 visibility: d.visibility.as_str().to_string(),
750 }
751}
752
753fn discussion_resolution_to_proto(
754 resolution: &DiscussionResolution,
755) -> grpc::heddle::v1::DiscussionResolution {
756 use grpc::heddle::v1::discussion_resolution::{
757 Dismissed, Open, ResolvedByEdit, ResolvedIntoAnnotation, State,
758 };
759 let state = match resolution {
760 DiscussionResolution::Open => State::Open(Open {}),
761 DiscussionResolution::ResolvedIntoAnnotation { annotation_id } => {
762 State::IntoAnnotation(ResolvedIntoAnnotation {
763 annotation_id: annotation_id.clone(),
764 })
765 }
766 DiscussionResolution::ResolvedByEdit { state_id } => State::ByEdit(ResolvedByEdit {
767 state_id: state_id.as_bytes().to_vec(),
768 }),
769 DiscussionResolution::Dismissed { reason } => State::Dismissed(Dismissed {
770 reason: reason.clone(),
771 }),
772 };
773 grpc::heddle::v1::DiscussionResolution { state: Some(state) }
774}
775
776#[derive(Debug, Clone)]
784struct ChangedPath {
785 path: String,
786 kind: DiffKind,
787}
788
789impl ChangedPath {
790 fn kind_str(&self) -> &'static str {
791 match self.kind {
792 DiffKind::Added => "added",
793 DiffKind::Modified => "modified",
794 DiffKind::Deleted => "deleted",
795 DiffKind::Unchanged => "unchanged",
796 }
797 }
798}
799
800struct DiffSummary {
807 files_changed: u32,
808 added_files: u32,
809 modified_files: u32,
810 deleted_files: u32,
811 added_lines: u32,
812 removed_lines: u32,
813 changed_paths: Vec<ChangedPath>,
814}
815
816fn compute_state_diff_summary(
825 repo: &Repository,
826 state: &State,
827) -> objects::error::Result<DiffSummary> {
828 use objects::object::Tree;
829 let parent_tree_hash = if let Some(parent_id) = state.parents.first() {
830 match repo.store().get_state(parent_id)? {
831 Some(parent_state) => parent_state.tree,
832 None => Tree::new().hash(),
833 }
834 } else {
835 Tree::new().hash()
836 };
837
838 let parent_tree_obj = repo.store().get_tree(&parent_tree_hash)?;
844 let new_tree_obj = repo.store().get_tree(&state.tree)?;
845
846 let changes = if parent_tree_obj.is_some() && new_tree_obj.is_some() {
852 repo.diff_trees(&parent_tree_hash, &state.tree)?
853 } else {
854 objects::object::FileChangeSet::new()
855 };
856
857 let mut added_lines: u32 = 0;
864 let mut removed_lines: u32 = 0;
865 let mut changed_paths: Vec<ChangedPath> = Vec::with_capacity(changes.len());
866
867 let parent_files = match parent_tree_obj.as_ref() {
868 Some(t) => collect_files(repo, t, "")?,
869 None => std::collections::HashMap::new(),
870 };
871 let new_files = match new_tree_obj.as_ref() {
872 Some(t) => collect_files(repo, t, "")?,
873 None => std::collections::HashMap::new(),
874 };
875
876 let mut added_files: u32 = 0;
877 let mut modified_files: u32 = 0;
878 let mut deleted_files: u32 = 0;
879
880 for change in changes.iter() {
881 match change.kind {
882 DiffKind::Added => {
883 added_files += 1;
884 if let Some(hash) = new_files.get(&change.path)
891 && let Some(blob) = repo.store().get_blob(hash)?
892 {
893 added_lines = added_lines.saturating_add(line_count(blob.content()));
894 }
895 }
896 DiffKind::Deleted => {
897 deleted_files += 1;
898 if let Some(hash) = parent_files.get(&change.path)
899 && let Some(blob) = repo.store().get_blob(hash)?
900 {
901 removed_lines = removed_lines.saturating_add(line_count(blob.content()));
902 }
903 }
904 DiffKind::Modified => {
905 modified_files += 1;
906 let old_blob = match parent_files.get(&change.path) {
913 Some(h) => repo.store().get_blob(h)?,
914 None => None,
915 };
916 let new_blob = match new_files.get(&change.path) {
917 Some(h) => repo.store().get_blob(h)?,
918 None => None,
919 };
920 if let (Some(old), Some(new)) = (old_blob, new_blob) {
921 for line in diff_blobs(&old, &new) {
922 match line {
923 objects::worktree::DiffLine::Added(_) => {
924 added_lines = added_lines.saturating_add(1);
925 }
926 objects::worktree::DiffLine::Removed(_) => {
927 removed_lines = removed_lines.saturating_add(1);
928 }
929 objects::worktree::DiffLine::Context(_) => {}
930 }
931 }
932 }
933 }
934 DiffKind::Unchanged => continue,
935 }
936 changed_paths.push(ChangedPath {
937 path: change.path.clone(),
938 kind: change.kind,
939 });
940 }
941
942 Ok(DiffSummary {
943 files_changed: changed_paths.len() as u32,
944 added_files,
945 modified_files,
946 deleted_files,
947 added_lines,
948 removed_lines,
949 changed_paths,
950 })
951}
952
953fn line_count(content: &[u8]) -> u32 {
958 let Ok(s) = std::str::from_utf8(content) else {
959 return 0;
960 };
961 if s.is_empty() {
962 return 0;
963 }
964 let trimmed = s.strip_suffix('\n').unwrap_or(s);
965 if trimmed.is_empty() {
966 return 1;
967 }
968 (trimmed.matches('\n').count() as u32).saturating_add(1)
969}
970
971#[cfg(test)]
976mod tests {
977 use std::sync::Arc;
978
979 use crypto::Signer as _;
980 use grpc::heddle::v1::ReviewScope as ProtoReviewScope;
981 use repo::{Repository, operation_dedup::OperationDedupStore};
982 use tempfile::TempDir;
983
984 use super::*;
985
986 fn fresh_service() -> (LocalStateReviewService, Arc<Repository>, TempDir) {
987 let temp = TempDir::new().expect("create tempdir");
988 unsafe {
992 std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Alice Tester");
993 std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "alice@example.com");
994 }
995 let repo = Repository::init_default(temp.path()).expect("init repo");
996 let dedup = OperationDedupStore::open(repo.heddle_dir()).expect("open dedup");
997 let repo = Arc::new(repo);
998 let svc =
999 LocalStateReviewService::new(GrpcLocalService::new(repo.clone(), Arc::new(dedup)));
1000 (svc, repo, temp)
1001 }
1002
1003 fn capture_state(repo: &Repository) -> ChangeId {
1004 std::fs::write(repo.root().join("hello.txt"), b"hi").expect("write file");
1006 let state = repo
1007 .snapshot(Some("seed".to_string()), None)
1008 .expect("snapshot");
1009 state.change_id
1010 }
1011
1012 fn sign_request(state_id: &ChangeId, op_id: &str) -> SignStateRequest {
1013 let signer = crypto::Ed25519Signer::generate().expect("generate ed25519 key");
1014 let scope = ReviewScope::WholeChange;
1015 let signed_at = chrono::Utc::now().timestamp();
1016 let payload = signing_payload(*state_id, ReviewKind::Read, &scope, signed_at, None);
1017 let signature = signer.sign(&payload).expect("sign payload");
1018 use grpc::heddle::v1::review_scope::{Scope, WholeChange};
1019 SignStateRequest {
1020 repo_path: String::new(),
1021 state_id: state_id.as_bytes().to_vec(),
1022 kind: grpc::heddle::v1::ReviewKind::Read as i32,
1023 scope: Some(ProtoReviewScope {
1024 scope: Some(Scope::WholeChange(WholeChange {})),
1025 }),
1026 justification: String::new(),
1027 algorithm: "ed25519".to_string(),
1028 public_key: signer.public_key().to_vec(),
1029 signature: signature.clone(),
1030 signed_at: Some(prost_types::Timestamp {
1031 seconds: signed_at,
1032 nanos: 0,
1033 }),
1034 client_operation_id: op_id.to_string(),
1035 }
1036 }
1037
1038 #[tokio::test]
1039 #[serial_test::serial(process_global)]
1040 async fn sign_state_persists_to_review_signatures_blob() {
1041 let (svc, repo, _tmp) = fresh_service();
1042 let state_id = capture_state(&repo);
1043
1044 let resp = svc
1045 .sign_state(Request::new(sign_request(&state_id, "")))
1046 .await
1047 .expect("sign_state");
1048 assert!(!resp.get_ref().signature_id.is_empty());
1049 assert_eq!(resp.get_ref().state_id, state_id.as_bytes().to_vec());
1050
1051 let listing = svc
1052 .list_signatures(Request::new(ListSignaturesRequest {
1053 repo_path: String::new(),
1054 state_id: state_id.as_bytes().to_vec(),
1055 }))
1056 .await
1057 .expect("list_signatures");
1058 let sigs = &listing.get_ref().signatures;
1059 assert_eq!(sigs.len(), 1, "expected one signature, got {sigs:?}");
1060 assert_eq!(sigs[0].kind, grpc::heddle::v1::ReviewKind::Read as i32);
1061 assert_eq!(sigs[0].algorithm, "ed25519");
1062 assert_eq!(sigs[0].actor_name, "Alice Tester");
1063 assert_eq!(sigs[0].actor_email, "alice@example.com");
1064 let scope_case = sigs[0].scope.as_ref().and_then(|s| s.scope.as_ref());
1065 assert!(matches!(
1066 scope_case,
1067 Some(grpc::heddle::v1::review_scope::Scope::WholeChange(_))
1068 ));
1069 }
1070
1071 #[tokio::test]
1072 #[serial_test::serial(process_global)]
1073 async fn sign_state_idempotent() {
1074 let (svc, repo, _tmp) = fresh_service();
1075 let state_id = capture_state(&repo);
1076 let op_id = objects::object::OperationId::new().to_string();
1077 let req = sign_request(&state_id, &op_id);
1080
1081 let first = svc
1082 .sign_state(Request::new(req.clone()))
1083 .await
1084 .expect("first sign_state");
1085 let second = svc
1086 .sign_state(Request::new(req))
1087 .await
1088 .expect("second sign_state");
1089 assert_eq!(
1090 first.get_ref().signature_id,
1091 second.get_ref().signature_id,
1092 "replayed call must return the same signature_id"
1093 );
1094
1095 let listing = svc
1096 .list_signatures(Request::new(ListSignaturesRequest {
1097 repo_path: String::new(),
1098 state_id: state_id.as_bytes().to_vec(),
1099 }))
1100 .await
1101 .expect("list_signatures");
1102 assert_eq!(
1103 listing.get_ref().signatures.len(),
1104 1,
1105 "idempotent replay must not append a duplicate signature"
1106 );
1107 }
1108
1109 #[tokio::test]
1110 #[serial_test::serial(process_global)]
1111 async fn sign_state_rejects_forged_signature() {
1112 let (svc, repo, _tmp) = fresh_service();
1113 let state_id = capture_state(&repo);
1114 let mut req = sign_request(&state_id, "");
1115 let last = req.signature.len() - 1;
1117 req.signature[last] ^= 0xff;
1118
1119 let err = svc
1120 .sign_state(Request::new(req))
1121 .await
1122 .expect_err("forged signature must be rejected");
1123 assert_eq!(err.code(), tonic::Code::InvalidArgument, "{err:?}");
1124 assert!(
1125 err.message().contains("failed verification"),
1126 "unexpected error message: {}",
1127 err.message()
1128 );
1129 }
1130
1131 #[tokio::test]
1132 #[serial_test::serial(process_global)]
1133 async fn sign_state_rejects_skewed_timestamp() {
1134 let (svc, repo, _tmp) = fresh_service();
1135 let state_id = capture_state(&repo);
1136 let mut req = sign_request(&state_id, "");
1137 if let Some(ts) = req.signed_at.as_mut() {
1139 ts.seconds += 60 * 60;
1140 }
1141
1142 let err = svc
1143 .sign_state(Request::new(req))
1144 .await
1145 .expect_err("skewed timestamp must be rejected");
1146 assert_eq!(err.code(), tonic::Code::InvalidArgument);
1147 assert!(err.message().contains("too far from server time"));
1148 }
1149
1150 #[tokio::test]
1151 #[serial_test::serial(process_global)]
1152 async fn sign_state_attributes_to_caller_not_state_author() {
1153 let (svc, repo, _tmp) = fresh_service();
1159 let state_id = capture_state(&repo);
1163
1164 unsafe {
1167 std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Bob Signer");
1168 std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "bob@example.com");
1169 }
1170
1171 svc.sign_state(Request::new(sign_request(&state_id, "")))
1172 .await
1173 .expect("sign_state");
1174
1175 let listing = svc
1176 .list_signatures(Request::new(ListSignaturesRequest {
1177 repo_path: String::new(),
1178 state_id: state_id.as_bytes().to_vec(),
1179 }))
1180 .await
1181 .expect("list_signatures");
1182 let sigs = &listing.get_ref().signatures;
1183 assert_eq!(sigs.len(), 1);
1184 assert_eq!(
1185 sigs[0].actor_name, "Bob Signer",
1186 "signature must attribute to the caller (Bob), not the state author (Alice)"
1187 );
1188 assert_eq!(sigs[0].actor_email, "bob@example.com");
1189
1190 unsafe {
1193 std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Alice Tester");
1194 std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "alice@example.com");
1195 }
1196 }
1197
1198 #[tokio::test]
1199 #[serial_test::serial(process_global)]
1200 async fn sign_state_serializes_concurrent_appends() {
1201 let (svc, repo, _tmp) = fresh_service();
1208 let state_id = capture_state(&repo);
1209
1210 let op_a = objects::object::OperationId::new().to_string();
1212 let op_b = objects::object::OperationId::new().to_string();
1213 let req_a = sign_request(&state_id, &op_a);
1214 let req_b = sign_request(&state_id, &op_b);
1215
1216 let svc_a = svc.clone();
1217 let svc_b = svc.clone();
1218 let (a, b) = tokio::join!(
1219 svc_a.sign_state(Request::new(req_a)),
1220 svc_b.sign_state(Request::new(req_b)),
1221 );
1222 a.expect("first sign_state");
1223 b.expect("second sign_state");
1224
1225 let listing = svc
1226 .list_signatures(Request::new(ListSignaturesRequest {
1227 repo_path: String::new(),
1228 state_id: state_id.as_bytes().to_vec(),
1229 }))
1230 .await
1231 .expect("list_signatures");
1232 assert_eq!(
1233 listing.get_ref().signatures.len(),
1234 2,
1235 "both concurrent signatures must land — neither should be lost \
1236 to a stale-blob clobber"
1237 );
1238 }
1239
1240 #[tokio::test]
1248 #[serial_test::serial(process_global)]
1249 async fn get_review_payload_populates_diff_summary_and_signals() {
1250 let (svc, repo, _tmp) = fresh_service();
1251
1252 std::fs::write(repo.root().join("hello.txt"), b"first\nsecond\nthird\n")
1255 .expect("write hello.txt");
1256 let first = repo
1257 .snapshot(Some("first capture".to_string()), None)
1258 .expect("first snapshot");
1259
1260 let resp_first = svc
1261 .get_review_payload(Request::new(GetReviewPayloadRequest {
1262 repo_path: String::new(),
1263 state_id: first.change_id.as_bytes().to_vec(),
1264 include_all_signals: false,
1265 }))
1266 .await
1267 .expect("get_review_payload first");
1268 let payload_first = resp_first.into_inner();
1269 let summary_first = payload_first.summary.as_ref().expect("summary present");
1270 assert!(
1271 summary_first.files_changed >= 1,
1272 "first state should report at least one file changed (vs empty parent), got {}",
1273 summary_first.files_changed
1274 );
1275 assert!(
1276 summary_first.added_lines >= 3,
1277 "first state should report 3+ added lines, got {}",
1278 summary_first.added_lines
1279 );
1280 assert!(
1281 !payload_first.in_budget_signals.is_empty(),
1282 "in_budget_signals must include a diff_summary entry"
1283 );
1284 let first_signal = &payload_first.in_budget_signals[0];
1285 assert!(
1286 first_signal.kind.starts_with("diff_summary."),
1287 "expected synthetic diff_summary signal kind, got {}",
1288 first_signal.kind
1289 );
1290 assert_eq!(first_signal.producer_module, "review_show.diff_summary");
1291 assert_eq!(first_signal.visibility, "visible");
1292
1293 std::fs::write(
1297 repo.root().join("hello.txt"),
1298 b"first\nsecond\nthird\nfourth\n",
1299 )
1300 .expect("modify hello.txt");
1301 let second = repo
1302 .snapshot(Some("second capture".to_string()), None)
1303 .expect("second snapshot");
1304
1305 let resp_second = svc
1306 .get_review_payload(Request::new(GetReviewPayloadRequest {
1307 repo_path: String::new(),
1308 state_id: second.change_id.as_bytes().to_vec(),
1309 include_all_signals: false,
1310 }))
1311 .await
1312 .expect("get_review_payload second");
1313 let payload_second = resp_second.into_inner();
1314 let summary_second = payload_second.summary.as_ref().expect("summary present");
1315 assert_eq!(
1316 summary_second.files_changed, 1,
1317 "second state should report exactly one modified file"
1318 );
1319 assert!(
1320 summary_second.added_lines >= 1,
1321 "second state should report at least one added line, got {}",
1322 summary_second.added_lines
1323 );
1324 assert!(
1325 !payload_second.in_budget_signals.is_empty(),
1326 "in_budget_signals must include a diff_summary entry"
1327 );
1328 let signal = &payload_second.in_budget_signals[0];
1329 assert_eq!(
1330 signal
1331 .anchor
1332 .as_ref()
1333 .map(|a| a.file.as_str())
1334 .unwrap_or(""),
1335 "hello.txt",
1336 "diff_summary signal should anchor on the modified file"
1337 );
1338 assert!(
1339 signal.reason.contains("files changed"),
1340 "first signal reason should carry the aggregate summary, got {}",
1341 signal.reason
1342 );
1343 assert_eq!(
1347 summary_second.in_budget_signal_count,
1348 payload_second.in_budget_signals.len() as u32,
1349 "in_budget_signal_count must match the array length"
1350 );
1351 }
1352
1353 #[tokio::test]
1354 #[serial_test::serial(process_global)]
1355 async fn get_review_payload_surfaces_gitlink_target_changes() {
1356 let (svc, repo, _tmp) = fresh_service();
1357
1358 let old_target = "0303030303030303030303030303030303030303"
1359 .parse()
1360 .expect("old git oid");
1361 let new_target = "0404040404040404040404040404040404040404"
1362 .parse()
1363 .expect("new git oid");
1364 let old_tree = objects::object::Tree::from_entries(vec![
1365 objects::object::TreeEntry::gitlink("vendor", old_target).expect("old gitlink"),
1366 ]);
1367 let new_tree = objects::object::Tree::from_entries(vec![
1368 objects::object::TreeEntry::gitlink("vendor", new_target).expect("new gitlink"),
1369 ]);
1370 let old_tree_hash = repo.store().put_tree(&old_tree).expect("put old tree");
1371 let new_tree_hash = repo.store().put_tree(&new_tree).expect("put new tree");
1372 let base = State::new_snapshot(
1373 old_tree_hash,
1374 Vec::new(),
1375 objects::object::Attribution::human(objects::object::Principal::new(
1376 "Gitlink Reviewer",
1377 "gitlink@example.test",
1378 )),
1379 );
1380 let base_id = base.change_id;
1381 repo.store().put_state(&base).expect("put base state");
1382 let changed = State::new_snapshot(
1383 new_tree_hash,
1384 vec![base_id],
1385 objects::object::Attribution::human(objects::object::Principal::new(
1386 "Gitlink Reviewer",
1387 "gitlink@example.test",
1388 )),
1389 );
1390 let changed_id = changed.change_id;
1391 repo.store().put_state(&changed).expect("put changed state");
1392
1393 let resp = svc
1394 .get_review_payload(Request::new(GetReviewPayloadRequest {
1395 repo_path: String::new(),
1396 state_id: changed_id.as_bytes().to_vec(),
1397 include_all_signals: false,
1398 }))
1399 .await
1400 .expect("get_review_payload gitlink change");
1401 let payload = resp.into_inner();
1402 let summary = payload.summary.as_ref().expect("summary present");
1403 assert_eq!(
1404 summary.files_changed, 1,
1405 "gitlink pointer change should count as one changed path"
1406 );
1407 assert_eq!(summary.added_lines, 0);
1408 assert_eq!(summary.removed_lines, 0);
1409 assert_eq!(
1410 payload
1411 .in_budget_signals
1412 .first()
1413 .and_then(|signal| signal.anchor.as_ref())
1414 .map(|anchor| anchor.file.as_str()),
1415 Some("vendor"),
1416 "diff_summary signal should be anchored on the gitlink path"
1417 );
1418 let partition = payload.partition.expect("partition present");
1419 let surfaced = partition
1420 .structural
1421 .iter()
1422 .chain(partition.consequence.iter())
1423 .chain(partition.tests_and_docs.iter())
1424 .any(|symbol| symbol.file == "vendor" && symbol.symbol == "vendor");
1425 assert!(surfaced, "gitlink change should be path-visible in review");
1426 }
1427
1428 #[tokio::test]
1440 #[serial_test::serial(process_global)]
1441 async fn get_review_payload_tolerates_missing_tree() {
1442 let (svc, repo, _tmp) = fresh_service();
1443 let state_id = capture_state(&repo);
1444
1445 let state = repo
1450 .store()
1451 .get_state(&state_id)
1452 .expect("get state")
1453 .expect("state present");
1454 let bogus_tree = objects::object::ContentHash::compute(b"definitely-not-in-store-bytes");
1455 let mut mutated = state.clone();
1456 mutated.tree = bogus_tree;
1457 repo.store().put_state(&mutated).expect("put mutated state");
1458
1459 let resp = svc
1462 .get_review_payload(Request::new(GetReviewPayloadRequest {
1463 repo_path: String::new(),
1464 state_id: state_id.as_bytes().to_vec(),
1465 include_all_signals: false,
1466 }))
1467 .await
1468 .expect("missing tree must not block review payload");
1469 let payload = resp.into_inner();
1470 let summary = payload.summary.as_ref().expect("summary present");
1471 assert_eq!(
1472 summary.files_changed, 0,
1473 "missing tree must produce a zero-change summary, got {} files",
1474 summary.files_changed
1475 );
1476 assert!(
1480 !payload.in_budget_signals.is_empty(),
1481 "in_budget_signals should always contain at least the synthetic diff_summary entry"
1482 );
1483 let kind = &payload.in_budget_signals[0].kind;
1484 assert!(
1485 kind.starts_with("diff_summary."),
1486 "expected synthetic diff_summary signal, got {kind}"
1487 );
1488 }
1489
1490 #[test]
1494 fn line_count_matches_git_semantics() {
1495 assert_eq!(line_count(b""), 0);
1496 assert_eq!(line_count(b"\n"), 1);
1497 assert_eq!(line_count(b"hello"), 1);
1498 assert_eq!(line_count(b"hello\n"), 1);
1499 assert_eq!(line_count(b"hello\nworld"), 2);
1500 assert_eq!(line_count(b"hello\nworld\n"), 2);
1501 assert_eq!(line_count(b"a\nb\nc\n"), 3);
1502 assert_eq!(line_count(&[0xff, 0xfe, 0xfd]), 0);
1504 }
1505}