| 1 | //! Packages over REST and MCP, at GitHub's addresses with the workspace in |
| 2 | //! place of the organization: a workspace's packages and their versions, |
| 3 | //! deleting and restoring them, their visibility and repository, who has a |
| 4 | //! role on them, and which repositories' workflows may use them (Manage |
| 5 | //! Actions access). |
| 6 | //! |
| 7 | //! The packages service decides who may do what (`g1t_contracts::packages`) |
| 8 | //! and records the audit entries; this is its public shape, in snake_case. |
| 9 | //! A package is named by its type (`container`, `npm`, `cargo`, `maven`, |
| 10 | //! `nuget`, `rubygems`, `composer`) and its name, URL-encoded when it holds |
| 11 | //! a slash (`web%2Fworker`). |
| 12 | |
| 13 | use g1t_contracts::packages::*; |
| 14 | use g1t_contracts::{FailureCode, Outcome, User, Viewer}; |
| 15 | use serde::de::DeserializeOwned; |
| 16 | use serde::Serialize; |
| 17 | use serde_json::{Value, json}; |
| 18 | use worker::Result; |
| 19 | |
| 20 | use crate::operations::Services; |
| 21 | |
| 22 | /// One operation on packages. |
| 23 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 24 | pub enum PackagesOp { |
| 25 | ListPackages, |
| 26 | GetPackage, |
| 27 | ListVersions, |
| 28 | GetVersion, |
| 29 | ListAccess, |
| 30 | ListActionsAccess, |
| 31 | UpdatePackage, |
| 32 | LinkPackage, |
| 33 | UnlinkPackage, |
| 34 | SetAccess, |
| 35 | RemoveAccess, |
| 36 | SetActionsAccess, |
| 37 | RemoveActionsAccess, |
| 38 | DeletePackage, |
| 39 | RestorePackage, |
| 40 | DeleteVersion, |
| 41 | RestoreVersion, |
| 42 | } |
| 43 | |
| 44 | impl PackagesOp { |
| 45 | /// Every one: `Op::ALL` lists each as `Op::Packages(…)`, which a test |
| 46 | /// checks against this. |
| 47 | #[cfg(test)] |
| 48 | pub const ALL: [PackagesOp; 17] = [ |
| 49 | PackagesOp::ListPackages, |
| 50 | PackagesOp::GetPackage, |
| 51 | PackagesOp::ListVersions, |
| 52 | PackagesOp::GetVersion, |
| 53 | PackagesOp::ListAccess, |
| 54 | PackagesOp::ListActionsAccess, |
| 55 | PackagesOp::UpdatePackage, |
| 56 | PackagesOp::LinkPackage, |
| 57 | PackagesOp::UnlinkPackage, |
| 58 | PackagesOp::SetAccess, |
| 59 | PackagesOp::RemoveAccess, |
| 60 | PackagesOp::SetActionsAccess, |
| 61 | PackagesOp::RemoveActionsAccess, |
| 62 | PackagesOp::DeletePackage, |
| 63 | PackagesOp::RestorePackage, |
| 64 | PackagesOp::DeleteVersion, |
| 65 | PackagesOp::RestoreVersion, |
| 66 | ]; |
| 67 | |
| 68 | pub fn name(self) -> &'static str { |
| 69 | match self { |
| 70 | PackagesOp::ListPackages => "list_packages", |
| 71 | PackagesOp::GetPackage => "get_package", |
| 72 | PackagesOp::ListVersions => "list_package_versions", |
| 73 | PackagesOp::GetVersion => "get_package_version", |
| 74 | PackagesOp::ListAccess => "list_package_access", |
| 75 | PackagesOp::ListActionsAccess => "list_package_actions_access", |
| 76 | PackagesOp::UpdatePackage => "update_package", |
| 77 | PackagesOp::LinkPackage => "link_package", |
| 78 | PackagesOp::UnlinkPackage => "unlink_package", |
| 79 | PackagesOp::SetAccess => "set_package_access", |
| 80 | PackagesOp::RemoveAccess => "remove_package_access", |
| 81 | PackagesOp::SetActionsAccess => "set_package_actions_access", |
| 82 | PackagesOp::RemoveActionsAccess => "remove_package_actions_access", |
| 83 | PackagesOp::DeletePackage => "delete_package", |
| 84 | PackagesOp::RestorePackage => "restore_package", |
| 85 | PackagesOp::DeleteVersion => "delete_package_version", |
| 86 | PackagesOp::RestoreVersion => "restore_package_version", |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | /// For the API reference. |
| 91 | pub fn title(self) -> &'static str { |
| 92 | match self { |
| 93 | PackagesOp::ListPackages => "List a workspace's packages", |
| 94 | PackagesOp::GetPackage => "Get a package", |
| 95 | PackagesOp::ListVersions => "List a package's versions", |
| 96 | PackagesOp::GetVersion => "Get a package version", |
| 97 | PackagesOp::ListAccess => "List who has access to a package", |
| 98 | PackagesOp::ListActionsAccess => "List a package's Actions access", |
| 99 | PackagesOp::UpdatePackage => "Update a package", |
| 100 | PackagesOp::LinkPackage => "Link a package to a repository", |
| 101 | PackagesOp::UnlinkPackage => "Unlink a package from its repository", |
| 102 | PackagesOp::SetAccess => "Give a person or team a role on a package", |
| 103 | PackagesOp::RemoveAccess => "Remove a person's or team's role on a package", |
| 104 | PackagesOp::SetActionsAccess => "Give a repository's workflows access to a package", |
| 105 | PackagesOp::RemoveActionsAccess => "Remove a repository's Actions access to a package", |
| 106 | PackagesOp::DeletePackage => "Delete a package", |
| 107 | PackagesOp::RestorePackage => "Restore a package", |
| 108 | PackagesOp::DeleteVersion => "Delete a package version", |
| 109 | PackagesOp::RestoreVersion => "Restore a package version", |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | pub fn description(self) -> &'static str { |
| 114 | match self { |
| 115 | PackagesOp::ListPackages => "List a workspace's packages you may pull, most recently updated first: each with its id, name, package_type, address (what a client is given, such as g1t.sh/acme/web), visibility, the repository it is linked to, version_count, latest, size_in_bytes, download_count, inherit_access and html_url. Narrow with package_type and q (part of the name). With state deleted, its deleted packages that can still be restored instead, those you administer, each with deleted_at, deleted_by and purge_at. Public packages are open to anyone.", |
| 116 | PackagesOp::GetPackage => "Get one package by its package_type and package_name (URL-encode a slash in a REST path: web%2Fworker). Not found when you may not pull it, as for one that does not exist.", |
| 117 | PackagesOp::ListVersions => "List a package's versions, newest first: each with its id (ver_…), name (the version, or for a container image its digest), digest, size_in_bytes, download_count, tags, media_type, platforms, published_by and created_at. With state deleted, its deleted versions that can still be restored, with deleted_at, deleted_by and purge_at: for the package's admins only.", |
| 118 | PackagesOp::GetVersion => "Get one version of a package by its id (ver_…), its version, its digest, or a tag that points to it.", |
| 119 | PackagesOp::ListAccess => "Who has a role on a package itself (read pulls, write publishes, admin deletes, restores and changes its settings), people and teams, with inherit_access: whether a linked package also takes its repository's roles. Owners of the workspace administer every package. For the package's admins.", |
| 120 | PackagesOp::ListActionsAccess => "Which repositories' workflows may use a package with their job token (G1T_TOKEN), with the read or write role: its linked repository (linked, always write) and those added under Manage Actions access. A job's token from any other repository is refused. For the package's admins.", |
| 121 | PackagesOp::UpdatePackage => "Change a package's visibility (public or private: an unlinked package only, as a linked one has its repository's) or, for a linked package, inherit_access: whether it takes its repository's roles. Off, only the roles given on the package itself and the workspace's owners count. Takes the Admin role on the package. Returns the package.", |
| 122 | PackagesOp::LinkPackage => "Link a package to a repository of its workspace (repository: its name or owner/name): it then has that repository's visibility and, unless inherit_access is off, its roles, and the repository's workflows may publish it. Takes the Admin role on the package and on the repository. Returns the package.", |
| 123 | PackagesOp::UnlinkPackage => "Unlink a package from its repository: it is then the workspace's, private until someone makes it public, and the repository's workflows lose their access unless it is added under Manage Actions access. Takes the Admin role on the package. Returns the package.", |
| 124 | PackagesOp::SetAccess => "Give a person (username) or a team of the workspace (team: its slug) the read, write or admin role on a package, or change theirs. It adds to what its repository or workspace gives them. Takes the Admin role on the package. Returns everyone with a role on it.", |
| 125 | PackagesOp::RemoveAccess => "Take a person's (username) or team's (team) role on a package away. What its repository or workspace gives them stays. Takes the Admin role on the package. Returns everyone left with a role on it.", |
| 126 | PackagesOp::SetActionsAccess => "Let a repository of the package's workspace (repository: its name or owner/name) use the package from its workflows, with the read or write role, or change its role. Takes the Admin role on the package. Returns the package's Actions access.", |
| 127 | PackagesOp::RemoveActionsAccess => "Stop a repository's workflows using a package. The linked repository's access cannot be removed: unlink the package instead. Takes the Admin role on the package. Returns the package's Actions access.", |
| 128 | PackagesOp::DeletePackage => "Delete a package and every version: it is gone from the registries at once, and can be restored for 30 days, during which its name cannot be taken. Takes the Admin role on the package.", |
| 129 | PackagesOp::RestorePackage => "Restore a deleted package, with the versions it had, while it can be (30 days after it was deleted). Takes the Admin role on it. Returns the package.", |
| 130 | PackagesOp::DeleteVersion => "Delete one version by its id, version, digest or a tag that points to it: it is gone from the registries at once, with its tags, and can be restored for 30 days. Its version (or digest) cannot be published again until then. Composer versions follow their repository's tags: delete the tag instead. Takes the Admin role on the package.", |
| 131 | PackagesOp::RestoreVersion => "Restore a deleted version by its id or version, while it can be (30 days after it was deleted), with the tags that still pointed to it. Takes the Admin role on the package. Returns the version.", |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /// Whether it changes anything. |
| 136 | #[cfg(test)] |
| 137 | pub fn writes(self) -> bool { |
| 138 | !matches!( |
| 139 | self, |
| 140 | PackagesOp::ListPackages |
| 141 | | PackagesOp::GetPackage |
| 142 | | PackagesOp::ListVersions |
| 143 | | PackagesOp::GetVersion |
| 144 | | PackagesOp::ListAccess |
| 145 | | PackagesOp::ListActionsAccess |
| 146 | ) |
| 147 | } |
| 148 | |
| 149 | /// Whether anyone may call it, signed in or not: reading public packages. |
| 150 | pub fn anonymous(self) -> bool { |
| 151 | matches!(self, PackagesOp::ListPackages | PackagesOp::GetPackage | PackagesOp::ListVersions | PackagesOp::GetVersion) |
| 152 | } |
| 153 | |
| 154 | pub fn input(self) -> Value { |
| 155 | let workspace = json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." }); |
| 156 | let package_type = json!({ |
| 157 | "type": "string", |
| 158 | "enum": ["container", "npm", "cargo", "maven", "nuget", "rubygems", "composer"], |
| 159 | "description": "The registry: container (also docker), npm, cargo, maven, nuget, rubygems or composer.", |
| 160 | }); |
| 161 | let package_name = json!({ "type": "string", "description": "The package's name without the workspace: web for g1t.sh/acme/web, web/worker for an image with more parts, group:artifact for Maven." }); |
| 162 | let version_id = json!({ "type": "string", "description": "The version's id (ver_…), its version, its digest, or a tag that points to it." }); |
| 163 | let role = |roles: &[&str]| json!({ "type": "string", "enum": roles, "description": "read pulls, write publishes, admin deletes, restores and changes its settings." }); |
| 164 | let package = |mut properties: Value| { |
| 165 | properties["workspace"] = workspace.clone(); |
| 166 | properties["package_type"] = package_type.clone(); |
| 167 | properties["package_name"] = package_name.clone(); |
| 168 | properties |
| 169 | }; |
| 170 | let base = ["workspace", "package_type", "package_name"]; |
| 171 | let (properties, required): (Value, Vec<&str>) = match self { |
| 172 | PackagesOp::ListPackages => ( |
| 173 | json!({ |
| 174 | "workspace": workspace, |
| 175 | "package_type": package_type, |
| 176 | "q": { "type": "string", "description": "Only packages whose name holds this." }, |
| 177 | "state": { "type": "string", "enum": ["active", "deleted"], "description": "active (the default), or deleted: deleted packages that can still be restored." }, |
| 178 | }), |
| 179 | vec!["workspace"], |
| 180 | ), |
| 181 | PackagesOp::GetPackage |
| 182 | | PackagesOp::ListAccess |
| 183 | | PackagesOp::ListActionsAccess |
| 184 | | PackagesOp::UnlinkPackage |
| 185 | | PackagesOp::DeletePackage |
| 186 | | PackagesOp::RestorePackage => (package(json!({})), base.to_vec()), |
| 187 | PackagesOp::ListVersions => ( |
| 188 | package(json!({ |
| 189 | "state": { "type": "string", "enum": ["active", "deleted"], "description": "active (the default), or deleted: deleted versions that can still be restored." }, |
| 190 | })), |
| 191 | base.to_vec(), |
| 192 | ), |
| 193 | PackagesOp::GetVersion | PackagesOp::DeleteVersion | PackagesOp::RestoreVersion => { |
| 194 | (package(json!({ "version_id": version_id })), [base.as_slice(), &["version_id"]].concat()) |
| 195 | } |
| 196 | PackagesOp::UpdatePackage => ( |
| 197 | package(json!({ |
| 198 | "visibility": { "type": "string", "enum": ["public", "private"], "description": "Who may pull an unlinked package: anyone, or the workspace's members by its base permission." }, |
| 199 | "inherit_access": { "type": "boolean", "description": "For a linked package: whether it takes its repository's roles." }, |
| 200 | })), |
| 201 | base.to_vec(), |
| 202 | ), |
| 203 | PackagesOp::LinkPackage => ( |
| 204 | package(json!({ "repository": { "type": "string", "description": "A repository of the package's workspace: its name, or owner/name." } })), |
| 205 | [base.as_slice(), &["repository"]].concat(), |
| 206 | ), |
| 207 | PackagesOp::SetAccess => ( |
| 208 | package(json!({ |
| 209 | "username": { "type": "string", "description": "The person's username. Give this or team." }, |
| 210 | "team": { "type": "string", "description": "A team of the workspace: its slug, or workspace/slug. Give this or username." }, |
| 211 | "role": role(&["read", "write", "admin"]), |
| 212 | })), |
| 213 | [base.as_slice(), &["role"]].concat(), |
| 214 | ), |
| 215 | PackagesOp::RemoveAccess => ( |
| 216 | package(json!({ |
| 217 | "username": { "type": "string", "description": "The person's username. Give this or team." }, |
| 218 | "team": { "type": "string", "description": "The team's slug, or workspace/slug. Give this or username." }, |
| 219 | })), |
| 220 | base.to_vec(), |
| 221 | ), |
| 222 | PackagesOp::SetActionsAccess => ( |
| 223 | package(json!({ |
| 224 | "repository": { "type": "string", "description": "A repository of the package's workspace: its name, or owner/name." }, |
| 225 | "role": json!({ "type": "string", "enum": ["read", "write"], "description": "read pulls the package from the repository's workflows; write publishes it too." }), |
| 226 | })), |
| 227 | [base.as_slice(), &["repository", "role"]].concat(), |
| 228 | ), |
| 229 | PackagesOp::RemoveActionsAccess => ( |
| 230 | package(json!({ "repository": { "type": "string", "description": "The repository: its name, or owner/name." } })), |
| 231 | [base.as_slice(), &["repository"]].concat(), |
| 232 | ), |
| 233 | }; |
| 234 | json!({ "type": "object", "properties": properties, "required": required }) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | fn text(input: &Value, key: &str) -> Option<String> { |
| 239 | input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned) |
| 240 | } |
| 241 | |
| 242 | /// The registry `package_type` names: GitHub's `docker` is a container |
| 243 | /// image too. |
| 244 | pub(crate) fn ecosystem(text: &str) -> Option<Ecosystem> { |
| 245 | match text.trim().to_ascii_lowercase().as_str() { |
| 246 | "docker" | "container" | "oci" => Some(Ecosystem::Container), |
| 247 | other => Ecosystem::parse(other).filter(|ecosystem| *ecosystem != Ecosystem::Go), |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | /// The page of a package on the site. |
| 252 | fn html_url(site: &str, package: &PackageSummary) -> String { |
| 253 | format!( |
| 254 | "{}/{}/-/packages/{}/{}", |
| 255 | site.trim_end_matches('/'), |
| 256 | package.workspace, |
| 257 | package.ecosystem.as_str(), |
| 258 | package.name |
| 259 | ) |
| 260 | } |
| 261 | |
| 262 | /// A package as the API shows one. |
| 263 | pub fn package_json(package: &PackageSummary, site: &str) -> Value { |
| 264 | json!({ |
| 265 | "id": package.id, |
| 266 | "name": package.name, |
| 267 | "package_type": package.ecosystem.as_str(), |
| 268 | "workspace": package.workspace, |
| 269 | "address": package.address, |
| 270 | "visibility": package.visibility.as_str(), |
| 271 | "repository": package.repo.as_ref().map(|repo| json!({ |
| 272 | "id": repo.id, |
| 273 | "name": repo.name, |
| 274 | "full_name": format!("{}/{}", repo.namespace, repo.name), |
| 275 | })), |
| 276 | "description": package.description, |
| 277 | "version_count": package.versions, |
| 278 | "latest": package.latest, |
| 279 | "size_in_bytes": package.size, |
| 280 | "download_count": package.downloads, |
| 281 | "inherit_access": package.inherit_access, |
| 282 | "created_at": package.created_at, |
| 283 | "updated_at": package.updated_at, |
| 284 | "deleted_at": package.deleted_at, |
| 285 | "deleted_by": package.deleted_by, |
| 286 | "purge_at": package.purge_at, |
| 287 | "html_url": html_url(site, package), |
| 288 | }) |
| 289 | } |
| 290 | |
| 291 | /// A version as the API shows one. |
| 292 | pub fn version_json(version: &PackageVersion) -> Value { |
| 293 | json!({ |
| 294 | "id": version.id, |
| 295 | "name": version.version, |
| 296 | "digest": version.digest, |
| 297 | "size_in_bytes": version.size, |
| 298 | "download_count": version.downloads.unwrap_or(0), |
| 299 | "tags": version.tags, |
| 300 | "media_type": version.media_type, |
| 301 | "artifact_type": version.artifact_type, |
| 302 | "subject": version.subject, |
| 303 | "platforms": version.platforms, |
| 304 | "published_by": version.published_by, |
| 305 | "created_at": version.published_at, |
| 306 | "deprecated": version.deprecated, |
| 307 | "deleted_at": version.deleted_at, |
| 308 | "deleted_by": version.deleted_by, |
| 309 | "purge_at": version.purge_at, |
| 310 | }) |
| 311 | } |
| 312 | |
| 313 | fn access_json(access: &[PackageAccess]) -> Value { |
| 314 | Value::Array( |
| 315 | access |
| 316 | .iter() |
| 317 | .map(|entry| json!({ "type": entry.kind.as_str(), "id": entry.id, "name": entry.name, "role": entry.role.as_str(), "created_at": entry.created_at })) |
| 318 | .collect(), |
| 319 | ) |
| 320 | } |
| 321 | |
| 322 | fn actions_json(access: &[ActionsAccess]) -> Value { |
| 323 | Value::Array( |
| 324 | access |
| 325 | .iter() |
| 326 | .map(|entry| json!({ "repository_id": entry.repo_id, "repository": entry.repo, "role": entry.role.as_str(), "linked": entry.linked, "created_at": entry.created_at })) |
| 327 | .collect(), |
| 328 | ) |
| 329 | } |
| 330 | |
| 331 | fn mapped<T>(outcome: Outcome<T>, f: impl FnOnce(T) -> Value) -> Outcome<Value> { |
| 332 | match outcome { |
| 333 | Outcome::Ok(value) => Outcome::Ok(f(value)), |
| 334 | Outcome::Fail(refused) => Outcome::Fail(refused), |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | async fn call<T: DeserializeOwned>(services: &Services, method: &str, args: &impl Serialize) -> Result<Outcome<T>> { |
| 339 | g1t_kit::call(&services.packages, method, args).await |
| 340 | } |
| 341 | |
| 342 | /// The person making a change, or the refusal for nobody. |
| 343 | fn actor(viewer: &Viewer) -> std::result::Result<User, Outcome<Value>> { |
| 344 | viewer.clone().ok_or_else(|| Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token.")) |
| 345 | } |
| 346 | |
| 347 | pub async fn run(op: PackagesOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 348 | let site = services.addresses.site.clone(); |
| 349 | let Some(workspace) = text(input, "workspace").map(|w| w.to_lowercase()) else { |
| 350 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the workspace's slug.")); |
| 351 | }; |
| 352 | if op == PackagesOp::ListPackages { |
| 353 | let ecosystem = match text(input, "package_type") { |
| 354 | Some(given) => match ecosystem(&given) { |
| 355 | Some(found) => Some(found), |
| 356 | None => return Ok(Outcome::fail(FailureCode::Invalid, format!("{given} is not a package type: container, npm, cargo, maven, nuget, rubygems or composer."))), |
| 357 | }, |
| 358 | None => None, |
| 359 | }; |
| 360 | let found: Outcome<Vec<PackageSummary>> = if text(input, "state").as_deref() == Some("deleted") { |
| 361 | call(services, "deleted_packages", &DeletedPackagesArgs { workspace, viewer: viewer.clone() }).await? |
| 362 | } else { |
| 363 | let args = ListPackagesArgs { workspace, viewer: viewer.clone(), ecosystem, repo_id: None, query: text(input, "q") }; |
| 364 | call(services, "list_packages", &args).await? |
| 365 | }; |
| 366 | return Ok(mapped(found, |list| { |
| 367 | Value::Array(list.iter().filter(|p| ecosystem.is_none_or(|e| e == p.ecosystem)).map(|p| package_json(p, &site)).collect()) |
| 368 | })); |
| 369 | } |
| 370 | let Some(given) = text(input, "package_type") else { |
| 371 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the package_type: container, npm, cargo, maven, nuget, rubygems or composer.")); |
| 372 | }; |
| 373 | let Some(ecosystem) = ecosystem(&given) else { |
| 374 | return Ok(Outcome::fail(FailureCode::Invalid, format!("{given} is not a package type: container, npm, cargo, maven, nuget, rubygems or composer."))); |
| 375 | }; |
| 376 | let Some(name) = text(input, "package_name") else { |
| 377 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the package_name.")); |
| 378 | }; |
| 379 | let surface = Some(services.audit.surface); |
| 380 | let version = text(input, "version_id").unwrap_or_default(); |
| 381 | if matches!(op, PackagesOp::GetVersion | PackagesOp::DeleteVersion | PackagesOp::RestoreVersion) && version.is_empty() { |
| 382 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the version_id: the version's id, version, digest or a tag.")); |
| 383 | } |
| 384 | let changed = |outcome: Outcome<PackageSummary>| mapped(outcome, |p| package_json(&p, &site)); |
| 385 | macro_rules! actor { |
| 386 | () => { |
| 387 | match actor(viewer) { |
| 388 | Ok(actor) => actor, |
| 389 | Err(refused) => return Ok(refused), |
| 390 | } |
| 391 | }; |
| 392 | } |
| 393 | Ok(match op { |
| 394 | PackagesOp::ListPackages => unreachable!("answered above"), |
| 395 | PackagesOp::GetPackage => { |
| 396 | let found: Outcome<PackageDetail> = call(services, "get_package", &GetPackageArgs { workspace, ecosystem, name, viewer: viewer.clone() }).await?; |
| 397 | mapped(found, |detail| package_json(&detail.package, &site)) |
| 398 | } |
| 399 | PackagesOp::ListVersions => { |
| 400 | let deleted = text(input, "state").as_deref() == Some("deleted"); |
| 401 | let found: Outcome<Vec<PackageVersion>> = |
| 402 | call(services, "list_versions", &ListVersionsArgs { workspace, ecosystem, name, viewer: viewer.clone(), deleted }).await?; |
| 403 | mapped(found, |list| Value::Array(list.iter().map(version_json).collect())) |
| 404 | } |
| 405 | PackagesOp::GetVersion => { |
| 406 | let found: Outcome<PackageVersion> = call(services, "get_version", &GetVersionArgs { workspace, ecosystem, name, viewer: viewer.clone(), version }).await?; |
| 407 | mapped(found, |v| version_json(&v)) |
| 408 | } |
| 409 | PackagesOp::ListAccess | PackagesOp::ListActionsAccess => { |
| 410 | let found: Outcome<PackageSettings> = |
| 411 | call(services, "package_settings", &PackageSettingsArgs { workspace, ecosystem, name, viewer: viewer.clone() }).await?; |
| 412 | mapped(found, |settings| { |
| 413 | if op == PackagesOp::ListAccess { |
| 414 | json!({ "inherit_access": settings.package.inherit_access, "access": access_json(&settings.access) }) |
| 415 | } else { |
| 416 | json!({ "repositories": actions_json(&settings.actions_access) }) |
| 417 | } |
| 418 | }) |
| 419 | } |
| 420 | PackagesOp::UpdatePackage => { |
| 421 | let visibility = match text(input, "visibility").as_deref() { |
| 422 | None => None, |
| 423 | Some("public") => Some(Visibility::Public), |
| 424 | Some("private") => Some(Visibility::Private), |
| 425 | Some(other) => return Ok(Outcome::fail(FailureCode::Invalid, format!("{other} is not a visibility: public or private."))), |
| 426 | }; |
| 427 | let inherit_access = input["inherit_access"].as_bool(); |
| 428 | if visibility.is_none() && inherit_access.is_none() { |
| 429 | return Ok(Outcome::fail(FailureCode::Invalid, "Give visibility or inherit_access.")); |
| 430 | } |
| 431 | let args = SetPackageArgs { actor: actor!(), workspace, ecosystem, name, visibility, link: None, unlink: false, inherit_access, surface }; |
| 432 | changed(call(services, "set_package", &args).await?) |
| 433 | } |
| 434 | PackagesOp::LinkPackage => { |
| 435 | let Some(repository) = text(input, "repository") else { |
| 436 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository: its name, or owner/name.")); |
| 437 | }; |
| 438 | let args = SetPackageArgs { actor: actor!(), workspace, ecosystem, name, visibility: None, link: Some(repository), unlink: false, inherit_access: None, surface }; |
| 439 | changed(call(services, "set_package", &args).await?) |
| 440 | } |
| 441 | PackagesOp::UnlinkPackage => { |
| 442 | let args = SetPackageArgs { actor: actor!(), workspace, ecosystem, name, visibility: None, link: None, unlink: true, inherit_access: None, surface }; |
| 443 | changed(call(services, "set_package", &args).await?) |
| 444 | } |
| 445 | PackagesOp::SetAccess => { |
| 446 | let Some(role) = text(input, "role").and_then(|r| PackageRole::parse(&r)) else { |
| 447 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the role: read, write or admin.")); |
| 448 | }; |
| 449 | let user = text(input, "username").or_else(|| text(input, "user")); |
| 450 | let args = SetPackageAccessArgs { actor: actor!(), workspace, ecosystem, name, user, team: text(input, "team"), role, surface }; |
| 451 | mapped(call(services, "set_package_access", &args).await?, |list: Vec<PackageAccess>| access_json(&list)) |
| 452 | } |
| 453 | PackagesOp::RemoveAccess => { |
| 454 | let user = text(input, "username").or_else(|| text(input, "user")); |
| 455 | let args = RemovePackageAccessArgs { actor: actor!(), workspace, ecosystem, name, user, team: text(input, "team"), surface }; |
| 456 | mapped(call(services, "remove_package_access", &args).await?, |list: Vec<PackageAccess>| access_json(&list)) |
| 457 | } |
| 458 | PackagesOp::SetActionsAccess => { |
| 459 | let Some(repo) = text(input, "repository") else { |
| 460 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository: its name, or owner/name.")); |
| 461 | }; |
| 462 | let role = match text(input, "role").and_then(|r| PackageRole::parse(&r)) { |
| 463 | Some(role @ (PackageRole::Read | PackageRole::Write)) => role, |
| 464 | _ => return Ok(Outcome::fail(FailureCode::Invalid, "Give the role: read or write.")), |
| 465 | }; |
| 466 | let args = SetActionsAccessArgs { actor: actor!(), workspace, ecosystem, name, repo, role, surface }; |
| 467 | mapped(call(services, "set_actions_access", &args).await?, |list: Vec<ActionsAccess>| json!({ "repositories": actions_json(&list) })) |
| 468 | } |
| 469 | PackagesOp::RemoveActionsAccess => { |
| 470 | let Some(repo) = text(input, "repository") else { |
| 471 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository: its name, or owner/name.")); |
| 472 | }; |
| 473 | let args = RemoveActionsAccessArgs { actor: actor!(), workspace, ecosystem, name, repo, surface }; |
| 474 | mapped(call(services, "remove_actions_access", &args).await?, |list: Vec<ActionsAccess>| json!({ "repositories": actions_json(&list) })) |
| 475 | } |
| 476 | PackagesOp::DeletePackage => { |
| 477 | let args = DeletePackageArgs { actor: actor!(), workspace, ecosystem, name, surface }; |
| 478 | mapped(call::<()>(services, "delete_package", &args).await?, |_| json!({ "deleted": true })) |
| 479 | } |
| 480 | PackagesOp::RestorePackage => { |
| 481 | let args = RestorePackageArgs { actor: actor!(), workspace, ecosystem, name, surface }; |
| 482 | changed(call(services, "restore_package", &args).await?) |
| 483 | } |
| 484 | PackagesOp::DeleteVersion => { |
| 485 | let args = DeleteVersionArgs { actor: actor!(), workspace, ecosystem, name, version, surface }; |
| 486 | mapped(call::<()>(services, "delete_version", &args).await?, |_| json!({ "deleted": true })) |
| 487 | } |
| 488 | PackagesOp::RestoreVersion => { |
| 489 | let args = RestoreVersionArgs { actor: actor!(), workspace, ecosystem, name, version, surface }; |
| 490 | mapped(call(services, "restore_version", &args).await?, |v: PackageVersion| version_json(&v)) |
| 491 | } |
| 492 | }) |
| 493 | } |
| 494 | |
| 495 | #[cfg(test)] |
| 496 | mod tests { |
| 497 | use super::*; |
| 498 | |
| 499 | fn summary() -> PackageSummary { |
| 500 | PackageSummary { |
| 501 | id: "pkg_1".into(), |
| 502 | workspace: "acme".into(), |
| 503 | ecosystem: Ecosystem::Container, |
| 504 | name: "web/worker".into(), |
| 505 | address: "g1t.sh/acme/web/worker".into(), |
| 506 | visibility: Visibility::Private, |
| 507 | repo: Some(LinkedRepo { id: "rep_1".into(), namespace: "acme".into(), name: "web".into() }), |
| 508 | description: None, |
| 509 | versions: 3, |
| 510 | latest: Some("latest".into()), |
| 511 | size: 1024, |
| 512 | downloads: 7, |
| 513 | created_at: "2026-10-01T00:00:00.000Z".into(), |
| 514 | updated_at: "2026-10-02T00:00:00.000Z".into(), |
| 515 | inherit_access: true, |
| 516 | deleted_at: None, |
| 517 | deleted_by: None, |
| 518 | purge_at: None, |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | #[test] |
| 523 | fn a_package_is_snake_case_with_its_type_and_page() { |
| 524 | let shown = package_json(&summary(), "https://g1t.sh/"); |
| 525 | assert_eq!(shown["package_type"], "container"); |
| 526 | assert_eq!(shown["repository"]["full_name"], "acme/web"); |
| 527 | assert_eq!(shown["html_url"], "https://g1t.sh/acme/-/packages/container/web/worker"); |
| 528 | assert_eq!(shown["download_count"], 7); |
| 529 | assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty()); |
| 530 | } |
| 531 | |
| 532 | #[test] |
| 533 | fn package_types_are_read_as_github_writes_them() { |
| 534 | assert_eq!(ecosystem("docker"), Some(Ecosystem::Container)); |
| 535 | assert_eq!(ecosystem("NuGet"), Some(Ecosystem::Nuget)); |
| 536 | assert_eq!(ecosystem("go"), None, "Go modules are read from git, not managed here"); |
| 537 | assert_eq!(ecosystem("pypi"), None); |
| 538 | } |
| 539 | |
| 540 | #[test] |
| 541 | fn each_operation_is_described_with_a_schema_and_a_scope() { |
| 542 | use g1t_contracts::scopes::{Level, scope_for}; |
| 543 | for op in PackagesOp::ALL { |
| 544 | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Packages(op)), "{}", op.name()); |
| 545 | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); |
| 546 | assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name()); |
| 547 | let level = scope_for(op.name()).unwrap().level(); |
| 548 | assert_eq!(op.writes(), level != Level::Read, "{}", op.name()); |
| 549 | } |
| 550 | } |
| 551 | } |