use api::heddle::api::v1alpha1::{
AppendTurnRequest, Discussion as ProtoDiscussion, DiscussionStatusFilter,
ListDiscussionsByStateRequest, OpenDiscussionRequest, PathSymbolRef, StateId as ProtoStateId,
list_discussions_response,
};
use objects::object::{ChangeId, StateId};
use wire::ProtocolError;
use super::{HostedClient, helpers::hosted_to_protocol_error};
#[derive(Debug, Clone)]
pub struct HostedDiscussionTurn {
pub author_name: String,
pub author_email: String,
pub body: String,
pub posted_at_secs: i64,
}
#[derive(Debug, Clone)]
pub struct HostedDiscussion {
pub id: String,
pub file: String,
pub symbol: String,
pub opened_against_state: Option<StateId>,
pub visibility: String,
pub turns: Vec<HostedDiscussionTurn>,
}
fn decode_discussion(proto: ProtoDiscussion) -> HostedDiscussion {
let anchor = proto.anchor.unwrap_or_default();
HostedDiscussion {
id: proto.id,
file: anchor.file,
symbol: anchor.symbol,
opened_against_state: proto
.opened_against_state
.and_then(|state| StateId::try_from_slice(&state.value).ok()),
visibility: proto.visibility,
turns: proto
.turns
.into_iter()
.map(|turn| HostedDiscussionTurn {
author_name: turn.author_name,
author_email: turn.author_email,
body: turn.body,
posted_at_secs: turn.posted_at.map(|ts| ts.seconds).unwrap_or(0),
})
.collect(),
}
}
fn change_id_state_field(change_id: ChangeId) -> Option<ProtoStateId> {
Some(ProtoStateId {
value: change_id.as_bytes().to_vec(),
})
}
impl HostedClient {
pub fn authenticated_username(&self) -> Option<String> {
self.context
.signing_identity()
.and_then(|principal| principal.strip_prefix("principal:"))
.map(|subject| subject.trim().to_string())
.filter(|subject| !subject.is_empty())
}
#[allow(clippy::too_many_arguments)]
pub async fn open_discussion(
&mut self,
repo_path: &str,
change_id: ChangeId,
file: &str,
symbol: &str,
body: &str,
visibility: &str,
client_operation_id: String,
) -> Result<HostedDiscussion, ProtocolError> {
let request = OpenDiscussionRequest {
repo_path: super::helpers::repository_ref(repo_path),
state_id: change_id_state_field(change_id),
anchor: Some(PathSymbolRef {
file: file.to_string(),
symbol: symbol.to_string(),
}),
body: body.to_string(),
visibility: visibility.to_string(),
thread_ref: String::new(),
client_operation_id,
thread_id: String::new(),
};
let response = self
.routes()
.open_discussion(&request)
.await
.map_err(hosted_to_protocol_error)?;
Ok(decode_discussion(response))
}
pub async fn append_turn(
&mut self,
repo_path: &str,
discussion_id: &str,
body: &str,
client_operation_id: String,
) -> Result<HostedDiscussion, ProtocolError> {
let request = AppendTurnRequest {
repo_path: super::helpers::repository_ref(repo_path),
discussion_id: discussion_id.to_string(),
body: body.to_string(),
client_operation_id,
};
let response = self
.routes()
.append_turn(&request)
.await
.map_err(hosted_to_protocol_error)?;
Ok(decode_discussion(response))
}
pub async fn list_discussions_by_state(
&mut self,
repo_path: &str,
change_id: ChangeId,
status: &str,
) -> Result<Vec<HostedDiscussion>, ProtocolError> {
let status = discussion_status_filter(status)? as i32;
let mut discussions = Vec::new();
let mut page_token = String::new();
loop {
let request = ListDiscussionsByStateRequest {
repo_path: super::helpers::repository_ref(repo_path),
state_id: change_id_state_field(change_id),
status,
page_size: api::MAX_PAGE_SIZE,
page_token: page_token.clone(),
};
let mut stream = self
.routes()
.list_discussions_by_state(&request)
.await
.map_err(hosted_to_protocol_error)?;
let mut next_page_token = None;
while let Some(response) = stream.next().await.map_err(hosted_to_protocol_error)? {
match response.frame {
Some(list_discussions_response::Frame::Item(discussion)) => {
discussions.push(decode_discussion(*discussion));
}
Some(list_discussions_response::Frame::PageEnd(page_end)) => {
next_page_token = Some(page_end.next_page_token);
}
None => {
return Err(ProtocolError::InvalidState(
"ListByState emitted an empty frame".to_string(),
));
}
}
}
let next_page_token = next_page_token.ok_or_else(|| {
ProtocolError::InvalidState(
"ListByState ended without a terminal page frame".to_string(),
)
})?;
if next_page_token.is_empty() {
return Ok(discussions);
}
if next_page_token == page_token {
return Err(ProtocolError::InvalidState(
"ListByState returned a repeated page token".to_string(),
));
}
page_token = next_page_token;
}
}
}
fn discussion_status_filter(status: &str) -> Result<DiscussionStatusFilter, ProtocolError> {
match status {
"all" => Ok(DiscussionStatusFilter::Unspecified),
"open" => Ok(DiscussionStatusFilter::Open),
"resolved" => Ok(DiscussionStatusFilter::Resolved),
"orphaned" => Ok(DiscussionStatusFilter::Orphaned),
other => Err(ProtocolError::InvalidState(format!(
"invalid discussion status filter: {other}"
))),
}
}
#[cfg(test)]
mod tests {
use api::heddle::api::v1alpha1::{
DiscussionTurn as ProtoTurn, PathSymbolRef, StateId as ProtoStateId,
};
use objects::object::ChangeId;
use super::*;
#[test]
fn discussion_status_filter_accepts_known_and_rejects_unknown() {
assert_eq!(
discussion_status_filter("all").unwrap(),
DiscussionStatusFilter::Unspecified
);
assert_eq!(
discussion_status_filter("open").unwrap(),
DiscussionStatusFilter::Open
);
assert_eq!(
discussion_status_filter("resolved").unwrap(),
DiscussionStatusFilter::Resolved
);
assert_eq!(
discussion_status_filter("orphaned").unwrap(),
DiscussionStatusFilter::Orphaned
);
assert!(discussion_status_filter("closed").is_err());
}
#[test]
fn change_id_state_field_wraps_16_byte_change_id() {
let change = ChangeId::from_bytes([0x11; 16]);
let field = change_id_state_field(change).expect("proto state wrapper");
assert_eq!(field.value, change.as_bytes().to_vec());
}
#[test]
fn decode_discussion_maps_anchor_turns_and_optional_state() {
let state = StateId::from_bytes([0x22; 32]);
let proto = ProtoDiscussion {
id: "disc-1".into(),
anchor: Some(PathSymbolRef {
file: "src/lib.rs".into(),
symbol: "main".into(),
}),
opened_against_state: Some(ProtoStateId {
value: state.as_bytes().to_vec(),
}),
visibility: "team".into(),
turns: vec![ProtoTurn {
author_name: "alice".into(),
author_email: "a@x".into(),
body: "lgtm".into(),
posted_at: Some(prost_types::Timestamp {
seconds: 42,
nanos: 0,
}),
}],
..Default::default()
};
let decoded = decode_discussion(proto);
assert_eq!(decoded.id, "disc-1");
assert_eq!(decoded.file, "src/lib.rs");
assert_eq!(decoded.symbol, "main");
assert_eq!(decoded.opened_against_state, Some(state));
assert_eq!(decoded.visibility, "team");
assert_eq!(decoded.turns.len(), 1);
assert_eq!(decoded.turns[0].author_name, "alice");
assert_eq!(decoded.turns[0].body, "lgtm");
assert_eq!(decoded.turns[0].posted_at_secs, 42);
let empty = decode_discussion(ProtoDiscussion {
id: "empty".into(),
..Default::default()
});
assert!(empty.file.is_empty());
assert!(empty.opened_against_state.is_none());
assert!(empty.turns.is_empty());
}
}