Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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