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.
| Merge branch 'projects-kind-and-links' | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.