diff --git a/.github/workflows/codescene.yml b/.github/workflows/codescene.yml deleted file mode 100644 index 76a0420d..00000000 --- a/.github/workflows/codescene.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: CodeScene - -on: - pull_request: - -jobs: - delta-analysis: - name: Delta analysis - runs-on: ubuntu-latest - container: - image: empear/codescene-ci-cd:latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Run delta analysis - env: - CODESCENE_USER: ${{ secrets.CODESCENE_USER }} - CODESCENE_PASSWORD: ${{ secrets.CODESCENE_PASSWORD }} - CODESCENE_DELTA_ANALYSIS_URL: ${{ secrets.CODESCENE_DELTA_ANALYSIS_URL }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - git config --global --add safe.directory /__w/cli/cli - git branch -a - # Extract data from event - export PREVIOUS_COMMIT=$(jq -r '.before' < ${GITHUB_EVENT_PATH}) - export PR_NUMBER=$(jq -r '.pull_request.number' < ${GITHUB_EVENT_PATH}) - # Check required env vars - if [[ -z "${CODESCENE_DELTA_ANALYSIS_URL}" ]] ; then - echo "No value specified for CODESCENE_DELTA_ANALYSIS_URL!" - exit 1 - fi - if [[ -z "${CODESCENE_USER}" ]] ; then - echo "No value specified for CODESCENE_USER!" - exit 1 - fi - if [[ -z "${CODESCENE_PASSWORD}" ]] ; then - echo "No value specified for CODESCENE_PASSWORD!" - exit 1 - fi - # Perform analysis - codescene-ci-cd.sh \ - --codescene-delta-analysis-url ${CODESCENE_DELTA_ANALYSIS_URL} \ - --codescene-user ${CODESCENE_USER} \ - --codescene-password ${CODESCENE_PASSWORD} \ - --codescene-repository ${GITHUB_REPOSITORY#*/} \ - --fail-on-failed-goal \ - --fail-on-declining-code-health \ - --analyze-branch-diff \ - --current-commit "remotes/origin/${{ github.head_ref }}" \ - --base-revision "remotes/origin/${{ github.base_ref }}" \ - --risk-threshold ${CODESCENE_RISK_THRESHOLD-7} \ - --coupling-threshold-percent ${CODESCENE_COUPLING_THRESHOLD_PERCENT-80} \ - --http-timeout ${CODESCENE_TIMEOUT-30000} \ - --create-github-comment \ - --github-api-url "https://api.github.com" \ - --github-api-token ${GITHUB_TOKEN} \ - --github-owner ${GITHUB_REPOSITORY%/*} \ - --github-repo ${GITHUB_REPOSITORY#*/} \ - --github-pull-request-id ${PR_NUMBER} \ - --log-result diff --git a/build/docker/alpine.Dockerfile b/build/docker/alpine.Dockerfile index bcb7aef3..320ae487 100644 --- a/build/docker/alpine.Dockerfile +++ b/build/docker/alpine.Dockerfile @@ -37,6 +37,16 @@ RUN wget https://services.gradle.org/distributions/gradle-$GRADLE_VERSION-bin.zi unzip gradle-$GRADLE_VERSION-bin.zip -d $GRADLE_HOME && \ rm gradle-$GRADLE_VERSION-bin.zip +# Add SBT, used for Scala resolution +ENV SBT_VERSION="1.10.11" +ENV SBT_HOME="/usr/lib/sbt" +ENV PATH="$SBT_HOME/bin:$PATH" +RUN wget https://github.com/sbt/sbt/releases/download/v${SBT_VERSION}/sbt-${SBT_VERSION}.tgz && \ + mkdir -p $SBT_HOME && \ + tar -zxvf sbt-${SBT_VERSION}.tgz -C $SBT_HOME --strip-components=1 && \ + rm sbt-${SBT_VERSION}.tgz && \ + ln -s $SBT_HOME/bin/sbt /usr/bin/sbt + # g++ needed to compile python packages with C dependencies (numpy, scipy, etc.) RUN apk --no-cache --update add \ openjdk21-jdk \ @@ -47,7 +57,8 @@ RUN apk --no-cache --update add \ npm \ yarn \ g++ \ - curl + curl \ + bash RUN apk --no-cache --update add dotnet8-sdk go~=1.23 --repository=https://dl-cdn.alpinelinux.org/alpine/v3.20/community @@ -68,7 +79,7 @@ RUN apk add --no-cache --virtual build-dependencies curl && \ curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer \ && apk del build-dependencies -RUN php -v && composer --version +RUN php -v && composer --version && sbt --version CMD [ "debricked", "scan" ] diff --git a/build/docker/debian.Dockerfile b/build/docker/debian.Dockerfile index 1aa32bb1..3d28c687 100644 --- a/build/docker/debian.Dockerfile +++ b/build/docker/debian.Dockerfile @@ -111,6 +111,18 @@ RUN apt update -y && \ sh -c 'echo "deb https://packages.sury.org/php/ bookworm main" > /etc/apt/sources.list.d/php.list' && \ apt -y clean && rm -rf /var/lib/apt/lists/* +# Add SBT, used for Scala resolution +ENV SBT_VERSION="1.10.11" +ENV SBT_HOME="/usr/lib/sbt" +ENV PATH="$SBT_HOME/bin:$PATH" +RUN curl -fsSLO https://github.com/sbt/sbt/releases/download/v${SBT_VERSION}/sbt-${SBT_VERSION}.tgz && \ + mkdir -p $SBT_HOME && \ + tar -zxvf sbt-${SBT_VERSION}.tgz -C $SBT_HOME --strip-components=1 && \ + rm sbt-${SBT_VERSION}.tgz && \ + ln -s $SBT_HOME/bin/sbt /usr/bin/sbt + +RUN sbt --version + RUN apt -y update && apt -y install \ php8.3 \ php8.3-curl \ diff --git a/internal/file/finder.go b/internal/file/finder.go index 21a85569..6ea0b04c 100644 --- a/internal/file/finder.go +++ b/internal/file/finder.go @@ -213,6 +213,14 @@ func (finder *Finder) GetSupportedFormats() ([]*CompiledFormat, error) { return nil, err } + sbtEntry := &Format{ + ManifestFileRegex: "^build\\.sbt$", + DocumentationUrl: "https://docs.debricked.com/overview/language-support/scala-sbt", + LockFileRegexes: []string{""}, + } + + formats = append(formats, sbtEntry) + var compiledDependencyFileFormats []*CompiledFormat for _, format := range formats { compiledDependencyFileFormat, err := NewCompiledFormat(format) diff --git a/internal/resolution/pm/maven/testdata/cmd_factory_mock.go b/internal/resolution/pm/maven/testdata/cmd_factory_mock.go index d2172f73..5a2a6ac1 100644 --- a/internal/resolution/pm/maven/testdata/cmd_factory_mock.go +++ b/internal/resolution/pm/maven/testdata/cmd_factory_mock.go @@ -1,6 +1,9 @@ package testdata -import "os/exec" +import ( + "os/exec" + "runtime" +) type CmdFactoryMock struct { Err error @@ -12,5 +15,10 @@ func (f CmdFactoryMock) MakeDependencyTreeCmd(_ string) (*exec.Cmd, error) { if len(f.Arg) == 0 { f.Arg = `"MakeDependencyTreeCmd"` } + + if runtime.GOOS == "windows" && f.Name == "echo" { + return exec.Command("cmd", "/C", f.Name, f.Arg), nil + } + return exec.Command(f.Name, f.Arg), f.Err } diff --git a/internal/resolution/pm/pm.go b/internal/resolution/pm/pm.go index b535070a..c8312526 100644 --- a/internal/resolution/pm/pm.go +++ b/internal/resolution/pm/pm.go @@ -9,6 +9,7 @@ import ( "github.com/debricked/cli/internal/resolution/pm/npm" "github.com/debricked/cli/internal/resolution/pm/nuget" "github.com/debricked/cli/internal/resolution/pm/pip" + "github.com/debricked/cli/internal/resolution/pm/sbt" "github.com/debricked/cli/internal/resolution/pm/yarn" ) @@ -28,5 +29,6 @@ func Pms() []IPm { bower.NewPm(), nuget.NewPm(), composer.NewPm(), + sbt.NewPm(), } } diff --git a/internal/resolution/pm/sbt/README.md b/internal/resolution/pm/sbt/README.md new file mode 100644 index 00000000..7c8a7073 --- /dev/null +++ b/internal/resolution/pm/sbt/README.md @@ -0,0 +1,55 @@ +# SBT (Scala) Resolution Logic + +The resolution of SBT (Scala Build Tool) dependencies works as follows: + +1. Parse the `build.sbt` file to identify any modules +2. Run `sbt makePom` in the project directory to generate a POM file +3. Find the generated `.pom` file (typically in `target/scala-/-.pom`) +4. Copy/rename the `.pom` file to `pom.xml` in the same directory as `build.sbt` +5. Use the existing Maven resolver to handle the `pom.xml` file + +This approach allows SBT projects to leverage the existing Maven resolution logic after the POM file is generated. + +## Requirements + +1. SBT must be installed and available in the PATH +2. The SBT project must be configured to support the `makePom` command (most SBT projects support this by default) +3. Maven dependencies must be resolvable as described in the Maven resolution documentation + +## Private Dependencies + +Similar to Maven projects, SBT projects might use dependencies from repositories other than the default ones. The +SBT `makePom` command will include these repository configurations in the generated POM file, and then the Maven +resolution process will handle them as described in the Maven README. + +## Troubleshooting + +If you encounter issues with SBT resolution: + +1. Verify SBT is installed and accessible in the PATH +2. Try running `sbt makePom` manually in your project directory to check if it works +3. Inspect the generated `.pom` file in `target/scala-*/` to ensure it contains the correct dependencies +4. Check if any repository authentication is required for your dependencies + +## Error Messages + +Common error messages and their meanings: + +- `SBT wasn't found`: The SBT executable isn't installed or isn't in the PATH +- `Failed to generate Maven POM file`: There was an error during the POM generation process +- `SBT configuration file not found`: The build.sbt file couldn't be found or accessed +- `Failed to parse SBT build file`: The build.sbt file contains syntax errors +- `We weren't able to retrieve one or more dependencies or plugins`: Network issues prevented dependency resolution + +## Example Command + +```shell +debricked resolve /path/to/scala/project +``` + +This will: + +1. Find all `build.sbt` files in the specified path +2. Generate a POM file for each one +3. Resolve dependencies using the Maven resolver +4. Create `maven.debricked.lock` files with the dependency information \ No newline at end of file diff --git a/internal/resolution/pm/sbt/build_service.go b/internal/resolution/pm/sbt/build_service.go new file mode 100644 index 00000000..a7d705e4 --- /dev/null +++ b/internal/resolution/pm/sbt/build_service.go @@ -0,0 +1,67 @@ +package sbt + +import ( + "os" + "path/filepath" + "regexp" +) + +type IBuildService interface { + ParseBuildModules(path string) ([]string, error) + FindPomFile(dir string) (string, error) + RenamePomToXml(pomFile, destDir string) (string, error) +} + +type BuildService struct{} + +func (b BuildService) ParseBuildModules(path string) ([]string, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + moduleRegex := regexp.MustCompile(`project\s*\(\s*"([^"]+)"\s*\)`) + matches := moduleRegex.FindAllStringSubmatch(string(content), -1) + + modules := make([]string, 0, len(matches)) + for _, match := range matches { + if len(match) > 1 { + modules = append(modules, match[1]) + } + } + + return modules, nil +} + +func (b BuildService) FindPomFile(dir string) (string, error) { + targetDir := filepath.Join(dir, "target") + + scalaVersionDirs, err := filepath.Glob(filepath.Join(targetDir, "scala-*")) + if err != nil || len(scalaVersionDirs) == 0 { + return "", err + } + + for _, scalaDir := range scalaVersionDirs { + pomFiles, err := filepath.Glob(filepath.Join(scalaDir, "*.pom")) + if err == nil && len(pomFiles) > 0 { + return pomFiles[0], nil + } + } + + return "", nil +} + +func (b BuildService) RenamePomToXml(pomFile, destDir string) (string, error) { + content, err := os.ReadFile(pomFile) + if err != nil { + return "", err + } + + pomXmlPath := filepath.Join(destDir, "pom.xml") + err = os.WriteFile(pomXmlPath, content, 0600) + if err != nil { + return "", err + } + + return pomXmlPath, nil +} diff --git a/internal/resolution/pm/sbt/build_service_test.go b/internal/resolution/pm/sbt/build_service_test.go new file mode 100644 index 00000000..01c07e3b --- /dev/null +++ b/internal/resolution/pm/sbt/build_service_test.go @@ -0,0 +1,121 @@ +package sbt + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseBuildModules(t *testing.T) { + content := ` + name := "test-project" + version := "1.0.0" + + lazy val core = project("core") + .settings( + libraryDependencies += "org.scala-lang" % "scala-library" % "2.13.8" + ) + + lazy val api = project("api") + .dependsOn(core) + .settings( + libraryDependencies += "com.typesafe.akka" %% "akka-http" % "10.2.9" + ) + + lazy val root = (project in file(".")) + .aggregate(core, api) + ` + + tmpFile, err := os.CreateTemp("", "build.sbt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + _, err = tmpFile.WriteString(content) + if err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + tmpFile.Close() + + b := BuildService{} + modules, err := b.ParseBuildModules(tmpFile.Name()) + + assert.Nil(t, err) + assert.Contains(t, modules, "core") + assert.Contains(t, modules, "api") +} + +func TestParseBuildModulesInvalidFile(t *testing.T) { + b := BuildService{} + _, err := b.ParseBuildModules("non_existent_file.sbt") + + assert.NotNil(t, err) +} + +func TestFindPomFile(t *testing.T) { + tempDir, err := os.MkdirTemp("", "sbt-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + scalaDir := filepath.Join(tempDir, "target", "scala-2.13") + err = os.MkdirAll(scalaDir, 0755) + if err != nil { + t.Fatalf("Failed to create directories: %v", err) + } + + pomPath := filepath.Join(scalaDir, "test-project-1.0.0.pom") + err = os.WriteFile(pomPath, []byte(""), 0600) + if err != nil { + t.Fatalf("Failed to create pom file: %v", err) + } + + b := BuildService{} + foundPom, err := b.FindPomFile(tempDir) + + assert.Nil(t, err) + assert.Equal(t, pomPath, foundPom) +} + +func TestFindPomFileNoTarget(t *testing.T) { + tempDir, err := os.MkdirTemp("", "sbt-test-no-target") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + b := BuildService{} + foundPom, err := b.FindPomFile(tempDir) + + assert.Nil(t, err) + assert.Empty(t, foundPom) +} + +func TestRenamePomToXml(t *testing.T) { + tempDir, err := os.MkdirTemp("", "sbt-rename-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + pomContent := "test" + pomPath := filepath.Join(tempDir, "test.pom") + err = os.WriteFile(pomPath, []byte(pomContent), 0600) + if err != nil { + t.Fatalf("Failed to create pom file: %v", err) + } + + b := BuildService{} + xmlPath, err := b.RenamePomToXml(pomPath, tempDir) + + assert.Nil(t, err) + assert.Equal(t, filepath.Join(tempDir, "pom.xml"), xmlPath) + + content, err := os.ReadFile(xmlPath) + assert.Nil(t, err) + assert.Equal(t, pomContent, string(content)) +} diff --git a/internal/resolution/pm/sbt/cmd_factory.go b/internal/resolution/pm/sbt/cmd_factory.go new file mode 100644 index 00000000..0b9aab27 --- /dev/null +++ b/internal/resolution/pm/sbt/cmd_factory.go @@ -0,0 +1,22 @@ +package sbt + +import "os/exec" + +type ICmdFactory interface { + MakePomCmd(workingDirectory string) (*exec.Cmd, error) +} + +type CmdFactory struct{} + +func (CmdFactory) MakePomCmd(workingDirectory string) (*exec.Cmd, error) { + path, err := exec.LookPath("sbt") + + return &exec.Cmd{ + Path: path, + Args: []string{ + "sbt", + "makePom", + }, + Dir: workingDirectory, + }, err +} diff --git a/internal/resolution/pm/sbt/cmd_factory_test.go b/internal/resolution/pm/sbt/cmd_factory_test.go new file mode 100644 index 00000000..74cd23d2 --- /dev/null +++ b/internal/resolution/pm/sbt/cmd_factory_test.go @@ -0,0 +1,15 @@ +package sbt + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMakePomCmd(t *testing.T) { + cmd, _ := CmdFactory{}.MakePomCmd(".") + assert.NotNil(t, cmd) + args := cmd.Args + assert.Contains(t, args, "sbt") + assert.Contains(t, args, "makePom") +} diff --git a/internal/resolution/pm/sbt/job.go b/internal/resolution/pm/sbt/job.go new file mode 100644 index 00000000..1d3f91da --- /dev/null +++ b/internal/resolution/pm/sbt/job.go @@ -0,0 +1,323 @@ +package sbt + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/debricked/cli/internal/resolution/job" + "github.com/debricked/cli/internal/resolution/pm/maven" + "github.com/debricked/cli/internal/resolution/pm/util" +) + +const ( + executableNotFoundErrRegex = `executable file not found` + pomGenerationErrRegex = `Error occurred while processing command: makePom` + sbtFileNotFoundErrRegex = `not found: .*build\.sbt` + nonParseableBuildErrRegex = `Illegal character in build file` + networkUnreachableErrRegex = `Connection timed out` +) + +type Job struct { + job.BaseJob + cmdFactory ICmdFactory + buildService IBuildService + mavenPomService maven.IPomService + mavenCmdFactory maven.ICmdFactory +} + +func NewJob(file string, cmdFactory ICmdFactory, buildService IBuildService, mavenPomService maven.IPomService, mavenCmdFactory maven.ICmdFactory) *Job { + return &Job{ + BaseJob: job.NewBaseJob(file), + cmdFactory: cmdFactory, + buildService: buildService, + mavenPomService: mavenPomService, + mavenCmdFactory: mavenCmdFactory, + } +} + +func (j *Job) Run() { + if err := j.parseBuildFile(); err != nil { + return + } + + if err := j.generatePomFile(); err != nil { + return + } + + pomFile, err := j.locatePomFile() + if err != nil { + return + } + + pomXml, err := j.convertToPomXml(pomFile) + if err != nil { + return + } + + if err := j.parseAndProcessWithMaven(pomXml); err != nil { + return + } +} + +func (j *Job) parseBuildFile() error { + status := "parsing SBT build file" + j.SendStatus(status) + + file := j.GetFile() + _, err := j.buildService.ParseBuildModules(file) + if err != nil { + doc := err.Error() + if doc == "EOF" { + doc = "This file doesn't contain valid SBT build content" + } + + parsingError := util.NewPMJobError(err.Error()) + parsingError.SetStatus(status) + parsingError.SetDocumentation(doc) + j.Errors().Critical(parsingError) + + return err + } + + return nil +} + +func (j *Job) generatePomFile() error { + file := j.GetFile() + workingDirectory := filepath.Dir(filepath.Clean(file)) + cmd, err := j.cmdFactory.MakePomCmd(workingDirectory) + if err != nil { + j.handleError(util.NewPMJobError(err.Error())) + + return err + } + + status := "generating Maven POM file" + j.SendStatus(status) + + output, err := cmd.CombinedOutput() + if err != nil { + errContent := err.Error() + if output != nil { + errContent = string(output) + } + + cmdErr := util.NewPMJobError(errContent) + cmdErr.SetStatus(status) + j.handleError(cmdErr) + + return err + } + + return nil +} + +func (j *Job) locatePomFile() (string, error) { + file := j.GetFile() + workingDirectory := filepath.Dir(filepath.Clean(file)) + status := "locating generated POM file" + j.SendStatus(status) + + pomFile, err := j.buildService.FindPomFile(workingDirectory) + if err != nil || pomFile == "" { + errorMsg := "No pom file found in target directory" + if err != nil { + errorMsg = err.Error() + } + + cmdErr := util.NewPMJobError(errorMsg) + cmdErr.SetStatus(status) + j.handleError(cmdErr) + + return "", err + } + + return pomFile, nil +} + +func (j *Job) convertToPomXml(pomFile string) (string, error) { + file := j.GetFile() + workingDirectory := filepath.Dir(filepath.Clean(file)) + status := "converting POM file to pom.xml" + j.SendStatus(status) + + pomXml, err := j.buildService.RenamePomToXml(pomFile, workingDirectory) + if err != nil { + cmdErr := util.NewPMJobError(err.Error()) + cmdErr.SetStatus(status) + j.handleError(cmdErr) + + return "", err + } + + return pomXml, nil +} + +func (j *Job) parseAndProcessWithMaven(pomXml string) error { + status := "parsing Maven POM file" + j.SendStatus(status) + + if err := j.parseMavenPom(pomXml); err != nil { + return err + } + + file := j.GetFile() + workingDirectory := filepath.Dir(filepath.Clean(file)) + if err := j.createMavenDependencyGraph(workingDirectory, pomXml); err != nil { + return err + } + + status = fmt.Sprintf("processing dependencies with Maven resolver using %s", pomXml) + j.SendStatus(status) + + return nil +} + +func (j *Job) parseMavenPom(pomXml string) error { + status := "parsing Maven POM file" + j.SendStatus(status) + + _, err := j.mavenPomService.ParsePomModules(pomXml) + if err != nil { + doc := err.Error() + if doc == "EOF" { + doc = "This file doesn't contain valid XML" + } + + parsingError := util.NewPMJobError(err.Error()) + parsingError.SetStatus(status) + parsingError.SetDocumentation(doc) + j.Errors().Critical(parsingError) + + return err + } + + return nil +} + +func (j *Job) createMavenDependencyGraph(workingDirectory string, pomXml string) error { + status := "creating Maven dependency graph" + j.SendStatus(status) + + cmd, err := j.mavenCmdFactory.MakeDependencyTreeCmd(workingDirectory) + if err != nil { + j.handleError(util.NewPMJobError(err.Error())) + + return err + } + + output, err := cmd.Output() + if err != nil { + errContent := err.Error() + if output != nil { + errContent = string(output) + } + + cmdErr := util.NewPMJobError(errContent) + cmdErr.SetStatus(status) + j.handleError(cmdErr) + + return err + } + + return nil +} + +func (j *Job) handleError(cmdError job.IError) { + expressions := []string{ + executableNotFoundErrRegex, + pomGenerationErrRegex, + sbtFileNotFoundErrRegex, + nonParseableBuildErrRegex, + networkUnreachableErrRegex, + } + + for _, expression := range expressions { + regex := regexp.MustCompile(expression) + if regex.MatchString(cmdError.Error()) { + matches := regex.FindAllStringSubmatch(cmdError.Error(), -1) + cmdError = j.addDocumentation(expression, matches, cmdError) + j.Errors().Critical(cmdError) + + return + } + } + + j.Errors().Critical(cmdError) +} + +func (j *Job) addDocumentation(expr string, matches [][]string, cmdError job.IError) job.IError { + documentation := cmdError.Documentation() + + switch expr { + case executableNotFoundErrRegex: + documentation = j.GetExecutableNotFoundErrorDocumentation("SBT") + case pomGenerationErrRegex: + documentation = j.addPomGenerationErrorDocumentation(matches) + case sbtFileNotFoundErrRegex: + documentation = j.addSbtFileNotFoundErrorDocumentation(matches) + case nonParseableBuildErrRegex: + documentation = j.addNonParseableBuildErrorDocumentation(matches) + case networkUnreachableErrRegex: + documentation = j.addNetworkUnreachableErrorDocumentation() + } + + cmdError.SetDocumentation(documentation) + + return cmdError +} + +func (j *Job) addPomGenerationErrorDocumentation(matches [][]string) string { + message := "Error occurred while generating the POM file" + if len(matches) > 0 && len(matches[0]) > 1 { + message = matches[0][1] + } + + return strings.Join( + []string{ + "Failed to generate Maven POM file.", + "SBT encountered an error during the makePom task.", + "Error details:", + message, + }, " ") +} + +func (j *Job) addSbtFileNotFoundErrorDocumentation(matches [][]string) string { + message := "build.sbt file not found" + if len(matches) > 0 && len(matches[0]) > 1 { + message = matches[0][1] + } + + return strings.Join( + []string{ + "SBT configuration file not found.", + "Please ensure that your project contains a valid build.sbt file.", + "Error details:", + message, + }, " ") +} + +func (j *Job) addNonParseableBuildErrorDocumentation(matches [][]string) string { + message := "the build file for errors" + if len(matches) > 0 && len(matches[0]) > 1 { + message = matches[0][1] + } + + return strings.Join( + []string{ + "Failed to parse SBT build file.", + "Your build.sbt file contains syntax errors.", + "Please check", + message, + }, " ") +} + +func (j *Job) addNetworkUnreachableErrorDocumentation() string { + return strings.Join( + []string{ + "We weren't able to retrieve one or more dependencies or plugins.", + "Please check your Internet connection and try again.", + }, " ") +} diff --git a/internal/resolution/pm/sbt/job_test.go b/internal/resolution/pm/sbt/job_test.go new file mode 100644 index 00000000..e4624c40 --- /dev/null +++ b/internal/resolution/pm/sbt/job_test.go @@ -0,0 +1,262 @@ +package sbt + +import ( + "errors" + "os" + "path/filepath" + "testing" + + jobTestdata "github.com/debricked/cli/internal/resolution/job/testdata" + mavenTestdata "github.com/debricked/cli/internal/resolution/pm/maven/testdata" + "github.com/debricked/cli/internal/resolution/pm/sbt/testdata" + "github.com/debricked/cli/internal/resolution/pm/util" + "github.com/stretchr/testify/assert" +) + +func TestNewJob(t *testing.T) { + j := NewJob( + "file", + CmdFactory{}, + BuildService{}, + mavenTestdata.PomServiceMock{}, + mavenTestdata.CmdFactoryMock{}, + ) + assert.Equal(t, "file", j.GetFile()) + assert.False(t, j.Errors().HasError()) +} + +func TestRunCmdErr(t *testing.T) { + cases := []struct { + name string + error string + doc string + }{ + { + name: "General error", + error: "cmd-error", + doc: util.UnknownError, + }, + { + name: "SBT not found", + error: " |exec: \"sbt\": executable file not found in $PATH", + doc: "SBT wasn't found. Please check if it is installed and accessible by the CLI.", + }, + { + name: "POM generation error", + error: " |[error] Error occurred while processing command: makePom", + doc: "Failed to generate Maven POM file. SBT encountered an error during the makePom task. Error details: Error occurred while generating the POM file", + }, + { + name: "Build file not found", + error: " |[error] not found: /home/user/project/build.sbt", + doc: "SBT configuration file not found. Please ensure that your project contains a valid build.sbt file. Error details: build.sbt file not found", + }, + { + name: "Invalid build file", + error: " |[error] Illegal character in build file at line 15", + doc: "Failed to parse SBT build file. Your build.sbt file contains syntax errors. Please check the build file for errors", + }, + { + name: "No Internet", + error: " |[error] Connection timed out: connect", + doc: "We weren't able to retrieve one or more dependencies or plugins. Please check your Internet connection and try again.", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + expectedError := util.NewPMJobError(c.error) + expectedError.SetDocumentation(c.doc) + + cmdErr := errors.New(c.error) + j := NewJob( + "file", + testdata.CmdFactoryMock{Err: cmdErr}, + testdata.BuildServiceMock{}, + mavenTestdata.PomServiceMock{}, + mavenTestdata.CmdFactoryMock{}, + ) + + go jobTestdata.WaitStatus(j) + + j.Run() + + allErrors := j.Errors().GetAll() + + assert.Len(t, allErrors, 1) + assert.Contains(t, allErrors, expectedError) + }) + } +} + +func TestRunCmdOutputErr(t *testing.T) { + j := NewJob( + "file", + testdata.CmdFactoryMock{Name: "bad-name"}, + testdata.BuildServiceMock{}, + mavenTestdata.PomServiceMock{}, + mavenTestdata.CmdFactoryMock{}, + ) + + go jobTestdata.WaitStatus(j) + + j.Run() + + error := j.Errors() + assert.True(t, error.HasError()) + + allErrors := error.GetAll() + assert.Len(t, allErrors, 1) + assert.Contains(t, allErrors[0].Error(), "executable file not found") +} + +func TestRunCmdOutputErrNoOutput(t *testing.T) { + j := NewJob( + "file", + testdata.CmdFactoryMock{Name: "go", Arg: "bad-arg"}, + testdata.BuildServiceMock{}, + mavenTestdata.PomServiceMock{}, + mavenTestdata.CmdFactoryMock{}, + ) + + go jobTestdata.WaitStatus(j) + + j.Run() + + errs := j.Errors().GetAll() + assert.Len(t, errs, 1) + + assert.Contains(t, errs[0].Error(), "unknown command") +} + +func TestRun(t *testing.T) { + tempDir := t.TempDir() + buildSbtPath := filepath.Join(tempDir, "build.sbt") + + buildSbtContent := ` +lazy val root = project.in(file(".")) +lazy val core = project("core-module") +lazy val api = project("api-module") +lazy val util = project("util-module") +` + err := os.WriteFile(buildSbtPath, []byte(buildSbtContent), 0600) + assert.NoError(t, err) + + targetDir := filepath.Join(tempDir, "target", "scala-3.6.4") + err = os.MkdirAll(targetDir, 0755) + assert.NoError(t, err) + + pomFilePath := filepath.Join(targetDir, "project.pom") + err = os.WriteFile(pomFilePath, []byte(""), 0600) + assert.NoError(t, err) + + // Create the job with the test file and explicit command + j := NewJob( + buildSbtPath, + testdata.CmdFactoryMock{ + Name: "echo", + Arg: "mock output", + }, + BuildService{}, + mavenTestdata.PomServiceMock{}, + mavenTestdata.CmdFactoryMock{ + Name: "echo", + Arg: "mock maven output", + }, + ) + + go jobTestdata.WaitStatus(j) + + j.Run() + errs := j.Errors().GetAll() + assert.Len(t, errs, 0) + assert.Equal(t, 0, len(errs)) + + assert.False(t, j.Errors().HasError()) +} + +func TestRunWithBuildServiceError(t *testing.T) { + cases := []struct { + name string + error string + doc string + }{ + { + name: "empty file", + error: "EOF", + doc: "This file doesn't contain valid SBT build content", + }, + { + name: "syntax error", + error: "syntax error in build.sbt", + doc: "syntax error in build.sbt", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + j := NewJob( + "file", + testdata.CmdFactoryMock{Name: "echo"}, + testdata.BuildServiceMock{ + Err: errors.New(c.error), + }, + mavenTestdata.PomServiceMock{}, + mavenTestdata.CmdFactoryMock{}, + ) + + go jobTestdata.WaitStatus(j) + + j.Run() + + allErrors := j.Errors().GetAll() + + expectedError := util.NewPMJobError(c.error) + expectedError.SetStatus("parsing SBT build file") + expectedError.SetDocumentation(c.doc) + + assert.Len(t, allErrors, 1) + assert.Contains(t, allErrors, expectedError) + }) + } +} + +func TestPomParsingError(t *testing.T) { + j := NewJob( + "file", + testdata.CmdFactoryMock{Name: "echo"}, + testdata.BuildServiceMock{}, + mavenTestdata.PomServiceMock{ + Err: errors.New("invalid XML"), + }, + mavenTestdata.CmdFactoryMock{}, + ) + + go jobTestdata.WaitStatus(j) + + j.Run() + + allErrors := j.Errors().GetAll() + assert.Len(t, allErrors, 1) + assert.Contains(t, allErrors[0].Error(), "invalid XML") +} + +func TestMavenCommandError(t *testing.T) { + j := NewJob( + "file", + testdata.CmdFactoryMock{Name: "echo"}, + testdata.BuildServiceMock{}, + mavenTestdata.PomServiceMock{}, + mavenTestdata.CmdFactoryMock{ + Err: errors.New("mvn not found"), + }, + ) + + go jobTestdata.WaitStatus(j) + + j.Run() + + allErrors := j.Errors().GetAll() + assert.Len(t, allErrors, 1) + assert.Contains(t, allErrors[0].Error(), "mvn not found") +} diff --git a/internal/resolution/pm/sbt/pm.go b/internal/resolution/pm/sbt/pm.go new file mode 100644 index 00000000..5d6289ed --- /dev/null +++ b/internal/resolution/pm/sbt/pm.go @@ -0,0 +1,23 @@ +package sbt + +const Name = "sbt" + +type Pm struct { + name string +} + +func NewPm() Pm { + return Pm{ + name: Name, + } +} + +func (pm Pm) Name() string { + return pm.name +} + +func (Pm) Manifests() []string { + return []string{ + `^build\.sbt$`, + } +} diff --git a/internal/resolution/pm/sbt/pm_test.go b/internal/resolution/pm/sbt/pm_test.go new file mode 100644 index 00000000..587f4171 --- /dev/null +++ b/internal/resolution/pm/sbt/pm_test.go @@ -0,0 +1,43 @@ +package sbt + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewPm(t *testing.T) { + pm := NewPm() + assert.Equal(t, Name, pm.name) +} + +func TestName(t *testing.T) { + pm := NewPm() + assert.Equal(t, Name, pm.Name()) +} + +func TestManifests(t *testing.T) { + pm := Pm{} + manifests := pm.Manifests() + assert.Len(t, manifests, 1) + manifest := manifests[0] + assert.Equal(t, `^build\.sbt$`, manifest) + _, err := regexp.Compile(manifest) + assert.NoError(t, err) + + cases := map[string]bool{ + "build.sbt": true, + "BUILD.sbt": false, + "build.sbt.backup": false, + "mybuild.sbt": false, + "build.scala": false, + "pom.xml": false, + } + for file, isMatch := range cases { + t.Run(file, func(t *testing.T) { + matched, _ := regexp.MatchString(manifest, file) + assert.Equal(t, isMatch, matched) + }) + } +} diff --git a/internal/resolution/pm/sbt/strategy.go b/internal/resolution/pm/sbt/strategy.go new file mode 100644 index 00000000..4aeb7bd2 --- /dev/null +++ b/internal/resolution/pm/sbt/strategy.go @@ -0,0 +1,34 @@ +package sbt + +import ( + "github.com/debricked/cli/internal/resolution/job" + "github.com/debricked/cli/internal/resolution/pm/maven" +) + +type Strategy struct { + files []string + cmdFactory ICmdFactory + buildService IBuildService + mavenPomService maven.IPomService + mavenCmdFactory maven.ICmdFactory +} + +func NewStrategy(files []string) Strategy { + return Strategy{ + files: files, + cmdFactory: CmdFactory{}, + buildService: BuildService{}, + mavenPomService: maven.PomService{}, + mavenCmdFactory: maven.CmdFactory{}, + } +} + +func (s Strategy) Invoke() ([]job.IJob, error) { + var jobs []job.IJob + + for _, file := range s.files { + jobs = append(jobs, NewJob(file, s.cmdFactory, s.buildService, s.mavenPomService, s.mavenCmdFactory)) + } + + return jobs, nil +} diff --git a/internal/resolution/pm/sbt/strategy_test.go b/internal/resolution/pm/sbt/strategy_test.go new file mode 100644 index 00000000..7bbf344d --- /dev/null +++ b/internal/resolution/pm/sbt/strategy_test.go @@ -0,0 +1,55 @@ +package sbt + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type BuildServiceMock struct{} + +func (b BuildServiceMock) ParseBuildModules(_ string) ([]string, error) { + return []string{}, nil +} + +func TestNewStrategy(t *testing.T) { + s := NewStrategy(nil) + assert.NotNil(t, s) + assert.Len(t, s.files, 0) + + s = NewStrategy([]string{}) + assert.NotNil(t, s) + assert.Len(t, s.files, 0) + + s = NewStrategy([]string{"file"}) + assert.NotNil(t, s) + assert.Len(t, s.files, 1) + + s = NewStrategy([]string{"file-1", "file-2"}) + assert.NotNil(t, s) + assert.Len(t, s.files, 2) +} + +func TestInvokeNoFiles(t *testing.T) { + s := NewStrategy([]string{}) + + jobs, _ := s.Invoke() + + assert.Empty(t, jobs) +} + +func TestInvokeOneFile(t *testing.T) { + s := NewStrategy([]string{"file"}) + + jobs, _ := s.Invoke() + + assert.Len(t, jobs, 1) +} + +func TestInvokeManyFiles(t *testing.T) { + s := NewStrategy([]string{"file-1", "file-2"}) + + jobs, _ := s.Invoke() + + assert.Len(t, jobs, 2) +} diff --git a/internal/resolution/pm/sbt/testdata/build_service_mock.go b/internal/resolution/pm/sbt/testdata/build_service_mock.go new file mode 100644 index 00000000..2b446645 --- /dev/null +++ b/internal/resolution/pm/sbt/testdata/build_service_mock.go @@ -0,0 +1,34 @@ +package testdata + +type BuildServiceMock struct { + Value []string + Err error +} + +func (b BuildServiceMock) ParseBuildModules(_ string) ([]string, error) { + if b.Err != nil { + return nil, b.Err + } + + if b.Value == nil { + return []string{"default-module"}, nil + } + + return b.Value, nil +} + +func (b BuildServiceMock) FindPomFile(_ string) (string, error) { + if b.Err != nil { + return "", b.Err + } + + return "pom.xml", nil +} + +func (b BuildServiceMock) RenamePomToXml(pomFile, destDir string) (string, error) { + if b.Err != nil { + return "", b.Err + } + + return pomFile, nil +} diff --git a/internal/resolution/pm/sbt/testdata/cmd_factory_mock.go b/internal/resolution/pm/sbt/testdata/cmd_factory_mock.go new file mode 100644 index 00000000..90289806 --- /dev/null +++ b/internal/resolution/pm/sbt/testdata/cmd_factory_mock.go @@ -0,0 +1,24 @@ +package testdata + +import ( + "os/exec" + "runtime" +) + +type CmdFactoryMock struct { + Err error + Name string + Arg string +} + +func (f CmdFactoryMock) MakePomCmd(_ string) (*exec.Cmd, error) { + if len(f.Arg) == 0 { + f.Arg = `"MakePomCmd"` + } + + if runtime.GOOS == "windows" && f.Name == "echo" { + return exec.Command("cmd", "/C", f.Name, f.Arg), f.Err + } + + return exec.Command(f.Name, f.Arg), f.Err +} diff --git a/internal/resolution/pm/sbt/testdata/invalidBuild.sbt b/internal/resolution/pm/sbt/testdata/invalidBuild.sbt new file mode 100644 index 00000000..b93e1a98 --- /dev/null +++ b/internal/resolution/pm/sbt/testdata/invalidBuild.sbt @@ -0,0 +1,12 @@ +name := "invalid-project" +version := "1.0.0" + +libraryDependencies ++= Seq( + "org.scala-lang" % "scala-library" % "2.13.8", + // Missing closing parenthesis + "com.typesafe.akka" %% "akka-http" % "10.2.9" + "com.typesafe.akka" %% "akka-stream" % "10.2.9" +) + +// Invalid syntax +scalaVersion = "2.13.8" \ No newline at end of file diff --git a/internal/resolution/pm/sbt/testdata/notABuild.sbt b/internal/resolution/pm/sbt/testdata/notABuild.sbt new file mode 100644 index 00000000..47fff546 --- /dev/null +++ b/internal/resolution/pm/sbt/testdata/notABuild.sbt @@ -0,0 +1,3 @@ +org.scala-lang:scala-library:2.13.8 +com.typesafe.akka:akka-http:10.2.9 +# This is not a valid SBT build file \ No newline at end of file diff --git a/internal/resolution/strategy/strategy_factory.go b/internal/resolution/strategy/strategy_factory.go index d6a59113..20f4148a 100644 --- a/internal/resolution/strategy/strategy_factory.go +++ b/internal/resolution/strategy/strategy_factory.go @@ -12,6 +12,7 @@ import ( "github.com/debricked/cli/internal/resolution/pm/npm" "github.com/debricked/cli/internal/resolution/pm/nuget" "github.com/debricked/cli/internal/resolution/pm/pip" + "github.com/debricked/cli/internal/resolution/pm/sbt" "github.com/debricked/cli/internal/resolution/pm/yarn" ) @@ -47,6 +48,8 @@ func (sf Factory) Make(pmFileBatch file.IBatch, paths []string) (IStrategy, erro return nuget.NewStrategy(pmFileBatch.Files()), nil case composer.Name: return composer.NewStrategy(pmFileBatch.Files()), nil + case sbt.Name: + return sbt.NewStrategy(pmFileBatch.Files()), nil default: return nil, fmt.Errorf("failed to make strategy from %s", name) } diff --git a/internal/resolution/strategy/strategy_factory_test.go b/internal/resolution/strategy/strategy_factory_test.go index 492bb0c7..11538a98 100644 --- a/internal/resolution/strategy/strategy_factory_test.go +++ b/internal/resolution/strategy/strategy_factory_test.go @@ -10,6 +10,7 @@ import ( "github.com/debricked/cli/internal/resolution/pm/maven" "github.com/debricked/cli/internal/resolution/pm/nuget" "github.com/debricked/cli/internal/resolution/pm/pip" + "github.com/debricked/cli/internal/resolution/pm/sbt" "github.com/debricked/cli/internal/resolution/pm/testdata" "github.com/debricked/cli/internal/resolution/pm/yarn" "github.com/stretchr/testify/assert" @@ -37,6 +38,7 @@ func TestMake(t *testing.T) { yarn.Name: yarn.NewStrategy(nil), nuget.Name: nuget.NewStrategy(nil), composer.Name: composer.NewStrategy(nil), + sbt.Name: sbt.NewStrategy(nil), } f := NewStrategyFactory() var batch file.IBatch