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.
| API: pinned projects over REST and MCP | 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 | } |