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
287 linesCodeBlameRaw

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.

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

This file's history is long; its oldest lines are credited to the oldest commit read.