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
385 linesCodeBlameRaw
1//! 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
10use g1t_contracts::{access, actions, ai, checks, codeowners, integrations, repos, rules, search, teams, webhooks, work};
11use 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;
18use crate::checks::ChecksOp;
19use crate::rules::RulesOp;
20use crate::deploy_keys::DeployKeysOp;
21use crate::coordination::CoordinationOp;
22
23/// A key as `#[serde(rename_all = "camelCase")]` writes it.
24fn camel_key(key: &str) -> String {
25 let mut out = String::with_capacity(key.len());
26 let mut upper = false;
27 for c in key.chars() {
28 if c == '_' {
29 upper = true;
30 } else if upper {
31 out.extend(c.to_uppercase());
32 upper = false;
33 } else {
34 out.push(c);
35 }
36 }
37 out
38}
39
40/// A response as the services send it: `camelCase`, but for the maps the
41/// converter passes through, which are data.
42fn as_services_send(value: &Value) -> Value {
43 match value {
44 Value::Object(fields) => {
45 let mut out = Map::new();
46 for (key, value) in fields {
47 let user_keyed = value.is_object()
48 && (USER_KEYED.contains(&key.as_str()) || key.starts_with("by_"));
49 let value = if user_keyed { value.clone() } else { as_services_send(value) };
50 // A `by_…` map keeps its name in the converter's spelling.
51 let key = if key.starts_with("by_") { key.clone() } else { camel_key(key) };
52 out.insert(key, value);
53 }
54 Value::Object(out)
55 }
56 Value::Array(items) => Value::Array(items.iter().map(as_services_send).collect()),
57 other => other.clone(),
58 }
59}
60
61/// `value` decoded as `T` and encoded again, as the service would send it.
62fn through<T: DeserializeOwned + Serialize>(op: Op, value: Value) -> Value {
63 let decoded: T = serde_json::from_value(value)
64 .unwrap_or_else(|error| panic!("{}: the example is not a {}: {error}", op.name(), std::any::type_name::<T>()));
65 serde_json::to_value(decoded).unwrap()
66}
67
68/// What the service behind an operation sends, from its example.
69fn sample(op: Op, example: &Value) -> Value {
70 // Types serde already writes in `snake_case`: a person, and who has
71 // access. Sent as they are.
72 let as_is = example.clone();
73 match op {
74 Op::Whoami => return through::<g1t_contracts::User>(op, as_is),
75 Op::ListCollaborators => return through::<access::RepoAccess>(op, as_is),
76 Op::AddCollaborator => return through::<access::Added>(op, as_is),
77 Op::UpdateCollaborator => return through::<access::Collaborator>(op, as_is),
78 Op::GetCollaboratorPermission => return through::<access::PermissionInfo>(op, as_is),
79 Op::ListRepoInvitations | Op::ListMyRepoInvitations => {
80 return through::<Vec<access::RepoInvitation>>(op, as_is);
81 }
82 Op::RevokeRepoInvitation | Op::AcceptRepoInvitation | Op::DeclineRepoInvitation => {
83 return through::<access::RepoInvitation>(op, as_is);
84 }
85 Op::ListOutsideCollaborators => return through::<Vec<access::OutsideCollaborator>>(op, as_is),
86 // What Ask answers with, also `snake_case`.
87 Op::GetAskSettings | Op::SetAskSettings => return through::<ai::AskSetup>(op, as_is),
88 // Members, also `snake_case`.
89 Op::ListMembers => return through::<Vec<g1t_contracts::identity::Member>>(op, as_is),
90 Op::UpdateMember => return through::<g1t_contracts::identity::Member>(op, as_is),
91 Op::RemoveMember | Op::TransferOwnership | Op::LeaveWorkspace => return through::<bool>(op, as_is),
92 // Teams and code owners, also `snake_case`.
93 Op::ListTeams | Op::ListChildTeams | Op::ListUserTeams => return through::<Vec<teams::Team>>(op, as_is),
94 Op::GetTeam | Op::CreateTeam | Op::UpdateTeam | Op::SetTeamReviewAssignment => {
95 return through::<teams::Team>(op, as_is);
96 }
97 Op::ListTeamMembers => return through::<Vec<teams::TeamMember>>(op, as_is),
98 Op::SetTeamMember => return through::<teams::TeamMember>(op, as_is),
99 Op::ListTeamRepos => return through::<Vec<teams::TeamRepo>>(op, as_is),
100 Op::SetTeamRepo => return through::<teams::TeamRepo>(op, as_is),
101 Op::DeleteTeam | Op::RemoveTeamMember | Op::RemoveTeamRepo => return through::<bool>(op, as_is),
102 Op::GetCodeownersErrors => return through::<codeowners::CodeOwnersReport>(op, as_is),
103 // Rulesets travel in `snake_case` between services too.
104 Op::Rules(RulesOp::ListRepoRulesets | RulesOp::ListWorkspaceRulesets) => {
105 return through::<Vec<rules::Ruleset>>(op, as_is);
106 }
107 Op::Rules(
108 RulesOp::GetRepoRuleset
109 | RulesOp::CreateRepoRuleset
110 | RulesOp::UpdateRepoRuleset
111 | RulesOp::GetWorkspaceRuleset
112 | RulesOp::CreateWorkspaceRuleset
113 | RulesOp::UpdateWorkspaceRuleset,
114 ) => return through::<rules::Ruleset>(op, as_is),
115 Op::Rules(RulesOp::GetBranchRules) => return through::<rules::EffectiveRules>(op, as_is),
116 Op::Rules(RulesOp::ListRuleEvaluations | RulesOp::ListWorkspaceRuleEvaluations) => {
117 return through::<rules::EvaluationPage>(op, as_is);
118 }
119 // Built by the API itself.
120 Op::Rules(RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset) => return as_is,
121 // Deployments travel in `snake_case` between services too.
122 Op::Deployments(_) => return as_is,
123 // So does telemetry.
124 Op::Telemetry(_) => return as_is,
125 // So does Getting started, from identity.
126 Op::Onboarding(_) => return through::<g1t_contracts::identity::Onboarding>(op, as_is),
127 // Artifacts are shaped by the API itself, in `snake_case`.
128 Op::Artifacts(_) => return as_is,
129 // The cache's entries travel in `snake_case` from the actions service.
130 Op::Caches(_) => return as_is,
131 // So are archive links.
132 Op::Archives(_) => return as_is,
133 // Deploy keys travel in `snake_case` from identity.
134 Op::DeployKeys(DeployKeysOp::ListDeployKeys) => return through::<Vec<g1t_contracts::deploy_keys::DeployKey>>(op, as_is),
135 Op::DeployKeys(DeployKeysOp::GetDeployKey | DeployKeysOp::CreateDeployKey) => {
136 return through::<g1t_contracts::deploy_keys::DeployKey>(op, as_is);
137 }
138 Op::DeployKeys(DeployKeysOp::DeleteDeployKey) => return through::<bool>(op, as_is),
139 // Packages are shaped by the API itself, in `snake_case`.
140 Op::Packages(_) => return as_is,
141 // Artifacts are shaped by the API itself, in `snake_case`.
142 Op::Folios(_) => return as_is,
143 // Built by the API itself, in `snake_case`.
144 Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is),
145 Op::DismissSecurityAlert | Op::ReopenSecurityAlert => {
146 return through::<crate::alerts::SecurityAlert>(op, as_is);
147 }
148 _ => {}
149 }
150 let mut sent = as_services_send(example);
151 // A pull request's code owners are `snake_case` inside it.
152 if op == Op::GetPullRequest
153 && let Some(code_owners) = example.get("code_owners")
154 {
155 sent["codeOwners"] = code_owners.clone();
156 }
157 match op {
158 Op::GetWorkspace | Op::CreateWorkspace | Op::UpdateWorkspace => through::<g1t_contracts::identity::Workspace>(op, sent),
159 Op::ListRepos | Op::ListForks => through::<Vec<repos::Repo>>(op, sent),
160 Op::SyncFork => through::<repos::SyncedFork>(op, sent),
161 Op::Search => through::<search::SearchResults>(op, sent),
162 Op::GetRepo
163 | Op::CreateRepo
164 | Op::UpdateRepo
165 | Op::TransferRepo
166 | Op::ForkRepo
167 | Op::DetachFork
168 | Op::RenameRepo
169 | Op::RenameBranch
170 | Op::ArchiveRepo
171 | Op::UnarchiveRepo
172 | Op::SetRepoVisibility
173 | Op::RestoreRepo => through::<repos::Repo>(op, sent),
174 Op::CreateBranch | Op::DeleteBranch => through::<repos::Branch>(op, sent),
175 Op::DeleteRepo => through::<repos::DeletedRepo>(op, sent),
176 Op::ListDeletedRepos => through::<Vec<repos::DeletedRepo>>(op, sent),
177 Op::GetRepoSettings | Op::UpdateRepoSettings => through::<work::RepoSettings>(op, sent),
178 Op::ListCheckNames => through::<Vec<work::SeenCheck>>(op, sent),
179 Op::GetMergeQueue => through::<work::QueueView>(op, sent),
180 Op::ListDecisions => through::<Vec<g1t_contracts::decisions::Decision>>(op, sent),
181 Op::RecordDecision => through::<g1t_contracts::decisions::Decision>(op, sent),
182 Op::Why => through::<g1t_contracts::decisions::Why>(op, sent),
183 Op::ListIssues => through::<Vec<work::Issue>>(op, sent),
184 Op::CreateIssue | Op::UpdateIssue | Op::CloseIssue | Op::ReopenIssue => {
185 through::<work::Issue>(op, sent)
186 }
187 Op::GetIssue => through::<work::IssueDetail>(op, sent),
188 Op::ListPullRequests => through::<Vec<work::Pull>>(op, sent),
189 Op::UpdatePullRequest => through::<work::Pull>(op, sent),
190 Op::ListLabels | Op::AddDefaultLabels | Op::ListIssueLabels => through::<Vec<work::Label>>(op, sent),
191 Op::CreateLabel | Op::UpdateLabel => through::<work::Label>(op, sent),
192 Op::ListMilestones => through::<Vec<work::Milestone>>(op, sent),
193 Op::CreateMilestone | Op::UpdateMilestone => through::<work::Milestone>(op, sent),
194 Op::GetMilestone => through::<work::MilestoneDetail>(op, sent),
195 Op::GetPullRequest => through::<work::PullDetail>(op, sent),
196 Op::MarkPullRequestReady
197 | Op::ClosePullRequest
198 | Op::ReopenPullRequest
199 | Op::ConvertPullRequestToDraft
200 | Op::MergePullRequest
201 | Op::EnablePullRequestAutoMerge
202 | Op::DisablePullRequestAutoMerge
203 | Op::DeletePullRequestHeadBranch
204 | Op::RestorePullRequestHeadBranch
205 | Op::RequestReviewers
206 | Op::RemoveRequestedReviewers => {
207 through::<work::Pull>(op, sent)
208 }
209 Op::UpdatePullRequestBranch => through::<repos::PullBranchUpdate>(op, sent),
210 Op::GetPullRequestMergeOptions => through::<work::MergeChoices>(op, sent),
211 Op::ListRepoCommitComments | Op::ListCommitComments => through::<Vec<work::CommitComment>>(op, sent),
212 Op::CreateCommitComment | Op::UpdateCommitComment => through::<work::CommitComment>(op, sent),
213 Op::ListWorkflows => through::<Vec<actions::Workflow>>(op, sent),
214 Op::GetWorkflow => through::<actions::WorkflowDetail>(op, sent),
215 Op::ListWorkflowRuns => through::<Vec<actions::WorkflowRun>>(op, sent),
216 Op::GetWorkflowRun => through::<actions::RunDetail>(op, sent),
217 Op::GetJobLogs => through::<actions::JobLog>(op, sent),
218 Op::DispatchWorkflow | Op::CancelWorkflowRun | Op::RerunWorkflowRun => {
219 through::<actions::WorkflowRun>(op, sent)
220 }
221 Op::ListActionsSecrets | Op::ListActionsVariables => through::<Vec<actions::Setting>>(op, sent),
222 Op::ListRunners => through::<Vec<g1t_contracts::runners::Runner>>(op, sent),
223 Op::ListRunnerGroups => through::<Vec<g1t_contracts::runners::RunnerGroup>>(op, sent),
224 Op::CreateRunnerGroup | Op::UpdateRunnerGroup => through::<g1t_contracts::runners::RunnerGroup>(op, sent),
225 Op::GetRunnerSettings | Op::UpdateRunnerSettings => through::<g1t_contracts::runners::RunnerSettings>(op, sent),
226 Op::CreateRunnerRegistrationToken => through::<g1t_contracts::runners::RegistrationToken>(op, sent),
227 Op::ListWebhooks => through::<Vec<webhooks::Hook>>(op, sent),
228 Op::ListIntegrations => through::<Vec<integrations::Connection>>(op, sent),
229 Op::ListAiProviders => through::<Vec<ai::AiConnection>>(op, sent),
230 Op::AddAiProvider | Op::UpdateAiProvider => through::<ai::AiConnection>(op, sent),
231 Op::RemoveAiProvider => through::<bool>(op, sent),
232 Op::TestAiProvider => through::<integrations::Tested>(op, sent),
233 Op::ListEvents => through::<Vec<g1t_contracts::events::Event>>(op, sent),
234 Op::ListEmails | Op::AddEmail | Op::RemoveEmail | Op::UpdateEmailSettings => {
235 through::<g1t_contracts::accounts::AccountEmails>(op, sent)
236 }
237 Op::ConfirmEmail => through::<g1t_contracts::accounts::EmailConfirmed>(op, sent),
238 Op::ListInvites => through::<g1t_contracts::identity::InvitesOverview>(op, sent),
239 Op::CreateInvite | Op::RevokeInvite | Op::ResendInvite | Op::InviteMember | Op::RevokeWorkspaceInvite => {
240 through::<g1t_contracts::identity::Invite>(op, sent)
241 }
242 Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent),
243 Op::ListInvitations => through::<Vec<g1t_contracts::identity::WorkspaceInvitation>>(op, sent),
244 Op::ListNotifications => through::<g1t_contracts::inbox::InboxPage>(op, sent),
245 Op::GetNotificationThread | Op::MarkThreadRead | Op::MarkThreadDone | Op::SaveThread | Op::SnoozeThread => {
246 through::<g1t_contracts::inbox::InboxThread>(op, sent)
247 }
248 Op::GetThreadSubscription | Op::SetThreadSubscription | Op::DeleteThreadSubscription => {
249 through::<g1t_contracts::inbox::ThreadSubscription>(op, sent)
250 }
251 Op::Checks(ChecksOp::CreateCommitStatus) => through::<work::CommitStatus>(op, sent),
252 Op::Checks(ChecksOp::ListCommitStatuses) => through::<Vec<work::CommitStatus>>(op, sent),
253 Op::Checks(ChecksOp::GetCombinedStatus) => through::<checks::CombinedStatus>(op, sent),
254 Op::Checks(ChecksOp::CreateCheckRun | ChecksOp::UpdateCheckRun | ChecksOp::GetCheckRun) => {
255 through::<checks::CommitCheckRun>(op, sent)
256 }
257 Op::Checks(ChecksOp::ListCheckRunAnnotations) => through::<Vec<checks::CheckAnnotation>>(op, sent),
258 Op::Checks(ChecksOp::ListCheckRunsForRef) => through::<checks::CheckRunList>(op, sent),
259 Op::Checks(ChecksOp::ListCheckSuitesForRef) => through::<checks::CheckSuiteList>(op, sent),
260 Op::Checks(ChecksOp::GetCheckSuite) => through::<checks::CommitCheckSuite>(op, sent),
261 Op::Coordination(CoordinationOp::StartSession | CoordinationOp::UpdateSession | CoordinationOp::EndSession) => {
262 through::<work::Session>(op, sent)
263 }
264 Op::Coordination(CoordinationOp::GetSession) => through::<work::SessionDetail>(op, sent),
265 Op::Coordination(CoordinationOp::ListSessions) => through::<Vec<work::Session>>(op, sent),
266 Op::Coordination(CoordinationOp::RecordSessionEntries) => through::<work::Recorded>(op, sent),
267 Op::Coordination(CoordinationOp::ClaimIssue) => through::<work::Claimed>(op, sent),
268 Op::Coordination(CoordinationOp::ReleaseClaim | CoordinationOp::HandOffIssue) => through::<work::Claim>(op, sent),
269 Op::Coordination(CoordinationOp::ListClaims) => through::<Vec<work::Claim>>(op, sent),
270 Op::Coordination(CoordinationOp::GetWork) => through::<work::WorkContext>(op, sent),
271 Op::Coordination(CoordinationOp::StartAgentSession) => through::<g1t_contracts::agent_sessions::AgentSessionStarted>(op, sent),
272 _ => sent,
273 }
274}
275
276/// Every key of `example`, as paths, outside the maps passed through.
277fn paths(value: &Value, path: &str, out: &mut Vec<String>) {
278 match value {
279 Value::Object(fields) => {
280 for (key, value) in fields {
281 let here = format!("{path}.{key}");
282 out.push(here.clone());
283 let user_keyed = value.is_object()
284 && (USER_KEYED.contains(&key.as_str()) || key.starts_with("by_"));
285 if !user_keyed {
286 paths(value, &here, out);
287 }
288 }
289 }
290 Value::Array(items) => {
291 for item in items {
292 paths(item, &format!("{path}[]"), out);
293 }
294 }
295 _ => {}
296 }
297}
298
299#[test]
300fn no_route_answers_with_camel_case() {
301 let document = document();
302 let (mut checked, mut converted) = (0, 0);
303 for (path, methods) in document["paths"].as_object().unwrap() {
304 for (method, operation) in methods.as_object().unwrap() {
305 let example = &operation["responses"]["200"]["content"]["application/json"]["example"];
306 let tool = operation["x-operation"].as_str().unwrap_or_default();
307 let Some(op) = Op::by_name(tool) else {
308 // Device sign-in, which is written in `snake_case` by hand.
309 assert!(wire::camel_case_keys(example).is_empty(), "{method} {path}");
310 continue;
311 };
312 let mut sample = sample(op, example);
313 // Each person gets their chosen case on the way out (people.rs).
314 crate::people::fill(&mut sample, &std::collections::HashMap::new());
315 converted += wire::camel_case_keys(&sample).len();
316 let sent = wire::snake_case(sample);
317 let leaked = wire::camel_case_keys(&sent);
318 assert!(leaked.is_empty(), "{method} {path} sends {leaked:?}");
319 // The reference shows what is sent: each of its names is one.
320 let (mut shown, mut real) = (Vec::new(), Vec::new());
321 paths(example, "", &mut shown);
322 paths(&sent, "", &mut real);
323 for name in shown {
324 assert!(real.contains(&name), "{method} {path}: the reference shows {name}, which is not sent");
325 }
326 checked += 1;
327 }
328 }
329 assert!(checked >= Op::ALL.len());
330 // The samples are in the services' spelling, so there was something to
331 // convert.
332 assert!(converted > 100, "{converted}");
333}
334
335#[test]
336fn every_route_has_a_sample() {
337 let document = document();
338 for route in crate::rest::ROUTES {
339 if route.no_content() {
340 continue;
341 }
342 let path = route
343 .path
344 .split('/')
345 .map(|segment| match segment.strip_prefix(':') {
346 Some(name) => format!("{{{name}}}"),
347 None => segment.to_owned(),
348 })
349 .collect::<Vec<_>>()
350 .join("/");
351 let example = &document["paths"][&path][route.method.to_lowercase()]["responses"]["200"]
352 ["content"]["application/json"]["example"];
353 assert!(!example.is_null(), "{} {path}", route.method);
354 }
355}
356
357#[test]
358fn errors_and_reports_are_snake_case() {
359 let failure = g1t_contracts::Failure {
360 code: g1t_contracts::FailureCode::NotFound,
361 message: "No such endpoint.".to_owned(),
362 };
363 assert!(wire::camel_case_keys(&wire::snake_case(json!({ "error": failure }))).is_empty());
364}
365
366#[test]
367fn a_job_spec_keeps_github_s_spelling() {
368 let spec = json!({
369 "job": "job_1",
370 "spec": { "runs-on": "ubuntu-latest", "timeoutMinutes": 5 },
371 "workflow": { "env": { "nodeEnv": "x" } },
372 "github": { "eventName": "push", "headRef": "" },
373 "event": { "pull_request": { "headSha": "x" } },
374 "contexts": { "inputs": { "dryRun": true }, "matrix": { "nodeVersion": 20 } },
375 "checkout": { "ref": "main" },
376 "timeoutMinutes": 30,
377 "masks": [],
378 });
379 let sent = wire::snake_case_keeping(spec.clone(), crate::JOB_SPEC_AS_GIVEN);
380 assert_eq!(sent["timeout_minutes"], 30);
381 assert!(sent.get("timeoutMinutes").is_none());
382 for kept in ["spec", "workflow", "github", "event", "contexts", "checkout"] {
383 assert_eq!(sent[kept], spec[kept], "{kept}");
384 }
385}