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
366 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.

Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing1//! 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
Merge checks: statuses and check runs on every commit10use g1t_contracts::{access, actions, checks, codeowners, integrations, repos, rules, search, teams, webhooks, work};
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing11use 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;
Merge checks: statuses and check runs on every commit18use crate::checks::ChecksOp;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge19use crate::rules::RulesOp;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca20use crate::deploy_keys::DeployKeysOp;
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'21use crate::coordination::CoordinationOp;
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing22
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),
Merge main (membership, two-factor, GitHub repo roles) into tokens86 // Members, also `snake_case`.
87 Op::ListMembers => return through::<Vec<g1t_contracts::identity::Member>>(op, as_is),
88 Op::UpdateMember => return through::<g1t_contracts::identity::Member>(op, as_is),
89 Op::RemoveMember | Op::TransferOwnership | Op::LeaveWorkspace => return through::<bool>(op, as_is),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar90 // Teams and code owners, also `snake_case`.
91 Op::ListTeams | Op::ListChildTeams | Op::ListUserTeams => return through::<Vec<teams::Team>>(op, as_is),
92 Op::GetTeam | Op::CreateTeam | Op::UpdateTeam | Op::SetTeamReviewAssignment => {
93 return through::<teams::Team>(op, as_is);
94 }
95 Op::ListTeamMembers => return through::<Vec<teams::TeamMember>>(op, as_is),
96 Op::SetTeamMember => return through::<teams::TeamMember>(op, as_is),
97 Op::ListTeamRepos => return through::<Vec<teams::TeamRepo>>(op, as_is),
98 Op::SetTeamRepo => return through::<teams::TeamRepo>(op, as_is),
99 Op::DeleteTeam | Op::RemoveTeamMember | Op::RemoveTeamRepo => return through::<bool>(op, as_is),
100 Op::GetCodeownersErrors => return through::<codeowners::CodeOwnersReport>(op, as_is),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge101 // Rulesets travel in `snake_case` between services too.
102 Op::Rules(RulesOp::ListRepoRulesets | RulesOp::ListWorkspaceRulesets) => {
103 return through::<Vec<rules::Ruleset>>(op, as_is);
104 }
105 Op::Rules(
106 RulesOp::GetRepoRuleset
107 | RulesOp::CreateRepoRuleset
108 | RulesOp::UpdateRepoRuleset
109 | RulesOp::GetWorkspaceRuleset
110 | RulesOp::CreateWorkspaceRuleset
111 | RulesOp::UpdateWorkspaceRuleset,
112 ) => return through::<rules::Ruleset>(op, as_is),
113 Op::Rules(RulesOp::GetBranchRules) => return through::<rules::EffectiveRules>(op, as_is),
114 Op::Rules(RulesOp::ListRuleEvaluations | RulesOp::ListWorkspaceRuleEvaluations) => {
115 return through::<rules::EvaluationPage>(op, as_is);
116 }
117 // Built by the API itself.
118 Op::Rules(RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset) => return as_is,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97119 // Deployments travel in `snake_case` between services too.
120 Op::Deployments(_) => return as_is,
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.121 // So does telemetry.
122 Op::Telemetry(_) => return as_is,
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2123 // Artifacts are shaped by the API itself, in `snake_case`.
124 Op::Artifacts(_) => return as_is,
Merge branch 'worktree-agent-a3b9b1f730f55b5bf'125 // The cache's entries travel in `snake_case` from the actions service.
126 Op::Caches(_) => return as_is,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.127 // So are archive links.
128 Op::Archives(_) => return as_is,
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca129 // Deploy keys travel in `snake_case` from identity.
130 Op::DeployKeys(DeployKeysOp::ListDeployKeys) => return through::<Vec<g1t_contracts::deploy_keys::DeployKey>>(op, as_is),
131 Op::DeployKeys(DeployKeysOp::GetDeployKey | DeployKeysOp::CreateDeployKey) => {
132 return through::<g1t_contracts::deploy_keys::DeployKey>(op, as_is);
133 }
134 Op::DeployKeys(DeployKeysOp::DeleteDeployKey) => return through::<bool>(op, as_is),
Merge packages: roles, Actions access, source label, soft delete, API135 // Packages are shaped by the API itself, in `snake_case`.
136 Op::Packages(_) => return as_is,
Merge main into Artifacts Phase 2137 // Artifacts are shaped by the API itself, in `snake_case`.
138 Op::Folios(_) => return as_is,
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing139 // Built by the API itself, in `snake_case`.
140 Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is),
141 Op::DismissSecurityAlert | Op::ReopenSecurityAlert => {
142 return through::<crate::alerts::SecurityAlert>(op, as_is);
143 }
144 _ => {}
145 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar146 let mut sent = as_services_send(example);
147 // A pull request's code owners are `snake_case` inside it.
148 if op == Op::GetPullRequest
149 && let Some(code_owners) = example.get("code_owners")
150 {
151 sent["codeOwners"] = code_owners.clone();
152 }
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing153 match op {
Merge branch 'worktree-agent-ad7c6d88d93adc817'154 Op::GetWorkspace | Op::CreateWorkspace | Op::UpdateWorkspace => through::<g1t_contracts::identity::Workspace>(op, sent),
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing155 Op::ListRepos => through::<Vec<repos::Repo>>(op, sent),
156 Op::Search => through::<search::SearchResults>(op, sent),
157 Op::GetRepo
158 | Op::CreateRepo
159 | Op::UpdateRepo
160 | Op::TransferRepo
161 | Op::RenameRepo
162 | Op::RenameBranch
163 | Op::ArchiveRepo
164 | Op::UnarchiveRepo
165 | Op::SetRepoVisibility
166 | Op::RestoreRepo => through::<repos::Repo>(op, sent),
The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days.167 Op::CreateBranch | Op::DeleteBranch => through::<repos::Branch>(op, sent),
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing168 Op::DeleteRepo => through::<repos::DeletedRepo>(op, sent),
169 Op::ListDeletedRepos => through::<Vec<repos::DeletedRepo>>(op, sent),
170 Op::GetRepoSettings | Op::UpdateRepoSettings => through::<work::RepoSettings>(op, sent),
171 Op::ListCheckNames => through::<Vec<work::SeenCheck>>(op, sent),
172 Op::GetMergeQueue => through::<work::QueueView>(op, sent),
Merge branch 'worktree-agent-ab8033ab973f8d99a'173 Op::ListDecisions => through::<Vec<g1t_contracts::decisions::Decision>>(op, sent),
174 Op::RecordDecision => through::<g1t_contracts::decisions::Decision>(op, sent),
175 Op::Why => through::<g1t_contracts::decisions::Why>(op, sent),
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing176 Op::ListIssues => through::<Vec<work::Issue>>(op, sent),
177 Op::CreateIssue | Op::UpdateIssue | Op::CloseIssue | Op::ReopenIssue => {
178 through::<work::Issue>(op, sent)
179 }
180 Op::GetIssue => through::<work::IssueDetail>(op, sent),
181 Op::ListPullRequests => through::<Vec<work::Pull>>(op, sent),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar182 Op::UpdatePullRequest => through::<work::Pull>(op, sent),
183 Op::ListLabels | Op::AddDefaultLabels | Op::ListIssueLabels => through::<Vec<work::Label>>(op, sent),
184 Op::CreateLabel | Op::UpdateLabel => through::<work::Label>(op, sent),
185 Op::ListMilestones => through::<Vec<work::Milestone>>(op, sent),
186 Op::CreateMilestone | Op::UpdateMilestone => through::<work::Milestone>(op, sent),
187 Op::GetMilestone => through::<work::MilestoneDetail>(op, sent),
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing188 Op::GetPullRequest => through::<work::PullDetail>(op, sent),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar189 Op::MarkPullRequestReady
190 | Op::ClosePullRequest
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts191 | Op::ReopenPullRequest
192 | Op::ConvertPullRequestToDraft
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar193 | Op::MergePullRequest
194 | Op::RequestReviewers
195 | Op::RemoveRequestedReviewers => {
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing196 through::<work::Pull>(op, sent)
197 }
198 Op::ListWorkflows => through::<Vec<actions::Workflow>>(op, sent),
Merge branch 'worktree-agent-a4db4b523bb973765'199 Op::GetWorkflow => through::<actions::WorkflowDetail>(op, sent),
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing200 Op::ListWorkflowRuns => through::<Vec<actions::WorkflowRun>>(op, sent),
201 Op::GetWorkflowRun => through::<actions::RunDetail>(op, sent),
202 Op::GetJobLogs => through::<actions::JobLog>(op, sent),
203 Op::DispatchWorkflow | Op::CancelWorkflowRun | Op::RerunWorkflowRun => {
204 through::<actions::WorkflowRun>(op, sent)
205 }
206 Op::ListActionsSecrets | Op::ListActionsVariables => through::<Vec<actions::Setting>>(op, sent),
207 Op::ListRunners => through::<Vec<g1t_contracts::runners::Runner>>(op, sent),
208 Op::ListRunnerGroups => through::<Vec<g1t_contracts::runners::RunnerGroup>>(op, sent),
209 Op::CreateRunnerGroup | Op::UpdateRunnerGroup => through::<g1t_contracts::runners::RunnerGroup>(op, sent),
210 Op::GetRunnerSettings | Op::UpdateRunnerSettings => through::<g1t_contracts::runners::RunnerSettings>(op, sent),
211 Op::CreateRunnerRegistrationToken => through::<g1t_contracts::runners::RegistrationToken>(op, sent),
212 Op::ListWebhooks => through::<Vec<webhooks::Hook>>(op, sent),
213 Op::ListIntegrations => through::<Vec<integrations::Connection>>(op, sent),
214 Op::ListEvents => through::<Vec<g1t_contracts::events::Event>>(op, sent),
215 Op::ListEmails | Op::AddEmail | Op::RemoveEmail | Op::UpdateEmailSettings => {
216 through::<g1t_contracts::accounts::AccountEmails>(op, sent)
217 }
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)218 Op::ConfirmEmail => through::<g1t_contracts::accounts::EmailConfirmed>(op, sent),
Merge branch 'worktree-agent-a6da31375506fc440'219 Op::ListInvites => through::<g1t_contracts::identity::InvitesOverview>(op, sent),
220 Op::CreateInvite | Op::RevokeInvite | Op::ResendInvite | Op::InviteMember | Op::RevokeWorkspaceInvite => {
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing221 through::<g1t_contracts::identity::Invite>(op, sent)
222 }
223 Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent),
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)224 Op::ListInvitations => through::<Vec<g1t_contracts::identity::WorkspaceInvitation>>(op, sent),
API: notifications over REST and MCP, with notifications scopes225 Op::ListNotifications => through::<g1t_contracts::inbox::InboxPage>(op, sent),
226 Op::GetNotificationThread | Op::MarkThreadRead | Op::MarkThreadDone | Op::SaveThread | Op::SnoozeThread => {
227 through::<g1t_contracts::inbox::InboxThread>(op, sent)
228 }
229 Op::GetThreadSubscription | Op::SetThreadSubscription | Op::DeleteThreadSubscription => {
230 through::<g1t_contracts::inbox::ThreadSubscription>(op, sent)
231 }
Merge checks: statuses and check runs on every commit232 Op::Checks(ChecksOp::CreateCommitStatus) => through::<work::CommitStatus>(op, sent),
233 Op::Checks(ChecksOp::ListCommitStatuses) => through::<Vec<work::CommitStatus>>(op, sent),
234 Op::Checks(ChecksOp::GetCombinedStatus) => through::<checks::CombinedStatus>(op, sent),
235 Op::Checks(ChecksOp::CreateCheckRun | ChecksOp::UpdateCheckRun | ChecksOp::GetCheckRun) => {
236 through::<checks::CommitCheckRun>(op, sent)
237 }
238 Op::Checks(ChecksOp::ListCheckRunAnnotations) => through::<Vec<checks::CheckAnnotation>>(op, sent),
239 Op::Checks(ChecksOp::ListCheckRunsForRef) => through::<checks::CheckRunList>(op, sent),
240 Op::Checks(ChecksOp::ListCheckSuitesForRef) => through::<checks::CheckSuiteList>(op, sent),
241 Op::Checks(ChecksOp::GetCheckSuite) => through::<checks::CommitCheckSuite>(op, sent),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'242 Op::Coordination(CoordinationOp::StartSession | CoordinationOp::UpdateSession | CoordinationOp::EndSession) => {
243 through::<work::Session>(op, sent)
244 }
245 Op::Coordination(CoordinationOp::GetSession) => through::<work::SessionDetail>(op, sent),
246 Op::Coordination(CoordinationOp::ListSessions) => through::<Vec<work::Session>>(op, sent),
247 Op::Coordination(CoordinationOp::RecordSessionEntries) => through::<work::Recorded>(op, sent),
248 Op::Coordination(CoordinationOp::ClaimIssue) => through::<work::Claimed>(op, sent),
249 Op::Coordination(CoordinationOp::ReleaseClaim | CoordinationOp::HandOffIssue) => through::<work::Claim>(op, sent),
250 Op::Coordination(CoordinationOp::ListClaims) => through::<Vec<work::Claim>>(op, sent),
251 Op::Coordination(CoordinationOp::GetWork) => through::<work::WorkContext>(op, sent),
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.252 Op::Coordination(CoordinationOp::StartAgentSession) => through::<g1t_contracts::agent_sessions::AgentSessionStarted>(op, sent),
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing253 _ => sent,
254 }
255}
256
257/// Every key of `example`, as paths, outside the maps passed through.
258fn paths(value: &Value, path: &str, out: &mut Vec<String>) {
259 match value {
260 Value::Object(fields) => {
261 for (key, value) in fields {
262 let here = format!("{path}.{key}");
263 out.push(here.clone());
264 let user_keyed = value.is_object()
265 && (USER_KEYED.contains(&key.as_str()) || key.starts_with("by_"));
266 if !user_keyed {
267 paths(value, &here, out);
268 }
269 }
270 }
271 Value::Array(items) => {
272 for item in items {
273 paths(item, &format!("{path}[]"), out);
274 }
275 }
276 _ => {}
277 }
278}
279
280#[test]
281fn no_route_answers_with_camel_case() {
282 let document = document();
283 let (mut checked, mut converted) = (0, 0);
284 for (path, methods) in document["paths"].as_object().unwrap() {
285 for (method, operation) in methods.as_object().unwrap() {
286 let example = &operation["responses"]["200"]["content"]["application/json"]["example"];
287 let tool = operation["x-operation"].as_str().unwrap_or_default();
288 let Some(op) = Op::by_name(tool) else {
289 // Device sign-in, which is written in `snake_case` by hand.
290 assert!(wire::camel_case_keys(example).is_empty(), "{method} {path}");
291 continue;
292 };
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar293 let mut sample = sample(op, example);
294 // Each person gets their chosen case on the way out (people.rs).
295 crate::people::fill(&mut sample, &std::collections::HashMap::new());
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing296 converted += wire::camel_case_keys(&sample).len();
297 let sent = wire::snake_case(sample);
298 let leaked = wire::camel_case_keys(&sent);
299 assert!(leaked.is_empty(), "{method} {path} sends {leaked:?}");
300 // The reference shows what is sent: each of its names is one.
301 let (mut shown, mut real) = (Vec::new(), Vec::new());
302 paths(example, "", &mut shown);
303 paths(&sent, "", &mut real);
304 for name in shown {
305 assert!(real.contains(&name), "{method} {path}: the reference shows {name}, which is not sent");
306 }
307 checked += 1;
308 }
309 }
310 assert!(checked >= Op::ALL.len());
311 // The samples are in the services' spelling, so there was something to
312 // convert.
313 assert!(converted > 100, "{converted}");
314}
315
316#[test]
317fn every_route_has_a_sample() {
318 let document = document();
319 for route in crate::rest::ROUTES {
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts320 if route.no_content() {
321 continue;
322 }
Repos: a path found not to be a file is remembered for 10 minutes, so repeated misses read nothing323 let path = route
324 .path
325 .split('/')
326 .map(|segment| match segment.strip_prefix(':') {
327 Some(name) => format!("{{{name}}}"),
328 None => segment.to_owned(),
329 })
330 .collect::<Vec<_>>()
331 .join("/");
332 let example = &document["paths"][&path][route.method.to_lowercase()]["responses"]["200"]
333 ["content"]["application/json"]["example"];
334 assert!(!example.is_null(), "{} {path}", route.method);
335 }
336}
337
338#[test]
339fn errors_and_reports_are_snake_case() {
340 let failure = g1t_contracts::Failure {
341 code: g1t_contracts::FailureCode::NotFound,
342 message: "No such endpoint.".to_owned(),
343 };
344 assert!(wire::camel_case_keys(&wire::snake_case(json!({ "error": failure }))).is_empty());
345}
346
347#[test]
348fn a_job_spec_keeps_github_s_spelling() {
349 let spec = json!({
350 "job": "job_1",
351 "spec": { "runs-on": "ubuntu-latest", "timeoutMinutes": 5 },
352 "workflow": { "env": { "nodeEnv": "x" } },
353 "github": { "eventName": "push", "headRef": "" },
354 "event": { "pull_request": { "headSha": "x" } },
355 "contexts": { "inputs": { "dryRun": true }, "matrix": { "nodeVersion": 20 } },
356 "checkout": { "ref": "main" },
357 "timeoutMinutes": 30,
358 "masks": [],
359 });
360 let sent = wire::snake_case_keeping(spec.clone(), crate::JOB_SPEC_AS_GIVEN);
361 assert_eq!(sent["timeout_minutes"], 30);
362 assert!(sent.get("timeoutMinutes").is_none());
363 for kept in ["spec", "workflow", "github", "event", "contexts", "checkout"] {
364 assert_eq!(sent[kept], spec[kept], "{kept}");
365 }
366}

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