| 1 | //! Pinned projects: the projects a person keeps at the top of a |
| 2 | //! workspace's sidebar, in their order, up to eight a workspace. The |
| 3 | //! projects service keeps them; this is their public shape, in snake_case. |
| 4 | //! |
| 5 | //! A person's own: a personal access token or a session, never a |
| 6 | //! workspace's token or g1t's agents. |
| 7 | |
| 8 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Viewer}; |
| 9 | use serde_json::{Value, json}; |
| 10 | use worker::Result; |
| 11 | |
| 12 | use crate::operations::{Op, Services}; |
| 13 | |
| 14 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { |
| 15 | Ok(Outcome::fail(code, message)) |
| 16 | } |
| 17 | |
| 18 | fn text(input: &Value, key: &str) -> Option<String> { |
| 19 | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_lowercase) |
| 20 | } |
| 21 | |
| 22 | /// A place in the pins, 0 first, given as a number or as digits. |
| 23 | fn position(input: &Value) -> Option<u32> { |
| 24 | match &input["position"] { |
| 25 | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), |
| 26 | Value::String(digits) => digits.trim().parse().ok(), |
| 27 | _ => None, |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | /// The slugs `projects` names, in order: strings, or objects with a `slug`. |
| 32 | pub(crate) fn slugs(input: &Value) -> Option<Vec<String>> { |
| 33 | input["projects"].as_array().map(|items| { |
| 34 | items |
| 35 | .iter() |
| 36 | .filter_map(|item| item.as_str().or_else(|| item["slug"].as_str())) |
| 37 | .map(|slug| slug.trim().to_lowercase()) |
| 38 | .filter(|slug| !slug.is_empty()) |
| 39 | .collect() |
| 40 | }) |
| 41 | } |
| 42 | |
| 43 | /// A project as the projects service answers (camelCase), as the API shows |
| 44 | /// a pin: its place, what it is, and where it is. |
| 45 | pub(crate) fn pin_json(project: &Value, position: usize, site: &str) -> Value { |
| 46 | let workspace = project["workspace"].as_str().unwrap_or_default(); |
| 47 | let slug = project["slug"].as_str().unwrap_or_default(); |
| 48 | json!({ |
| 49 | "position": position, |
| 50 | "id": project["id"], |
| 51 | "workspace": workspace, |
| 52 | "slug": slug, |
| 53 | "name": project["name"], |
| 54 | "description": project["description"], |
| 55 | "private": project["private"], |
| 56 | "archived": project["archived"], |
| 57 | "kind": project["kind"], |
| 58 | "url": format!("{}/{workspace}/{slug}", site.trim_end_matches('/')), |
| 59 | "pushed_at": project["pushedAt"], |
| 60 | "updated_at": project["updatedAt"], |
| 61 | }) |
| 62 | } |
| 63 | |
| 64 | fn pins_json(projects: &[Value], site: &str) -> Value { |
| 65 | Value::Array(projects.iter().enumerate().map(|(at, project)| pin_json(project, at, site)).collect()) |
| 66 | } |
| 67 | |
| 68 | /// Runs one of the pin operations. |
| 69 | pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 70 | let user = match viewer { |
| 71 | Some(user) if user.kind == PrincipalKind::User => user, |
| 72 | Some(_) => { |
| 73 | return failed( |
| 74 | FailureCode::Forbidden, |
| 75 | "Pins are a person's own: use a personal access token, not a workspace's or an agent's.", |
| 76 | ); |
| 77 | } |
| 78 | None => return failed(FailureCode::Unauthenticated, "This needs a g1t access token."), |
| 79 | }; |
| 80 | let Some(workspace) = text(input, "workspace") else { |
| 81 | return failed(FailureCode::Invalid, "Give the workspace's slug."); |
| 82 | }; |
| 83 | let site = services.addresses.site.as_str(); |
| 84 | let changed = |outcome: Outcome<Vec<Value>>| -> Result<Outcome<Value>> { |
| 85 | Ok(match outcome { |
| 86 | Outcome::Ok(projects) => Outcome::Ok(pins_json(&projects, site)), |
| 87 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 88 | }) |
| 89 | }; |
| 90 | match op { |
| 91 | Op::ListPinnedProjects => { |
| 92 | let shortcuts: Value = g1t_kit::call( |
| 93 | &services.projects, |
| 94 | "shortcuts", |
| 95 | &json!({ "workspace": workspace, "viewer": viewer }), |
| 96 | ) |
| 97 | .await?; |
| 98 | let pinned = shortcuts["pinned"].as_array().cloned().unwrap_or_default(); |
| 99 | Ok(Outcome::Ok(pins_json(&pinned, site))) |
| 100 | } |
| 101 | Op::PinProject | Op::UnpinProject => { |
| 102 | let Some(slug) = text(input, "project") else { |
| 103 | return failed(FailureCode::Invalid, "Give the project's slug."); |
| 104 | }; |
| 105 | let (method, args) = if op == Op::PinProject { |
| 106 | ("pin", json!({ "actor": user, "workspace": workspace, "slug": slug, "position": position(input) })) |
| 107 | } else { |
| 108 | ("unpin", json!({ "actor": user, "workspace": workspace, "slug": slug })) |
| 109 | }; |
| 110 | changed(g1t_kit::call(&services.projects, method, &args).await?) |
| 111 | } |
| 112 | Op::ReorderPinnedProjects => { |
| 113 | let Some(order) = slugs(input) else { |
| 114 | return failed(FailureCode::Invalid, "Give projects: every pinned project's slug, in the order you want them."); |
| 115 | }; |
| 116 | changed( |
| 117 | g1t_kit::call( |
| 118 | &services.projects, |
| 119 | "reorder_pins", |
| 120 | &json!({ "actor": user, "workspace": workspace, "slugs": order }), |
| 121 | ) |
| 122 | .await?, |
| 123 | ) |
| 124 | } |
| 125 | _ => failed(FailureCode::Invalid, "Not a pin operation."), |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | #[cfg(test)] |
| 130 | mod tests { |
| 131 | use super::*; |
| 132 | |
| 133 | #[test] |
| 134 | fn a_pin_is_snake_case_with_its_place_and_address() { |
| 135 | let project = json!({ |
| 136 | "id": "prj_1", "workspace": "acme", "slug": "web", "name": "Web", "description": null, |
| 137 | "private": true, "archived": false, "kind": "app", "pushedAt": "2026-10-07T10:00:00.000Z", |
| 138 | "updatedAt": "2026-10-01T10:00:00.000Z", "descriptionInherited": false, |
| 139 | }); |
| 140 | let pin = pin_json(&project, 2, "https://g1t.sh/"); |
| 141 | assert_eq!(pin["position"], 2); |
| 142 | assert_eq!(pin["url"], "https://g1t.sh/acme/web"); |
| 143 | assert_eq!(pin["pushed_at"], "2026-10-07T10:00:00.000Z"); |
| 144 | assert!(pin.get("pushedAt").is_none()); |
| 145 | assert!(pin.get("descriptionInherited").is_none()); |
| 146 | } |
| 147 | |
| 148 | #[test] |
| 149 | fn the_order_is_read_from_slugs_or_objects() { |
| 150 | assert_eq!(slugs(&json!({ "projects": ["Web", { "slug": "api" }, ""] })), Some(vec!["web".into(), "api".into()])); |
| 151 | assert_eq!(slugs(&json!({})), None); |
| 152 | } |
| 153 | } |