| 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 | |
| 5 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 6 | use serde_json::{Map, Value, json}; |
| 7 | use worker::Result; |
| 8 | |
| 9 | use crate::operations::{Op, Services}; |
| 10 | |
| 11 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { |
| 12 | Ok(Outcome::fail(code, message)) |
| 13 | } |
| 14 | |
| 15 | fn 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. |
| 20 | fn 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. |
| 25 | pub(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). |
| 75 | const 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. |
| 88 | pub(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. |
| 124 | pub 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)] |
| 179 | mod 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 | } |