diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a9053..4b74d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.0] - 2026-02-14 + +### Changed +- **Breaking:** `.goodchangesrc.json` now uses a `targets` array instead of a single top-level target definition. Each entry in `targets` is a target object with `type`, `app`, `targetName`, and `changeDirs`. The `ignores` field remains at the top level (shared across all targets). + ## [0.11.2] - 2026-02-14 ### Fixed @@ -151,6 +156,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Multi-stage Docker build - Automated vendor upgrade workflow +[0.12.0]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.11.2...v0.12.0 [0.11.2]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.11.1...v0.11.2 [0.11.1]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.11.0...v0.11.1 [0.11.0]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.10.0...v0.11.0 diff --git a/README.md b/README.md index 732ad5e..901530d 100644 --- a/README.md +++ b/README.md @@ -80,20 +80,34 @@ Everything else is an **app** (bundled). Apps are not analyzed for granular expo ## Configuration -Each project can optionally have a `.goodchangesrc.json` file in its root directory. - -### Target - -Marks a project as an e2e test package. The package name is included in the output when any of the 4 trigger conditions are met. +Each project can optionally have a `.goodchangesrc.json` file in its root directory. A single config file can define multiple targets via the `targets` array. ```json { - "type": "target", - "app": "@gooddata/gdc-dashboards", + "targets": [ + { + "type": "target", + "app": "@gooddata/gdc-dashboards" + }, + { + "type": "virtual-target", + "targetName": "neobackstop", + "changeDirs": [ + { "glob": "src/**/*" }, + { "glob": "scenarios/**/*" }, + { "glob": "stories/**/*.stories.tsx", "type": "fine-grained" }, + { "glob": "neobackstop/**/*" } + ] + } + ], "ignores": ["scenarios/**/*.md"] } ``` +### Target + +Marks a project as an e2e test package. The package name is included in the output when any of the 4 trigger conditions are met. + **Trigger conditions:** 1. **Direct file changes** -- files changed in the project folder (excluding ignored paths) @@ -105,19 +119,6 @@ Marks a project as an e2e test package. The package name is included in the outp An aggregated target that uses glob patterns to match files across a project. Does not correspond to a real package name in the output -- uses `targetName` instead. -```json -{ - "type": "virtual-target", - "targetName": "neobackstop", - "changeDirs": [ - { "glob": "src/**/*" }, - { "glob": "scenarios/**/*" }, - { "glob": "stories/**/*.stories.tsx", "type": "fine-grained" }, - { "glob": "neobackstop/**/*" } - ] -} -``` - Each `changeDirs` entry is an object with: - `glob` -- glob pattern to match files (relative to project root). Uses doublestar syntax: `*` matches files in current directory only, `**/*` matches all nested files, `**/*.stories.tsx` matches specific patterns recursively. @@ -139,13 +140,21 @@ Each `changeDirs` entry is an object with: ### Fields reference +**Top-level fields:** + +| Field | Type | Description | +|-----------|---------------|------------------------------------------------------------| +| `targets` | `TargetDef[]` | Array of target definitions (see below) | +| `ignores` | `string[]` | Glob patterns for files to exclude from change detection | + +**TargetDef fields (each entry in `targets`):** + | Field | Type | Used by | Description | |--------------|----------------------------------|----------------|--------------------------------------------------------------------------------------------------------| -| `type` | `"target"` \| `"virtual-target"` | Both | Declares what kind of target this project is | +| `type` | `"target"` \| `"virtual-target"` | Both | Declares what kind of target this is | | `app` | `string` | Target | Package name of the corresponding app this e2e package tests | | `targetName` | `string` | Virtual target | Output name emitted when the virtual target is triggered | | `changeDirs` | `ChangeDir[]` | Virtual target | Glob patterns to match files. Each entry: `{"glob": "...", "filter?": "...", "type?": "fine-grained"}` | -| `ignores` | `string[]` | Both | Glob patterns for files to exclude from change detection | The `.goodchangesrc.json` file itself is always ignored. diff --git a/VERSION b/VERSION index bc859cb..ac454c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.11.2 +0.12.0 diff --git a/internal/rush/rush.go b/internal/rush/rush.go index cd5c4ef..59b5eec 100644 --- a/internal/rush/rush.go +++ b/internal/rush/rush.go @@ -118,12 +118,26 @@ func (cd ChangeDir) IsFineGrained() bool { return cd.Type != nil && *cd.Type == "fine-grained" } -type ProjectConfig struct { - Type *string `json:"type,omitempty"` // "target", "virtual-target" +type TargetDef struct { + Type string `json:"type"` // "target", "virtual-target" App *string `json:"app,omitempty"` // rush project name of corresponding app TargetName *string `json:"targetName,omitempty"` // output name for virtual targets - ChangeDirs []ChangeDir `json:"changeDirs,omitempty"` // dirs to watch for virtual targets - Ignores []string `json:"ignores,omitempty"` + ChangeDirs []ChangeDir `json:"changeDirs,omitempty"` // globs to watch for virtual targets +} + +// IsTarget returns true if this target definition is a regular target. +func (td TargetDef) IsTarget() bool { + return td.Type == "target" +} + +// IsVirtualTarget returns true if this target definition is a virtual target. +func (td TargetDef) IsVirtualTarget() bool { + return td.Type == "virtual-target" +} + +type ProjectConfig struct { + Targets []TargetDef `json:"targets,omitempty"` + Ignores []string `json:"ignores,omitempty"` } // LoadProjectConfig reads .goodchangesrc.json from the project folder. @@ -167,15 +181,7 @@ func (pc *ProjectConfig) IsIgnored(relPath string) bool { return false } -// IsTarget returns true if this project is configured as a target (e2e test package). -func (pc *ProjectConfig) IsTarget() bool { - return pc != nil && pc.Type != nil && *pc.Type == "target" -} -// IsVirtualTarget returns true if this project is configured as a virtual target. -func (pc *ProjectConfig) IsVirtualTarget() bool { - return pc != nil && pc.Type != nil && *pc.Type == "virtual-target" -} // FindChangedProjects determines which projects have files in the changed file list. // Files matching ignore globs in .goodchangesrc.json are excluded. diff --git a/main.go b/main.go index 1f0f400..5335613 100644 --- a/main.go +++ b/main.go @@ -102,10 +102,15 @@ func main() { var targetSeeds []string for _, rp := range rushConfig.Projects { cfg := configMap[rp.ProjectFolder] - if cfg.IsTarget() && matchesTargetFilter(rp.PackageName, targetPatterns) { - targetSeeds = append(targetSeeds, rp.PackageName) - } else if cfg.IsVirtualTarget() && cfg.TargetName != nil && matchesTargetFilter(*cfg.TargetName, targetPatterns) { - targetSeeds = append(targetSeeds, rp.PackageName) + if cfg == nil { + continue + } + for _, td := range cfg.Targets { + if td.IsTarget() && matchesTargetFilter(rp.PackageName, targetPatterns) { + targetSeeds = append(targetSeeds, rp.PackageName) + } else if td.IsVirtualTarget() && td.TargetName != nil && matchesTargetFilter(*td.TargetName, targetPatterns) { + targetSeeds = append(targetSeeds, rp.PackageName) + } } } relevantPackages = rush.FindTransitiveDependencies(projectMap, targetSeeds) @@ -303,119 +308,124 @@ func main() { for _, rp := range rushConfig.Projects { cfg := configMap[rp.ProjectFolder] + if cfg == nil { + continue + } - if cfg.IsTarget() { - if len(targetPatterns) > 0 && !matchesTargetFilter(rp.PackageName, targetPatterns) { - continue - } - // Target detection with 4 conditions: - // 1. Direct file changes (outside ignores) - // 2. External dep changes in lockfile - // 3. Tainted workspace imports - // 4. Corresponding app is tainted - info := projectMap[rp.PackageName] - if info == nil { - continue - } + for _, td := range cfg.Targets { + if td.IsTarget() { + if len(targetPatterns) > 0 && !matchesTargetFilter(rp.PackageName, targetPatterns) { + continue + } + // Target detection with 4 conditions: + // 1. Direct file changes (outside ignores) + // 2. External dep changes in lockfile + // 3. Tainted workspace imports + // 4. Corresponding app is tainted + info := projectMap[rp.PackageName] + if info == nil { + continue + } - // Condition 1: Direct file changes - triggered := false - for _, f := range changedFiles { - if strings.HasPrefix(f, rp.ProjectFolder+"/") { - relPath := strings.TrimPrefix(f, rp.ProjectFolder+"/") - if !cfg.IsIgnored(relPath) { - triggered = true - break + // Condition 1: Direct file changes + triggered := false + for _, f := range changedFiles { + if strings.HasPrefix(f, rp.ProjectFolder+"/") { + relPath := strings.TrimPrefix(f, rp.ProjectFolder+"/") + if !cfg.IsIgnored(relPath) { + triggered = true + break + } } } - } - if triggered { - changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} - continue - } - - // Condition 2: External dep changes in lockfile - if len(depChangedDeps[rp.ProjectFolder]) > 0 { - changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} - continue - } + if triggered { + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} + continue + } - // Condition 3: Tainted workspace imports - if analyzer.HasTaintedImports(rp.ProjectFolder, allUpstreamTaint, cfg) { - changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} - continue - } + // Condition 2: External dep changes in lockfile + if len(depChangedDeps[rp.ProjectFolder]) > 0 { + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} + continue + } - // Condition 4: Corresponding app is tainted - if cfg.App != nil { - appInfo := projectMap[*cfg.App] - if appInfo != nil { - if changedProjects[*cfg.App] != nil { - changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} - continue - } - if len(depChangedDeps[appInfo.ProjectFolder]) > 0 { - changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} - continue - } - if analyzer.HasTaintedImports(appInfo.ProjectFolder, allUpstreamTaint, nil) { - changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} - continue - } + // Condition 3: Tainted workspace imports + if analyzer.HasTaintedImports(rp.ProjectFolder, allUpstreamTaint, cfg) { + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} + continue } - } - } else if cfg.IsVirtualTarget() && cfg.TargetName != nil { - if len(targetPatterns) > 0 && !matchesTargetFilter(*cfg.TargetName, targetPatterns) { - continue - } - // Virtual target: check changeDirs globs for file changes or tainted imports. - // Normal globs trigger a full run; fine-grained globs collect specific affected files. - normalTriggered := false - var fineGrainedDetections []string - - for _, cd := range cfg.ChangeDirs { - if cd.IsFineGrained() { - filterPattern := "" - if cd.Filter != nil { - filterPattern = *cd.Filter - } - detected := analyzer.FindAffectedFiles(cd.Glob, filterPattern, allUpstreamTaint, changedFiles, rp.ProjectFolder, cfg, depChangedDeps[rp.ProjectFolder], mergeBase, flagIncludeTypes) - if len(detected) > 0 { - fineGrainedDetections = append(fineGrainedDetections, detected...) - } - } else { - // Normal: check for any changed file matching the glob - for _, f := range changedFiles { - if !strings.HasPrefix(f, rp.ProjectFolder+"/") { + + // Condition 4: Corresponding app is tainted + if td.App != nil { + appInfo := projectMap[*td.App] + if appInfo != nil { + if changedProjects[*td.App] != nil { + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} continue } - relPath := strings.TrimPrefix(f, rp.ProjectFolder+"/") - if cfg.IsIgnored(relPath) { + if len(depChangedDeps[appInfo.ProjectFolder]) > 0 { + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} continue } - if matched, _ := doublestar.Match(cd.Glob, relPath); matched { - normalTriggered = true - break + if analyzer.HasTaintedImports(appInfo.ProjectFolder, allUpstreamTaint, nil) { + changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName} + continue } } - if !normalTriggered { - if analyzer.HasTaintedImportsForGlob(rp.ProjectFolder, cd.Glob, allUpstreamTaint, cfg) { - normalTriggered = true + } + } else if td.IsVirtualTarget() && td.TargetName != nil { + if len(targetPatterns) > 0 && !matchesTargetFilter(*td.TargetName, targetPatterns) { + continue + } + // Virtual target: check changeDirs globs for file changes or tainted imports. + // Normal globs trigger a full run; fine-grained globs collect specific affected files. + normalTriggered := false + var fineGrainedDetections []string + + for _, cd := range td.ChangeDirs { + if cd.IsFineGrained() { + filterPattern := "" + if cd.Filter != nil { + filterPattern = *cd.Filter + } + detected := analyzer.FindAffectedFiles(cd.Glob, filterPattern, allUpstreamTaint, changedFiles, rp.ProjectFolder, cfg, depChangedDeps[rp.ProjectFolder], mergeBase, flagIncludeTypes) + if len(detected) > 0 { + fineGrainedDetections = append(fineGrainedDetections, detected...) + } + } else { + // Normal: check for any changed file matching the glob + for _, f := range changedFiles { + if !strings.HasPrefix(f, rp.ProjectFolder+"/") { + continue + } + relPath := strings.TrimPrefix(f, rp.ProjectFolder+"/") + if cfg.IsIgnored(relPath) { + continue + } + if matched, _ := doublestar.Match(cd.Glob, relPath); matched { + normalTriggered = true + break + } + } + if !normalTriggered { + if analyzer.HasTaintedImportsForGlob(rp.ProjectFolder, cd.Glob, allUpstreamTaint, cfg) { + normalTriggered = true + } } } + if normalTriggered { + break + } } - if normalTriggered { - break - } - } - if normalTriggered { - changedE2E[*cfg.TargetName] = &TargetResult{Name: *cfg.TargetName} - } else if len(fineGrainedDetections) > 0 { - sort.Strings(fineGrainedDetections) - changedE2E[*cfg.TargetName] = &TargetResult{ - Name: *cfg.TargetName, - Detections: fineGrainedDetections, + if normalTriggered { + changedE2E[*td.TargetName] = &TargetResult{Name: *td.TargetName} + } else if len(fineGrainedDetections) > 0 { + sort.Strings(fineGrainedDetections) + changedE2E[*td.TargetName] = &TargetResult{ + Name: *td.TargetName, + Detections: fineGrainedDetections, + } } } }