#![cfg(feature = "client")]
use anyhow::{Context, Result, anyhow};
use objects::object::ThreadName;
use repo::Repository;
use serde::Serialize;
use super::RecoveryAdvice;
use crate::{
cli::{
Cli,
cli_args::{
ThreadApprovalsArgs, ThreadApproveArgs, ThreadCheckMergeArgs, ThreadRevokeApprovalArgs,
},
should_output_json,
},
client::{HostedAuthMode, HostedGrpcClient},
config::UserConfig,
remote::{RemoteTarget, resolve_remote_with_key},
};
#[derive(Serialize)]
struct ApprovalOutput {
id: String,
repo_path: String,
source_thread: String,
target_thread: String,
source_state: String,
approver_user_id: String,
note: String,
approved_at: u64,
expires_at: u64,
}
fn ts_secs(ts: &Option<prost_types::Timestamp>) -> u64 {
ts.as_ref().map(|t| t.seconds.max(0) as u64).unwrap_or(0)
}
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()
}
#[derive(Serialize)]
struct UnmetOutput {
policy_id: String,
kind: String,
group_id: String,
reason: String,
needed: u32,
have: u32,
}
#[derive(Serialize)]
struct EligibilityOutput {
allowed: bool,
unmet: Vec<UnmetOutput>,
valid_approvals: Vec<ApprovalOutput>,
}
#[derive(Serialize)]
struct ApprovalRevokeOutput {
output_kind: &'static str,
id: String,
deleted: bool,
}
async fn open_heddle_client(
repo: &Repository,
remote_name: &str,
) -> Result<(HostedGrpcClient, String)> {
let (target, server_key) =
resolve_remote_with_key(repo, Some(remote_name)).map_err(anyhow::Error::msg)?;
let (addr, repo_path) = match target {
RemoteTarget::Network { addr, repo_path } => (
addr,
repo_path.context("hosted remote must include a repository path")?,
),
RemoteTarget::Local(_) => {
return Err(anyhow!(RecoveryAdvice::safety_refusal(
"hosted_remote_required",
format!("approvals require a hosted remote; remote '{remote_name}' is local"),
"Configure a hosted remote or retry against one that resolves to a network target.",
format!("remote '{remote_name}' is local, but approvals run on the hosted server"),
"running locally would imply a hosted approval policy change that no server recorded",
"no hosted request was sent and local repository state was left unchanged",
"heddle remote list",
vec!["heddle remote list".to_string()],
)));
}
};
let user_config = UserConfig::load_default()?;
let client =
HostedGrpcClient::open_session(addr, &user_config, server_key, HostedAuthMode::ConfigToken)
.await?;
Ok((client, repo_path))
}
fn thread_head_state(repo: &Repository, thread: &str) -> Result<String> {
repo.refs()
.get_thread(&ThreadName::new(thread))?
.map(|change_id| change_id.to_string())
.ok_or_else(|| anyhow!("thread '{thread}' has no head state"))
}
pub async fn cmd_thread_approve(cli: &Cli, args: ThreadApproveArgs) -> Result<()> {
let repo = cli.open_repo()?;
let source_state = thread_head_state(&repo, &args.source)?;
let (mut client, repo_path) = open_heddle_client(&repo, &args.remote).await?;
let approval = client
.approve_thread(
&repo_path,
&args.source,
&args.target,
&source_state,
args.note.as_deref(),
)
.await?;
if should_output_json(cli, Some(repo.config())) {
let out = ApprovalOutput {
id: approval.id,
repo_path: approval.repo_path,
source_thread: approval.source_thread,
target_thread: approval.target_thread,
source_state: bytes_to_change_id_string(&approval.source_state),
approver_user_id: approval.approver_user_id,
note: approval.note,
approved_at: ts_secs(&approval.approved_at),
expires_at: ts_secs(&approval.expires_at),
};
println!("{}", serde_json::to_string(&out)?);
} else {
println!(
"Approved {source} -> {target} at {state}",
source = args.source,
target = args.target,
state = &source_state[..source_state.len().min(12)],
);
println!(" approval id: {}", approval.id);
let exp_secs = ts_secs(&approval.expires_at);
if exp_secs > 0
&& let Some(d) = chrono::DateTime::from_timestamp(exp_secs as i64, 0)
{
println!(" expires at: {}", d.to_rfc3339());
}
if !approval.note.is_empty() {
println!(" note: {}", approval.note);
}
}
Ok(())
}
pub async fn cmd_thread_approvals(cli: &Cli, args: ThreadApprovalsArgs) -> Result<()> {
let repo = cli.open_repo()?;
let (mut client, repo_path) = open_heddle_client(&repo, &args.remote).await?;
let approvals = client
.list_thread_approvals(&repo_path, &args.source, &args.target)
.await?;
if should_output_json(cli, Some(repo.config())) {
let out: Vec<ApprovalOutput> = approvals
.into_iter()
.map(|a| ApprovalOutput {
id: a.id,
repo_path: a.repo_path,
source_thread: a.source_thread,
target_thread: a.target_thread,
source_state: bytes_to_change_id_string(&a.source_state),
approver_user_id: a.approver_user_id,
note: a.note,
approved_at: ts_secs(&a.approved_at),
expires_at: ts_secs(&a.expires_at),
})
.collect();
println!("{}", serde_json::to_string(&out)?);
} else if approvals.is_empty() {
println!(
"No approvals recorded for {} -> {}.",
args.source, args.target
);
} else {
println!(
"{} approval(s) for {} -> {}:",
approvals.len(),
args.source,
args.target
);
for a in approvals {
let approved_secs = ts_secs(&a.approved_at);
let when = chrono::DateTime::from_timestamp(approved_secs as i64, 0)
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| approved_secs.to_string());
let state_str = bytes_to_change_id_string(&a.source_state);
print!(
" {id} approver={user} state={state} approved_at={when}",
id = a.id,
user = a.approver_user_id,
state = &state_str[..state_str.len().min(12)],
);
let exp_secs = ts_secs(&a.expires_at);
if exp_secs > 0 {
let exp = chrono::DateTime::from_timestamp(exp_secs as i64, 0)
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| exp_secs.to_string());
print!(" expires_at={exp}");
}
if !a.note.is_empty() {
print!(" note=\"{}\"", a.note);
}
println!();
}
}
Ok(())
}
pub async fn cmd_thread_revoke_approval(cli: &Cli, args: ThreadRevokeApprovalArgs) -> Result<()> {
let repo = cli.open_repo()?;
let (mut client, _repo_path) = open_heddle_client(&repo, &args.remote).await?;
client.revoke_approval(&args.id).await?;
if should_output_json(cli, Some(repo.config())) {
let output = ApprovalRevokeOutput {
output_kind: "thread_revoke_approval",
id: args.id,
deleted: true,
};
println!("{}", serde_json::to_string(&output)?);
} else {
println!("Revoked approval {}.", args.id);
}
Ok(())
}
pub async fn cmd_thread_check_merge(cli: &Cli, args: ThreadCheckMergeArgs) -> Result<()> {
let repo = cli.open_repo()?;
let source_state = thread_head_state(&repo, &args.source)?;
let (mut client, repo_path) = open_heddle_client(&repo, &args.remote).await?;
let resp = client
.check_merge_eligibility(
&repo_path,
&args.source,
&args.target,
&source_state,
&args.gated_action,
args.changed_paths,
None,
)
.await?;
let unmet: Vec<UnmetOutput> = resp
.unmet
.into_iter()
.map(|u| UnmetOutput {
policy_id: u.policy_id,
kind: u.kind,
group_id: u.group_id,
reason: u.reason,
needed: u.needed,
have: u.have,
})
.collect();
let valid_approvals: Vec<ApprovalOutput> = resp
.valid_approvals
.into_iter()
.map(|a| ApprovalOutput {
id: a.id,
repo_path: a.repo_path,
source_thread: a.source_thread,
target_thread: a.target_thread,
source_state: bytes_to_change_id_string(&a.source_state),
approver_user_id: a.approver_user_id,
note: a.note,
approved_at: ts_secs(&a.approved_at),
expires_at: ts_secs(&a.expires_at),
})
.collect();
if should_output_json(cli, Some(repo.config())) {
let out = EligibilityOutput {
allowed: resp.allowed,
unmet,
valid_approvals,
};
println!("{}", serde_json::to_string(&out)?);
} else if resp.allowed {
println!("{} -> {} can merge.", args.source, args.target);
if !valid_approvals.is_empty() {
println!(" ({} approval(s) counted)", valid_approvals.len());
}
} else {
println!(
"{} -> {} BLOCKED by {} unmet requirement(s):",
args.source,
args.target,
unmet.len()
);
for u in unmet {
println!(
" [{kind}] {reason} (have {have}/{needed})",
kind = u.kind,
reason = u.reason,
have = u.have,
needed = u.needed,
);
}
std::process::exit(2);
}
Ok(())
}