Skip to content
551 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge packages: roles, Actions access, source label, soft delete, API1//! 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
13use g1t_contracts::packages::*;
14use g1t_contracts::{FailureCode, Outcome, User, Viewer};
15use serde::de::DeserializeOwned;
16use serde::Serialize;
17use serde_json::{Value, json};
18use worker::Result;
19
20use crate::operations::Services;
21
22/// One operation on packages.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub 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
44impl 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
238fn 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.
244pub(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.
252fn 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.
263pub 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.
292pub 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
313fn 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
322fn 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
331fn 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
338async 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.
343fn 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
347pub 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)]
496mod 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}

This file's history is long; its oldest lines are credited to the oldest commit read.