Skip to content

g1t/crates/kit/src/wire.rs

284 lines10,234 bytesCodeBlame

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.

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