flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/apps/api/src/responses.rs

241 lines10,546 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//! Every response the REST routes give, run through the converter the API
2//! sends them with, checked for `camelCase` that would leak out.
3//!
4//! The samples are the reference's example responses, put back into the
5//! `camelCase` the services send (as serde's `rename_all` writes it) and,
6//! where an operation returns a contract type, decoded into that type and
7//! encoded again, so that every field the type has is sent, not only the
8//! ones an example shows.
9
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look10use g1t_contracts::{access, actions, integrations, repos, search, webhooks, work};
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API11use g1t_kit::wire::{self, USER_KEYED};
12use serde::Serialize;
13use serde::de::DeserializeOwned;
14use serde_json::{Map, Value, json};
15
16use crate::openapi::document;
17use crate::operations::Op;
18
19/// A key as `#[serde(rename_all = "camelCase")]` writes it.
20fn camel_key(key: &str) -> String {
21 let mut out = String::with_capacity(key.len());
22 let mut upper = false;
23 for c in key.chars() {
24 if c == '_' {
25 upper = true;
26 } else if upper {
27 out.extend(c.to_uppercase());
28 upper = false;
29 } else {
30 out.push(c);
31 }
32 }
33 out
34}
35
36/// A response as the services send it: `camelCase`, but for the maps the
37/// converter passes through, which are data.
38fn as_services_send(value: &Value) -> Value {
39 match value {
40 Value::Object(fields) => {
41 let mut out = Map::new();
42 for (key, value) in fields {
43 let user_keyed = value.is_object()
44 && (USER_KEYED.contains(&key.as_str()) || key.starts_with("by_"));
45 let value = if user_keyed { value.clone() } else { as_services_send(value) };
46 // A `by_…` map keeps its name in the converter's spelling.
47 let key = if key.starts_with("by_") { key.clone() } else { camel_key(key) };
48 out.insert(key, value);
49 }
50 Value::Object(out)
51 }
52 Value::Array(items) => Value::Array(items.iter().map(as_services_send).collect()),
53 other => other.clone(),
54 }
55}
56
57/// `value` decoded as `T` and encoded again, as the service would send it.
58fn through<T: DeserializeOwned + Serialize>(op: Op, value: Value) -> Value {
59 let decoded: T = serde_json::from_value(value)
60 .unwrap_or_else(|error| panic!("{}: the example is not a {}: {error}", op.name(), std::any::type_name::<T>()));
61 serde_json::to_value(decoded).unwrap()
62}
63
64/// What the service behind an operation sends, from its example.
65fn sample(op: Op, example: &Value) -> Value {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look66 // Types serde already writes in `snake_case`: a person, and who has
67 // access. Sent as they are.
68 let as_is = example.clone();
69 match op {
70 Op::Whoami => return through::<g1t_contracts::User>(op, as_is),
71 Op::ListCollaborators => return through::<access::RepoAccess>(op, as_is),
72 Op::AddCollaborator => return through::<access::Added>(op, as_is),
73 Op::UpdateCollaborator => return through::<access::Collaborator>(op, as_is),
74 Op::GetCollaboratorPermission => return through::<access::PermissionInfo>(op, as_is),
75 Op::ListRepoInvitations | Op::ListMyRepoInvitations => {
76 return through::<Vec<access::RepoInvitation>>(op, as_is);
77 }
78 Op::RevokeRepoInvitation | Op::AcceptRepoInvitation | Op::DeclineRepoInvitation => {
79 return through::<access::RepoInvitation>(op, as_is);
80 }
81 Op::ListOutsideCollaborators => return through::<Vec<access::OutsideCollaborator>>(op, as_is),
82 _ => {}
83 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API84 let sent = as_services_send(example);
85 match op {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look86 Op::CreateWorkspace => through::<g1t_contracts::identity::Workspace>(op, sent),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API87 Op::ListRepos => through::<Vec<repos::Repo>>(op, sent),
Search across all of g1t, Explore, and a command palette88 Op::Search => through::<search::SearchResults>(op, sent),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look89 Op::GetRepo
90 | Op::CreateRepo
91 | Op::UpdateRepo
92 | Op::TransferRepo
93 | Op::RenameRepo
94 | Op::RenameBranch
95 | Op::ArchiveRepo
96 | Op::UnarchiveRepo
97 | Op::SetRepoVisibility
98 | Op::RestoreRepo => through::<repos::Repo>(op, sent),
99 Op::DeleteRepo => through::<repos::DeletedRepo>(op, sent),
100 Op::ListDeletedRepos => through::<Vec<repos::DeletedRepo>>(op, sent),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API101 Op::GetRepoSettings | Op::UpdateRepoSettings => through::<work::RepoSettings>(op, sent),
102 Op::GetMergeQueue => through::<work::QueueView>(op, sent),
103 Op::ListIssues => through::<Vec<work::Issue>>(op, sent),
104 Op::CreateIssue | Op::UpdateIssue | Op::CloseIssue | Op::ReopenIssue => {
105 through::<work::Issue>(op, sent)
106 }
107 Op::GetIssue => through::<work::IssueDetail>(op, sent),
108 Op::ListPullRequests => through::<Vec<work::Pull>>(op, sent),
109 Op::GetPullRequest => through::<work::PullDetail>(op, sent),
110 Op::MarkPullRequestReady | Op::ClosePullRequest | Op::MergePullRequest | Op::AssignIssue => {
111 through::<work::Pull>(op, sent)
112 }
113 Op::ListWorkflows => through::<Vec<actions::Workflow>>(op, sent),
114 Op::ListWorkflowRuns => through::<Vec<actions::WorkflowRun>>(op, sent),
115 Op::GetWorkflowRun => through::<actions::RunDetail>(op, sent),
116 Op::GetJobLogs => through::<actions::JobLog>(op, sent),
117 Op::DispatchWorkflow | Op::CancelWorkflowRun | Op::RerunWorkflowRun => {
118 through::<actions::WorkflowRun>(op, sent)
119 }
120 Op::ListActionsSecrets | Op::ListActionsVariables => through::<Vec<actions::Setting>>(op, sent),
121 Op::ListWebhooks => through::<Vec<webhooks::Hook>>(op, sent),
122 Op::ListIntegrations => through::<Vec<integrations::Connection>>(op, sent),
123 Op::GetModelRoutes | Op::SetModelRoutes => through::<Vec<integrations::ModelRoute>>(op, sent),
124 Op::ListEvents => through::<Vec<g1t_contracts::events::Event>>(op, sent),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look125 Op::ListEmails | Op::AddEmail | Op::RemoveEmail | Op::UpdateEmailSettings => {
126 through::<g1t_contracts::accounts::AccountEmails>(op, sent)
127 }
128 Op::ListInvites => through::<g1t_contracts::identity::InvitesOverview>(op, sent),
129 Op::CreateInvite | Op::RevokeInvite | Op::InviteMember | Op::RevokeWorkspaceInvite => {
130 through::<g1t_contracts::identity::Invite>(op, sent)
131 }
132 Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API133 _ => sent,
134 }
135}
136
137/// Every key of `example`, as paths, outside the maps passed through.
138fn paths(value: &Value, path: &str, out: &mut Vec<String>) {
139 match value {
140 Value::Object(fields) => {
141 for (key, value) in fields {
142 let here = format!("{path}.{key}");
143 out.push(here.clone());
144 let user_keyed = value.is_object()
145 && (USER_KEYED.contains(&key.as_str()) || key.starts_with("by_"));
146 if !user_keyed {
147 paths(value, &here, out);
148 }
149 }
150 }
151 Value::Array(items) => {
152 for item in items {
153 paths(item, &format!("{path}[]"), out);
154 }
155 }
156 _ => {}
157 }
158}
159
160#[test]
161fn no_route_answers_with_camel_case() {
162 let document = document();
163 let (mut checked, mut converted) = (0, 0);
164 for (path, methods) in document["paths"].as_object().unwrap() {
165 for (method, operation) in methods.as_object().unwrap() {
166 let example = &operation["responses"]["200"]["content"]["application/json"]["example"];
167 let tool = operation["x-mcp-tool"].as_str().unwrap_or_default();
168 let Some(op) = Op::by_name(tool) else {
169 // Device sign-in, which is written in `snake_case` by hand.
170 assert!(wire::camel_case_keys(example).is_empty(), "{method} {path}");
171 continue;
172 };
173 let sample = sample(op, example);
174 converted += wire::camel_case_keys(&sample).len();
175 let sent = wire::snake_case(sample);
176 let leaked = wire::camel_case_keys(&sent);
177 assert!(leaked.is_empty(), "{method} {path} sends {leaked:?}");
178 // The reference shows what is sent: each of its names is one.
179 let (mut shown, mut real) = (Vec::new(), Vec::new());
180 paths(example, "", &mut shown);
181 paths(&sent, "", &mut real);
182 for name in shown {
183 assert!(real.contains(&name), "{method} {path}: the reference shows {name}, which is not sent");
184 }
185 checked += 1;
186 }
187 }
188 assert!(checked >= Op::ALL.len());
189 // The samples are in the services' spelling, so there was something to
190 // convert.
191 assert!(converted > 100, "{converted}");
192}
193
194#[test]
195fn every_route_has_a_sample() {
196 let document = document();
197 for route in crate::rest::ROUTES {
198 let path = route
199 .path
200 .split('/')
201 .map(|segment| match segment.strip_prefix(':') {
202 Some(name) => format!("{{{name}}}"),
203 None => segment.to_owned(),
204 })
205 .collect::<Vec<_>>()
206 .join("/");
207 let example = &document["paths"][&path][route.method.to_lowercase()]["responses"]["200"]
208 ["content"]["application/json"]["example"];
209 assert!(!example.is_null(), "{} {path}", route.method);
210 }
211}
212
213#[test]
214fn errors_and_reports_are_snake_case() {
215 let failure = g1t_contracts::Failure {
216 code: g1t_contracts::FailureCode::NotFound,
217 message: "No such endpoint.".to_owned(),
218 };
219 assert!(wire::camel_case_keys(&wire::snake_case(json!({ "error": failure }))).is_empty());
220}
221
222#[test]
223fn a_job_spec_keeps_github_s_spelling() {
224 let spec = json!({
225 "job": "job_1",
226 "spec": { "runs-on": "ubuntu-latest", "timeoutMinutes": 5 },
227 "workflow": { "env": { "nodeEnv": "x" } },
228 "github": { "eventName": "push", "headRef": "" },
229 "event": { "pull_request": { "headSha": "x" } },
230 "contexts": { "inputs": { "dryRun": true }, "matrix": { "nodeVersion": 20 } },
231 "checkout": { "ref": "main" },
232 "timeoutMinutes": 30,
233 "masks": [],
234 });
235 let sent = wire::snake_case_keeping(spec.clone(), crate::JOB_SPEC_AS_GIVEN);
236 assert_eq!(sent["timeout_minutes"], 30);
237 assert!(sent.get("timeoutMinutes").is_none());
238 for kept in ["spec", "workflow", "github", "event", "contexts", "checkout"] {
239 assert_eq!(sent[kept], spec[kept], "{kept}");
240 }
241}