use grpc::heddle::v1::{
DiffForThreadRequest, DiffForThreadResponse, LogForThreadRequest, LogForThreadResponse,
StatusForThreadRequest, StatusForThreadResponse, Treeish,
};
use tonic::Request;
use wire::ProtocolError;
use super::{HostedGrpcClient, helpers::status_to_protocol_error};
impl HostedGrpcClient {
pub async fn status_for_thread(
&mut self,
repo_path: &str,
thread: &str,
compare_tree: Option<Treeish>,
) -> Result<StatusForThreadResponse, ProtocolError> {
let mut request = Request::new(StatusForThreadRequest {
repo_path: repo_path.to_string(),
thread: thread.to_string(),
compare_tree,
});
self.apply_signed_auth(&mut request, "/heddle.v1.TreeEditService/StatusForThread")?;
self.tree_edit
.status_for_thread(request)
.await
.map_err(status_to_protocol_error)
.map(|response| response.into_inner())
}
pub async fn diff_for_thread(
&mut self,
repo_path: &str,
thread: &str,
from: Treeish,
to: Treeish,
include_semantic: bool,
) -> Result<DiffForThreadResponse, ProtocolError> {
let mut request = Request::new(DiffForThreadRequest {
repo_path: repo_path.to_string(),
thread: thread.to_string(),
from: Some(from),
to: Some(to),
include_semantic,
});
self.apply_signed_auth(&mut request, "/heddle.v1.TreeEditService/DiffForThread")?;
self.tree_edit
.diff_for_thread(request)
.await
.map_err(status_to_protocol_error)
.map(|response| response.into_inner())
}
pub async fn log_for_thread(
&mut self,
repo_path: &str,
thread: &str,
limit: u32,
since_state: Option<&str>,
paths: Vec<String>,
agent_model_substring: Option<&str>,
) -> Result<LogForThreadResponse, ProtocolError> {
let mut request = Request::new(LogForThreadRequest {
repo_path: repo_path.to_string(),
thread: thread.to_string(),
limit,
since_state: since_state.map(str::to_string),
paths,
agent_model_substring: agent_model_substring.map(str::to_string),
});
self.apply_signed_auth(&mut request, "/heddle.v1.TreeEditService/LogForThread")?;
self.tree_edit
.log_for_thread(request)
.await
.map_err(status_to_protocol_error)
.map(|response| response.into_inner())
}
}
#[cfg(test)]
mod tests {
use cli_shared::ClientConfig;
use grpc::heddle::v1::{
CompareSummary, DiffForThreadRequest, DiffForThreadResponse, FileDiff, LogForThreadRequest,
LogForThreadResponse, StateSummary, StatusForThreadRequest, StatusForThreadResponse,
ThreadPathSet, Treeish,
tree_edit_service_server::{TreeEditService, TreeEditServiceServer},
treeish,
};
use tonic::{Request, Response, Status, transport::Server};
use super::HostedGrpcClient;
#[derive(Default)]
struct EchoTreeEditService;
#[tonic::async_trait]
impl TreeEditService for EchoTreeEditService {
async fn status_for_thread(
&self,
request: Request<StatusForThreadRequest>,
) -> Result<Response<StatusForThreadResponse>, Status> {
let req = request.into_inner();
let compared_to_supplied_tree = req.compare_tree.is_some();
Ok(Response::new(StatusForThreadResponse {
thread: req.thread,
head_state: "hd-head".into(),
base_state: "hd-base".into(),
target_thread: "main".into(),
coordination_status: "ahead".into(),
changes: Some(ThreadPathSet {
modified: vec!["src/lib.rs".into()],
added: vec![],
deleted: vec![],
}),
compared_to_supplied_tree,
}))
}
async fn diff_for_thread(
&self,
request: Request<DiffForThreadRequest>,
) -> Result<Response<DiffForThreadResponse>, Status> {
let req = request.into_inner();
if req.from.is_none() || req.to.is_none() {
return Err(Status::invalid_argument("from and to are both required"));
}
Ok(Response::new(DiffForThreadResponse {
from_state: "hd-from".into(),
to_state: "hd-to".into(),
files: vec![FileDiff {
path: "src/lib.rs".into(),
kind: "modified".into(),
hunks: vec![],
classification: "Logic".into(),
importance: "High".into(),
}],
summary: Some(CompareSummary {
added: 0,
modified: 1,
deleted: 0,
renamed: 0,
total: 1,
}),
}))
}
async fn log_for_thread(
&self,
request: Request<LogForThreadRequest>,
) -> Result<Response<LogForThreadResponse>, Status> {
let req = request.into_inner();
let states = (0..req.limit)
.map(|_| StateSummary::default())
.collect::<Vec<_>>();
Ok(Response::new(LogForThreadResponse { states }))
}
}
async fn connect_echo_service() -> Option<(HostedGrpcClient, tokio::task::JoinHandle<()>)> {
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
Ok(listener) => listener,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!("skipping tree-edit client test: TCP bind denied: {err}");
return None;
}
Err(err) => panic!("bind test server: {err}"),
};
let addr = listener.local_addr().expect("local addr");
let incoming = futures::stream::unfold(listener, |listener| async {
match listener.accept().await {
Ok((stream, _addr)) => Some((Ok::<_, std::io::Error>(stream), listener)),
Err(err) => Some((Err(err), listener)),
}
});
let handle = tokio::spawn(async move {
Server::builder()
.add_service(TreeEditServiceServer::new(EchoTreeEditService))
.serve_with_incoming(incoming)
.await
.expect("serve tree-edit test service");
});
let client = HostedGrpcClient::connect(addr, &ClientConfig::default())
.await
.expect("connect client");
Some((client, handle))
}
#[tokio::test]
async fn status_for_thread_without_overlay_reports_committed_only() {
let Some((mut client, server)) = connect_echo_service().await else {
return;
};
let resp = client
.status_for_thread("owner/repo", "feat/x", None)
.await
.expect("status_for_thread");
server.abort();
assert_eq!(resp.thread, "feat/x");
assert_eq!(resp.head_state, "hd-head");
assert!(
!resp.compared_to_supplied_tree,
"no compare_tree supplied = committed-only status"
);
}
#[tokio::test]
async fn status_for_thread_with_overlay_sets_compared_flag() {
let Some((mut client, server)) = connect_echo_service().await else {
return;
};
let overlay = Treeish {
value: Some(treeish::Value::CaptureId("cap-123".into())),
};
let resp = client
.status_for_thread("owner/repo", "feat/x", Some(overlay))
.await
.expect("status_for_thread");
server.abort();
assert!(
resp.compared_to_supplied_tree,
"compare_tree supplied = overlay comparison"
);
}
#[tokio::test]
async fn diff_for_thread_round_trips_file_diffs() {
let Some((mut client, server)) = connect_echo_service().await else {
return;
};
let from = Treeish {
value: Some(treeish::Value::StateId("hd-from".into())),
};
let to = Treeish {
value: Some(treeish::Value::Ref("feat/x".into())),
};
let resp = client
.diff_for_thread("owner/repo", "feat/x", from, to, true)
.await
.expect("diff_for_thread");
server.abort();
assert_eq!(resp.files.len(), 1);
assert_eq!(resp.files[0].path, "src/lib.rs");
assert_eq!(resp.summary.expect("summary").modified, 1);
}
#[tokio::test]
async fn log_for_thread_returns_requested_number_of_states() {
let Some((mut client, server)) = connect_echo_service().await else {
return;
};
let resp = client
.log_for_thread("owner/repo", "feat/x", 3, Some("hd-since"), vec![], None)
.await
.expect("log_for_thread");
server.abort();
assert_eq!(resp.states.len(), 3);
}
}