Skip to content

g1t/apps/api/src/projects.rs

262 lines11,259 bytesCodeBlame
1//! Projects: what a workspace builds and runs, where each runs, and its
2//! links. The projects service keeps them and decides who may see or change
3//! them; this is their public shape, in snake_case.
4
5use g1t_contracts::{FailureCode, Outcome, Viewer};
6use serde_json::{Map, Value, json};
7use worker::Result;
8
9use crate::operations::{Op, Services};
10
11fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
12 Ok(Outcome::fail(code, message))
13}
14
15fn slug(input: &Value, key: &str) -> Option<String> {
16 input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_lowercase)
17}
18
19/// A reason a kind was decided, as the API shows it.
20fn reason_json(reason: &Value) -> Value {
21 json!({ "by": reason["by"], "detail": reason["detail"] })
22}
23
24/// A project as the projects service answers (camelCase), as the API shows it.
25pub(crate) fn project_json(project: &Value, site: &str) -> Value {
26 let workspace = project["workspace"].as_str().unwrap_or_default();
27 let slug = project["slug"].as_str().unwrap_or_default();
28 let source = &project["source"];
29 let repository = match (source["repo"]["namespace"].as_str(), source["repo"]["name"].as_str()) {
30 (Some(namespace), Some(name)) => json!(format!("{namespace}/{name}")),
31 _ => Value::Null,
32 };
33 let links = &project["links"];
34 let custom: Vec<Value> = links["custom"]
35 .as_array()
36 .map(|items| items.iter().map(|link| json!({ "label": link["label"], "url": link["url"] })).collect())
37 .unwrap_or_default();
38 json!({
39 "id": project["id"],
40 "workspace": workspace,
41 "slug": slug,
42 "name": project["name"],
43 "description": project["description"],
44 "description_inherited": project["descriptionInherited"].as_bool().unwrap_or(false),
45 "url": format!("{}/{workspace}/{slug}", site.trim_end_matches('/')),
46 "repository": repository,
47 "root_dir": source["rootDir"].as_str().unwrap_or_default(),
48 "default_branch": source["defaultBranch"],
49 "private": project["private"],
50 "archived": project["archived"],
51 "primary": project["primary"],
52 "kind": project["kind"],
53 "kind_reason": reason_json(&project["kindReason"]),
54 "runs": project["runs"],
55 "production_url": project["productionUrl"],
56 "setting": { "kind": project["setting"]["kind"], "runs": project["setting"]["runs"] },
57 "detected": {
58 "kind": project["detected"]["kind"],
59 "reason": reason_json(&project["detected"]["reason"]),
60 },
61 "ecosystem": project["ecosystem"],
62 "links": {
63 "homepage": links["homepage"],
64 "homepage_inherited": links["homepageInherited"].as_bool().unwrap_or(false),
65 "docs": links["docs"],
66 "custom": custom,
67 },
68 "created_at": project["createdAt"],
69 "updated_at": project["updatedAt"],
70 "pushed_at": project["pushedAt"],
71 })
72}
73
74/// The fields a change may set: (input, service, whether null clears it).
75const CHANGES: &[(&str, &str, bool)] = &[
76 ("name", "name", false),
77 ("description", "description", true),
78 ("root_dir", "rootDir", false),
79 ("kind", "kind", false),
80 ("runs", "runs", false),
81 ("production_url", "productionUrl", true),
82 ("homepage", "homepage", true),
83 ("docs_url", "docsUrl", true),
84];
85
86/// The change `input` asks for, in the service's spelling: only what it
87/// gives, with null passed on for what null clears.
88pub(crate) fn changes(input: &Value) -> std::result::Result<Value, String> {
89 let mut out = Map::new();
90 for (key, field, clearable) in CHANGES {
91 match input.get(*key) {
92 None => {}
93 Some(Value::Null) if *clearable => {
94 out.insert((*field).to_owned(), Value::Null);
95 }
96 Some(Value::Null) => {}
97 Some(Value::String(text)) => {
98 out.insert((*field).to_owned(), json!(text));
99 }
100 Some(_) => {
101 let kind = if *clearable { "a string or null" } else { "a string" };
102 return Err(format!("{key} must be {kind}."));
103 }
104 }
105 }
106 match input.get("links") {
107 None | Some(Value::Null) => {}
108 Some(Value::Array(items)) => {
109 let mut links = Vec::with_capacity(items.len());
110 for item in items {
111 match (item["label"].as_str(), item["url"].as_str()) {
112 (Some(label), Some(url)) => links.push(json!({ "label": label, "url": url })),
113 _ => return Err("Each of links needs a label and a url.".to_owned()),
114 }
115 }
116 out.insert("links".to_owned(), Value::Array(links));
117 }
118 Some(_) => return Err("links must be a list of { label, url }.".to_owned()),
119 }
120 Ok(Value::Object(out))
121}
122
123/// Runs one of the project operations.
124pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
125 let Some(workspace) = slug(input, "workspace") else {
126 return failed(FailureCode::Invalid, "Give the workspace's slug.");
127 };
128 let site = services.addresses.site.as_str();
129 let one = |outcome: Outcome<Value>| -> Outcome<Value> {
130 match outcome {
131 Outcome::Ok(project) => Outcome::Ok(project_json(&project, site)),
132 Outcome::Fail(failure) => Outcome::Fail(failure),
133 }
134 };
135 if op == Op::ListProjects {
136 let listed: Outcome<Vec<Value>> =
137 g1t_kit::call(&services.projects, "list", &json!({ "workspace": workspace, "viewer": viewer })).await?;
138 return Ok(match listed {
139 Outcome::Ok(projects) => {
140 Outcome::Ok(Value::Array(projects.iter().map(|project| project_json(project, site)).collect()))
141 }
142 Outcome::Fail(failure) => Outcome::Fail(failure),
143 });
144 }
145 let Some(project) = slug(input, "project") else {
146 return failed(FailureCode::Invalid, "Give the project's slug.");
147 };
148 match op {
149 Op::GetProject => Ok(one(
150 g1t_kit::call(
151 &services.projects,
152 "get",
153 &json!({ "workspace": workspace, "slug": project, "viewer": viewer }),
154 )
155 .await?,
156 )),
157 Op::UpdateProject => {
158 let Some(actor) = viewer else {
159 return failed(FailureCode::Unauthenticated, "This needs a g1t access token.");
160 };
161 let changes = match changes(input) {
162 Ok(changes) => changes,
163 Err(message) => return failed(FailureCode::Invalid, &message),
164 };
165 Ok(one(
166 g1t_kit::call(
167 &services.projects,
168 "update",
169 &json!({ "actor": actor, "workspace": workspace, "slug": project, "changes": changes }),
170 )
171 .await?,
172 ))
173 }
174 _ => failed(FailureCode::Invalid, "Not a project operation."),
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use g1t_kit::wire;
182
183 fn project() -> Value {
184 json!({
185 "id": "prj_1", "workspace": "flagon-io", "slug": "g1t", "name": "g1t",
186 "description": "Git hosting for people and agents.", "descriptionInherited": true,
187 "source": {
188 "kind": "hosted", "repoId": "repo_1", "repo": { "namespace": "flagon-io", "name": "g1t" },
189 "rootDir": "", "defaultBranch": "main",
190 },
191 "private": false, "archived": false, "primary": true,
192 "setting": { "kind": "app", "runs": "elsewhere" },
193 "kind": "app", "kindReason": { "by": "set", "detail": "Set to an app." },
194 "runs": "elsewhere", "productionUrl": "https://g1t.sh",
195 "detected": { "kind": "app", "reason": { "by": "files", "detail": "It has a wrangler.jsonc." } },
196 "ecosystem": null,
197 "links": {
198 "homepage": "https://g1t.sh", "homepageInherited": true, "docs": "https://docs.g1t.sh",
199 "custom": [{ "label": "Status", "url": "https://status.g1t.sh" }],
200 },
201 "createdBy": "usr_1", "createdAt": "2026-09-01T10:00:00.000Z", "updatedAt": "2026-10-07T10:00:00.000Z",
202 "pushedAt": "2026-10-07T09:00:00.000Z", "activity": 12.5,
203 })
204 }
205
206 #[test]
207 fn a_project_is_snake_case_with_its_address() {
208 let shown = project_json(&project(), "https://g1t.sh/");
209 assert!(wire::camel_case_keys(&shown).is_empty(), "{:?}", wire::camel_case_keys(&shown));
210 assert_eq!(shown["url"], "https://g1t.sh/flagon-io/g1t");
211 assert_eq!(shown["repository"], "flagon-io/g1t");
212 assert_eq!(shown["root_dir"], "");
213 assert_eq!(shown["default_branch"], "main");
214 assert_eq!(shown["description_inherited"], true);
215 assert_eq!(shown["kind_reason"]["by"], "set");
216 assert_eq!(shown["production_url"], "https://g1t.sh");
217 assert_eq!(shown["setting"]["runs"], "elsewhere");
218 assert_eq!(shown["detected"]["reason"]["by"], "files");
219 assert_eq!(shown["links"]["homepage_inherited"], true);
220 assert_eq!(shown["links"]["custom"][0]["label"], "Status");
221 assert_eq!(shown["pushed_at"], "2026-10-07T09:00:00.000Z");
222 // What is the service's own business stays there.
223 assert!(shown.get("source").is_none());
224 assert!(shown.get("activity").is_none());
225 assert!(shown.get("created_by").is_none());
226 }
227
228 #[test]
229 fn a_change_is_only_what_is_given_in_the_service_s_spelling() {
230 let asked = json!({
231 "workspace": "flagon-io", "project": "g1t",
232 "kind": "app", "runs": "elsewhere", "production_url": "https://g1t.sh",
233 "root_dir": "apps/web", "docs_url": "docs.g1t.sh",
234 "links": [{ "label": "Status", "url": "https://status.g1t.sh", "extra": 1 }],
235 });
236 let sent = changes(&asked).unwrap();
237 assert_eq!(
238 sent,
239 json!({
240 "kind": "app", "runs": "elsewhere", "productionUrl": "https://g1t.sh", "rootDir": "apps/web",
241 "docsUrl": "docs.g1t.sh", "links": [{ "label": "Status", "url": "https://status.g1t.sh" }],
242 })
243 );
244 assert!(sent.get("workspace").is_none());
245 assert!(sent.get("name").is_none());
246 }
247
248 #[test]
249 fn null_clears_what_it_can_and_absent_changes_nothing() {
250 let sent = changes(&json!({ "description": null, "homepage": null, "production_url": null, "docs_url": null, "name": null, "kind": null })).unwrap();
251 assert_eq!(sent, json!({ "description": null, "homepage": null, "productionUrl": null, "docsUrl": null }));
252 assert_eq!(changes(&json!({})).unwrap(), json!({}));
253 assert_eq!(changes(&json!({ "links": [] })).unwrap(), json!({ "links": [] }));
254 }
255
256 #[test]
257 fn a_change_of_the_wrong_type_is_refused() {
258 assert!(changes(&json!({ "kind": 3 })).is_err());
259 assert!(changes(&json!({ "links": "https://g1t.sh" })).is_err());
260 assert!(changes(&json!({ "links": [{ "url": "https://g1t.sh" }] })).is_err());
261 }
262}