use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use grpc::heddle::v1::{
AppendTurnRequest, GetDiscussionRequest, ListDiscussionsByStateRequest,
ListDiscussionsBySymbolRequest, OpenDiscussionRequest, PathSymbolRef, ResolveDiscussionRequest,
discussion_service_server::DiscussionService,
};
use repo::{Repository, operation_dedup::OperationDedupStore};
use serde::Serialize;
use daemon::grpc_local_impl::{GrpcLocalService, LocalDiscussionService};
use super::history_target::resolve_state_id;
use crate::cli::{
cli_args::{
Cli, DiscussAppendArgs, DiscussCommands, DiscussListArgs, DiscussOpenArgs,
DiscussResolveArgs, DiscussShowArgs, ResolveModeArg,
},
should_output_json,
};
pub async fn run(cli: &Cli, command: &DiscussCommands) -> Result<()> {
let svc = open_service()?;
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>,
state_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 {
discussions: Vec<DiscussionOutput>,
}
fn open_service() -> Result<LocalDiscussionService> {
let cwd = std::env::current_dir().context("get current working directory")?;
let repo = Repository::open(&cwd).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_state(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, &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, &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 => {
let kind_str = args
.annotation_kind
.as_deref()
.ok_or_else(|| anyhow!("--annotation-kind is required for into-annotation"))?;
let kind = parse_annotation_kind(kind_str)?;
let content = args
.annotation_content
.clone()
.ok_or_else(|| anyhow!("--annotation-content is required for into-annotation"))?;
let tags = args
.annotation_tags
.as_deref()
.map(|raw| {
raw.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect()
})
.unwrap_or_default();
Resolution::IntoAnnotation(ResolveIntoAnnotation {
kind: kind as i32,
content,
tags,
})
}
ResolveModeArg::ByEdit => Resolution::ByEdit(ResolveByEdit {
state_id: resolve_state(args.state.as_deref())?,
}),
ResolveModeArg::Dismiss => Resolution::Dismissed(ResolveDismissed {
reason: args
.reason
.clone()
.ok_or_else(|| anyhow!("--reason is required for dismiss"))?,
}),
};
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, &to_view(&resp.into_inner()))
}
async fn run_list(cli: &Cli, svc: &LocalDiscussionService, args: &DiscussListArgs) -> Result<()> {
let discussions = if let (Some(file), Some(symbol)) = (&args.file, &args.symbol) {
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(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 {
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 req = GetDiscussionRequest {
repo_path: String::new(),
discussion_id: args.discussion_id.clone(),
};
let resp = svc
.get_discussion(tonic::Request::new(req))
.await
.map_err(status_to_anyhow)?;
emit_discussion(cli, &to_view(&resp.into_inner()))
}
fn emit_discussion(cli: &Cli, view: &DiscussionOutput) -> Result<()> {
if should_output_json(cli, None) {
println!(
"{}",
serde_json::to_string(view).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,
state_id: None,
reason: None,
},
Some(State::IntoAnnotation(p)) => ResolutionView {
kind: "resolved_into_annotation".into(),
annotation_id: opt_string(p.annotation_id.clone()),
state_id: None,
reason: None,
},
Some(State::ByEdit(p)) => ResolutionView {
kind: "resolved_by_edit".into(),
annotation_id: None,
state_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,
state_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 resolve_state(explicit: Option<&str>) -> Result<Vec<u8>> {
let cwd = std::env::current_dir().context("get current working directory")?;
let repo = Repository::open(&cwd).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!("repository has no HEAD; capture a state first"))?;
Ok(head.as_bytes().to_vec())
}
fn status_to_anyhow(status: tonic::Status) -> anyhow::Error {
anyhow!("{}: {}", status.code(), status.message())
}
fn parse_annotation_kind(value: &str) -> Result<grpc::heddle::v1::ContextAnnotationKind> {
use grpc::heddle::v1::ContextAnnotationKind;
match value.trim().to_ascii_lowercase().as_str() {
"constraint" => Ok(ContextAnnotationKind::Constraint),
"invariant" => Ok(ContextAnnotationKind::Invariant),
"rationale" => Ok(ContextAnnotationKind::Rationale),
other => Err(anyhow!(
"invalid --annotation-kind '{other}': expected constraint|invariant|rationale"
)),
}
}