heddle-cli 0.12.0

An AI-native version control system
// SPDX-License-Identifier: Apache-2.0
//! Hosted `CollaborationService` client wrappers.
//!
//! These are the caller-authenticated, PoP-signed RPCs the CLI uses to publish
//! and fetch discussions against a hosted weft. They are the write/read seam
//! for hosted collaboration: local discussions live in the append-only
//! [`repo::CollaborationStore`] op-log, and the CLI-side sync orchestrator
//! ([`heddle_cli`'s `discussion_sync`]) bridges that model to the server's
//! per-state `DiscussionsBlob` shape through these calls.
//!
//! Wire identity note: the canonical `CollaborationService` proto types the
//! discussion anchor state as a 32-byte `StateId`, but the hosted server still
//! resolves the inbound `state_id` field through a 16-byte `ChangeId` decode
//! (`OpenDiscussion`/`ListByState`). We therefore send the **ChangeId** bytes in
//! that field (matching weft's own canonical integration tests), while the
//! server echoes the genuine 32-byte `StateId` back in
//! `Discussion.opened_against_state`.

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};

/// One turn of a hosted discussion, decoded from the wire.
#[derive(Debug, Clone)]
pub struct HostedDiscussionTurn {
    pub author_name: String,
    pub author_email: String,
    pub body: String,
    pub posted_at_secs: i64,
}

/// A hosted discussion decoded from the `CollaborationService` wire types into
/// the shape the CLI-side sync bridge consumes.
#[derive(Debug, Clone)]
pub struct HostedDiscussion {
    /// Server-assigned discussion id (opaque string).
    pub id: String,
    pub file: String,
    pub symbol: String,
    /// Genuine 32-byte anchor `StateId` echoed by the server, when present.
    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(),
    }
}

/// The hosted server decodes the discussion anchor `state_id` field as a
/// 16-byte `ChangeId` (see the module note); wrap the change id in the proto
/// `StateId` message accordingly.
fn change_id_state_field(change_id: ChangeId) -> Option<ProtoStateId> {
    Some(ProtoStateId {
        value: change_id.as_bytes().to_vec(),
    })
}

impl HostedClient {
    /// The authenticated hosted username (the bearer token's `principal:<subject>`
    /// subject). weft stamps discussion turns with `Principal::new(username, "")`,
    /// so this is the author name our own pushed turns carry server-side — the
    /// identity the discussion-sync bridge uses to recognize turns we published.
    /// `None` for an anonymous/unsigned client.
    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())
    }

    /// Open a hosted discussion anchored at `change_id`'s state, seeded with
    /// `body` as the first turn. Caller-authenticated + PoP-signed.
    #[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))
    }

    /// Append `body` as a new turn on an existing hosted discussion.
    /// Caller-authenticated + PoP-signed.
    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))
    }

    /// List hosted discussions anchored at `change_id`'s state. `status` is one
    /// of `open` | `resolved` | `all` | `orphaned`.
    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);

        // Missing optional fields collapse cleanly.
        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());
    }
}