11package batches
22
33import (
4+ "encoding/json"
45 "fmt"
56 "strings"
67
@@ -38,8 +39,31 @@ type BatchSpec struct {
3839 TransformChanges * TransformChanges `json:"transformChanges,omitempty" yaml:"transformChanges,omitempty"`
3940 ImportChangesets []ImportChangeset `json:"importChangesets,omitempty" yaml:"importChangesets"`
4041 ChangesetTemplate * ChangesetTemplate `json:"changesetTemplate,omitempty" yaml:"changesetTemplate"`
42+ ChangesetHooks * ChangesetHooks `json:"changesetHooks,omitempty" yaml:"hooks,omitempty"`
4143}
4244
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+
4367type ChangesetTemplate struct {
4468 Title string `json:"title,omitempty" yaml:"title"`
4569 Body string `json:"body,omitempty" yaml:"body"`
@@ -90,13 +114,46 @@ func (oqor *OnQueryOrRepository) GetBranches() ([]string, error) {
90114}
91115
92116type Step struct {
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"`
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 )
100157}
101158
102159func (s * Step ) IfCondition () string {
@@ -161,33 +218,97 @@ func parseBatchSpec(schema string, data []byte) (*BatchSpec, error) {
161218 return nil , err
162219 }
163220
164- var errs error
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+ }
165230
231+ var errs error
166232 if len (spec .Steps ) != 0 && spec .ChangesetTemplate == nil {
167233 errs = errors .Append (errs , NewValidationError (errors .New ("batch spec includes steps but no changesetTemplate" )))
168234 }
169235
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+
170243 for i , step := range spec .Steps {
171244 for _ , mount := range step .Mount {
172- if strings .ContainsAny (mount .Path , invalidMountCharacters ) {
245+ if strings .Contains (mount .Path , invalidMountCharacters ) {
173246 errs = errors .Append (errs , NewValidationError (errors .Newf ("step %d mount path contains invalid characters" , i + 1 )))
174247 }
175- if strings .ContainsAny (mount .Mountpoint , invalidMountCharacters ) {
248+ if strings .Contains (mount .Mountpoint , invalidMountCharacters ) {
176249 errs = errors .Append (errs , NewValidationError (errors .Newf ("step %d mount mountpoint contains invalid characters" , i + 1 )))
177250 }
178251 }
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+ }
179258 for name := range step .Files {
180- if strings .ContainsAny (name , invalidMountCharacters ) {
259+ if strings .Contains (name , invalidMountCharacters ) {
181260 errs = errors .Append (errs , NewValidationError (errors .Newf ("step %d files target path contains invalid characters" , i + 1 )))
182261 }
183262 }
184263 }
185264
265+ if hookErr := validateHooks (& spec ); hookErr != nil {
266+ errs = errors .Append (errs , hookErr )
267+ }
268+
186269 return & spec , errs
187270}
188271
189- // docker uses Golang's `encoding/csv` library to parse arguments passed to `--mount`
190- const invalidMountCharacters = ",\" \n \r "
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 = ","
191312
192313func (on * OnQueryOrRepository ) String () string {
193314 if on .RepositoriesMatchingQuery != "" {
@@ -212,10 +333,6 @@ func (e BatchSpecValidationError) Error() string {
212333 return e .err .Error ()
213334}
214335
215- func IsValidationError (err error ) bool {
216- return errors.HasType [* BatchSpecValidationError ](err )
217- }
218-
219336// SkippedStepsForRepo calculates the steps required to run on the given repo.
220337func SkippedStepsForRepo (spec * BatchSpec , repoName string , fileMatches []string ) (skipped map [int ]struct {}, err error ) {
221338 skipped = map [int ]struct {}{}
0 commit comments