11package batches
22
33import (
4- "encoding/json"
54 "fmt"
65 "strings"
76
@@ -39,31 +38,8 @@ type BatchSpec struct {
3938 TransformChanges * TransformChanges `json:"transformChanges,omitempty" yaml:"transformChanges,omitempty"`
4039 ImportChangesets []ImportChangeset `json:"importChangesets,omitempty" yaml:"importChangesets"`
4140 ChangesetTemplate * ChangesetTemplate `json:"changesetTemplate,omitempty" yaml:"changesetTemplate"`
42- ChangesetHooks * ChangesetHooks `json:"changesetHooks,omitempty" yaml:"hooks,omitempty"`
4341}
4442
45- // Hooks declares side-effect actions to run at well-defined changeset
46- // lifecycle events. Only allowed when Version is 3.
47- type ChangesetHooks struct {
48- OnCIFailure ChangesetHookAction `json:"onCIFailure,omitempty" yaml:"onCIFailure,omitempty"`
49- OnMergeConflict ChangesetHookAction `json:"onMergeConflict,omitempty" yaml:"onMergeConflict,omitempty"`
50- }
51-
52- // HookAction is a single action attached to a changeset lifecycle event.
53- //
54- // Hook actions reuse the Step shape from the top-level steps block.
55- type ChangesetHookAction struct {
56- Steps []Step `json:"steps,omitempty" yaml:"steps,omitempty"`
57- }
58-
59- type changesetHookEvent string
60-
61- // Hook event names. Kept here so callers don't pass typoed strings.
62- const (
63- ChangesetHookEventOnCIFailure changesetHookEvent = "onCIFailure"
64- ChangesetHookEventOnMergeConflict changesetHookEvent = "onMergeConflict"
65- )
66-
6743type ChangesetTemplate struct {
6844 Title string `json:"title,omitempty" yaml:"title"`
6945 Body string `json:"body,omitempty" yaml:"body"`
@@ -114,46 +90,13 @@ func (oqor *OnQueryOrRepository) GetBranches() ([]string, error) {
11490}
11591
11692type Step struct {
117- Run string `json:"run,omitempty" yaml:"run"`
118- CodingAgent * CodingAgentStep `json:"codingAgent,omitempty" yaml:"codingAgent,omitempty"`
119- BuildImage * BuildImageStep `json:"buildImage,omitempty" yaml:"buildImage,omitempty"`
120- Container string `json:"container,omitempty" yaml:"container"`
121- Image string `json:"image,omitempty" yaml:"image"`
122- MaxAttempts int `json:"maxAttempts,omitempty" yaml:"maxAttempts,omitempty"`
123- Env env.Environment `json:"env" yaml:"env"`
124- Files map [string ]string `json:"files,omitempty" yaml:"files,omitempty"`
125- Outputs Outputs `json:"outputs,omitempty" yaml:"outputs,omitempty"`
126- Mount []Mount `json:"mount,omitempty" yaml:"mount,omitempty"`
127- If any `json:"if,omitempty" yaml:"if,omitempty"`
128- }
129-
130- type CodingAgentStep struct {
131- Type string `json:"type,omitempty" yaml:"type"`
132- Prompt string `json:"prompt,omitempty" yaml:"prompt"`
133- }
134-
135- type BuildImageStep struct {
136- Run string `json:"run" yaml:"run"`
137- BaseImage string `json:"baseImage" yaml:"baseImage"`
138- }
139-
140- // MarshalJSON canonicalizes the v3 `image:` field into `container:` on the
141- // wire. Both fields exist on Step for ergonomic reasons (v3 specs use
142- // `image:`, v1/v2 specs use `container:`), but src-cli's Step has only
143- // `Container`. Without canonicalization, the prep-side cache key — computed
144- // by JSON-marshaling Step — would include `image` while the executor side
145- // (which round-trips through src-cli) would not, producing divergent keys
146- // and silent cache misses for any v3 spec. See the regression test in
147- // lib/batches/execution/cache.
148- func (s Step ) MarshalJSON () ([]byte , error ) {
149- // Use an alias type to avoid infinite recursion through MarshalJSON.
150- type stepAlias Step
151- canon := stepAlias (s )
152- if canon .Container == "" {
153- canon .Container = canon .Image
154- }
155- canon .Image = ""
156- return json .Marshal (canon )
93+ Run string `json:"run,omitempty" yaml:"run"`
94+ Container string `json:"container,omitempty" yaml:"container"`
95+ Env env.Environment `json:"env" yaml:"env"`
96+ Files map [string ]string `json:"files,omitempty" yaml:"files,omitempty"`
97+ Outputs Outputs `json:"outputs,omitempty" yaml:"outputs,omitempty"`
98+ Mount []Mount `json:"mount,omitempty" yaml:"mount,omitempty"`
99+ If any `json:"if,omitempty" yaml:"if,omitempty"`
157100}
158101
159102func (s * Step ) IfCondition () string {
@@ -218,97 +161,33 @@ func parseBatchSpec(schema string, data []byte) (*BatchSpec, error) {
218161 return nil , err
219162 }
220163
221- if spec .Version == 3 {
222- // Mirror v3 `image:` into `container:` so in-memory consumers that
223- // read step.Container (e.g. the executor transform) keep working.
224- // JSON serialization is canonicalized separately in Step.MarshalJSON
225- // so prep-side cache hashing matches src-cli/executor serialization.
226- for i := range spec .Steps {
227- spec .Steps [i ].Container = spec .Steps [i ].Image
228- }
229- }
230-
231164 var errs error
165+
232166 if len (spec .Steps ) != 0 && spec .ChangesetTemplate == nil {
233167 errs = errors .Append (errs , NewValidationError (errors .New ("batch spec includes steps but no changesetTemplate" )))
234168 }
235169
236- // v3 specs do not support changesetTemplate.published — publication is
237- // driven exclusively via the batchchangeagent tools. Reject the field at
238- // parse time.
239- if spec .Version == 3 && spec .ChangesetTemplate != nil && spec .ChangesetTemplate .Published != nil {
240- errs = errors .Append (errs , NewValidationError (errors .New ("changesetTemplate.published is not supported in batch spec version 3; drive publication via the publish_changesets tool instead" )))
241- }
242-
243170 for i , step := range spec .Steps {
244171 for _ , mount := range step .Mount {
245- if strings .Contains (mount .Path , invalidMountCharacters ) {
172+ if strings .ContainsAny (mount .Path , invalidMountCharacters ) {
246173 errs = errors .Append (errs , NewValidationError (errors .Newf ("step %d mount path contains invalid characters" , i + 1 )))
247174 }
248- if strings .Contains (mount .Mountpoint , invalidMountCharacters ) {
175+ if strings .ContainsAny (mount .Mountpoint , invalidMountCharacters ) {
249176 errs = errors .Append (errs , NewValidationError (errors .Newf ("step %d mount mountpoint contains invalid characters" , i + 1 )))
250177 }
251178 }
252- if step .CodingAgent != nil && step .Run != "" {
253- errs = errors .Append (errs , NewValidationError (errors .Newf ("step %d: codingAgent and run cannot be combined in the same step" , i + 1 )))
254- }
255- if step .BuildImage != nil && step .Run != "" {
256- errs = errors .Append (errs , NewValidationError (errors .Newf ("step %d: buildImage and run cannot be combined in the same step" , i + 1 )))
257- }
258179 for name := range step .Files {
259- if strings .Contains (name , invalidMountCharacters ) {
180+ if strings .ContainsAny (name , invalidMountCharacters ) {
260181 errs = errors .Append (errs , NewValidationError (errors .Newf ("step %d files target path contains invalid characters" , i + 1 )))
261182 }
262183 }
263184 }
264185
265- if hookErr := validateHooks (& spec ); hookErr != nil {
266- errs = errors .Append (errs , hookErr )
267- }
268-
269186 return & spec , errs
270187}
271188
272- // validateHooks performs Go-level validation of spec.Hooks beyond what the
273- // JSON schema enforces. The schema already gates `hooks:` on `version: 3` and
274- // rejects unknown event names. We re-check the version invariant here so
275- // non-schema callers (and any future schema drift) still fail safely, and we
276- // run the per-step mount-character validator that the schema cannot express.
277- func validateHooks (spec * BatchSpec ) error {
278- if spec .ChangesetHooks == nil {
279- return nil
280- }
281-
282- var errs error
283-
284- if spec .Version != 3 {
285- errs = errors .Append (errs , NewValidationError (errors .New ("batch spec hooks require version: 3" )))
286- }
287-
288- validate := func (event changesetHookEvent , action ChangesetHookAction ) {
289- for i , step := range action .Steps {
290- for _ , mount := range step .Mount {
291- if strings .Contains (mount .Path , invalidMountCharacters ) {
292- errs = errors .Append (errs , NewValidationError (errors .Newf (
293- "hooks.%s step %d mount path contains invalid characters" , event , i + 1 ,
294- )))
295- }
296- if strings .Contains (mount .Mountpoint , invalidMountCharacters ) {
297- errs = errors .Append (errs , NewValidationError (errors .Newf (
298- "hooks.%s step %d mount mountpoint contains invalid characters" , event , i + 1 ,
299- )))
300- }
301- }
302- }
303- }
304-
305- validate (ChangesetHookEventOnCIFailure , spec .ChangesetHooks .OnCIFailure )
306- validate (ChangesetHookEventOnMergeConflict , spec .ChangesetHooks .OnMergeConflict )
307-
308- return errs
309- }
310-
311- const invalidMountCharacters = ","
189+ // docker uses Golang's `encoding/csv` library to parse arguments passed to `--mount`
190+ const invalidMountCharacters = ",\" \n \r "
312191
313192func (on * OnQueryOrRepository ) String () string {
314193 if on .RepositoriesMatchingQuery != "" {
@@ -333,6 +212,10 @@ func (e BatchSpecValidationError) Error() string {
333212 return e .err .Error ()
334213}
335214
215+ func IsValidationError (err error ) bool {
216+ return errors.HasType [* BatchSpecValidationError ](err )
217+ }
218+
336219// SkippedStepsForRepo calculates the steps required to run on the given repo.
337220func SkippedStepsForRepo (spec * BatchSpec , repoName string , fileMatches []string ) (skipped map [int ]struct {}, err error ) {
338221 skipped = map [int ]struct {}{}
0 commit comments