use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use daemon::grpc_local_impl::{GrpcLocalService, LocalDiscussionService};
use grpc::heddle::v1::{
AppendTurnRequest, ContextAnnotationKind, GetDiscussionRequest, ListDiscussionsByStateRequest,
ListDiscussionsBySymbolRequest, OpenDiscussionRequest, PathSymbolRef, ResolveDiscussionRequest,
discussion_service_server::DiscussionService,
};
use repo::{RepositoryCapability, operation_dedup::OperationDedupStore};
use serde::Serialize;
use super::{
advice::RecoveryAdvice, history_target::resolve_state_id, snapshot::ensure_current_state,
};
use crate::{
cli::{
cli_args::{
Cli, DiscussAppendArgs, DiscussCommands, DiscussListArgs, DiscussOpenArgs,
DiscussResolveArgs, DiscussShowArgs, ResolveModeArg,
},
should_output_json,
},
config::UserConfig,
};
pub async fn run(cli: &Cli, command: &DiscussCommands) -> Result<()> {
let svc = open_service(cli)?;
match command {
DiscussCommands::Open(args) => run_open(cli, &svc, args).await,
DiscussCommands::Append(args) => run_append(cli, &svc, args).await,
DiscussCommands::Resolve(args) => run_resolve(cli, &svc, args).await,
DiscussCommands::List(args) => run_list(cli, &svc, args).await,
DiscussCommands::Show(args) => run_show(cli, &svc, args).await,
}
}
#[derive(Serialize)]
struct DiscussionOutput {
id: String,
file: String,
symbol: String,
opened_against_state: String,
opened_at_secs: i64,
visibility: String,
body_changed_since_open: bool,
orphaned: bool,
resolution: ResolutionView,
turns: Vec<TurnView>,
resolved_annotation_id: Option<String>,
}
#[derive(Serialize)]
struct ResolutionView {
kind: String,
annotation_id: Option<String>,
change_id: Option<String>,
reason: Option<String>,
}
#[derive(Serialize)]
struct TurnView {
author_name: String,
author_email: String,
body: String,
posted_at_secs: i64,
}
#[derive(Serialize)]
struct DiscussionListOutput {
output_kind: &'static str,
discussions: Vec<DiscussionOutput>,
}
#[derive(Serialize)]
struct DiscussionEnvelope<'a> {
output_kind: &'static str,
#[serde(flatten)]
discussion: &'a DiscussionOutput,
}
fn open_service(cli: &Cli) -> Result<LocalDiscussionService> {
let repo = cli.open_repo().context("open Heddle repository")?;
let dedup = OperationDedupStore::open(repo.heddle_dir()).context("open dedup store")?;
let inner = GrpcLocalService::new(Arc::new(repo), Arc::new(dedup));
Ok(LocalDiscussionService::new(inner))
}
async fn run_open(cli: &Cli, svc: &LocalDiscussionService, args: &DiscussOpenArgs) -> Result<()> {
let state_id = resolve_open_state(cli, args.state.as_deref())?;
let req = OpenDiscussionRequest {
repo_path: String::new(),
state_id,
anchor: Some(PathSymbolRef {
file: args.file.clone(),
symbol: args.symbol.clone(),
}),
body: args.body.clone(),
visibility: args.visibility.clone().unwrap_or_default(),
thread_ref: args.thread.clone().unwrap_or_default(),
client_operation_id: crate::operation_id::wire(cli),
};
let resp = svc
.open_discussion(tonic::Request::new(req))
.await
.map_err(status_to_anyhow)?;
emit_discussion(cli, "discuss_open", &to_view(&resp.into_inner()))
}
async fn run_append(
cli: &Cli,
svc: &LocalDiscussionService,
args: &DiscussAppendArgs,
) -> Result<()> {
let req = AppendTurnRequest {
repo_path: String::new(),
discussion_id: args.discussion_id.clone(),
body: args.body.clone(),
client_operation_id: crate::operation_id::wire(cli),
};
let resp = svc
.append_turn(tonic::Request::new(req))
.await
.map_err(status_to_anyhow)?;
emit_discussion(cli, "discuss_append", &to_view(&resp.into_inner()))
}
async fn run_resolve(
cli: &Cli,
svc: &LocalDiscussionService,
args: &DiscussResolveArgs,
) -> Result<()> {
use grpc::heddle::v1::resolve_discussion_request::{
Resolution, ResolveByEdit, ResolveDismissed, ResolveIntoAnnotation,
};
let resolution =
match args.mode {
ResolveModeArg::IntoAnnotation => Resolution::IntoAnnotation(ResolveIntoAnnotation {
kind: parse_annotation_kind(args.annotation_kind.as_deref())? as i32,
content: args.annotation_content.clone().ok_or_else(|| {
anyhow!("--annotation-content is required for into-annotation")
})?,
tags: parse_annotation_tags(args.annotation_tags.as_deref()),
}),
ResolveModeArg::ByEdit => Resolution::ByEdit(ResolveByEdit {
state_id: resolve_state(cli, args.state.as_deref())?,
}),
ResolveModeArg::Dismiss => Resolution::Dismissed(ResolveDismissed {
reason: args.reason.clone().ok_or_else(|| {
anyhow!(RecoveryAdvice::discuss_resolve_missing_dismiss_reason())
})?,
}),
};
let req = ResolveDiscussionRequest {
repo_path: String::new(),
discussion_id: args.discussion_id.clone(),
resolution: Some(resolution),
client_operation_id: crate::operation_id::wire(cli),
};
let resp = svc
.resolve_discussion(tonic::Request::new(req))
.await
.map_err(status_to_anyhow)?;
emit_discussion(cli, "discuss_resolve", &to_view(&resp.into_inner()))
}
async fn run_list(cli: &Cli, svc: &LocalDiscussionService, args: &DiscussListArgs) -> Result<()> {
let discussions = if args.file.is_some() || args.symbol.is_some() {
let (Some(file), Some(symbol)) = (&args.file, &args.symbol) else {
return Err(anyhow!(
"discuss list --file and --symbol must be provided together"
));
};
let req = ListDiscussionsBySymbolRequest {
repo_path: String::new(),
anchor: Some(PathSymbolRef {
file: file.clone(),
symbol: symbol.clone(),
}),
status: args.status.clone(),
};
let resp = svc
.list_by_symbol(tonic::Request::new(req))
.await
.map_err(status_to_anyhow)?;
resp.into_inner().discussions
} else {
let state_id = resolve_state(cli, args.state.as_deref())?;
let req = ListDiscussionsByStateRequest {
repo_path: String::new(),
state_id,
status: args.status.clone(),
};
let resp = svc
.list_by_state(tonic::Request::new(req))
.await
.map_err(status_to_anyhow)?;
resp.into_inner().discussions
};
let output = DiscussionListOutput {
output_kind: "discuss_list",
discussions: discussions.iter().map(to_view).collect(),
};
if should_output_json(cli, None) {
println!(
"{}",
serde_json::to_string(&output).context("serialize discussion list")?
);
} else if output.discussions.is_empty() {
println!("(no discussions)");
} else {
for d in &output.discussions {
println!(
"{} [{}] {}:{} — {}",
d.id,
d.resolution.kind,
d.file,
d.symbol,
d.turns
.first()
.map(|t| t.body.lines().next().unwrap_or(""))
.unwrap_or("")
);
}
}
Ok(())
}
async fn run_show(cli: &Cli, svc: &LocalDiscussionService, args: &DiscussShowArgs) -> Result<()> {
let state_id = match args.state.as_deref() {
Some(s) => resolve_state(cli, Some(s))?,
None => Vec::new(),
};
let req = GetDiscussionRequest {
repo_path: String::new(),
discussion_id: args.discussion_id.clone(),
state_id,
};
let resp = svc
.get_discussion(tonic::Request::new(req))
.await
.map_err(status_to_anyhow)?;
emit_discussion(cli, "discuss_show", &to_view(&resp.into_inner()))
}
fn emit_discussion(cli: &Cli, output_kind: &'static str, view: &DiscussionOutput) -> Result<()> {
if should_output_json(cli, None) {
let envelope = DiscussionEnvelope {
output_kind,
discussion: view,
};
println!(
"{}",
serde_json::to_string(&envelope).context("serialize discussion")?
);
} else {
println!("discussion {}", view.id);
println!(" anchor: {}:{}", view.file, view.symbol);
println!(" state: {}", view.opened_against_state);
println!(" visibility: {}", view.visibility);
println!(" status: {}", view.resolution.kind);
if view.body_changed_since_open {
println!(" ⚠ body changed since open");
}
if view.orphaned {
println!(" ⚠ symbol no longer present (orphaned)");
}
for (i, turn) in view.turns.iter().enumerate() {
println!(
" [{i}] {} <{}> @ {}",
turn.author_name, turn.author_email, turn.posted_at_secs
);
for line in turn.body.lines() {
println!(" {line}");
}
}
if let Some(annotation_id) = &view.resolved_annotation_id {
println!(" resolved into annotation {annotation_id}");
}
}
Ok(())
}
fn to_view(d: &grpc::heddle::v1::Discussion) -> DiscussionOutput {
use grpc::heddle::v1::discussion_resolution::State;
let anchor = d.anchor.clone().unwrap_or_default();
let resolution_view = match d.resolution.as_ref().and_then(|r| r.state.as_ref()) {
Some(State::Open(_)) | None => ResolutionView {
kind: "open".into(),
annotation_id: None,
change_id: None,
reason: None,
},
Some(State::IntoAnnotation(p)) => ResolutionView {
kind: "resolved_into_annotation".into(),
annotation_id: opt_string(p.annotation_id.clone()),
change_id: None,
reason: None,
},
Some(State::ByEdit(p)) => ResolutionView {
kind: "resolved_by_edit".into(),
annotation_id: None,
change_id: if p.state_id.is_empty() {
None
} else {
objects::object::ChangeId::try_from_slice(&p.state_id)
.ok()
.map(|id| id.to_string_full())
},
reason: None,
},
Some(State::Dismissed(p)) => ResolutionView {
kind: "dismissed".into(),
annotation_id: None,
change_id: None,
reason: opt_string(p.reason.clone()),
},
};
DiscussionOutput {
id: d.id.clone(),
file: anchor.file,
symbol: anchor.symbol,
opened_against_state: objects::object::ChangeId::try_from_slice(&d.opened_against_state)
.map(|id| id.to_string_full())
.unwrap_or_default(),
opened_at_secs: d.opened_at.as_ref().map(|t| t.seconds).unwrap_or(0),
visibility: d.visibility.clone(),
body_changed_since_open: d.body_changed_since_open,
orphaned: d.orphaned,
resolution: resolution_view,
turns: d
.turns
.iter()
.map(|t| TurnView {
author_name: t.author_name.clone(),
author_email: t.author_email.clone(),
body: t.body.clone(),
posted_at_secs: t.posted_at.as_ref().map(|x| x.seconds).unwrap_or(0),
})
.collect(),
resolved_annotation_id: opt_string(d.resolved_annotation_id.clone()),
}
}
fn opt_string(s: String) -> Option<String> {
if s.is_empty() { None } else { Some(s) }
}
fn parse_annotation_kind(kind: Option<&str>) -> Result<ContextAnnotationKind> {
match kind.unwrap_or("rationale") {
"constraint" => Ok(ContextAnnotationKind::Constraint),
"invariant" => Ok(ContextAnnotationKind::Invariant),
"rationale" => Ok(ContextAnnotationKind::Rationale),
other => Err(anyhow!(
"invalid annotation kind '{other}'; expected constraint, invariant, or rationale"
)),
}
}
fn parse_annotation_tags(tags: Option<&str>) -> Vec<String> {
tags.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|tag| !tag.is_empty())
.map(str::to_string)
.collect()
}
fn resolve_state(cli: &Cli, explicit: Option<&str>) -> Result<Vec<u8>> {
let repo = cli.open_repo().context("open Heddle repository")?;
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_anchor_first("discuss")))?;
Ok(head.as_bytes().to_vec())
}
fn resolve_open_state(cli: &Cli, explicit: Option<&str>) -> Result<Vec<u8>> {
let repo = cli.open_repo().context("open Heddle repository")?;
if let Some(s) = explicit {
return Ok(resolve_state_id(&repo, s)?.as_bytes().to_vec());
}
if let Some(head) = repo.head().context("read HEAD")? {
return Ok(head.as_bytes().to_vec());
}
if repo.capability() == RepositoryCapability::GitOverlay
&& repo
.git_overlay_worktree_status()?
.is_some_and(|status| status.is_clean())
{
let state_id = ensure_current_state(
&repo,
&UserConfig::load_default()?,
Some("Bootstrap git-overlay before opening discussion".to_string()),
)?;
return Ok(state_id.as_bytes().to_vec());
}
Err(anyhow!(RecoveryAdvice::repository_no_head_anchor_first(
"discuss"
)))
}
fn status_to_anyhow(status: tonic::Status) -> anyhow::Error {
anyhow!("{}: {}", status.code(), status.message())
}