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 | |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 6 | //! workspace's token or a run's token. |
| API: pinned projects over REST and MCP | 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 | } |