Skip to content

Commit c9225b3

Browse files
committed
Sync crate ownership on crates.io
1 parent 1113bbb commit c9225b3

File tree

2 files changed

+216
-5
lines changed

2 files changed

+216
-5
lines changed

sync-team/src/crates_io/api.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,82 @@ impl CratesIoApi {
9090
Ok(configs)
9191
}
9292

93+
/// List owners of a given crate.
94+
pub(crate) fn list_crate_owners(&self, krate: &str) -> anyhow::Result<Vec<CratesIoOwner>> {
95+
#[derive(serde::Deserialize)]
96+
struct OwnersResponse {
97+
users: Vec<CratesIoOwner>,
98+
}
99+
100+
let response: OwnersResponse = self
101+
.req::<()>(
102+
reqwest::Method::GET,
103+
&format!("/crates/{krate}/owners"),
104+
HashMap::new(),
105+
None,
106+
)?
107+
.error_for_status()?
108+
.json_annotated()?;
109+
110+
Ok(response.users)
111+
}
112+
113+
/// Invite the specified user(s) or team(s) to own a given crate.
114+
pub(crate) fn invite_crate_owners(
115+
&self,
116+
krate: &str,
117+
owners: &[CratesIoOwner],
118+
) -> anyhow::Result<()> {
119+
debug!("Inviting owners {owners:?} to crate {krate}");
120+
121+
#[derive(serde::Serialize)]
122+
struct InviteOwnersRequest<'a> {
123+
owners: Vec<&'a str>,
124+
}
125+
126+
let owners = owners.iter().map(|o| o.login.as_str()).collect::<Vec<_>>();
127+
128+
if !self.dry_run {
129+
self.req(
130+
reqwest::Method::PUT,
131+
&format!("/crates/{krate}/owners"),
132+
HashMap::new(),
133+
Some(&InviteOwnersRequest { owners }),
134+
)?
135+
.error_for_status()?;
136+
}
137+
138+
Ok(())
139+
}
140+
141+
/// Delete the specified owner(s) of a given crate.
142+
pub(crate) fn delete_crate_owners(
143+
&self,
144+
krate: &str,
145+
owners: &[CratesIoOwner],
146+
) -> anyhow::Result<()> {
147+
debug!("Deleting owners {owners:?} from crate {krate}");
148+
149+
#[derive(serde::Serialize)]
150+
struct DeleteOwnersRequest<'a> {
151+
owners: &'a [&'a str],
152+
}
153+
154+
let owners = owners.iter().map(|o| o.login.as_str()).collect::<Vec<_>>();
155+
156+
if !self.dry_run {
157+
self.req(
158+
reqwest::Method::DELETE,
159+
&format!("/crates/{krate}/owners"),
160+
HashMap::new(),
161+
Some(&DeleteOwnersRequest { owners: &owners }),
162+
)?
163+
.error_for_status()
164+
.with_context(|| anyhow::anyhow!("Cannot delete owner(s) {owners:?} from krate {krate}"))?;
165+
}
166+
Ok(())
167+
}
168+
93169
/// Create a new trusted publishing configuration for a given crate.
94170
pub(crate) fn create_trusted_publishing_github_config(
95171
&self,
@@ -323,3 +399,33 @@ pub(crate) struct CratesIoCrate {
323399
#[serde(rename = "trustpub_only")]
324400
pub(crate) trusted_publishing_only: bool,
325401
}
402+
403+
#[derive(serde::Deserialize, Debug, PartialEq, Eq, Hash, Copy, Clone)]
404+
#[serde(rename_all = "kebab-case")]
405+
pub(crate) enum OwnerKind {
406+
User,
407+
Team,
408+
}
409+
410+
#[derive(serde::Deserialize, Debug, PartialEq, Eq, Hash, Clone)]
411+
pub(crate) struct CratesIoOwner {
412+
login: String,
413+
kind: OwnerKind,
414+
}
415+
416+
impl CratesIoOwner {
417+
pub(crate) fn team(org: String, name: String) -> Self {
418+
Self {
419+
login: format!("github:{org}:{name}"),
420+
kind: OwnerKind::Team,
421+
}
422+
}
423+
424+
pub(crate) fn kind(&self) -> OwnerKind {
425+
self.kind
426+
}
427+
428+
pub(crate) fn login(&self) -> &str {
429+
&self.login
430+
}
431+
}

sync-team/src/crates_io/mod.rs

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ mod api;
33
use crate::team_api::TeamApi;
44
use std::cmp::Ordering;
55

6-
use crate::crates_io::api::{CratesIoApi, CratesIoCrate, TrustedPublishingGitHubConfig, UserId};
6+
use crate::crates_io::api::{
7+
CratesIoApi, CratesIoCrate, CratesIoOwner, OwnerKind, TrustedPublishingGitHubConfig, UserId,
8+
};
79
use anyhow::Context;
810
use secrecy::SecretString;
9-
use std::collections::BTreeMap;
11+
use std::collections::{BTreeMap, HashMap, HashSet};
1012
use std::fmt::{Display, Formatter};
1113

1214
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
@@ -18,6 +20,12 @@ impl Display for CrateName {
1820
}
1921
}
2022

23+
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
24+
struct TeamOwner {
25+
org: String,
26+
name: String,
27+
}
28+
2129
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
2230
struct CrateConfig {
2331
krate: CrateName,
@@ -26,6 +34,7 @@ struct CrateConfig {
2634
workflow_file: String,
2735
environment: String,
2836
trusted_publishing_only: bool,
37+
teams: Vec<TeamOwner>,
2938
}
3039

3140
pub(crate) struct SyncCratesIo {
@@ -55,6 +64,7 @@ impl SyncCratesIo {
5564
let Some(publishing) = &krate.crates_io_publishing else {
5665
return None;
5766
};
67+
5868
Some((
5969
CrateName(krate.name.clone()),
6070
CrateConfig {
@@ -64,6 +74,15 @@ impl SyncCratesIo {
6474
workflow_file: publishing.workflow_file.clone(),
6575
environment: publishing.environment.clone(),
6676
trusted_publishing_only: krate.trusted_publishing_only,
77+
teams: krate
78+
.teams
79+
.clone()
80+
.into_iter()
81+
.map(|owner| TeamOwner {
82+
org: owner.org,
83+
name: owner.name,
84+
})
85+
.collect(),
6786
},
6887
))
6988
})
@@ -117,6 +136,7 @@ impl SyncCratesIo {
117136
// to enable doing a crates.io dry-run without a privileged token.
118137
// Because crates.io does not currently support read-only token
119138
if !is_ci_dry_run {
139+
// Sync trusted publishing configs
120140
let mut empty_vec = vec![];
121141
let configs = tp_configs.get_mut(&krate.0).unwrap_or(&mut empty_vec);
122142

@@ -153,6 +173,7 @@ impl SyncCratesIo {
153173
config_diffs.extend(configs.iter_mut().map(|c| ConfigDiff::Delete(c.clone())));
154174
}
155175

176+
// Sync "trusted publishing only" crate option
156177
let Some(crates_io_crate) = crates.get(&krate.0) else {
157178
return Err(anyhow::anyhow!(
158179
"Crate `{krate}` is not owned by user `{0}`. Please invite `{0}` to be its owner.",
@@ -165,6 +186,48 @@ impl SyncCratesIo {
165186
value: desired.trusted_publishing_only,
166187
});
167188
}
189+
190+
// Sync crate owners
191+
let owners = self
192+
.crates_io_api
193+
.list_crate_owners(&krate.0)
194+
.with_context(|| anyhow::anyhow!("Cannot list crate owners of {krate}"))?;
195+
196+
// Sync team owners
197+
let existing_teams: HashSet<CratesIoOwner> = owners
198+
.iter()
199+
.filter(|owner| match owner.kind() {
200+
OwnerKind::User => false,
201+
OwnerKind::Team => true,
202+
})
203+
.cloned()
204+
.collect();
205+
let target_teams: HashSet<CratesIoOwner> = desired
206+
.teams
207+
.iter()
208+
.map(|team| CratesIoOwner::team(team.org.clone(), team.name.clone()))
209+
.collect();
210+
let teams_to_add = target_teams
211+
.difference(&existing_teams)
212+
.cloned()
213+
.collect::<Vec<_>>();
214+
if !teams_to_add.is_empty() {
215+
crate_diffs.push(CrateDiff::AddOwners {
216+
krate: krate.to_string(),
217+
owners: teams_to_add,
218+
});
219+
}
220+
221+
let teams_to_remove = existing_teams
222+
.difference(&target_teams)
223+
.cloned()
224+
.collect::<Vec<_>>();
225+
if !teams_to_remove.is_empty() {
226+
crate_diffs.push(CrateDiff::RemoveOwners {
227+
krate: krate.to_string(),
228+
owners: teams_to_remove,
229+
});
230+
}
168231
}
169232

170233
// If any trusted publishing configs remained in the hashmap, they are leftover and should
@@ -174,8 +237,7 @@ impl SyncCratesIo {
174237
}
175238

176239
// We want to apply deletions first, and only then create new configs, to ensure that we
177-
// don't try to create a duplicate config where e.g. only the environment differs, which
178-
// would be an error in crates.io.
240+
// don't try to create a duplicate config where e.g. only the environment differs.
179241
config_diffs.sort_by(|a, b| match &(a, b) {
180242
(ConfigDiff::Delete(_), ConfigDiff::Create(_)) => Ordering::Less,
181243
(ConfigDiff::Create(_), ConfigDiff::Delete(_)) => Ordering::Greater,
@@ -302,7 +364,18 @@ impl std::fmt::Display for ConfigDiff {
302364
}
303365

304366
enum CrateDiff {
305-
SetTrustedPublishingOnly { krate: String, value: bool },
367+
SetTrustedPublishingOnly {
368+
krate: String,
369+
value: bool,
370+
},
371+
AddOwners {
372+
krate: String,
373+
owners: Vec<CratesIoOwner>,
374+
},
375+
RemoveOwners {
376+
krate: String,
377+
owners: Vec<CratesIoOwner>,
378+
},
306379
}
307380

308381
impl CrateDiff {
@@ -311,6 +384,12 @@ impl CrateDiff {
311384
Self::SetTrustedPublishingOnly { krate, value } => sync
312385
.crates_io_api
313386
.set_trusted_publishing_only(krate, *value),
387+
CrateDiff::AddOwners { krate, owners } => {
388+
sync.crates_io_api.invite_crate_owners(krate, owners)
389+
}
390+
CrateDiff::RemoveOwners { krate, owners } => {
391+
sync.crates_io_api.delete_crate_owners(krate, owners)
392+
}
314393
}
315394
}
316395
}
@@ -324,6 +403,32 @@ impl std::fmt::Display for CrateDiff {
324403
" Setting trusted publishing only option for krate `{krate}` to `{value}`",
325404
)?;
326405
}
406+
CrateDiff::AddOwners { krate, owners } => {
407+
for owner in owners {
408+
let kind = match owner.kind() {
409+
OwnerKind::User => "user",
410+
OwnerKind::Team => "team",
411+
};
412+
writeln!(
413+
f,
414+
" Adding `{kind}` owner `{}` to krate `{krate}`",
415+
owner.login()
416+
)?;
417+
}
418+
}
419+
CrateDiff::RemoveOwners { krate, owners } => {
420+
for owner in owners {
421+
let kind = match owner.kind() {
422+
OwnerKind::User => "user",
423+
OwnerKind::Team => "team",
424+
};
425+
writeln!(
426+
f,
427+
" Removing `{kind}` owner `{}` from krate `{krate}`",
428+
owner.login()
429+
)?;
430+
}
431+
}
327432
}
328433
Ok(())
329434
}

0 commit comments

Comments
 (0)