Skip to main content

heddle_client/grpc_hosted/
collaboration.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Hosted `CollaborationService` client wrappers.
3//!
4//! These are the caller-authenticated, PoP-signed RPCs the CLI uses to publish
5//! and fetch discussions against a hosted weft. They are the write/read seam
6//! for hosted collaboration: local discussions live in the append-only
7//! [`repo::CollaborationStore`] op-log, and the CLI-side sync orchestrator
8//! ([`heddle_cli`'s `discussion_sync`]) bridges that model to the server's
9//! per-state `DiscussionsBlob` shape through these calls.
10//!
11//! Wire identity note: the canonical `CollaborationService` proto types the
12//! discussion anchor state as a 32-byte `StateId`, but the hosted server still
13//! resolves the inbound `state_id` field through a 16-byte `ChangeId` decode
14//! (`OpenDiscussion`/`ListByState`). We therefore send the **ChangeId** bytes in
15//! that field (matching weft's own canonical integration tests), while the
16//! server echoes the genuine 32-byte `StateId` back in
17//! `Discussion.opened_against_state`.
18
19use grpc::heddle::api::v1alpha1::{
20    AppendTurnRequest, Discussion as ProtoDiscussion, ListDiscussionsByStateRequest,
21    OpenDiscussionRequest, PathSymbolRef, StateId as ProtoStateId,
22};
23use objects::object::{ChangeId, StateId};
24use tonic::Request;
25use wire::ProtocolError;
26
27use super::{HostedGrpcClient, helpers::status_to_protocol_error};
28
29/// One turn of a hosted discussion, decoded from the wire.
30#[derive(Debug, Clone)]
31pub struct HostedDiscussionTurn {
32    pub author_name: String,
33    pub author_email: String,
34    pub body: String,
35    pub posted_at_secs: i64,
36}
37
38/// A hosted discussion decoded from the `CollaborationService` wire types into
39/// the shape the CLI-side sync bridge consumes.
40#[derive(Debug, Clone)]
41pub struct HostedDiscussion {
42    /// Server-assigned discussion id (opaque string).
43    pub id: String,
44    pub file: String,
45    pub symbol: String,
46    /// Genuine 32-byte anchor `StateId` echoed by the server, when present.
47    pub opened_against_state: Option<StateId>,
48    pub visibility: String,
49    pub turns: Vec<HostedDiscussionTurn>,
50}
51
52const OPEN_METHOD: &str = "/heddle.api.v1alpha1.CollaborationService/OpenDiscussion";
53const APPEND_METHOD: &str = "/heddle.api.v1alpha1.CollaborationService/AppendTurn";
54const LIST_BY_STATE_METHOD: &str = "/heddle.api.v1alpha1.CollaborationService/ListByState";
55
56fn decode_discussion(proto: ProtoDiscussion) -> HostedDiscussion {
57    let anchor = proto.anchor.unwrap_or_default();
58    HostedDiscussion {
59        id: proto.id,
60        file: anchor.file,
61        symbol: anchor.symbol,
62        opened_against_state: proto
63            .opened_against_state
64            .and_then(|state| StateId::try_from_slice(&state.value).ok()),
65        visibility: proto.visibility,
66        turns: proto
67            .turns
68            .into_iter()
69            .map(|turn| HostedDiscussionTurn {
70                author_name: turn.author_name,
71                author_email: turn.author_email,
72                body: turn.body,
73                posted_at_secs: turn.posted_at.map(|ts| ts.seconds).unwrap_or(0),
74            })
75            .collect(),
76    }
77}
78
79/// The hosted server decodes the discussion anchor `state_id` field as a
80/// 16-byte `ChangeId` (see the module note); wrap the change id in the proto
81/// `StateId` message accordingly.
82fn change_id_state_field(change_id: ChangeId) -> Option<ProtoStateId> {
83    Some(ProtoStateId {
84        value: change_id.as_bytes().to_vec(),
85    })
86}
87
88impl HostedGrpcClient {
89    /// The authenticated hosted username (the bearer token's `principal:<subject>`
90    /// subject). weft stamps discussion turns with `Principal::new(username, "")`,
91    /// so this is the author name our own pushed turns carry server-side — the
92    /// identity the discussion-sync bridge uses to recognize turns we published.
93    /// `None` for an anonymous/unsigned client.
94    pub fn authenticated_username(&self) -> Option<String> {
95        self.authenticated_principal
96            .as_deref()
97            .and_then(|principal| principal.strip_prefix("principal:"))
98            .map(|subject| subject.trim().to_string())
99            .filter(|subject| !subject.is_empty())
100    }
101
102    /// Open a hosted discussion anchored at `change_id`'s state, seeded with
103    /// `body` as the first turn. Caller-authenticated + PoP-signed.
104    #[allow(clippy::too_many_arguments)]
105    pub async fn open_discussion(
106        &mut self,
107        repo_path: &str,
108        change_id: ChangeId,
109        file: &str,
110        symbol: &str,
111        body: &str,
112        visibility: &str,
113        client_operation_id: String,
114    ) -> Result<HostedDiscussion, ProtocolError> {
115        let mut request = Request::new(OpenDiscussionRequest {
116            repo_path: super::helpers::repository_ref(repo_path),
117            state_id: change_id_state_field(change_id),
118            anchor: Some(PathSymbolRef {
119                file: file.to_string(),
120                symbol: symbol.to_string(),
121            }),
122            body: body.to_string(),
123            visibility: visibility.to_string(),
124            thread_ref: String::new(),
125            client_operation_id,
126        });
127        self.apply_signed_auth(&mut request, OPEN_METHOD)?;
128        let response = self
129            .collaboration
130            .open_discussion(request)
131            .await
132            .map_err(status_to_protocol_error)?
133            .into_inner();
134        Ok(decode_discussion(response))
135    }
136
137    /// Append `body` as a new turn on an existing hosted discussion.
138    /// Caller-authenticated + PoP-signed.
139    pub async fn append_turn(
140        &mut self,
141        repo_path: &str,
142        discussion_id: &str,
143        body: &str,
144        client_operation_id: String,
145    ) -> Result<HostedDiscussion, ProtocolError> {
146        let mut request = Request::new(AppendTurnRequest {
147            repo_path: super::helpers::repository_ref(repo_path),
148            discussion_id: discussion_id.to_string(),
149            body: body.to_string(),
150            client_operation_id,
151        });
152        self.apply_signed_auth(&mut request, APPEND_METHOD)?;
153        let response = self
154            .collaboration
155            .append_turn(request)
156            .await
157            .map_err(status_to_protocol_error)?
158            .into_inner();
159        Ok(decode_discussion(response))
160    }
161
162    /// List hosted discussions anchored at `change_id`'s state. `status` is one
163    /// of `open` | `resolved` | `all` | `orphaned`.
164    pub async fn list_discussions_by_state(
165        &mut self,
166        repo_path: &str,
167        change_id: ChangeId,
168        status: &str,
169    ) -> Result<Vec<HostedDiscussion>, ProtocolError> {
170        let mut request = Request::new(ListDiscussionsByStateRequest {
171            repo_path: super::helpers::repository_ref(repo_path),
172            state_id: change_id_state_field(change_id),
173            status: status.to_string(),
174        });
175        self.apply_signed_auth(&mut request, LIST_BY_STATE_METHOD)?;
176        let response = self
177            .collaboration
178            .list_by_state(request)
179            .await
180            .map_err(status_to_protocol_error)?
181            .into_inner();
182        Ok(response.discussions.into_iter().map(decode_discussion).collect())
183    }
184}