Skip to content

g1t/crates/kit/src/wire.rs

287 lines10,360 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`,
Merge main: Deployments panel in the About, project homepage, both sides' operations25/// `labels` by name, an `sbom`, which is a standard's own document, and a
26/// deployment's `payload`, as its reporter gave it.
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API27pub 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 main: Deployments panel in the About, project homepage, both sides' operations45 // A deployment's payload, as its reporter gave it.
46 "payload",
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API47];
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.