Skip to content

g1t/apps/api/src/pins.rs

153 lines6,113 bytesCodeBlame
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
8use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Viewer};
9use serde_json::{Value, json};
10use worker::Result;
11
12use crate::operations::{Op, Services};
13
14fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
15 Ok(Outcome::fail(code, message))
16}
17
18fn 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.
23fn 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`.
32pub(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.
45pub(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
64fn 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.
69pub 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)]
130mod 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}