flagon-io/g1t

public

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/crates/kit/src/wire.rs

282 lines10,135 bytesCodeBlame
1//! JSON keys on the wire: `snake_case` in every body that leaves g1t.
2//!
3//! Services talk to each other, and to the site, in the contracts' own
4//! `camelCase`. Public bodies (API responses and errors, MCP tool results,
5//! webhook payloads) are converted on their way out, here, so the
6//! contracts stay as they are.
7//!
8//! Only keys that read as `camelCase` identifiers (`isPrivate`, `prUrl`,
9//! `last4`) are converted; anything else (`G1T_TOKEN`, `fail-fast`,
10//! `content-type`, a path) is left as it is. Values are never changed.
11//!
12//! People's own names are never renamed. A field in [`USER_KEYED`], or
13//! named `by_…`, whose value is an object is a map keyed by data, such as
14//! a workflow's inputs or a variable's name: it is passed through whole,
15//! keys and values as given. A surface that carries more of them names
16//! its own with [`snake_case_keeping`].
17
18use serde_json::{Map, Value};
19
20/// Fields whose object value is keyed by data rather than by g1t, wherever
21/// they appear: workflow `inputs` and `on.workflow_dispatch.inputs`
22/// (`dispatch`), `env`, `secrets`, `variables` and `vars` by name, a job's
23/// `matrix`, `needs` and `outputs`, an action's `with`, guardrail `rules`
24/// and `minutes` by id and kind of run, HTTP `headers`, `metadata`, and
25/// `labels` by name.
26pub const USER_KEYED: &[&str] = &[
27 "inputs",
28 "dispatch",
29 "env",
30 "secrets",
31 "variables",
32 "vars",
33 "matrix",
34 "needs",
35 "outputs",
36 "with",
37 "rules",
38 "minutes",
39 "headers",
40 "metadata",
41 "labels",
42];
43
44/// Whether a key is a `camelCase` identifier with something to convert.
45pub fn is_camel_case(key: &str) -> bool {
46 let mut chars = key.chars();
47 chars.next().is_some_and(|first| first.is_ascii_lowercase())
48 && key.chars().all(|c| c.is_ascii_alphanumeric())
49 && key.chars().any(|c| c.is_ascii_uppercase())
50}
51
52/// One key in `snake_case`: `prUrl` is `pr_url`, `headSHA` is `head_sha`,
53/// `last4` stays `last4`. A key that is not a `camelCase` identifier is
54/// returned as it is.
55pub fn snake_case_key(key: &str) -> String {
56 if !is_camel_case(key) {
57 return key.to_owned();
58 }
59 let chars: Vec<char> = key.chars().collect();
60 let mut out = String::with_capacity(key.len() + 4);
61 for (i, &c) in chars.iter().enumerate() {
62 if c.is_ascii_uppercase() {
63 let previous = chars[i - 1];
64 let next_is_lower = chars.get(i + 1).is_some_and(char::is_ascii_lowercase);
65 // A new word: after a lowercase letter or a digit, or the last
66 // capital of an acronym that starts the next word (`HTTPServer`).
67 if previous.is_ascii_lowercase()
68 || previous.is_ascii_digit()
69 || previous.is_ascii_uppercase() && next_is_lower
70 {
71 out.push('_');
72 }
73 out.push(c.to_ascii_lowercase());
74 } else {
75 out.push(c);
76 }
77 }
78 out
79}
80
81fn user_keyed(key: &str, extra: &[&str]) -> bool {
82 USER_KEYED.contains(&key) || extra.contains(&key) || key.starts_with("by_")
83}
84
85/// `value` with every key in `snake_case`, as it is sent out of g1t.
86pub fn snake_case(value: Value) -> Value {
87 snake_case_keeping(value, &[])
88}
89
90/// As [`snake_case`], with `extra` field names (in their `snake_case`
91/// spelling) whose object values are passed through as well.
92pub fn snake_case_keeping(value: Value, extra: &[&str]) -> Value {
93 match value {
94 Value::Object(fields) => {
95 let mut out = Map::with_capacity(fields.len());
96 for (given, value) in fields {
97 let key = snake_case_key(&given);
98 let value = if value.is_object() && user_keyed(&key, extra) {
99 value
100 } else {
101 snake_case_keeping(value, extra)
102 };
103 // A key sent in both spellings keeps the one given in
104 // `snake_case`.
105 if given != key && out.contains_key(&key) {
106 continue;
107 }
108 out.insert(key, value);
109 }
110 Value::Object(out)
111 }
112 Value::Array(items) => Value::Array(
113 items
114 .into_iter()
115 .map(|item| snake_case_keeping(item, extra))
116 .collect(),
117 ),
118 other => other,
119 }
120}
121
122/// The `camelCase` keys that would survive in `value`, as paths, outside
123/// the maps [`snake_case`] passes through. Empty for anything it returned.
124pub fn camel_case_keys(value: &Value) -> Vec<String> {
125 fn walk(value: &Value, path: &str, extra: &[&str], found: &mut Vec<String>) {
126 match value {
127 Value::Object(fields) => {
128 for (key, value) in fields {
129 let here = format!("{path}.{key}");
130 if is_camel_case(key) {
131 found.push(here.clone());
132 }
133 if !(value.is_object() && user_keyed(key, extra)) {
134 walk(value, &here, extra, found);
135 }
136 }
137 }
138 Value::Array(items) => {
139 for item in items {
140 walk(item, &format!("{path}[]"), extra, found);
141 }
142 }
143 _ => {}
144 }
145 }
146 let mut found = Vec::new();
147 walk(value, "", &[], &mut found);
148 found
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use serde_json::json;
155
156 #[test]
157 fn keys_are_converted() {
158 for (camel, snake) in [
159 ("isPrivate", "is_private"),
160 ("prUrl", "pr_url"),
161 ("createdAt", "created_at"),
162 ("last4", "last4"),
163 ("sha256Hex", "sha256_hex"),
164 ("headSHA", "head_sha"),
165 ("HTTPServer", "HTTPServer"),
166 ("httpServerURL", "http_server_url"),
167 ("costUsd", "cost_usd"),
168 ("id", "id"),
169 ] {
170 assert_eq!(snake_case_key(camel), snake, "{camel}");
171 }
172 }
173
174 #[test]
175 fn data_shaped_keys_are_left_alone() {
176 for key in ["G1T_TOKEN", "fail-fast", "content-type", "src/main.rs", "Title", "a b", ""] {
177 assert_eq!(snake_case_key(key), key);
178 }
179 }
180
181 #[test]
182 fn nested_objects_and_arrays_are_converted() {
183 let sent = snake_case(json!({
184 "pullRequests": [
185 { "prUrl": "x", "checkRuns": [{ "exitCode": 0, "durationMs": 5 }] },
186 [{ "deepNested": true }],
187 ],
188 "lastRun": { "startedAt": null },
189 }));
190 assert_eq!(
191 sent,
192 json!({
193 "pull_requests": [
194 { "pr_url": "x", "check_runs": [{ "exit_code": 0, "duration_ms": 5 }] },
195 [{ "deep_nested": true }],
196 ],
197 "last_run": { "started_at": null },
198 })
199 );
200 assert!(camel_case_keys(&sent).is_empty());
201 }
202
203 #[test]
204 fn snake_case_input_is_unchanged() {
205 let body = json!({ "is_private": false, "items": [{ "created_at": "t", "last4": "4242" }] });
206 assert_eq!(snake_case(body.clone()), body);
207 assert_eq!(snake_case(snake_case(json!({ "prUrl": 1 }))), json!({ "pr_url": 1 }));
208 }
209
210 #[test]
211 fn the_snake_case_spelling_wins_when_both_are_given() {
212 assert_eq!(
213 snake_case(json!({ "keep_open": true, "keepOpen": false })),
214 json!({ "keep_open": true })
215 );
216 }
217
218 #[test]
219 fn user_keyed_maps_pass_through_whole() {
220 let sent = snake_case(json!({
221 "workflowId": "wf_1",
222 "inputs": { "logLevel": "debug", "dryRun": { "nestedKey": 1 } },
223 "dispatch": { "targetEnv": { "type": "string" } },
224 "env": { "nodeEnv": "x" },
225 "secrets": { "apiKey": "…" },
226 "variables": { "baseUrl": "…" },
227 "rules": { "forcePush": false },
228 "minutes": { "implementFeature": 30 },
229 "headers": { "contentType": "application/json" },
230 "metadata": { "workspaceId": "wsp_1" },
231 "labels": { "goodFirstIssue": 3 },
232 "byModel": { "claudeOpus": 1.5 },
233 "data": { "issueId": "iss_1", "outputs": { "artifactId": "a" } },
234 }));
235 assert_eq!(
236 sent,
237 json!({
238 "workflow_id": "wf_1",
239 "inputs": { "logLevel": "debug", "dryRun": { "nestedKey": 1 } },
240 "dispatch": { "targetEnv": { "type": "string" } },
241 "env": { "nodeEnv": "x" },
242 "secrets": { "apiKey": "…" },
243 "variables": { "baseUrl": "…" },
244 "rules": { "forcePush": false },
245 "minutes": { "implementFeature": 30 },
246 "headers": { "contentType": "application/json" },
247 "metadata": { "workspaceId": "wsp_1" },
248 "labels": { "goodFirstIssue": 3 },
249 "by_model": { "claudeOpus": 1.5 },
250 "data": { "issue_id": "iss_1", "outputs": { "artifactId": "a" } },
251 })
252 );
253 assert!(camel_case_keys(&sent).is_empty());
254 }
255
256 #[test]
257 fn a_listed_name_holding_a_list_is_still_converted() {
258 // `secrets` is a map by name in one place and a list of findings in
259 // another: only a map is passed through.
260 assert_eq!(
261 snake_case(json!({ "secrets": [{ "updatedAt": "t" }], "labels": ["goodFirst"] })),
262 json!({ "secrets": [{ "updated_at": "t" }], "labels": ["goodFirst"] })
263 );
264 }
265
266 #[test]
267 fn a_surface_can_name_more_maps() {
268 let sent = snake_case_keeping(
269 json!({ "timeoutMinutes": 5, "github": { "eventName": "push" } }),
270 &["github"],
271 );
272 assert_eq!(sent, json!({ "timeout_minutes": 5, "github": { "eventName": "push" } }));
273 }
274
275 #[test]
276 fn surviving_camel_case_is_found() {
277 assert_eq!(
278 camel_case_keys(&json!({ "a": [{ "bC": 1 }], "inputs": { "dE": 1 } })),
279 vec![".a[].bC".to_owned()]
280 );
281 }
282}