diff --git a/docs/toml-schema.md b/docs/toml-schema.md index 5916df29d..a55c4168a 100644 --- a/docs/toml-schema.md +++ b/docs/toml-schema.md @@ -242,6 +242,10 @@ excluded-people = [ [[zulip-streams]] # The name of the Zulip stream (required) name = "t-overlords/private" +# Whether newly subscribed members can access messages sent before they joined +# the stream. This is optional. If omitted, the current setting is left +# unchanged. +public-history = false # This can be set to false to avoid including all the team members in the stream # It's useful if you want to create the stream with a different set of members # It's optional, and the default is `true`. @@ -266,6 +270,9 @@ excluded-people = [ ] ``` +The `public-history` option corresponds to Zulip's +[`history_public_to_subscribers` channel setting](https://zulip.com/api/update-stream#parameter-history_public_to_subscribers). + ### Configuring Zulip streams > [!TIP] diff --git a/rust_team_data/src/v1.rs b/rust_team_data/src/v1.rs index 69339bee4..726bd2944 100644 --- a/rust_team_data/src/v1.rs +++ b/rust_team_data/src/v1.rs @@ -168,6 +168,8 @@ pub struct ZulipGroups { pub struct ZulipStream { pub name: String, pub members: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public_history: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/src/schema.rs b/src/schema.rs index f15bad48d..746a31707 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -497,17 +497,20 @@ impl Team { let zulip_streams = self.raw_zulip_streams(); for raw_stream in zulip_streams { - streams.push(ZulipStream(ZulipCommon { - name: raw_stream.common.name.clone(), - includes_team_members: raw_stream.common.include_team_members, - members: self.expand_zulip_membership( - data, - &raw_stream.common, - |excluded| { - format_err!("'{excluded}' was specifically excluded from the Zulip stream '{}' but they were already not included", raw_stream.common.name) - }, - )?, - })); + streams.push(ZulipStream { + common: ZulipCommon { + name: raw_stream.common.name.clone(), + includes_team_members: raw_stream.common.include_team_members, + members: self.expand_zulip_membership( + data, + &raw_stream.common, + |excluded| { + format_err!("'{excluded}' was specifically excluded from the Zulip stream '{}' but they were already not included", raw_stream.common.name) + }, + )?, + }, + public_history: raw_stream.public_history, + }); } Ok(streams) } @@ -758,6 +761,8 @@ pub(crate) struct RawZulipGroup { pub(crate) struct RawZulipStream { #[serde(flatten)] pub(crate) common: RawZulipCommon, + #[serde(default)] + pub(crate) public_history: Option, } #[derive(Debug)] @@ -809,12 +814,21 @@ impl std::ops::Deref for ZulipGroup { } #[derive(Debug)] -pub(crate) struct ZulipStream(ZulipCommon); +pub(crate) struct ZulipStream { + common: ZulipCommon, + public_history: Option, +} + +impl ZulipStream { + pub(crate) fn public_history(&self) -> Option { + self.public_history + } +} impl std::ops::Deref for ZulipStream { type Target = ZulipCommon; fn deref(&self) -> &Self::Target { - &self.0 + &self.common } } diff --git a/src/static_api.rs b/src/static_api.rs index 5ece47a91..f17cb5b78 100644 --- a/src/static_api.rs +++ b/src/static_api.rs @@ -345,6 +345,7 @@ impl<'a> Generator<'a> { ZulipMember::MemberWithoutId { .. } => None, }) .collect(), + public_history: stream.public_history(), }, ); } diff --git a/src/sync/zulip/api.rs b/src/sync/zulip/api.rs index 064bbb969..98bf64f58 100644 --- a/src/sync/zulip/api.rs +++ b/src/sync/zulip/api.rs @@ -265,6 +265,38 @@ impl ZulipApi { Ok(()) } + /// Set whether a Zulip stream's history is public to new subscribers. + /// + /// See . + pub(crate) async fn update_stream_public_history( + &self, + stream_id: u64, + public_history: bool, + ) -> anyhow::Result<()> { + log::info!("updating stream {stream_id} to public_history={public_history}"); + + if self.dry_run { + return Ok(()); + } + + let history_public_to_subscribers = public_history.to_string(); + let mut form = HashMap::new(); + form.insert( + "history_public_to_subscribers", + history_public_to_subscribers.as_str(), + ); + + self.req( + reqwest::Method::PATCH, + &format!("/streams/{stream_id}"), + Some(form), + ) + .await? + .error_for_status()?; + + Ok(()) + } + /// Perform a request against the Zulip API async fn req( &self, @@ -330,6 +362,7 @@ pub(crate) struct ZulipStream { pub(crate) stream_id: u64, pub(crate) name: String, pub(crate) invite_only: bool, + pub(crate) history_public_to_subscribers: bool, } /// Membership of a Zulip stream diff --git a/src/sync/zulip/mod.rs b/src/sync/zulip/mod.rs index 22ba10758..c3d116ce1 100644 --- a/src/sync/zulip/mod.rs +++ b/src/sync/zulip/mod.rs @@ -11,10 +11,15 @@ use std::collections::BTreeMap; pub(crate) struct SyncZulip { zulip_controller: ZulipController, - stream_definitions: BTreeMap>, + stream_definitions: BTreeMap, user_group_definitions: BTreeMap>, } +struct StreamDefinition { + member_ids: Vec, + public_history: Option, +} + impl SyncZulip { pub(crate) async fn new( username: String, @@ -41,8 +46,8 @@ impl SyncZulip { pub(crate) async fn diff_all(&self) -> anyhow::Result { let stream_membership_diffs = futures_util::stream::iter(&self.stream_definitions) - .filter_map(|(stream_name, member_ids)| async move { - self.diff_stream_membership(stream_name, member_ids) + .filter_map(|(stream_name, definition)| async move { + self.diff_stream_membership(stream_name, &definition.member_ids) .await .transpose() }) @@ -50,6 +55,14 @@ impl SyncZulip { .await .into_iter() .collect::>>()?; + let stream_history_diffs = self + .stream_definitions + .iter() + .filter_map(|(stream_name, definition)| { + self.diff_stream_history(stream_name, definition.public_history) + .transpose() + }) + .collect::>>()?; let user_group_diffs = self .user_group_definitions .iter() @@ -60,10 +73,39 @@ impl SyncZulip { .collect::>>()?; Ok(Diff { user_group_diffs, + stream_history_diffs, stream_membership_diffs, }) } + fn diff_stream_history( + &self, + stream_name: &str, + public_history: Option, + ) -> anyhow::Result> { + let Some(public_history) = public_history else { + return Ok(None); + }; + + let stream = self + .zulip_controller + .stream_from_name(stream_name) + .with_context(|| format!("no '{stream_name}' stream found on Zulip"))?; + if stream.history_public_to_subscribers == public_history { + log::debug!( + "'{stream_name}' stream ({}) already has public_history={public_history}", + stream.stream_id + ); + Ok(None) + } else { + Ok(Some(UpdateStreamHistoryDiff { + stream_name: stream_name.to_owned(), + stream_id: stream.stream_id, + public_history, + })) + } + } + fn diff_user_group( &self, user_group_name: &str, @@ -174,12 +216,12 @@ impl SyncZulip { } async fn add_rust_lang_owner_to_private_streams( - stream_definitions: &mut BTreeMap>, + stream_definitions: &mut BTreeMap, zulip_controller: &ZulipController, ) -> anyhow::Result<()> { // Id of the `rust-lang-owner` Zulip user. let rust_lang_owner_id = 494485; - for (stream_name, members) in stream_definitions { + for (stream_name, definition) in stream_definitions { let stream_id = zulip_controller .stream_id_from_name(stream_name) .with_context(|| { @@ -194,7 +236,7 @@ async fn add_rust_lang_owner_to_private_streams( .is_stream_private(stream_id) .await?; if is_stream_private { - members.insert(0, rust_lang_owner_id); + definition.member_ids.insert(0, rust_lang_owner_id); } } Ok(()) @@ -202,6 +244,7 @@ async fn add_rust_lang_owner_to_private_streams( pub(crate) struct Diff { user_group_diffs: Vec, + stream_history_diffs: Vec, stream_membership_diffs: Vec, } @@ -210,6 +253,9 @@ impl Diff { for user_group_diff in &self.user_group_diffs { user_group_diff.apply(sync).await?; } + for stream_history_diff in &self.stream_history_diffs { + stream_history_diff.apply(sync).await?; + } for stream_membership_diff in &self.stream_membership_diffs { stream_membership_diff.apply(sync).await?; } @@ -217,7 +263,14 @@ impl Diff { } pub(crate) fn is_empty(&self) -> bool { - self.user_group_diffs.is_empty() && self.stream_membership_diffs.is_empty() + let Self { + user_group_diffs, + stream_history_diffs, + stream_membership_diffs, + } = self; + user_group_diffs.is_empty() + && stream_history_diffs.is_empty() + && stream_membership_diffs.is_empty() } } @@ -230,6 +283,13 @@ impl std::fmt::Display for Diff { } } + if !&self.stream_history_diffs.is_empty() { + writeln!(f, "💻 Stream History Diffs:")?; + for stream_history_diff in &self.stream_history_diffs { + write!(f, "{stream_history_diff}")?; + } + } + if !&self.stream_membership_diffs.is_empty() { writeln!(f, "💻 Stream Membership Diffs:")?; for stream_membership_diff in &self.stream_membership_diffs { @@ -241,6 +301,30 @@ impl std::fmt::Display for Diff { } } +struct UpdateStreamHistoryDiff { + stream_name: String, + stream_id: u64, + public_history: bool, +} + +impl UpdateStreamHistoryDiff { + async fn apply(&self, sync: &SyncZulip) -> anyhow::Result<()> { + sync.zulip_controller + .zulip_api + .update_stream_public_history(self.stream_id, self.public_history) + .await + } +} + +impl std::fmt::Display for UpdateStreamHistoryDiff { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "📝 Updating stream history:")?; + writeln!(f, " Name: {}", self.stream_name)?; + writeln!(f, " ID: {}", self.stream_id)?; + writeln!(f, " Public history: {}", self.public_history) + } +} + enum StreamMembershipDiff { Update(UpdateStreamMembershipDiff), } @@ -424,7 +508,7 @@ async fn get_user_group_definitions( async fn get_stream_definitions( team_api: &TeamApi, zulip_api: &ZulipApi, -) -> anyhow::Result>> { +) -> anyhow::Result> { let email_map = zulip_api .get_users() .await? @@ -451,7 +535,13 @@ async fn get_stream_definitions( ZulipStreamMember::Id(id) => Some(*id), }) .collect::>(); - (name, member_ids) + ( + name, + StreamDefinition { + member_ids, + public_history: stream.public_history, + }, + ) }) .collect(); Ok(stream_definitions) @@ -503,6 +593,10 @@ impl ZulipController { self.stream_ids.get(stream_name).map(|st| st.stream_id) } + fn stream_from_name(&self, stream_name: &str) -> Option<&ZulipStream> { + self.stream_ids.get(stream_name) + } + /// Create a user group with a certain name, description, and members async fn create_user_group( &self, diff --git a/teams/rust-analyzer.toml b/teams/rust-analyzer.toml index c1bfd5be4..27830c1e5 100644 --- a/teams/rust-analyzer.toml +++ b/teams/rust-analyzer.toml @@ -40,3 +40,4 @@ name = "T-rust-analyzer" [[zulip-streams]] name = "t-compiler/rust-analyzer-private" +public-history = false diff --git a/tests/static-api/_expected/v1/zulip-streams.json b/tests/static-api/_expected/v1/zulip-streams.json index d2a81e0fa..8f5f09266 100644 --- a/tests/static-api/_expected/v1/zulip-streams.json +++ b/tests/static-api/_expected/v1/zulip-streams.json @@ -9,7 +9,8 @@ { "id": 4321 } - ] + ], + "public_history": false } } } diff --git a/tests/static-api/teams/foo.toml b/tests/static-api/teams/foo.toml index 9af613b23..90ed67e15 100644 --- a/tests/static-api/teams/foo.toml +++ b/tests/static-api/teams/foo.toml @@ -50,3 +50,4 @@ name = "T-foo" [[zulip-streams]] name = "t-foo/private" +public-history = false