1use ::state_review::{
11 PathSymbol, ReadingOrderPartition, SymbolKind, build_review_payload_partition,
12};
13use api::heddle::api::v1alpha1::{
14 AnchoredDiscussion, GetRepoSignalHealthRequest, GetReviewPayloadRequest,
15 GetReviewProgressRequest, GetReviewProgressResponse, ListSignaturesRequest,
16 ListSignaturesResponse, MergeRequirement, PathSymbolRef as ProtoPathSymbolRef,
17 ReadingOrderPartition as ProtoReadingOrderPartition, RecordCheckAckRequest,
18 RecordCheckAckResponse, RecordVerdictRequest, RecordVerdictResponse, RepoSignalHealthReport,
19 ReviewPayload, ReviewScope as ProtoReviewScope, ReviewSignature as ProtoReviewSignature,
20 ReviewSummary, RiskSignal as ProtoRiskSignal, SignStateRequest, SignStateResponse,
21 SignalAnchor as ProtoSignalAnchor, SignalHealthEntry as ProtoSignalHealthEntry, SigningFooter,
22 Verdict as ProtoVerdict, state_review_service_server::StateReviewService,
23};
24use crypto::verify_payload_signature;
25use objects::{
26 lock::RepositoryLockExt,
27 object::{
28 Blob, DiffKind, Discussion, DiscussionResolution, DiscussionsBlob, ReviewKind, ReviewScope,
29 ReviewSignature, ReviewSignaturesBlob, RiskSignalBlob, State, StateAttachment,
30 StateAttachmentBody, StateId, SymbolAnchor, signing_payload,
31 },
32 store::ObjectStore,
33 worktree::diff_blobs,
34};
35use prost::Message;
36use repo::{Repository, StateAttachmentKind};
37use tonic::{Request, Response, Status};
38
39use super::{GrpcLocalService, to_status, with_idempotency};
40
41const SIGN_TIMESTAMP_SKEW_SECS: i64 = 5 * 60;
45
46const VERDICT_SIGNING_PAYLOAD_TAG: &[u8] = b"hd-rev-sig-v2\x00";
49
50const VERDICT_ENVELOPE_TAG: &str = "\u{1}hd-verdict-v1\u{1}";
53
54#[derive(Clone)]
56pub struct LocalStateReviewService {
57 inner: GrpcLocalService,
58}
59
60impl LocalStateReviewService {
61 pub fn new(inner: GrpcLocalService) -> Self {
62 Self { inner }
63 }
64}
65
66#[tonic::async_trait]
67impl StateReviewService for LocalStateReviewService {
68 async fn get_repo_signal_health(
69 &self,
70 request: Request<GetRepoSignalHealthRequest>,
71 ) -> Result<Response<RepoSignalHealthReport>, Status> {
72 let report =
73 super::get_repo_signal_health(self.inner.repo(), request.into_inner().window_states)
74 .map_err(to_status)?;
75 Ok(Response::new(RepoSignalHealthReport {
76 entries: report
77 .entries
78 .into_iter()
79 .map(|entry| ProtoSignalHealthEntry {
80 module_id: entry.module_id,
81 fire_rate: entry.fire_rate,
82 warn: entry.warn,
83 })
84 .collect(),
85 window_states: report.window_states,
86 }))
87 }
88
89 async fn get_review_payload(
90 &self,
91 request: Request<GetReviewPayloadRequest>,
92 ) -> Result<Response<ReviewPayload>, Status> {
93 let req = request.into_inner();
94 let state_id = parse_state_id(&req.state_id)?;
95 let repo = self.inner.repo();
96 let state = repo
97 .store()
98 .get_state(&state_id)
99 .map_err(to_status)?
100 .ok_or_else(|| {
101 Status::not_found(format!("state {} not found", state_id.to_string_full()))
102 })?;
103
104 let diff_summary = compute_state_diff_summary(repo, &state).map_err(to_status)?;
110
111 let summary = ReviewSummary {
112 headline: state.intent.clone().unwrap_or_default(),
113 files_changed: diff_summary.files_changed,
114 added_lines: diff_summary.added_lines,
115 removed_lines: diff_summary.removed_lines,
116 in_budget_signal_count: 0,
117 hidden_signal_count: 0,
118 };
119
120 let agent_narrative = if state.attribution.agent.is_some() {
121 state.intent.clone().unwrap_or_default()
122 } else {
123 String::new()
124 };
125
126 let mut all_signals: Vec<ProtoRiskSignal> = Vec::new();
130 if req.include_all_signals
131 && let Some(hash) =
132 attachment_hash(repo, &state.state_id, StateAttachmentKind::RiskSignals)?
133 && let Some(blob) = repo.store().get_blob(&hash).map_err(to_status)?
134 {
135 let decoded = RiskSignalBlob::decode(blob.content())
136 .map_err(|err| Status::internal(format!("decode risk signals: {err}")))?;
137 all_signals = decoded
138 .signals
139 .into_iter()
140 .map(|s| risk_signal_to_proto(s, "visible"))
141 .collect();
142 }
143
144 let mut in_budget_signals: Vec<ProtoRiskSignal> = Vec::new();
153 let summary_kind = match (
154 diff_summary.added_files,
155 diff_summary.modified_files,
156 diff_summary.deleted_files,
157 ) {
158 (a, 0, 0) if a > 0 => "diff_summary.added_only",
159 (0, m, 0) if m > 0 => "diff_summary.modified_only",
160 (0, 0, d) if d > 0 => "diff_summary.deleted_only",
161 (0, 0, 0) => "diff_summary.empty",
162 _ => "diff_summary.mixed",
163 };
164 let summary_reason = format!(
165 "{} files changed (+{}/-{}, {} added, {} modified, {} deleted)",
166 diff_summary.files_changed,
167 diff_summary.added_lines,
168 diff_summary.removed_lines,
169 diff_summary.added_files,
170 diff_summary.modified_files,
171 diff_summary.deleted_files,
172 );
173 const MAX_DIFF_SIGNAL_ANCHORS: usize = 32;
178 if diff_summary.changed_paths.is_empty() {
179 in_budget_signals.push(ProtoRiskSignal {
180 kind: summary_kind.to_string(),
181 anchor: Some(ProtoSignalAnchor {
182 file: String::new(),
183 symbol: String::new(),
184 start_line: 0,
185 end_line: 0,
186 }),
187 reason: summary_reason.clone(),
188 producer_module: "review_show.diff_summary".to_string(),
189 producer_version: 1,
190 computed_at: None,
191 visibility: "visible".to_string(),
192 });
193 } else {
194 for (idx, path_kind) in diff_summary
195 .changed_paths
196 .iter()
197 .take(MAX_DIFF_SIGNAL_ANCHORS)
198 .enumerate()
199 {
200 let reason = if idx == 0 {
201 summary_reason.clone()
202 } else {
203 format!("{} ({})", path_kind.path, path_kind.kind_str())
204 };
205 in_budget_signals.push(ProtoRiskSignal {
206 kind: summary_kind.to_string(),
207 anchor: Some(ProtoSignalAnchor {
208 file: path_kind.path.clone(),
209 symbol: String::new(),
210 start_line: 0,
211 end_line: 0,
212 }),
213 reason,
214 producer_module: "review_show.diff_summary".to_string(),
215 producer_version: 1,
216 computed_at: None,
217 visibility: "visible".to_string(),
218 });
219 }
220 }
221
222 let symbols = changed_files_as_symbols(repo, &state, &diff_summary.changed_paths)
226 .map_err(to_status)?;
227 let partition = build_review_payload_partition(&symbols);
228
229 let discussions =
232 match attachment_hash(repo, &state.state_id, StateAttachmentKind::Discussions)? {
233 Some(hash) => {
234 let blob = repo
235 .store()
236 .get_blob(&hash)
237 .map_err(to_status)?
238 .ok_or_else(|| {
239 Status::internal(format!(
240 "discussions blob {} referenced by state {} is missing",
241 hash,
242 state.state_id.to_string_full()
243 ))
244 })?;
245 let decoded = DiscussionsBlob::decode(blob.content())
246 .map_err(|err| Status::internal(format!("decode discussions: {err}")))?;
247 decoded
248 .discussions
249 .iter()
250 .map(discussion_to_anchored_proto)
251 .collect()
252 }
253 None => Vec::<AnchoredDiscussion>::new(),
254 };
255
256 let mut summary = summary;
257 summary.in_budget_signal_count = in_budget_signals.len() as u32;
258 summary.hidden_signal_count =
259 all_signals.len().saturating_sub(in_budget_signals.len()) as u32;
260
261 let payload = ReviewPayload {
262 state_id: req.state_id.clone(),
263 summary: Some(summary),
264 agent_narrative,
265 partition: Some(partition_to_proto(partition)),
266 in_budget_signals,
267 all_signals,
268 tick_budget: 3,
269 discussions,
270 merge_requirements: Vec::<MergeRequirement>::new(),
273 signing_footer: Some(SigningFooter {
274 available_kinds: vec![
275 api::heddle::api::v1alpha1::ReviewKind::Read as i32,
276 api::heddle::api::v1alpha1::ReviewKind::AgentPreview as i32,
277 api::heddle::api::v1alpha1::ReviewKind::AgentCoReview as i32,
278 ],
279 }),
280 };
281
282 Ok(Response::new(payload))
283 }
284
285 async fn sign_state(
286 &self,
287 request: Request<SignStateRequest>,
288 ) -> Result<Response<SignStateResponse>, Status> {
289 let req = request.into_inner();
290 let req_bytes = req.encode_to_vec();
291 let client_operation_id = req.client_operation_id.clone();
292 let inner = self.inner.clone();
293
294 let response = with_idempotency(
295 &self.inner,
296 &client_operation_id,
297 "state_review.sign_state",
298 &req_bytes,
299 move || {
300 let inner = inner.clone();
301 async move { execute_sign_state(&inner, req).await }
302 },
303 )
304 .await?;
305
306 Ok(Response::new(response))
307 }
308
309 async fn list_signatures(
310 &self,
311 request: Request<ListSignaturesRequest>,
312 ) -> Result<Response<ListSignaturesResponse>, Status> {
313 let req = request.into_inner();
314 let state_id = parse_state_id(&req.state_id)?;
315 let repo = self.inner.repo();
316 let state = repo
317 .store()
318 .get_state(&state_id)
319 .map_err(to_status)?
320 .ok_or_else(|| {
321 Status::not_found(format!("state {} not found", state_id.to_string_full()))
322 })?;
323
324 let signatures =
325 match attachment_hash(repo, &state.state_id, StateAttachmentKind::ReviewSignatures)? {
326 Some(hash) => {
327 let blob = repo
328 .store()
329 .get_blob(&hash)
330 .map_err(to_status)?
331 .ok_or_else(|| {
332 Status::internal(format!(
333 "review signatures blob {} missing from object store",
334 hash
335 ))
336 })?;
337 let decoded = ReviewSignaturesBlob::decode(blob.content()).map_err(|err| {
338 Status::internal(format!("decode review signatures: {err}"))
339 })?;
340 decoded
341 .signatures
342 .into_iter()
343 .enumerate()
344 .map(|(idx, sig)| {
345 review_signature_to_proto(sig, synthetic_signature_id(idx))
346 })
347 .collect()
348 }
349 None => Vec::new(),
350 };
351
352 Ok(Response::new(ListSignaturesResponse { signatures }))
353 }
354
355 async fn record_verdict(
356 &self,
357 request: Request<RecordVerdictRequest>,
358 ) -> Result<Response<RecordVerdictResponse>, Status> {
359 let req = request.into_inner();
360 let req_bytes = req.encode_to_vec();
361 let client_operation_id = req.client_operation_id.clone();
362 let inner = self.inner.clone();
363
364 let response = with_idempotency(
365 &self.inner,
366 &client_operation_id,
367 "state_review.record_verdict",
368 &req_bytes,
369 move || {
370 let inner = inner.clone();
371 async move { execute_record_verdict(&inner, req).await }
372 },
373 )
374 .await?;
375
376 Ok(Response::new(response))
377 }
378
379 async fn record_check_ack(
380 &self,
381 _request: Request<RecordCheckAckRequest>,
382 ) -> Result<Response<RecordCheckAckResponse>, Status> {
383 Err(Status::unimplemented(
384 "record_check_ack is not available in local mode: local repositories do not persist review check acknowledgements",
385 ))
386 }
387
388 async fn get_review_progress(
389 &self,
390 _request: Request<GetReviewProgressRequest>,
391 ) -> Result<Response<GetReviewProgressResponse>, Status> {
392 Err(Status::unimplemented(
393 "get_review_progress is not available in local mode: local repositories do not persist review progress",
394 ))
395 }
396}
397
398async fn execute_sign_state(
401 inner: &GrpcLocalService,
402 req: SignStateRequest,
403) -> Result<SignStateResponse, Status> {
404 let kind = match api::heddle::api::v1alpha1::ReviewKind::try_from(req.kind)
406 .map_err(|_| Status::invalid_argument(format!("unknown review kind tag {}", req.kind)))?
407 {
408 api::heddle::api::v1alpha1::ReviewKind::Read => ReviewKind::Read,
409 api::heddle::api::v1alpha1::ReviewKind::AgentPreview => ReviewKind::AgentPreview,
410 api::heddle::api::v1alpha1::ReviewKind::AgentCoReview => ReviewKind::AgentCoReview,
411 api::heddle::api::v1alpha1::ReviewKind::Unspecified => {
412 return Err(Status::invalid_argument("review kind is required"));
413 }
414 };
415
416 let state_id = parse_state_id(&req.state_id)?;
418 let repo = inner.repo();
419
420 let scope = match req.scope.as_ref() {
422 Some(s) => proto_scope_to_object(s)?,
423 None => ReviewScope::WholeChange,
424 };
425
426 let actor = repo
434 .get_principal()
435 .map_err(|err| Status::internal(format!("resolve caller principal: {err}")))?;
436 if req.justification.starts_with(VERDICT_ENVELOPE_TAG) {
437 return Err(Status::invalid_argument(
438 "justification must not begin with the reserved verdict-envelope prefix",
439 ));
440 }
441 let justification = if req.justification.is_empty() {
442 None
443 } else {
444 Some(req.justification.clone())
445 };
446
447 let now = chrono::Utc::now().timestamp();
448 let signed_at = req.signed_at.as_ref().map(|t| t.seconds).unwrap_or(0);
449 if signed_at == 0 {
450 return Err(Status::invalid_argument(
451 "signed_at is required and must match the timestamp the client signed over",
452 ));
453 }
454 if (signed_at - now).abs() > SIGN_TIMESTAMP_SKEW_SECS {
455 return Err(Status::invalid_argument(format!(
456 "signed_at={signed_at} is too far from server time={now} (max skew {SIGN_TIMESTAMP_SKEW_SECS}s)"
457 )));
458 }
459
460 let new_sig = ReviewSignature {
461 actor,
462 kind,
463 scope: scope.clone(),
464 justification: justification.clone(),
465 signed_at,
466 algorithm: req.algorithm.clone(),
467 public_key: hex::encode(&req.public_key),
468 signature: hex::encode(&req.signature),
469 };
470 new_sig
471 .validate()
472 .map_err(|err| Status::invalid_argument(format!("invalid review signature: {err}")))?;
473
474 let public_key_bytes = req.public_key.clone();
475 let signature_bytes = req.signature.clone();
476 let payload = signing_payload(state_id, kind, &scope, signed_at, justification.as_deref());
477 verify_payload_signature(
478 &payload,
479 &req.algorithm,
480 &public_key_bytes,
481 &signature_bytes,
482 )
483 .map_err(|err| {
484 Status::invalid_argument(format!(
485 "review signature failed verification ({}): {err}",
486 req.algorithm
487 ))
488 })?;
489
490 let new_index = append_review_signature(repo, state_id, new_sig)?;
491
492 Ok(SignStateResponse {
493 signature_id: synthetic_signature_id(new_index),
494 state_id: req.state_id,
495 })
496}
497
498async fn execute_record_verdict(
499 inner: &GrpcLocalService,
500 req: RecordVerdictRequest,
501) -> Result<RecordVerdictResponse, Status> {
502 let verdict = verdict_str_from_proto(req.verdict)?;
503 let state_id = parse_state_id(&req.state_id)?;
504 if matches!(verdict, "hold" | "reject") && req.reason.trim().is_empty() {
505 return Err(Status::invalid_argument(
506 "reason is required for HOLD and REJECT verdicts",
507 ));
508 }
509 let scope = match req.scope.as_ref() {
510 Some(scope) => proto_scope_to_object(scope)?,
511 None => ReviewScope::WholeChange,
512 };
513 let signed_at = req.signed_at.as_ref().map(|time| time.seconds).unwrap_or(0);
514 if signed_at == 0 {
515 return Err(Status::invalid_argument(
516 "signed_at is required and must match the timestamp the client signed over",
517 ));
518 }
519 let now = chrono::Utc::now().timestamp();
520 if (signed_at - now).abs() > SIGN_TIMESTAMP_SKEW_SECS {
521 return Err(Status::invalid_argument(format!(
522 "signed_at={signed_at} is too far from server time={now} (max skew {SIGN_TIMESTAMP_SKEW_SECS}s)"
523 )));
524 }
525
526 let payload = verdict_signing_payload(state_id, verdict, &scope, signed_at, &req.reason);
527 verify_payload_signature(&payload, &req.algorithm, &req.public_key, &req.signature).map_err(
528 |err| {
529 Status::invalid_argument(format!(
530 "verdict signature failed verification ({}): {err}",
531 req.algorithm
532 ))
533 },
534 )?;
535
536 let repo = inner.repo();
537 let signature = ReviewSignature {
538 actor: repo
539 .get_principal()
540 .map_err(|err| Status::internal(format!("resolve caller principal: {err}")))?,
541 kind: ReviewKind::Read,
542 scope,
543 justification: Some(encode_verdict_envelope(verdict, &req.reason)),
544 signed_at,
545 algorithm: req.algorithm.clone(),
546 public_key: hex::encode(&req.public_key),
547 signature: hex::encode(&req.signature),
548 };
549 signature
550 .validate()
551 .map_err(|err| Status::invalid_argument(format!("invalid review signature: {err}")))?;
552
553 let new_index = append_review_signature(repo, state_id, signature)?;
554 Ok(RecordVerdictResponse {
555 signature_id: synthetic_signature_id(new_index),
556 state_id: req.state_id,
557 })
558}
559
560fn append_review_signature(
564 repo: &Repository,
565 state_id: StateId,
566 signature: ReviewSignature,
567) -> Result<usize, Status> {
568 let _lock = repo
569 .locker()
570 .write()
571 .map_err(|err| Status::internal(err.to_string()))?;
572 repo.store()
573 .get_state(&state_id)
574 .map_err(to_status)?
575 .ok_or_else(|| {
576 Status::not_found(format!("state {} not found", state_id.to_string_full()))
577 })?;
578 let prior = repo
579 .latest_state_attachment(&state_id, StateAttachmentKind::ReviewSignatures)
580 .map_err(to_status)?;
581 let mut blob = match prior.as_ref().map(|attachment| {
582 let StateAttachmentBody::ReviewSignatures(hash) = &attachment.body else {
583 unreachable!()
584 };
585 *hash
586 }) {
587 Some(hash) => {
588 let raw = repo
589 .store()
590 .get_blob(&hash)
591 .map_err(to_status)?
592 .ok_or_else(|| {
593 Status::internal(format!(
594 "existing review signatures blob {} missing from object store",
595 hash
596 ))
597 })?;
598 ReviewSignaturesBlob::decode(raw.content())
599 .map_err(|err| Status::internal(format!("decode review signatures: {err}")))?
600 }
601 None => ReviewSignaturesBlob::new(Vec::new()),
602 };
603 blob.signatures.push(signature);
604 let new_index = blob.signatures.len() - 1;
605
606 let bytes = blob
607 .encode()
608 .map_err(|err| Status::internal(format!("encode review signatures: {err}")))?;
609 let content_hash = repo
610 .store()
611 .put_blob(&Blob::new(bytes))
612 .map_err(to_status)?;
613
614 let attachment = StateAttachment {
615 state_id,
616 body: StateAttachmentBody::ReviewSignatures(content_hash),
617 attribution: repo.get_attribution().map_err(to_status)?,
618 created_at: chrono::Utc::now(),
619 supersedes: prior.map(|attachment| attachment.id()),
620 };
621 repo.put_state_attachment(&attachment).map_err(to_status)?;
622 Ok(new_index)
623}
624
625fn synthetic_signature_id(index: usize) -> String {
629 format!("rs-{index}")
630}
631
632fn attachment_hash(
633 repo: &Repository,
634 state_id: &StateId,
635 kind: StateAttachmentKind,
636) -> Result<Option<objects::object::ContentHash>, Status> {
637 let Some(attachment) = repo
638 .latest_state_attachment(state_id, kind)
639 .map_err(to_status)?
640 else {
641 return Ok(None);
642 };
643 let hash = match attachment.body {
644 StateAttachmentBody::RiskSignals(hash)
645 | StateAttachmentBody::ReviewSignatures(hash)
646 | StateAttachmentBody::Discussions(hash) => hash,
647 _ => unreachable!(),
648 };
649 Ok(Some(hash))
650}
651
652fn parse_state_id(
653 state_id: &Option<api::heddle::api::v1alpha1::StateId>,
654) -> Result<StateId, Status> {
655 let bytes = state_id
656 .as_ref()
657 .ok_or_else(|| Status::invalid_argument("state_id is required"))?;
658 StateId::try_from_slice(&bytes.value)
659 .map_err(|err| Status::invalid_argument(format!("invalid state_id: {err}")))
660}
661
662fn api_state_id(state_id: &StateId) -> api::heddle::api::v1alpha1::StateId {
663 api::heddle::api::v1alpha1::StateId {
664 value: state_id.as_bytes().to_vec(),
665 }
666}
667
668fn verdict_signing_payload(
669 state_id: StateId,
670 verdict: &str,
671 scope: &ReviewScope,
672 signed_at: i64,
673 reason: &str,
674) -> Vec<u8> {
675 let mut payload = Vec::with_capacity(VERDICT_SIGNING_PAYLOAD_TAG.len() + 256);
676 payload.extend_from_slice(VERDICT_SIGNING_PAYLOAD_TAG);
677 payload.extend_from_slice(state_id.to_string_full().as_bytes());
678 payload.push(0);
679 payload.extend_from_slice(verdict.as_bytes());
680 payload.push(0);
681 match scope {
682 ReviewScope::WholeChange => {
683 payload.extend_from_slice(b"whole_change");
684 payload.push(0);
685 }
686 ReviewScope::Symbols(symbols) => {
687 payload.extend_from_slice(b"symbols");
688 payload.push(0);
689 payload.extend_from_slice(&(symbols.len() as u32).to_le_bytes());
690 for symbol in symbols {
691 payload.extend_from_slice(symbol.file.as_bytes());
692 payload.push(0);
693 payload.extend_from_slice(symbol.symbol.as_bytes());
694 payload.push(0);
695 }
696 }
697 }
698 payload.extend_from_slice(&signed_at.to_le_bytes());
699 payload.extend_from_slice(reason.as_bytes());
700 payload.push(0);
701 payload
702}
703
704fn verdict_str_from_proto(tag: i32) -> Result<&'static str, Status> {
705 match ProtoVerdict::try_from(tag)
706 .map_err(|_| Status::invalid_argument(format!("unknown verdict tag {tag}")))?
707 {
708 ProtoVerdict::Sign => Ok("sign"),
709 ProtoVerdict::Hold => Ok("hold"),
710 ProtoVerdict::Reject => Ok("reject"),
711 ProtoVerdict::Unspecified => Err(Status::invalid_argument("verdict is required")),
712 }
713}
714
715fn encode_verdict_envelope(verdict: &str, reason: &str) -> String {
716 let body = serde_json::json!({ "verdict": verdict, "reason": reason });
717 format!("{VERDICT_ENVELOPE_TAG}{body}")
718}
719
720fn decode_verdict_envelope(justification: &str) -> Option<(String, String)> {
721 let body = justification.strip_prefix(VERDICT_ENVELOPE_TAG)?;
722 let value: serde_json::Value = serde_json::from_str(body).ok()?;
723 let verdict = value.get("verdict")?.as_str()?.to_string();
724 let reason = value
725 .get("reason")
726 .and_then(|reason| reason.as_str())
727 .unwrap_or_default()
728 .to_string();
729 Some((verdict, reason))
730}
731
732fn verdict_proto_from_str(verdict: &str) -> ProtoVerdict {
733 match verdict {
734 "sign" => ProtoVerdict::Sign,
735 "hold" => ProtoVerdict::Hold,
736 "reject" => ProtoVerdict::Reject,
737 _ => ProtoVerdict::Unspecified,
738 }
739}
740
741fn proto_scope_to_object(scope: &ProtoReviewScope) -> Result<ReviewScope, Status> {
742 use api::heddle::api::v1alpha1::review_scope::Scope;
743 match scope.scope.as_ref() {
744 None | Some(Scope::WholeChange(_)) => Ok(ReviewScope::WholeChange),
745 Some(Scope::Symbols(list)) => {
746 if list.symbols.is_empty() {
747 return Err(Status::invalid_argument(
748 "symbols scope requires at least one symbol anchor",
749 ));
750 }
751 let symbols = list
752 .symbols
753 .iter()
754 .map(|s| SymbolAnchor::new(s.file.clone(), s.symbol.clone()))
755 .collect();
756 Ok(ReviewScope::Symbols(symbols))
757 }
758 }
759}
760
761fn object_scope_to_proto(scope: &ReviewScope) -> ProtoReviewScope {
762 use api::heddle::api::v1alpha1::review_scope::{Scope, SymbolList, WholeChange};
763 let inner = match scope {
764 ReviewScope::WholeChange => Scope::WholeChange(WholeChange {}),
765 ReviewScope::Symbols(symbols) => Scope::Symbols(SymbolList {
766 symbols: symbols
767 .iter()
768 .map(|s| ProtoPathSymbolRef {
769 file: s.file.clone(),
770 symbol: s.symbol.clone(),
771 })
772 .collect(),
773 }),
774 };
775 ProtoReviewScope { scope: Some(inner) }
776}
777
778fn review_signature_to_proto(sig: ReviewSignature, signature_id: String) -> ProtoReviewSignature {
779 let (verdict, reason, justification) = match sig
780 .justification
781 .as_deref()
782 .and_then(decode_verdict_envelope)
783 {
784 Some((verdict, reason)) => (
785 verdict_proto_from_str(&verdict) as i32,
786 reason,
787 String::new(),
788 ),
789 None => (
790 ProtoVerdict::Unspecified as i32,
791 String::new(),
792 sig.justification.unwrap_or_default(),
793 ),
794 };
795 ProtoReviewSignature {
796 signature_id,
797 actor_name: sig.actor.name.clone(),
798 actor_email: sig.actor.email.clone(),
799 kind: review_kind_to_proto(sig.kind) as i32,
800 scope: Some(object_scope_to_proto(&sig.scope)),
801 justification,
802 signed_at: Some(prost_types::Timestamp {
803 seconds: sig.signed_at,
804 nanos: 0,
805 }),
806 algorithm: sig.algorithm,
807 public_key: hex::decode(&sig.public_key).unwrap_or_default(),
808 signature: hex::decode(&sig.signature).unwrap_or_default(),
809 verdict,
810 reason,
811 }
812}
813
814fn review_kind_to_proto(kind: ReviewKind) -> api::heddle::api::v1alpha1::ReviewKind {
815 match kind {
816 ReviewKind::Read => api::heddle::api::v1alpha1::ReviewKind::Read,
817 ReviewKind::AgentPreview => api::heddle::api::v1alpha1::ReviewKind::AgentPreview,
818 ReviewKind::AgentCoReview => api::heddle::api::v1alpha1::ReviewKind::AgentCoReview,
819 }
820}
821
822fn risk_signal_to_proto(sig: objects::object::RiskSignal, visibility: &str) -> ProtoRiskSignal {
823 let (start_line, end_line) = sig.anchor.line_range.unwrap_or((0, 0));
824 ProtoRiskSignal {
825 kind: sig.kind.as_str().to_string(),
826 anchor: Some(ProtoSignalAnchor {
827 file: sig.anchor.file,
828 symbol: sig.anchor.symbol.unwrap_or_default(),
829 start_line,
830 end_line,
831 }),
832 reason: sig.reason,
833 producer_module: sig.producer.module,
834 producer_version: sig.producer.version,
835 computed_at: Some(prost_types::Timestamp {
836 seconds: sig.computed_at,
837 nanos: 0,
838 }),
839 visibility: visibility.to_string(),
840 }
841}
842
843fn changed_files_as_symbols(
854 repo: &Repository,
855 state: &State,
856 changed_paths: &[ChangedPath],
857) -> objects::error::Result<Vec<PathSymbol>> {
858 let new_tree = match repo.store().get_tree(&state.tree)? {
859 Some(t) => t,
860 None => return Ok(Vec::new()),
861 };
862 let new_files = collect_files(repo, &new_tree, "")?;
863
864 let mut out: Vec<PathSymbol> = Vec::new();
865 for path_kind in changed_paths {
866 let path = &path_kind.path;
867 #[cfg_attr(not(feature = "semantic"), allow(unused_mut))]
868 let mut emitted_any = false;
869 if let Some(hash) = new_files.get(path) {
870 #[cfg(feature = "semantic")]
871 {
872 if let Some(blob) = repo.store().get_blob(hash)? {
873 emitted_any = extract_file_symbols(path, blob.content(), &mut out);
874 }
875 }
876 #[cfg(not(feature = "semantic"))]
877 {
878 let _ = hash;
879 }
880 }
881 if !emitted_any {
882 out.push(PathSymbol {
883 file: path.clone(),
884 symbol: path.clone(),
885 kind: SymbolKind::Other,
886 });
887 }
888 }
889 Ok(out)
890}
891
892#[cfg(feature = "semantic")]
893fn extract_file_symbols(path: &str, source: &[u8], out: &mut Vec<PathSymbol>) -> bool {
894 use ::semantic::symbol_resolver::{Definition, DefinitionKind, extract_definitions};
895 let definitions: Vec<Definition> = match extract_definitions(source, std::path::Path::new(path))
896 {
897 Ok(defs) => defs,
898 Err(_) => return false,
899 };
900 if definitions.is_empty() {
901 return false;
902 }
903 for d in definitions {
904 let kind = match d.kind {
905 DefinitionKind::Type => SymbolKind::Type,
906 DefinitionKind::Trait => SymbolKind::Trait,
907 DefinitionKind::Class => SymbolKind::Class,
908 DefinitionKind::Interface => SymbolKind::Interface,
909 DefinitionKind::TypeAlias => SymbolKind::TypeAlias,
910 DefinitionKind::EnumDef => SymbolKind::EnumDef,
911 DefinitionKind::ConstDecl => SymbolKind::ConstDecl,
912 DefinitionKind::Module => SymbolKind::Module,
913 DefinitionKind::Function => SymbolKind::Function,
914 DefinitionKind::Other => SymbolKind::Other,
915 };
916 let symbol = match d.parent_name.as_deref() {
917 Some(parent) if !parent.is_empty() => format!("{parent}::{}", d.name),
918 _ => d.name,
919 };
920 out.push(PathSymbol {
921 file: path.to_string(),
922 symbol,
923 kind,
924 });
925 }
926 true
927}
928
929fn collect_files(
930 repo: &Repository,
931 tree: &objects::object::Tree,
932 prefix: &str,
933) -> objects::error::Result<std::collections::HashMap<String, objects::object::ContentHash>> {
934 let mut out = std::collections::HashMap::new();
935 for entry in tree.entries() {
936 let path = if prefix.is_empty() {
937 entry.name().to_string()
938 } else {
939 format!("{prefix}/{}", entry.name())
940 };
941 if entry.is_tree() {
942 if let Some(hash) = entry.tree_hash()
943 && let Some(subtree) = repo.store().get_tree(&hash)?
944 {
945 let sub = collect_files(repo, &subtree, &path)?;
946 out.extend(sub);
947 }
948 } else if let Some(hash) = entry.content_hash() {
949 out.insert(path, hash);
950 }
951 }
952 Ok(out)
953}
954
955fn partition_to_proto(p: ReadingOrderPartition) -> ProtoReadingOrderPartition {
956 ProtoReadingOrderPartition {
957 structural: p.structural.iter().map(path_symbol_to_proto).collect(),
958 consequence: p.consequence.iter().map(path_symbol_to_proto).collect(),
959 tests_and_docs: p.tests_and_docs.iter().map(path_symbol_to_proto).collect(),
960 }
961}
962
963fn path_symbol_to_proto(p: &PathSymbol) -> ProtoPathSymbolRef {
964 ProtoPathSymbolRef {
965 file: p.file.clone(),
966 symbol: p.symbol.clone(),
967 }
968}
969
970fn discussion_to_anchored_proto(d: &Discussion) -> AnchoredDiscussion {
971 AnchoredDiscussion {
972 id: d.id.clone(),
973 anchor: Some(ProtoPathSymbolRef {
974 file: d.anchor.file.clone(),
975 symbol: d.anchor.symbol.clone(),
976 }),
977 opened_against_state: Some(api_state_id(&d.opened_against_state)),
978 opened_at: Some(prost_types::Timestamp {
979 seconds: d.opened_at,
980 nanos: 0,
981 }),
982 turns: d
983 .turns
984 .iter()
985 .map(|t| api::heddle::api::v1alpha1::DiscussionTurn {
986 author_name: t.author.name.clone(),
987 author_email: t.author.email.clone(),
988 body: t.body.clone(),
989 posted_at: Some(prost_types::Timestamp {
990 seconds: t.posted_at,
991 nanos: 0,
992 }),
993 })
994 .collect(),
995 resolution: Some(discussion_resolution_to_proto(&d.resolution)),
996 body_changed_since_open: d.body_changed_since_open,
997 orphaned: d.orphaned,
998 visibility: d.visibility.as_str().to_string(),
999 }
1000}
1001
1002fn discussion_resolution_to_proto(
1003 resolution: &DiscussionResolution,
1004) -> api::heddle::api::v1alpha1::DiscussionResolution {
1005 use api::heddle::api::v1alpha1::discussion_resolution::{
1006 Dismissed, Open, ResolvedByEdit, ResolvedIntoAnnotation, State,
1007 };
1008 let state = match resolution {
1009 DiscussionResolution::Open => State::Open(Open {}),
1010 DiscussionResolution::ResolvedIntoAnnotation { annotation_id } => {
1011 State::IntoAnnotation(ResolvedIntoAnnotation {
1012 annotation_id: annotation_id.clone(),
1013 })
1014 }
1015 DiscussionResolution::ResolvedByEdit { state_id } => State::ByEdit(ResolvedByEdit {
1016 state_id: Some(api_state_id(state_id)),
1017 }),
1018 DiscussionResolution::Dismissed { reason } => State::Dismissed(Dismissed {
1019 reason: reason.clone(),
1020 }),
1021 };
1022 api::heddle::api::v1alpha1::DiscussionResolution { state: Some(state) }
1023}
1024
1025#[derive(Debug, Clone)]
1033struct ChangedPath {
1034 path: String,
1035 kind: DiffKind,
1036}
1037
1038impl ChangedPath {
1039 fn kind_str(&self) -> &'static str {
1040 match self.kind {
1041 DiffKind::Added => "added",
1042 DiffKind::Modified => "modified",
1043 DiffKind::Deleted => "deleted",
1044 DiffKind::Unchanged => "unchanged",
1045 }
1046 }
1047}
1048
1049struct DiffSummary {
1056 files_changed: u32,
1057 added_files: u32,
1058 modified_files: u32,
1059 deleted_files: u32,
1060 added_lines: u32,
1061 removed_lines: u32,
1062 changed_paths: Vec<ChangedPath>,
1063}
1064
1065fn compute_state_diff_summary(
1074 repo: &Repository,
1075 state: &State,
1076) -> objects::error::Result<DiffSummary> {
1077 use objects::object::Tree;
1078 let parent_tree_hash = if let Some(parent_id) = state.parents.first() {
1079 match repo.store().get_state(parent_id)? {
1080 Some(parent_state) => parent_state.tree,
1081 None => Tree::new().hash(),
1082 }
1083 } else {
1084 Tree::new().hash()
1085 };
1086
1087 let parent_tree_obj = repo.store().get_tree(&parent_tree_hash)?;
1093 let new_tree_obj = repo.store().get_tree(&state.tree)?;
1094
1095 let changes = if parent_tree_obj.is_some() && new_tree_obj.is_some() {
1101 repo.diff_trees(&parent_tree_hash, &state.tree)?
1102 } else {
1103 objects::object::FileChangeSet::new()
1104 };
1105
1106 let mut added_lines: u32 = 0;
1113 let mut removed_lines: u32 = 0;
1114 let mut changed_paths: Vec<ChangedPath> = Vec::with_capacity(changes.len());
1115
1116 let parent_files = match parent_tree_obj.as_ref() {
1117 Some(t) => collect_files(repo, t, "")?,
1118 None => std::collections::HashMap::new(),
1119 };
1120 let new_files = match new_tree_obj.as_ref() {
1121 Some(t) => collect_files(repo, t, "")?,
1122 None => std::collections::HashMap::new(),
1123 };
1124
1125 let mut added_files: u32 = 0;
1126 let mut modified_files: u32 = 0;
1127 let mut deleted_files: u32 = 0;
1128
1129 for change in changes.iter() {
1130 match change.kind {
1131 DiffKind::Added => {
1132 added_files += 1;
1133 if let Some(hash) = new_files.get(&change.path)
1140 && let Some(blob) = repo.store().get_blob(hash)?
1141 {
1142 added_lines = added_lines.saturating_add(line_count(blob.content()));
1143 }
1144 }
1145 DiffKind::Deleted => {
1146 deleted_files += 1;
1147 if let Some(hash) = parent_files.get(&change.path)
1148 && let Some(blob) = repo.store().get_blob(hash)?
1149 {
1150 removed_lines = removed_lines.saturating_add(line_count(blob.content()));
1151 }
1152 }
1153 DiffKind::Modified => {
1154 modified_files += 1;
1155 let old_blob = match parent_files.get(&change.path) {
1162 Some(h) => repo.store().get_blob(h)?,
1163 None => None,
1164 };
1165 let new_blob = match new_files.get(&change.path) {
1166 Some(h) => repo.store().get_blob(h)?,
1167 None => None,
1168 };
1169 if let (Some(old), Some(new)) = (old_blob, new_blob) {
1170 for line in diff_blobs(&old, &new) {
1171 match line {
1172 objects::worktree::DiffLine::Added(_) => {
1173 added_lines = added_lines.saturating_add(1);
1174 }
1175 objects::worktree::DiffLine::Removed(_) => {
1176 removed_lines = removed_lines.saturating_add(1);
1177 }
1178 objects::worktree::DiffLine::Context(_) => {}
1179 }
1180 }
1181 }
1182 }
1183 DiffKind::Unchanged => continue,
1184 }
1185 changed_paths.push(ChangedPath {
1186 path: change.path.clone(),
1187 kind: change.kind,
1188 });
1189 }
1190
1191 Ok(DiffSummary {
1192 files_changed: changed_paths.len() as u32,
1193 added_files,
1194 modified_files,
1195 deleted_files,
1196 added_lines,
1197 removed_lines,
1198 changed_paths,
1199 })
1200}
1201
1202fn line_count(content: &[u8]) -> u32 {
1207 let Ok(s) = std::str::from_utf8(content) else {
1208 return 0;
1209 };
1210 if s.is_empty() {
1211 return 0;
1212 }
1213 let trimmed = s.strip_suffix('\n').unwrap_or(s);
1214 if trimmed.is_empty() {
1215 return 1;
1216 }
1217 (trimmed.matches('\n').count() as u32).saturating_add(1)
1218}
1219
1220#[cfg(test)]
1225mod tests {
1226 use std::sync::Arc;
1227
1228 use api::heddle::api::v1alpha1::ReviewScope as ProtoReviewScope;
1229 use crypto::Signer as _;
1230 use repo::{Repository, operation_dedup::OperationDedupStore};
1231 use tempfile::TempDir;
1232
1233 use super::*;
1234
1235 fn fresh_service() -> (LocalStateReviewService, Arc<Repository>, TempDir) {
1236 let temp = TempDir::new().expect("create tempdir");
1237 unsafe {
1241 std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Alice Tester");
1242 std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "alice@example.com");
1243 }
1244 let repo = Repository::init_default(temp.path()).expect("init repo");
1245 let dedup = OperationDedupStore::open(repo.heddle_dir()).expect("open dedup");
1246 let repo = Arc::new(repo);
1247 let svc =
1248 LocalStateReviewService::new(GrpcLocalService::new(repo.clone(), Arc::new(dedup)));
1249 (svc, repo, temp)
1250 }
1251
1252 fn capture_state(repo: &Repository) -> StateId {
1253 std::fs::write(repo.root().join("hello.txt"), b"hi").expect("write file");
1255 let state = repo
1256 .snapshot(Some("seed".to_string()), None)
1257 .expect("snapshot");
1258 state.state_id
1259 }
1260
1261 fn sign_request(state_id: &StateId, op_id: &str) -> SignStateRequest {
1262 let signer = crypto::Ed25519Signer::generate().expect("generate ed25519 key");
1263 let scope = ReviewScope::WholeChange;
1264 let signed_at = chrono::Utc::now().timestamp();
1265 let payload = signing_payload(*state_id, ReviewKind::Read, &scope, signed_at, None);
1266 let signature = signer.sign(&payload).expect("sign payload");
1267 use api::heddle::api::v1alpha1::review_scope::{Scope, WholeChange};
1268 SignStateRequest {
1269 repo_path: None,
1270 state_id: Some(api_state_id(state_id)),
1271 kind: api::heddle::api::v1alpha1::ReviewKind::Read as i32,
1272 scope: Some(ProtoReviewScope {
1273 scope: Some(Scope::WholeChange(WholeChange {})),
1274 }),
1275 justification: String::new(),
1276 algorithm: "ed25519".to_string(),
1277 public_key: signer.public_key().to_vec(),
1278 signature: signature.clone(),
1279 signed_at: Some(prost_types::Timestamp {
1280 seconds: signed_at,
1281 nanos: 0,
1282 }),
1283 client_operation_id: op_id.to_string(),
1284 }
1285 }
1286
1287 fn verdict_request(
1288 state_id: &StateId,
1289 verdict: ProtoVerdict,
1290 reason: &str,
1291 op_id: &str,
1292 ) -> RecordVerdictRequest {
1293 let signer = crypto::Ed25519Signer::generate().expect("generate ed25519 key");
1294 let scope = ReviewScope::WholeChange;
1295 let signed_at = chrono::Utc::now().timestamp();
1296 let verdict_str = verdict_str_from_proto(verdict as i32).expect("explicit verdict");
1297 let payload = verdict_signing_payload(*state_id, verdict_str, &scope, signed_at, reason);
1298 let signature = signer.sign(&payload).expect("sign verdict payload");
1299 use api::heddle::api::v1alpha1::review_scope::{Scope, WholeChange};
1300 RecordVerdictRequest {
1301 repo_path: None,
1302 state_id: Some(api_state_id(state_id)),
1303 verdict: verdict as i32,
1304 scope: Some(ProtoReviewScope {
1305 scope: Some(Scope::WholeChange(WholeChange {})),
1306 }),
1307 reason: reason.to_string(),
1308 algorithm: "ed25519".to_string(),
1309 public_key: signer.public_key().to_vec(),
1310 signature,
1311 signed_at: Some(prost_types::Timestamp {
1312 seconds: signed_at,
1313 nanos: 0,
1314 }),
1315 client_operation_id: op_id.to_string(),
1316 }
1317 }
1318
1319 #[tokio::test]
1320 #[serial_test::serial(process_global)]
1321 async fn sign_state_persists_to_review_signatures_blob() {
1322 let (svc, repo, _tmp) = fresh_service();
1323 let state_id = capture_state(&repo);
1324
1325 let resp = svc
1326 .sign_state(Request::new(sign_request(&state_id, "")))
1327 .await
1328 .expect("sign_state");
1329 assert!(!resp.get_ref().signature_id.is_empty());
1330 assert_eq!(resp.get_ref().state_id, Some(api_state_id(&state_id)));
1331
1332 let listing = svc
1333 .list_signatures(Request::new(ListSignaturesRequest {
1334 repo_path: None,
1335 state_id: Some(api_state_id(&state_id)),
1336 }))
1337 .await
1338 .expect("list_signatures");
1339 let sigs = &listing.get_ref().signatures;
1340 assert_eq!(sigs.len(), 1, "expected one signature, got {sigs:?}");
1341 assert_eq!(
1342 sigs[0].kind,
1343 api::heddle::api::v1alpha1::ReviewKind::Read as i32
1344 );
1345 assert_eq!(sigs[0].algorithm, "ed25519");
1346 assert_eq!(sigs[0].actor_name, "Alice Tester");
1347 assert_eq!(sigs[0].actor_email, "alice@example.com");
1348 let scope_case = sigs[0].scope.as_ref().and_then(|s| s.scope.as_ref());
1349 assert!(matches!(
1350 scope_case,
1351 Some(api::heddle::api::v1alpha1::review_scope::Scope::WholeChange(_))
1352 ));
1353 }
1354
1355 #[tokio::test]
1356 #[serial_test::serial(process_global)]
1357 async fn record_verdict_persists_signed_opinion_without_changing_state() {
1358 let (svc, repo, _tmp) = fresh_service();
1359 let state_id = capture_state(&repo);
1360 let op_id = objects::object::OperationId::new().to_string();
1361 let request = verdict_request(&state_id, ProtoVerdict::Hold, "needs another pass", &op_id);
1362
1363 let first = svc
1364 .record_verdict(Request::new(request.clone()))
1365 .await
1366 .expect("record verdict");
1367 let replay = svc
1368 .record_verdict(Request::new(request))
1369 .await
1370 .expect("replay verdict");
1371 assert_eq!(first.get_ref(), replay.get_ref());
1372 assert_eq!(first.get_ref().state_id, Some(api_state_id(&state_id)));
1373 assert_eq!(repo.head().expect("read head"), Some(state_id));
1374
1375 let listing = svc
1376 .list_signatures(Request::new(ListSignaturesRequest {
1377 repo_path: None,
1378 state_id: Some(api_state_id(&state_id)),
1379 }))
1380 .await
1381 .expect("list signatures");
1382 let signatures = &listing.get_ref().signatures;
1383 assert_eq!(signatures.len(), 1, "idempotent replay must append once");
1384 assert_eq!(signatures[0].verdict, ProtoVerdict::Hold as i32);
1385 assert_eq!(signatures[0].reason, "needs another pass");
1386 assert!(signatures[0].justification.is_empty());
1387 assert_eq!(
1388 signatures[0].kind,
1389 api::heddle::api::v1alpha1::ReviewKind::Read as i32
1390 );
1391 }
1392
1393 #[tokio::test]
1394 #[serial_test::serial(process_global)]
1395 async fn record_verdict_rejects_reason_changed_after_signing() {
1396 let (svc, repo, _tmp) = fresh_service();
1397 let state_id = capture_state(&repo);
1398 let mut request = verdict_request(&state_id, ProtoVerdict::Reject, "unsafe", "");
1399 request.reason = "different reason".to_string();
1400
1401 let error = svc
1402 .record_verdict(Request::new(request))
1403 .await
1404 .expect_err("changed signed reason must fail verification");
1405 assert_eq!(error.code(), tonic::Code::InvalidArgument);
1406 assert!(error.message().contains("failed verification"));
1407 }
1408
1409 #[tokio::test]
1410 #[serial_test::serial(process_global)]
1411 async fn full_contract_routes_signal_health_and_stubs_review_progress() {
1412 let (svc, _repo, _tmp) = fresh_service();
1413 let health = svc
1414 .get_repo_signal_health(Request::new(GetRepoSignalHealthRequest {
1415 repo_path: None,
1416 window_states: 1,
1417 }))
1418 .await
1419 .expect("local signal health");
1420 assert_eq!(health.get_ref().window_states, 1);
1421
1422 let ack_error = svc
1423 .record_check_ack(Request::new(RecordCheckAckRequest::default()))
1424 .await
1425 .expect_err("local check acks are unsupported");
1426 assert_eq!(ack_error.code(), tonic::Code::Unimplemented);
1427 assert!(ack_error.message().contains("not available in local mode"));
1428
1429 let progress_error = svc
1430 .get_review_progress(Request::new(GetReviewProgressRequest::default()))
1431 .await
1432 .expect_err("local review progress is unsupported");
1433 assert_eq!(progress_error.code(), tonic::Code::Unimplemented);
1434 assert!(
1435 progress_error
1436 .message()
1437 .contains("not available in local mode")
1438 );
1439 }
1440
1441 #[tokio::test]
1442 #[serial_test::serial(process_global)]
1443 async fn sign_state_idempotent() {
1444 let (svc, repo, _tmp) = fresh_service();
1445 let state_id = capture_state(&repo);
1446 let op_id = objects::object::OperationId::new().to_string();
1447 let req = sign_request(&state_id, &op_id);
1450
1451 let first = svc
1452 .sign_state(Request::new(req.clone()))
1453 .await
1454 .expect("first sign_state");
1455 let second = svc
1456 .sign_state(Request::new(req))
1457 .await
1458 .expect("second sign_state");
1459 assert_eq!(
1460 first.get_ref().signature_id,
1461 second.get_ref().signature_id,
1462 "replayed call must return the same signature_id"
1463 );
1464
1465 let listing = svc
1466 .list_signatures(Request::new(ListSignaturesRequest {
1467 repo_path: None,
1468 state_id: Some(api_state_id(&state_id)),
1469 }))
1470 .await
1471 .expect("list_signatures");
1472 assert_eq!(
1473 listing.get_ref().signatures.len(),
1474 1,
1475 "idempotent replay must not append a duplicate signature"
1476 );
1477 }
1478
1479 #[tokio::test]
1480 #[serial_test::serial(process_global)]
1481 async fn sign_state_rejects_forged_signature() {
1482 let (svc, repo, _tmp) = fresh_service();
1483 let state_id = capture_state(&repo);
1484 let mut req = sign_request(&state_id, "");
1485 let last = req.signature.len() - 1;
1487 req.signature[last] ^= 0xff;
1488
1489 let err = svc
1490 .sign_state(Request::new(req))
1491 .await
1492 .expect_err("forged signature must be rejected");
1493 assert_eq!(err.code(), tonic::Code::InvalidArgument, "{err:?}");
1494 assert!(
1495 err.message().contains("failed verification"),
1496 "unexpected error message: {}",
1497 err.message()
1498 );
1499 }
1500
1501 #[tokio::test]
1502 #[serial_test::serial(process_global)]
1503 async fn sign_state_rejects_skewed_timestamp() {
1504 let (svc, repo, _tmp) = fresh_service();
1505 let state_id = capture_state(&repo);
1506 let mut req = sign_request(&state_id, "");
1507 if let Some(ts) = req.signed_at.as_mut() {
1509 ts.seconds += 60 * 60;
1510 }
1511
1512 let err = svc
1513 .sign_state(Request::new(req))
1514 .await
1515 .expect_err("skewed timestamp must be rejected");
1516 assert_eq!(err.code(), tonic::Code::InvalidArgument);
1517 assert!(err.message().contains("too far from server time"));
1518 }
1519
1520 #[tokio::test]
1521 #[serial_test::serial(process_global)]
1522 async fn sign_state_attributes_to_caller_not_state_author() {
1523 let (svc, repo, _tmp) = fresh_service();
1529 let state_id = capture_state(&repo);
1533
1534 unsafe {
1537 std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Bob Signer");
1538 std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "bob@example.com");
1539 }
1540
1541 svc.sign_state(Request::new(sign_request(&state_id, "")))
1542 .await
1543 .expect("sign_state");
1544
1545 let listing = svc
1546 .list_signatures(Request::new(ListSignaturesRequest {
1547 repo_path: None,
1548 state_id: Some(api_state_id(&state_id)),
1549 }))
1550 .await
1551 .expect("list_signatures");
1552 let sigs = &listing.get_ref().signatures;
1553 assert_eq!(sigs.len(), 1);
1554 assert_eq!(
1555 sigs[0].actor_name, "Bob Signer",
1556 "signature must attribute to the caller (Bob), not the state author (Alice)"
1557 );
1558 assert_eq!(sigs[0].actor_email, "bob@example.com");
1559
1560 unsafe {
1563 std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Alice Tester");
1564 std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "alice@example.com");
1565 }
1566 }
1567
1568 #[tokio::test]
1569 #[serial_test::serial(process_global)]
1570 async fn sign_state_serializes_concurrent_appends() {
1571 let (svc, repo, _tmp) = fresh_service();
1572 let state_id = capture_state(&repo);
1573
1574 let op_a = objects::object::OperationId::new().to_string();
1576 let op_b = objects::object::OperationId::new().to_string();
1577 let req_a = sign_request(&state_id, &op_a);
1578 let req_b = sign_request(&state_id, &op_b);
1579
1580 let svc_a = svc.clone();
1581 let svc_b = svc.clone();
1582 let (a, b) = tokio::join!(
1583 svc_a.sign_state(Request::new(req_a)),
1584 svc_b.sign_state(Request::new(req_b)),
1585 );
1586 a.expect("first sign_state");
1587 b.expect("second sign_state");
1588
1589 let listing = svc
1590 .list_signatures(Request::new(ListSignaturesRequest {
1591 repo_path: None,
1592 state_id: Some(api_state_id(&state_id)),
1593 }))
1594 .await
1595 .expect("list_signatures");
1596 assert_eq!(
1597 listing.get_ref().signatures.len(),
1598 2,
1599 "both concurrent signatures must land — neither should be lost \
1600 to a stale-blob clobber"
1601 );
1602 }
1603
1604 #[tokio::test]
1612 #[serial_test::serial(process_global)]
1613 async fn get_review_payload_populates_diff_summary_and_signals() {
1614 let (svc, repo, _tmp) = fresh_service();
1615
1616 std::fs::write(repo.root().join("hello.txt"), b"first\nsecond\nthird\n")
1619 .expect("write hello.txt");
1620 let first = repo
1621 .snapshot(Some("first capture".to_string()), None)
1622 .expect("first snapshot");
1623
1624 let resp_first = svc
1625 .get_review_payload(Request::new(GetReviewPayloadRequest {
1626 repo_path: None,
1627 state_id: Some(api_state_id(&first.state_id)),
1628 include_all_signals: false,
1629 }))
1630 .await
1631 .expect("get_review_payload first");
1632 let payload_first = resp_first.into_inner();
1633 let summary_first = payload_first.summary.as_ref().expect("summary present");
1634 assert!(
1635 summary_first.files_changed >= 1,
1636 "first state should report at least one file changed (vs empty parent), got {}",
1637 summary_first.files_changed
1638 );
1639 assert!(
1640 summary_first.added_lines >= 3,
1641 "first state should report 3+ added lines, got {}",
1642 summary_first.added_lines
1643 );
1644 assert!(
1645 !payload_first.in_budget_signals.is_empty(),
1646 "in_budget_signals must include a diff_summary entry"
1647 );
1648 let first_signal = &payload_first.in_budget_signals[0];
1649 assert!(
1650 first_signal.kind.starts_with("diff_summary."),
1651 "expected synthetic diff_summary signal kind, got {}",
1652 first_signal.kind
1653 );
1654 assert_eq!(first_signal.producer_module, "review_show.diff_summary");
1655 assert_eq!(first_signal.visibility, "visible");
1656
1657 std::fs::write(
1661 repo.root().join("hello.txt"),
1662 b"first\nsecond\nthird\nfourth\n",
1663 )
1664 .expect("modify hello.txt");
1665 let second = repo
1666 .snapshot(Some("second capture".to_string()), None)
1667 .expect("second snapshot");
1668
1669 let resp_second = svc
1670 .get_review_payload(Request::new(GetReviewPayloadRequest {
1671 repo_path: None,
1672 state_id: Some(api_state_id(&second.state_id)),
1673 include_all_signals: false,
1674 }))
1675 .await
1676 .expect("get_review_payload second");
1677 let payload_second = resp_second.into_inner();
1678 let summary_second = payload_second.summary.as_ref().expect("summary present");
1679 assert_eq!(
1680 summary_second.files_changed, 1,
1681 "second state should report exactly one modified file"
1682 );
1683 assert!(
1684 summary_second.added_lines >= 1,
1685 "second state should report at least one added line, got {}",
1686 summary_second.added_lines
1687 );
1688 assert!(
1689 !payload_second.in_budget_signals.is_empty(),
1690 "in_budget_signals must include a diff_summary entry"
1691 );
1692 let signal = &payload_second.in_budget_signals[0];
1693 assert_eq!(
1694 signal
1695 .anchor
1696 .as_ref()
1697 .map(|a| a.file.as_str())
1698 .unwrap_or(""),
1699 "hello.txt",
1700 "diff_summary signal should anchor on the modified file"
1701 );
1702 assert!(
1703 signal.reason.contains("files changed"),
1704 "first signal reason should carry the aggregate summary, got {}",
1705 signal.reason
1706 );
1707 assert_eq!(
1711 summary_second.in_budget_signal_count,
1712 payload_second.in_budget_signals.len() as u32,
1713 "in_budget_signal_count must match the array length"
1714 );
1715 }
1716
1717 #[tokio::test]
1718 #[serial_test::serial(process_global)]
1719 async fn get_review_payload_surfaces_gitlink_target_changes() {
1720 let (svc, repo, _tmp) = fresh_service();
1721
1722 let old_target = "0303030303030303030303030303030303030303"
1723 .parse()
1724 .expect("old git oid");
1725 let new_target = "0404040404040404040404040404040404040404"
1726 .parse()
1727 .expect("new git oid");
1728 let old_tree = objects::object::Tree::from_entries(vec![
1729 objects::object::TreeEntry::gitlink("vendor", old_target).expect("old gitlink"),
1730 ]);
1731 let new_tree = objects::object::Tree::from_entries(vec![
1732 objects::object::TreeEntry::gitlink("vendor", new_target).expect("new gitlink"),
1733 ]);
1734 let old_tree_hash = repo.store().put_tree(&old_tree).expect("put old tree");
1735 let new_tree_hash = repo.store().put_tree(&new_tree).expect("put new tree");
1736 let base = State::new_snapshot(
1737 old_tree_hash,
1738 Vec::new(),
1739 objects::object::Attribution::human(objects::object::Principal::new(
1740 "Gitlink Reviewer",
1741 "gitlink@example.test",
1742 )),
1743 );
1744 let base_id = base.state_id;
1745 repo.store().put_state(&base).expect("put base state");
1746 let changed = State::new_snapshot(
1747 new_tree_hash,
1748 vec![base_id],
1749 objects::object::Attribution::human(objects::object::Principal::new(
1750 "Gitlink Reviewer",
1751 "gitlink@example.test",
1752 )),
1753 );
1754 let changed_id = changed.state_id;
1755 repo.store().put_state(&changed).expect("put changed state");
1756
1757 let resp = svc
1758 .get_review_payload(Request::new(GetReviewPayloadRequest {
1759 repo_path: None,
1760 state_id: Some(api_state_id(&changed_id)),
1761 include_all_signals: false,
1762 }))
1763 .await
1764 .expect("get_review_payload gitlink change");
1765 let payload = resp.into_inner();
1766 let summary = payload.summary.as_ref().expect("summary present");
1767 assert_eq!(
1768 summary.files_changed, 1,
1769 "gitlink pointer change should count as one changed path"
1770 );
1771 assert_eq!(summary.added_lines, 0);
1772 assert_eq!(summary.removed_lines, 0);
1773 assert_eq!(
1774 payload
1775 .in_budget_signals
1776 .first()
1777 .and_then(|signal| signal.anchor.as_ref())
1778 .map(|anchor| anchor.file.as_str()),
1779 Some("vendor"),
1780 "diff_summary signal should be anchored on the gitlink path"
1781 );
1782 let partition = payload.partition.expect("partition present");
1783 let surfaced = partition
1784 .structural
1785 .iter()
1786 .chain(partition.consequence.iter())
1787 .chain(partition.tests_and_docs.iter())
1788 .any(|symbol| symbol.file == "vendor" && symbol.symbol == "vendor");
1789 assert!(surfaced, "gitlink change should be path-visible in review");
1790 }
1791
1792 #[tokio::test]
1804 #[serial_test::serial(process_global)]
1805 async fn get_review_payload_tolerates_missing_tree() {
1806 let (svc, repo, _tmp) = fresh_service();
1807 let state_id = capture_state(&repo);
1808
1809 let state = repo
1814 .store()
1815 .get_state(&state_id)
1816 .expect("get state")
1817 .expect("state present");
1818 let bogus_tree = objects::object::ContentHash::compute(b"definitely-not-in-store-bytes");
1819 let mut mutated = state.clone();
1820 mutated.tree = bogus_tree;
1821 let missing_tree_state_id = mutated.id();
1822 repo.store().put_state(&mutated).expect("put mutated state");
1823
1824 let resp = svc
1827 .get_review_payload(Request::new(GetReviewPayloadRequest {
1828 repo_path: None,
1829 state_id: Some(api_state_id(&missing_tree_state_id)),
1830 include_all_signals: false,
1831 }))
1832 .await
1833 .expect("missing tree must not block review payload");
1834 let payload = resp.into_inner();
1835 let summary = payload.summary.as_ref().expect("summary present");
1836 assert_eq!(
1837 summary.files_changed, 0,
1838 "missing tree must produce a zero-change summary, got {} files",
1839 summary.files_changed
1840 );
1841 assert!(
1845 !payload.in_budget_signals.is_empty(),
1846 "in_budget_signals should always contain at least the synthetic diff_summary entry"
1847 );
1848 let kind = &payload.in_budget_signals[0].kind;
1849 assert!(
1850 kind.starts_with("diff_summary."),
1851 "expected synthetic diff_summary signal, got {kind}"
1852 );
1853 }
1854
1855 #[test]
1859 fn line_count_matches_git_semantics() {
1860 assert_eq!(line_count(b""), 0);
1861 assert_eq!(line_count(b"\n"), 1);
1862 assert_eq!(line_count(b"hello"), 1);
1863 assert_eq!(line_count(b"hello\n"), 1);
1864 assert_eq!(line_count(b"hello\nworld"), 2);
1865 assert_eq!(line_count(b"hello\nworld\n"), 2);
1866 assert_eq!(line_count(b"a\nb\nc\n"), 3);
1867 assert_eq!(line_count(&[0xff, 0xfe, 0xfd]), 0);
1869 }
1870}