|
| 1 | +package batches |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "strings" |
| 6 | + |
| 7 | + "github.com/sourcegraph/sourcegraph/lib/batches/env" |
| 8 | + "github.com/sourcegraph/sourcegraph/lib/batches/overridable" |
| 9 | + "github.com/sourcegraph/sourcegraph/lib/batches/schema" |
| 10 | + "github.com/sourcegraph/sourcegraph/lib/batches/template" |
| 11 | + "github.com/sourcegraph/sourcegraph/lib/batches/yaml" |
| 12 | + "github.com/sourcegraph/sourcegraph/lib/errors" |
| 13 | +) |
| 14 | + |
| 15 | +// Some general notes about the struct definitions below. |
| 16 | +// |
| 17 | +// 1. They map _very_ closely to the batch spec JSON schema. We don't |
| 18 | +// auto-generate the types because we need YAML support (more on that in a |
| 19 | +// moment) and because no generator can currently handle oneOf fields |
| 20 | +// gracefully in Go, but that's a potential future enhancement. |
| 21 | +// |
| 22 | +// 2. Fields are tagged with _both_ JSON and YAML tags. Internally, the JSON |
| 23 | +// schema library needs to be able to marshal the struct to JSON for |
| 24 | +// validation, so we need to ensure that we're generating the right JSON to |
| 25 | +// represent the YAML that we unmarshalled. |
| 26 | +// |
| 27 | +// 3. All JSON tags include omitempty so that the schema validation can pick up |
| 28 | +// omitted fields. The other option here was to have everything unmarshal to |
| 29 | +// pointers, which is ugly and inefficient. |
| 30 | + |
| 31 | +type BatchSpec struct { |
| 32 | + Version int `json:"version,omitempty" yaml:"version"` |
| 33 | + Name string `json:"name,omitempty" yaml:"name"` |
| 34 | + Description string `json:"description,omitempty" yaml:"description"` |
| 35 | + On []OnQueryOrRepository `json:"on,omitempty" yaml:"on"` |
| 36 | + Workspaces []WorkspaceConfiguration `json:"workspaces,omitempty" yaml:"workspaces"` |
| 37 | + Steps []Step `json:"steps,omitempty" yaml:"steps"` |
| 38 | + TransformChanges *TransformChanges `json:"transformChanges,omitempty" yaml:"transformChanges,omitempty"` |
| 39 | + ImportChangesets []ImportChangeset `json:"importChangesets,omitempty" yaml:"importChangesets"` |
| 40 | + ChangesetTemplate *ChangesetTemplate `json:"changesetTemplate,omitempty" yaml:"changesetTemplate"` |
| 41 | +} |
| 42 | + |
| 43 | +type ChangesetTemplate struct { |
| 44 | + Title string `json:"title,omitempty" yaml:"title"` |
| 45 | + Body string `json:"body,omitempty" yaml:"body"` |
| 46 | + Branch string `json:"branch,omitempty" yaml:"branch"` |
| 47 | + Fork *bool `json:"fork,omitempty" yaml:"fork"` |
| 48 | + Commit ExpandedGitCommitDescription `json:"commit" yaml:"commit"` |
| 49 | + Published *overridable.BoolOrString `json:"published" yaml:"published"` |
| 50 | +} |
| 51 | + |
| 52 | +type GitCommitAuthor struct { |
| 53 | + Name string `json:"name" yaml:"name"` |
| 54 | + Email string `json:"email" yaml:"email"` |
| 55 | +} |
| 56 | + |
| 57 | +type ExpandedGitCommitDescription struct { |
| 58 | + Message string `json:"message,omitempty" yaml:"message"` |
| 59 | + Author *GitCommitAuthor `json:"author,omitempty" yaml:"author"` |
| 60 | +} |
| 61 | + |
| 62 | +type ImportChangeset struct { |
| 63 | + Repository string `json:"repository" yaml:"repository"` |
| 64 | + ExternalIDs []any `json:"externalIDs" yaml:"externalIDs"` |
| 65 | +} |
| 66 | + |
| 67 | +type WorkspaceConfiguration struct { |
| 68 | + RootAtLocationOf string `json:"rootAtLocationOf,omitempty" yaml:"rootAtLocationOf"` |
| 69 | + In string `json:"in,omitempty" yaml:"in"` |
| 70 | + OnlyFetchWorkspace bool `json:"onlyFetchWorkspace,omitempty" yaml:"onlyFetchWorkspace"` |
| 71 | +} |
| 72 | + |
| 73 | +type OnQueryOrRepository struct { |
| 74 | + RepositoriesMatchingQuery string `json:"repositoriesMatchingQuery,omitempty" yaml:"repositoriesMatchingQuery"` |
| 75 | + Repository string `json:"repository,omitempty" yaml:"repository"` |
| 76 | + Branch string `json:"branch,omitempty" yaml:"branch"` |
| 77 | + Branches []string `json:"branches,omitempty" yaml:"branches"` |
| 78 | +} |
| 79 | + |
| 80 | +var ErrConflictingBranches = NewValidationError(errors.New("both branch and branches specified")) |
| 81 | + |
| 82 | +func (oqor *OnQueryOrRepository) GetBranches() ([]string, error) { |
| 83 | + if oqor.Branch != "" { |
| 84 | + if len(oqor.Branches) > 0 { |
| 85 | + return nil, ErrConflictingBranches |
| 86 | + } |
| 87 | + return []string{oqor.Branch}, nil |
| 88 | + } |
| 89 | + return oqor.Branches, nil |
| 90 | +} |
| 91 | + |
| 92 | +type 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"` |
| 100 | +} |
| 101 | + |
| 102 | +func (s *Step) IfCondition() string { |
| 103 | + switch v := s.If.(type) { |
| 104 | + case bool: |
| 105 | + if v { |
| 106 | + return "true" |
| 107 | + } |
| 108 | + return "false" |
| 109 | + case string: |
| 110 | + return v |
| 111 | + default: |
| 112 | + return "" |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +type Outputs map[string]Output |
| 117 | + |
| 118 | +type Output struct { |
| 119 | + Value string `json:"value,omitempty" yaml:"value,omitempty"` |
| 120 | + Format string `json:"format,omitempty" yaml:"format,omitempty"` |
| 121 | +} |
| 122 | + |
| 123 | +type TransformChanges struct { |
| 124 | + Group []Group `json:"group,omitempty" yaml:"group"` |
| 125 | +} |
| 126 | + |
| 127 | +type Group struct { |
| 128 | + Directory string `json:"directory,omitempty" yaml:"directory"` |
| 129 | + Branch string `json:"branch,omitempty" yaml:"branch"` |
| 130 | + Repository string `json:"repository,omitempty" yaml:"repository"` |
| 131 | +} |
| 132 | + |
| 133 | +type Mount struct { |
| 134 | + Mountpoint string `json:"mountpoint" yaml:"mountpoint"` |
| 135 | + Path string `json:"path" yaml:"path"` |
| 136 | +} |
| 137 | + |
| 138 | +func ParseBatchSpec(data []byte) (*BatchSpec, error) { |
| 139 | + return parseBatchSpec(schema.BatchSpecJSON, data) |
| 140 | +} |
| 141 | + |
| 142 | +func parseBatchSpec(schema string, data []byte) (*BatchSpec, error) { |
| 143 | + var spec BatchSpec |
| 144 | + if err := yaml.UnmarshalValidate(schema, data, &spec); err != nil { |
| 145 | + var multiErr errors.MultiError |
| 146 | + if errors.As(err, &multiErr) { |
| 147 | + var newMultiError error |
| 148 | + |
| 149 | + for _, e := range multiErr.Errors() { |
| 150 | + // In case of `name` we try to make the error message more user-friendly. |
| 151 | + if strings.Contains(e.Error(), "name: Does not match pattern") { |
| 152 | + newMultiError = errors.Append(newMultiError, NewValidationError(errors.Newf("The batch change name can only contain word characters, dots and dashes. No whitespace or newlines allowed."))) |
| 153 | + } else { |
| 154 | + newMultiError = errors.Append(newMultiError, NewValidationError(e)) |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + return nil, newMultiError |
| 159 | + } |
| 160 | + |
| 161 | + return nil, err |
| 162 | + } |
| 163 | + |
| 164 | + var errs error |
| 165 | + |
| 166 | + if len(spec.Steps) != 0 && spec.ChangesetTemplate == nil { |
| 167 | + errs = errors.Append(errs, NewValidationError(errors.New("batch spec includes steps but no changesetTemplate"))) |
| 168 | + } |
| 169 | + |
| 170 | + for i, step := range spec.Steps { |
| 171 | + for _, mount := range step.Mount { |
| 172 | + if strings.Contains(mount.Path, invalidMountCharacters) { |
| 173 | + errs = errors.Append(errs, NewValidationError(errors.Newf("step %d mount path contains invalid characters", i+1))) |
| 174 | + } |
| 175 | + if strings.Contains(mount.Mountpoint, invalidMountCharacters) { |
| 176 | + errs = errors.Append(errs, NewValidationError(errors.Newf("step %d mount mountpoint contains invalid characters", i+1))) |
| 177 | + } |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + return &spec, errs |
| 182 | +} |
| 183 | + |
| 184 | +const invalidMountCharacters = "," |
| 185 | + |
| 186 | +func (on *OnQueryOrRepository) String() string { |
| 187 | + if on.RepositoriesMatchingQuery != "" { |
| 188 | + return on.RepositoriesMatchingQuery |
| 189 | + } else if on.Repository != "" { |
| 190 | + return "repository:" + on.Repository |
| 191 | + } |
| 192 | + |
| 193 | + return fmt.Sprintf("%v", *on) |
| 194 | +} |
| 195 | + |
| 196 | +// BatchSpecValidationError is returned when parsing/using values from the batch spec failed. |
| 197 | +type BatchSpecValidationError struct { |
| 198 | + err error |
| 199 | +} |
| 200 | + |
| 201 | +func NewValidationError(err error) BatchSpecValidationError { |
| 202 | + return BatchSpecValidationError{err} |
| 203 | +} |
| 204 | + |
| 205 | +func (e BatchSpecValidationError) Error() string { |
| 206 | + return e.err.Error() |
| 207 | +} |
| 208 | + |
| 209 | +func IsValidationError(err error) bool { |
| 210 | + return errors.HasType[*BatchSpecValidationError](err) |
| 211 | +} |
| 212 | + |
| 213 | +// SkippedStepsForRepo calculates the steps required to run on the given repo. |
| 214 | +func SkippedStepsForRepo(spec *BatchSpec, repoName string, fileMatches []string) (skipped map[int]struct{}, err error) { |
| 215 | + skipped = map[int]struct{}{} |
| 216 | + |
| 217 | + for idx, step := range spec.Steps { |
| 218 | + // If no if condition is set the step is always run. |
| 219 | + if step.IfCondition() == "" { |
| 220 | + continue |
| 221 | + } |
| 222 | + |
| 223 | + batchChange := template.BatchChangeAttributes{ |
| 224 | + Name: spec.Name, |
| 225 | + Description: spec.Description, |
| 226 | + } |
| 227 | + // TODO: This step ctx is incomplete, is this allowed? |
| 228 | + // We can at least optimize further here and do more static evaluation |
| 229 | + // when we have a cached result for the previous step. |
| 230 | + stepCtx := &template.StepContext{ |
| 231 | + Repository: template.Repository{ |
| 232 | + Name: repoName, |
| 233 | + FileMatches: fileMatches, |
| 234 | + }, |
| 235 | + BatchChange: batchChange, |
| 236 | + } |
| 237 | + static, boolVal, err := template.IsStaticBool(step.IfCondition(), stepCtx) |
| 238 | + if err != nil { |
| 239 | + return nil, err |
| 240 | + } |
| 241 | + |
| 242 | + if static && !boolVal { |
| 243 | + skipped[idx] = struct{}{} |
| 244 | + } |
| 245 | + } |
| 246 | + |
| 247 | + return skipped, nil |
| 248 | +} |
| 249 | + |
| 250 | +// RequiredEnvVars inspects all steps for outer environment variables used and |
| 251 | +// compiles a deduplicated list from those. |
| 252 | +func (s *BatchSpec) RequiredEnvVars() []string { |
| 253 | + requiredMap := map[string]struct{}{} |
| 254 | + required := []string{} |
| 255 | + for _, step := range s.Steps { |
| 256 | + for _, v := range step.Env.OuterVars() { |
| 257 | + if _, ok := requiredMap[v]; !ok { |
| 258 | + requiredMap[v] = struct{}{} |
| 259 | + required = append(required, v) |
| 260 | + } |
| 261 | + } |
| 262 | + } |
| 263 | + return required |
| 264 | +} |
0 commit comments