use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use daemon::grpc_local_impl::{GrpcLocalService, LocalSignalService, LocalStateReviewService};
use grpc::heddle::v1::{
GetRepoSignalHealthRequest, GetReviewPayloadRequest, ListSignaturesRequest, PathSymbolRef,
ReviewScope as ProtoReviewScope, SignStateRequest, signal_service_server::SignalService,
state_review_service_server::StateReviewService,
};
use repo::{HistoryQuery, operation_dedup::OperationDedupStore};
use serde::Serialize;
use super::{
advice::RecoveryAdvice,
history_target::{resolve_state_id, resolve_state_id_bytes},
};
use crate::cli::{
cli_args::{
Cli, ReviewCommands, ReviewHealthArgs, ReviewNextArgs, ReviewShowArgs, ReviewSignArgs,
},
should_output_json,
};
const AGENT_GLYPH: &str = "※";
const HUMAN_GLYPH: &str = "✓";
pub async fn run(cli: &Cli, command: &ReviewCommands) -> Result<()> {
match command {
ReviewCommands::Show(args) => run_show(cli, args).await,
ReviewCommands::Sign(args) => run_sign(cli, args).await,
ReviewCommands::Next(args) => run_next(cli, args).await,
ReviewCommands::Health(args) => run_health(cli, args).await,
}
}
#[derive(Serialize)]
struct ReviewShowOutput {
output_kind: &'static str,
change_id: String,
headline: String,
agent_narrative: Option<String>,
files_changed: u32,
in_budget_signals: Vec<SignalView>,
all_signals: Vec<SignalView>,
discussions: Vec<DiscussionView>,
signing_kinds: Vec<String>,
signatures: Vec<SignatureView>,
}
#[derive(Serialize)]
struct SignalView {
kind: String,
file: String,
symbol: String,
reason: String,
producer: String,
visibility: String,
}
#[derive(Serialize)]
struct DiscussionView {
id: String,
file: String,
symbol: String,
status: String,
body_changed_since_open: bool,
orphaned: bool,
}
#[derive(Serialize)]
struct SignatureView {
actor_name: String,
actor_email: String,
kind: String,
glyph: &'static str,
is_agent: bool,
signed_at_secs: i64,
scope_kind: String,
scope_symbols: Vec<String>,
}
async fn run_show(cli: &Cli, args: &ReviewShowArgs) -> Result<()> {
let svc = open_state_review_service(cli)?;
let state_id = resolve_state(cli, args.state.as_deref())?;
let payload_resp = svc
.get_review_payload(tonic::Request::new(GetReviewPayloadRequest {
repo_path: String::new(),
state_id: state_id.clone(),
include_all_signals: args.all_signals,
}))
.await
.map_err(status_to_anyhow)?
.into_inner();
let signatures_resp = svc
.list_signatures(tonic::Request::new(ListSignaturesRequest {
repo_path: String::new(),
state_id: state_id.clone(),
}))
.await
.map_err(status_to_anyhow)?
.into_inner();
use grpc::heddle::v1::ReviewKind;
let summary = payload_resp.summary.unwrap_or_default();
let signatures: Vec<SignatureView> = signatures_resp
.signatures
.iter()
.map(|s| {
let kind = ReviewKind::try_from(s.kind).unwrap_or(ReviewKind::Unspecified);
let is_agent = matches!(kind, ReviewKind::AgentPreview | ReviewKind::AgentCoReview);
let (scope_kind, scope_symbols) = match s.scope.as_ref().and_then(|x| x.scope.as_ref())
{
Some(grpc::heddle::v1::review_scope::Scope::WholeChange(_)) => {
("whole_change".to_string(), Vec::new())
}
Some(grpc::heddle::v1::review_scope::Scope::Symbols(list)) => (
"symbols".to_string(),
list.symbols
.iter()
.map(|sym| format!("{}:{}", sym.file, sym.symbol))
.collect(),
),
None => (String::new(), Vec::new()),
};
SignatureView {
actor_name: s.actor_name.clone(),
actor_email: s.actor_email.clone(),
kind: review_kind_to_str(kind).to_string(),
glyph: if is_agent { AGENT_GLYPH } else { HUMAN_GLYPH },
is_agent,
signed_at_secs: s.signed_at.as_ref().map(|t| t.seconds).unwrap_or(0),
scope_kind,
scope_symbols,
}
})
.collect();
let output = ReviewShowOutput {
output_kind: "review_show",
change_id: bytes_to_change_id_string(&payload_resp.state_id),
headline: summary.headline,
agent_narrative: opt_string(payload_resp.agent_narrative),
files_changed: summary.files_changed,
in_budget_signals: payload_resp
.in_budget_signals
.iter()
.map(signal_view)
.collect(),
all_signals: payload_resp.all_signals.iter().map(signal_view).collect(),
discussions: payload_resp
.discussions
.iter()
.map(discussion_view)
.collect(),
signing_kinds: payload_resp
.signing_footer
.map(|f| {
f.available_kinds
.into_iter()
.map(|k| {
review_kind_to_str(
ReviewKind::try_from(k).unwrap_or(ReviewKind::Unspecified),
)
.to_string()
})
.collect()
})
.unwrap_or_default(),
signatures,
};
if should_output_json(cli, None) {
println!(
"{}",
serde_json::to_string(&output).context("serialize review payload")?
);
} else {
render_text(&output, args.all_signals);
}
Ok(())
}
fn render_text(out: &ReviewShowOutput, all_signals: bool) {
println!("review of state {}", out.change_id);
if !out.headline.is_empty() {
println!(" {}", out.headline);
}
if let Some(narrative) = &out.agent_narrative
&& !narrative.is_empty()
{
println!("\n agent narrative:");
for line in narrative.lines() {
println!(" {line}");
}
}
if !out.in_budget_signals.is_empty() {
println!("\n signals (in budget):");
for s in &out.in_budget_signals {
println!(" ▸ [{}] {}:{} — {}", s.kind, s.file, s.symbol, s.reason);
}
}
if all_signals && !out.all_signals.is_empty() {
println!("\n signals (all):");
for s in &out.all_signals {
let marker = if s.visibility == "hidden" {
"·"
} else {
"▸"
};
println!(
" {marker} [{}] {}:{} — {} [{}]",
s.kind, s.file, s.symbol, s.reason, s.visibility
);
}
}
if !out.discussions.is_empty() {
println!("\n discussions:");
for d in &out.discussions {
let mut suffix = String::new();
if d.body_changed_since_open {
suffix.push_str(" [body changed]");
}
if d.orphaned {
suffix.push_str(" [orphaned]");
}
println!(
" {} ({}) {}:{}{}",
d.id, d.status, d.file, d.symbol, suffix
);
}
}
if !out.signatures.is_empty() {
println!("\n signatures:");
for s in &out.signatures {
println!(
" {} {} <{}> [{}]",
s.glyph, s.actor_name, s.actor_email, s.kind
);
}
}
if !out.signing_kinds.is_empty() {
println!(
"\n available signing kinds: {}",
out.signing_kinds.join(", ")
);
}
}
async fn run_sign(cli: &Cli, args: &ReviewSignArgs) -> Result<()> {
use grpc::heddle::v1::review_scope::{Scope, SymbolList, WholeChange};
let svc = open_state_review_service(cli)?;
let state_id_bytes = resolve_state_id_bytes(&cli.open_repo()?, &args.state)?;
let scope_inner = if args.symbols.is_empty() {
Scope::WholeChange(WholeChange {})
} else {
let parsed: Result<Vec<_>> = args
.symbols
.iter()
.map(|s| {
let (file, symbol) = s
.split_once(':')
.ok_or_else(|| anyhow!(RecoveryAdvice::review_symbols_malformed(s)))?;
Ok(PathSymbolRef {
file: file.to_string(),
symbol: symbol.to_string(),
})
})
.collect();
Scope::Symbols(SymbolList { symbols: parsed? })
};
let scope = ProtoReviewScope {
scope: Some(scope_inner),
};
let req = SignStateRequest {
repo_path: String::new(),
state_id: state_id_bytes,
kind: args.kind.as_proto() as i32,
scope: Some(scope),
justification: args.justification.clone().unwrap_or_default(),
algorithm: args.algorithm.clone(),
public_key: hex::decode(&args.public_key)
.map_err(|e| anyhow::anyhow!("public_key must be hex-encoded: {e}"))?,
signature: hex::decode(&args.signature)
.map_err(|e| anyhow::anyhow!("signature must be hex-encoded: {e}"))?,
signed_at: Some(prost_types::Timestamp {
seconds: args.signed_at_unix,
nanos: 0,
}),
client_operation_id: crate::operation_id::wire(cli),
};
let resp = svc
.sign_state(tonic::Request::new(req))
.await
.map_err(status_to_anyhow)?
.into_inner();
if should_output_json(cli, None) {
let state_str = bytes_to_change_id_string(&resp.state_id);
let out = serde_json::json!({
"output_kind": "review_sign",
"signature_id": resp.signature_id,
"change_id": state_str,
});
println!("{out}");
} else {
println!(
"signed state {} as {} (signature_id {})",
bytes_to_change_id_string(&resp.state_id),
args.kind.as_wire(),
resp.signature_id
);
}
Ok(())
}
async fn run_next(cli: &Cli, args: &ReviewNextArgs) -> Result<()> {
let svc = open_state_review_service(cli)?;
let repo = cli.open_repo()?;
let head = repo.head().context("read HEAD")?.ok_or_else(|| {
anyhow!(RecoveryAdvice::repository_no_head_capture_first(
"review next"
))
})?;
let actor_email = args
.mine_only
.then(|| {
repo.config()
.principal
.as_ref()
.map(|p| p.email.clone())
.ok_or_else(|| anyhow!(review_mine_only_principal_required_advice()))
})
.transpose()?;
let history = repo
.query_history(&HistoryQuery::new(Some(head)).with_limit(NEXT_SCAN_LIMIT))
.context("walk history for pending reviews")?;
let mut next_state: Option<NextStateView> = None;
for state in history {
let state_id_bytes = state.change_id.as_bytes().to_vec();
let state_id_str = state.change_id.to_string_full();
let signatures = svc
.list_signatures(tonic::Request::new(ListSignaturesRequest {
repo_path: String::new(),
state_id: state_id_bytes,
}))
.await
.map_err(status_to_anyhow)?
.into_inner()
.signatures;
let satisfied = signatures.iter().any(|s| {
let actor_match = match actor_email.as_deref() {
Some(email) => s.actor_email.eq_ignore_ascii_case(email),
None => true,
};
let kind_match = match args.kind.as_deref() {
Some(k) => {
let parsed = grpc::heddle::v1::ReviewKind::try_from(s.kind)
.unwrap_or(grpc::heddle::v1::ReviewKind::Unspecified);
review_kind_to_str(parsed) == k
}
None => true,
};
actor_match && kind_match
});
if !satisfied {
next_state = Some(NextStateView {
change_id: state_id_str,
headline: state.intent.clone().unwrap_or_default(),
existing_signatures: signatures.len() as u32,
});
break;
}
}
if should_output_json(cli, None) {
let envelope = match &next_state {
Some(view) => serde_json::json!({
"output_kind": "review_next",
"change_id": view.change_id,
"headline": view.headline,
"existing_signatures": view.existing_signatures,
"next": view,
}),
None => serde_json::json!({
"output_kind": "review_next",
"next": serde_json::Value::Null,
}),
};
println!(
"{}",
serde_json::to_string(&envelope).context("serialize review next envelope")?
);
} else {
match &next_state {
Some(view) => {
println!("next pending review: {}", view.change_id);
if !view.headline.is_empty() {
println!(" {}", view.headline);
}
println!(" existing signatures: {}", view.existing_signatures);
}
None => println!(
"no pending reviews in the last {NEXT_SCAN_LIMIT} states reachable from HEAD"
),
}
}
Ok(())
}
const NEXT_SCAN_LIMIT: usize = 50;
#[derive(Serialize)]
struct NextStateView {
change_id: String,
headline: String,
existing_signatures: u32,
}
async fn run_health(cli: &Cli, args: &ReviewHealthArgs) -> Result<()> {
let svc = open_signal_service(cli)?;
let resp = svc
.get_repo_signal_health(tonic::Request::new(GetRepoSignalHealthRequest {
repo_path: String::new(),
window_states: args.window.unwrap_or(0),
}))
.await
.map_err(status_to_anyhow)?
.into_inner();
if should_output_json(cli, None) {
let entries: Vec<_> = resp
.entries
.iter()
.map(|e| {
serde_json::json!({
"module_id": e.module_id,
"fire_rate": e.fire_rate,
"warn": e.warn,
})
})
.collect();
let out = serde_json::json!({
"output_kind": "review_health",
"entries": entries,
"window_states": resp.window_states,
});
println!("{out}");
} else {
println!("signal health (window: {} states)", resp.window_states);
if resp.entries.is_empty() {
println!(" (no signals fired in the window)");
} else {
for e in &resp.entries {
let warn = if e.warn { " ⚠" } else { "" };
println!(" {:30} {:>6.1}%{}", e.module_id, e.fire_rate * 100.0, warn);
}
}
}
Ok(())
}
fn open_state_review_service(cli: &Cli) -> Result<LocalStateReviewService> {
let repo = cli.open_repo()?;
let dedup = OperationDedupStore::open(repo.heddle_dir()).context("open dedup store")?;
let inner = GrpcLocalService::new(Arc::new(repo), Arc::new(dedup));
Ok(LocalStateReviewService::new(inner))
}
fn open_signal_service(cli: &Cli) -> Result<LocalSignalService> {
let repo = cli.open_repo()?;
let dedup = OperationDedupStore::open(repo.heddle_dir()).context("open dedup store")?;
let inner = GrpcLocalService::new(Arc::new(repo), Arc::new(dedup));
Ok(LocalSignalService::new(inner))
}
fn signal_view(s: &grpc::heddle::v1::RiskSignal) -> SignalView {
let anchor = s.anchor.clone().unwrap_or_default();
SignalView {
kind: s.kind.clone(),
file: anchor.file,
symbol: anchor.symbol,
reason: s.reason.clone(),
producer: s.producer_module.clone(),
visibility: s.visibility.clone(),
}
}
fn discussion_view(d: &grpc::heddle::v1::AnchoredDiscussion) -> DiscussionView {
use grpc::heddle::v1::discussion_resolution::State;
let anchor = d.anchor.clone().unwrap_or_default();
let status = match d.resolution.as_ref().and_then(|r| r.state.as_ref()) {
Some(State::Open(_)) | None => "open",
Some(State::IntoAnnotation(_)) => "resolved_into_annotation",
Some(State::ByEdit(_)) => "resolved_by_edit",
Some(State::Dismissed(_)) => "dismissed",
}
.to_string();
DiscussionView {
id: d.id.clone(),
file: anchor.file,
symbol: anchor.symbol,
status,
body_changed_since_open: d.body_changed_since_open,
orphaned: d.orphaned,
}
}
fn opt_string(s: String) -> Option<String> {
if s.is_empty() { None } else { Some(s) }
}
fn resolve_state(cli: &Cli, explicit: Option<&str>) -> Result<Vec<u8>> {
let repo = cli.open_repo()?;
if let Some(s) = explicit {
return Ok(resolve_state_id(&repo, s)?.as_bytes().to_vec());
}
let head = repo
.head()
.context("read HEAD")?
.ok_or_else(|| anyhow!(RecoveryAdvice::repository_no_head_capture_first("review")))?;
Ok(head.as_bytes().to_vec())
}
fn status_to_anyhow(status: tonic::Status) -> anyhow::Error {
anyhow!("{}: {}", status.code(), status.message())
}
fn review_mine_only_principal_required_advice() -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"review_mine_only_principal_required",
"--mine-only requires a configured principal in repo config",
"Configure a repository principal with `heddle init --principal-name <name> --principal-email <email>`, or rerun `heddle review next` without `--mine-only`.",
"`--mine-only` needs the repository principal email, but repo config has no principal",
"guessing an actor email could report the wrong pending review state",
"review signatures, repository state, refs, metadata, and worktree files were left unchanged",
"heddle init --principal-name <name> --principal-email <email>",
vec![
"heddle init --principal-name <name> --principal-email <email>".to_string(),
"heddle review next".to_string(),
],
)
}
fn bytes_to_change_id_string(bytes: &[u8]) -> String {
if bytes.is_empty() {
return String::new();
}
objects::object::ChangeId::try_from_slice(bytes)
.map(|id| id.to_string_full())
.unwrap_or_default()
}
fn review_kind_to_str(kind: grpc::heddle::v1::ReviewKind) -> &'static str {
use grpc::heddle::v1::ReviewKind;
match kind {
ReviewKind::Read => "read",
ReviewKind::AgentPreview => "agent_preview",
ReviewKind::AgentCoReview => "agent_co_review",
ReviewKind::Unspecified => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn review_health_json_shape() {
let entries = vec![
serde_json::json!({
"module_id": "novelty.tree_sitter",
"fire_rate": 0.42_f32,
"warn": false,
}),
serde_json::json!({
"module_id": "self_flagged_uncertainty",
"fire_rate": 0.81_f32,
"warn": true,
}),
];
let out = serde_json::json!({
"entries": entries,
"window_states": 12u32,
});
let serialized = serde_json::to_string(&out).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
let obj = parsed.as_object().expect("top-level is an object");
assert!(obj.contains_key("entries"), "missing entries");
assert!(obj.contains_key("window_states"), "missing window_states");
assert!(
obj["window_states"].is_number(),
"window_states must be a number"
);
let arr = obj["entries"].as_array().expect("entries is array");
assert_eq!(arr.len(), 2, "two sample entries round-trip");
for entry in arr {
let e = entry.as_object().expect("entry is object");
assert!(e.contains_key("module_id"));
assert!(e.contains_key("fire_rate"));
assert!(e.contains_key("warn"));
assert!(e["module_id"].is_string(), "module_id must be string");
assert!(e["fire_rate"].is_number(), "fire_rate must be number");
assert!(e["warn"].is_boolean(), "warn must be boolean");
}
assert_eq!(arr[0]["module_id"], "novelty.tree_sitter");
assert_eq!(arr[1]["warn"], true);
}
#[test]
fn review_kind_to_str_covers_known_variants() {
use grpc::heddle::v1::ReviewKind;
assert_eq!(review_kind_to_str(ReviewKind::Read), "read");
assert_eq!(
review_kind_to_str(ReviewKind::AgentPreview),
"agent_preview"
);
assert_eq!(
review_kind_to_str(ReviewKind::AgentCoReview),
"agent_co_review"
);
assert_eq!(review_kind_to_str(ReviewKind::Unspecified), "");
}
#[test]
fn mine_only_principal_advice_is_typed() {
let advice = review_mine_only_principal_required_advice();
assert_eq!(advice.kind, "review_mine_only_principal_required");
assert_eq!(
advice.primary_command,
"heddle init --principal-name <name> --principal-email <email>"
);
assert!(advice.primary_hint().contains("heddle review next"));
assert!(advice.unsafe_condition.contains("--mine-only"));
assert!(advice.would_change.contains("wrong pending review"));
assert!(advice.preserved.contains("review signatures"));
}
}