diff --git a/docs/toml-schema.md b/docs/toml-schema.md index b801acd5e..403ada597 100644 --- a/docs/toml-schema.md +++ b/docs/toml-schema.md @@ -202,6 +202,20 @@ excluded-people = [ "rylev", ] +# Define Zulip streams owned +# It's optional, and there can be more than one +[[zulip-streams]] +# The name of the Zulip stream (required) +name = "t-overlords" +# Zulip groups that will have access to the stream if it is private (optional) +groups = ["T-overlords"] +# Visibility of the stream (optional, default = "public") +# Possible values: +# "public": a web-public stream readable by anyone even without Zulip login +# "private-shared": a private stream with a shared history (joining the stream reveals you the whole history) +# "private-protected": a private stream with a protected history (joining the stream doesn't reveal its history) +visibility = "public" + # Roles to define in Discord. [[discord-roles]] # The name of the role. diff --git a/rust_team_data/src/v1.rs b/rust_team_data/src/v1.rs index 2ca2c063d..d0ce9c61c 100644 --- a/rust_team_data/src/v1.rs +++ b/rust_team_data/src/v1.rs @@ -123,6 +123,26 @@ pub struct ZulipGroups { pub groups: IndexMap, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ZulipStream { + pub name: String, + pub groups: Vec, + pub visibility: ZulipStreamVisibility, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum ZulipStreamVisibility { + WebPublic, + PrivateSharedHistory, + PrivateProtectedHistory, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ZulipStreams { + pub streams: IndexMap, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Permission { pub github_users: Vec, diff --git a/src/data.rs b/src/data.rs index 6ea3b5df4..7b8eb12a5 100644 --- a/src/data.rs +++ b/src/data.rs @@ -1,5 +1,6 @@ use crate::schema::{Config, List, Person, Repo, Team, ZulipGroup}; use anyhow::{bail, Context as _, Error}; +use rust_team_data::v1::ZulipStream; use serde::de::DeserializeOwned; use std::collections::{HashMap, HashSet}; use std::ffi::OsStr; @@ -112,6 +113,16 @@ impl Data { Ok(groups) } + pub(crate) fn zulip_streams(&self) -> Result, Error> { + let mut streams = HashMap::new(); + for team in self.teams() { + for stream in team.zulip_streams()? { + streams.insert(stream.name.clone(), stream); + } + } + Ok(streams) + } + pub(crate) fn team(&self, name: &str) -> Option<&Team> { self.teams.get(name) } diff --git a/src/schema.rs b/src/schema.rs index d6bd70b22..681920040 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,6 +1,7 @@ use crate::data::Data; pub(crate) use crate::permissions::Permissions; use anyhow::{bail, format_err, Error}; +use rust_team_data::v1::{ZulipStream, ZulipStreamVisibility}; use serde::de::{Deserialize, Deserializer}; use serde_untagged::UntaggedEnumVisitor; use std::collections::{HashMap, HashSet}; @@ -172,6 +173,8 @@ pub(crate) struct Team { lists: Vec, #[serde(default)] zulip_groups: Vec, + #[serde(default)] + zulip_streams: Vec, discord_roles: Option>, } @@ -410,6 +413,27 @@ impl Team { Ok(groups) } + pub(crate) fn zulip_streams(&self) -> Result, Error> { + let streams = self + .zulip_streams + .iter() + .map(|stream| ZulipStream { + name: stream.name.clone(), + groups: stream.groups.clone(), + visibility: match stream.visibility { + RawZulipVisibility::Public => ZulipStreamVisibility::WebPublic, + RawZulipVisibility::PrivateShared => { + ZulipStreamVisibility::PrivateSharedHistory + } + RawZulipVisibility::PrivateProtected => { + ZulipStreamVisibility::PrivateProtectedHistory + } + }, + }) + .collect(); + Ok(streams) + } + pub(crate) fn permissions(&self) -> &Permissions { &self.permissions } @@ -680,6 +704,28 @@ pub(crate) struct RawZulipGroup { pub(crate) excluded_people: Vec, } +#[derive(serde_derive::Deserialize, Debug)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub(crate) struct RawZulipStream { + pub(crate) name: String, + #[serde(default)] + pub(crate) groups: Vec, + #[serde(default = "default_zulip_stream_visibility")] + pub(crate) visibility: RawZulipVisibility, +} + +fn default_zulip_stream_visibility() -> RawZulipVisibility { + RawZulipVisibility::Public +} + +#[derive(serde_derive::Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum RawZulipVisibility { + Public, + PrivateShared, + PrivateProtected, +} + #[derive(Debug)] pub(crate) struct List { address: String, diff --git a/src/static_api.rs b/src/static_api.rs index 4a49f0cc6..e82770e68 100644 --- a/src/static_api.rs +++ b/src/static_api.rs @@ -4,6 +4,7 @@ use anyhow::{ensure, Context as _, Error}; use indexmap::IndexMap; use log::info; use rust_team_data::v1; +use rust_team_data::v1::ZulipStream; use std::collections::HashMap; use std::path::Path; @@ -27,6 +28,7 @@ impl<'a> Generator<'a> { self.generate_repos()?; self.generate_lists()?; self.generate_zulip_groups()?; + self.generate_zulip_streams()?; self.generate_permissions()?; self.generate_rfcbot()?; self.generate_zulip_map()?; @@ -272,6 +274,33 @@ impl<'a> Generator<'a> { Ok(()) } + fn generate_zulip_streams(&self) -> Result<(), Error> { + let mut streams = IndexMap::new(); + + for stream in self.data.zulip_streams()?.values() { + let ZulipStream { + name, + groups, + visibility, + } = stream; + + let mut groups = groups.to_vec(); + groups.sort(); + streams.insert( + stream.name.clone(), + v1::ZulipStream { + name: name.clone(), + groups, + visibility: visibility.clone(), + }, + ); + } + + streams.sort_keys(); + self.add("v1/zulip-streams.json", &v1::ZulipStreams { streams })?; + Ok(()) + } + fn generate_permissions(&self) -> Result<(), Error> { for perm in &Permissions::available(self.data.config()) { let allowed = crate::permissions::allowed_people(self.data, perm)?; diff --git a/src/validate.rs b/src/validate.rs index 5357e4026..7d0e12472 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -44,6 +44,7 @@ static CHECKS: &[Check)>] = checks![ validate_discord_team_members_have_discord_ids, validate_zulip_group_ids, validate_zulip_group_extra_people, + validate_zulip_streams, validate_repos, validate_branch_protections, validate_member_roles, @@ -738,6 +739,39 @@ fn validate_zulip_group_extra_people(data: &Data, errors: &mut Vec) { }); } +/// Ensure that each group with access to a Zulip stream exists and that stream names are unique. +fn validate_zulip_streams(data: &Data, errors: &mut Vec) { + let mut stream_names: HashSet = HashSet::default(); + + wrapper(data.teams(), errors, |team, errors| { + let groups: HashSet = team + .zulip_groups(data)? + .into_iter() + .map(|g| g.name().to_string()) + .collect(); + wrapper(team.zulip_streams()?.iter(), errors, |stream, errors| { + // Check that mentioned Zulip groups exist + wrapper(stream.groups.iter(), errors, |group, _| { + if !groups.contains(group) { + bail!( + "Zulip group `{group}` in stream `{}` of team `{}` does not exist", + stream.name, + team.name() + ); + } + Ok(()) + }); + + if !stream_names.insert(stream.name.clone()) { + bail!("Zulip stream `{}` is duplicated", stream.name); + } + + Ok(()) + }); + Ok(()) + }); +} + /// Ensure repos reference valid teams fn validate_repos(data: &Data, errors: &mut Vec) { let allowed_orgs = data.config().allowed_github_orgs(); diff --git a/tests/static-api/_expected/v1/zulip-streams.json b/tests/static-api/_expected/v1/zulip-streams.json new file mode 100644 index 000000000..6e1440561 --- /dev/null +++ b/tests/static-api/_expected/v1/zulip-streams.json @@ -0,0 +1,11 @@ +{ + "streams": { + "t-foo": { + "name": "t-foo", + "groups": [ + "T-foo" + ], + "visibility": "private-shared-history" + } + } +} \ No newline at end of file diff --git a/tests/static-api/teams/foo.toml b/tests/static-api/teams/foo.toml index 4db1a8352..0df90901e 100644 --- a/tests/static-api/teams/foo.toml +++ b/tests/static-api/teams/foo.toml @@ -47,3 +47,8 @@ extra-teams = ["wg-test"] [[zulip-groups]] name = "T-foo" + +[[zulip-streams]] +name = "t-foo" +groups = ["T-foo"] +visibility = "private-shared"