diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b111a38..a1ebe637 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,7 @@ jobs: with: go-version: '1.21' - name: golangci-lint - uses: golangci/golangci-lint-action@v3.7.0 - with: - version: latest + uses: reviewdog/action-golangci-lint@v2 test: runs-on: ${{ matrix.os }} @@ -38,31 +36,91 @@ jobs: - uses: actions/setup-go@v4 with: go-version: '1.21' + - name: Set up gotestfmt + uses: gotesttools/gotestfmt-action@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} - if: ${{ matrix.os == 'ubuntu-latest' }} uses: docker/login-action@v3 with: registry: registry.jetbrains.team username: ${{ secrets.SPACE_USERNAME }} password: ${{ secrets.SPACE_PASSWORD }} - - run: go test -v ./... -coverprofile cover.out -coverpkg=./... + - name: Run tests (with coverage) + if: ${{ matrix.os != 'windows-latest' }} + run: | + set -euo pipefail + go test -json -v ./... -coverprofile coverage-${{ matrix.os }}.out -coverpkg=./... 2>&1 | tee /tmp/gotest.log | gotestfmt + env: + QODANA_LICENSE_ONLY_TOKEN: ${{ secrets.QODANA_LICENSE_ONLY_TOKEN }} + - name: Run tests (with coverage) for Windows + if: ${{ matrix.os == 'windows-latest' }} + run: go test -v ./... -coverprofile coverage-${{ matrix.os }}.out -coverpkg=./... env: QODANA_LICENSE_ONLY_TOKEN: ${{ secrets.QODANA_LICENSE_ONLY_TOKEN }} - - if: startsWith(matrix.os, 'ubuntu') - uses: JetBrains/qodana-action@main + - name: Upload coverage artifact + uses: actions/upload-artifact@v3 + with: + name: coverage-${{ matrix.os }}.out + path: coverage-${{ matrix.os }}.out + + code-quality: + runs-on: ubuntu-latest + needs: [ lint, test ] + permissions: + checks: write + pull-requests: write + actions: read + contents: write + security-events: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: docker/login-action@v3 + with: + registry: registry.jetbrains.team + username: ${{ secrets.SPACE_USERNAME }} + password: ${{ secrets.SPACE_PASSWORD }} + - name: Download all coverage artifacts + uses: actions/download-artifact@v3 + with: + path: cov/ + - name: Merge coverage profiles + run: | + go install -v github.com/hansboder/gocovmerge@latest + mkdir -p .qodana/code-coverage + export PATH=$PATH:$(go env GOPATH)/bin + ls -R cov/ + gocovmerge cov/coverage-macos-latest.out/coverage-macos-latest.out cov/coverage-ubuntu-latest.out/coverage-ubuntu-latest.out cov/coverage-windows-latest.out/coverage-windows-latest.out > .qodana/code-coverage/coverage.out + - uses: JetBrains/qodana-action@main env: QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} with: args: --fail-threshold,0 - - if: startsWith(matrix.os, 'ubuntu') - uses: github/codeql-action/init@v2 + - uses: github/codeql-action/init@v2 with: languages: go - - if: startsWith(matrix.os, 'ubuntu') - uses: github/codeql-action/autobuild@v2 - - if: startsWith(matrix.os, 'ubuntu') - uses: github/codeql-action/analyze@v2 - - if: startsWith(matrix.os, 'ubuntu') - name: install chocolatey + - uses: github/codeql-action/autobuild@v2 + - uses: github/codeql-action/analyze@v2 + + release-nightly: + runs-on: ubuntu-latest + needs: [ lint, test, code-quality ] + permissions: + checks: write + pull-requests: write + actions: read + contents: write + security-events: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v4 + with: + go-version: '1.21' + - name: install chocolatey run: | mkdir -p /opt/chocolatey wget -q -O - "https://github.com/chocolatey/choco/releases/download/${CHOCOLATEY_VERSION}/chocolatey.v${CHOCOLATEY_VERSION}.tar.gz" | tar -xz -C "/opt/chocolatey" @@ -71,14 +129,12 @@ jobs: chmod +x /usr/local/bin/choco env: CHOCOLATEY_VERSION: 1.2.0 - - if: startsWith(matrix.os, 'ubuntu') - uses: goreleaser/goreleaser-action@v5 + - uses: goreleaser/goreleaser-action@v5 with: distribution: goreleaser version: latest args: --snapshot --clean --debug - - if: startsWith(matrix.os, 'ubuntu') && github.ref == 'refs/heads/233' - run: | + - run: | cd dist gh release --repo JetBrains/qodana-cli delete nightly -y || true git push --delete origin nightly || true diff --git a/README.md b/README.md index c4a02cbe..72b10b5c 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,11 @@ You can also add the linter by its name with the `--linter` option (e.g. `--lint #### macOS and Linux ##### Install with [Homebrew](https://brew.sh) (recommended) -```console +```shell brew install jetbrains/utils/qodana ``` ##### Install with our installer -```console +```shell curl -fsSL https://jb.gg/qodana-cli/install | bash ``` Also, you can install `nightly` or any other version (e.g. `v2023.2.9`) the following way: @@ -52,15 +52,15 @@ curl -fsSL https://jb.gg/qodana-cli/install | bash -s -- nightly #### Windows ##### Install with [Windows Package Manager](https://learn.microsoft.com/en-us/windows/package-manager/winget/) (recommended) -```console +```shell winget install -e --id JetBrains.QodanaCLI ``` ##### Install with [Chocolatey](https://chocolatey.org) -```console +```shell choco install qodana ``` ##### Install with [Scoop](https://scoop.sh) -```console +```shell scoop bucket add jetbrains https://github.com/JetBrains/scoop-utils scoop install qodana ``` @@ -72,7 +72,7 @@ from [this page](https://github.com/JetBrains/qodana-cli/releases/latest). Or, if you have Go installed, you can install the latest version of the CLI with the following command: -```console +```shell go install github.com/JetBrains/qodana-cli/v2023@main ``` @@ -90,7 +90,7 @@ If you know what linter you want to use, you can skip this step. Also, Qodana CLI can choose a linter for you. Just run the following command in your **project root**: -```console +```shell qodana init ``` @@ -99,7 +99,7 @@ qodana init Right after you configured your project (or remember linter's name you want to run), you can run Qodana inspections simply by invoking the following command in your project root: -```console +```shell qodana scan ``` @@ -112,7 +112,7 @@ After the analysis, the results are saved to `.//JetBrains//JetBrains//results/report`, you can find a Qodana HTML report. To view it in the browser, run the following command from your project root: -```console +```shell qodana show ``` @@ -141,7 +141,7 @@ Configure a project for Qodana: prepare Qodana configuration file by analyzing the project structure and generating a default configuration qodana.yaml file. -``` +```shell qodana init [flags] ``` @@ -167,7 +167,7 @@ Note that most options can be configured via qodana.yaml (https://www.jetbrains. But you can always override qodana.yaml options with the following command-line options. -``` +```shell qodana scan [flags] ``` @@ -222,7 +222,7 @@ be viewed via the file:// protocol (by double-clicking the index.html file). https://www.jetbrains.com/help/qodana/html-report.html This command serves the Qodana report locally and opens a browser to it. -``` +```shell qodana show [flags] ``` @@ -237,6 +237,33 @@ qodana show [flags] -r, --report-dir string Specify HTML report path (the one with index.html inside) (default /JetBrains//results/report) ``` +### send + +Send a Qodana report to Cloud + +#### Synopsis + +Send the report (qodana.sarif.json and other analysis results) to Qodana Cloud. + +If report directory is not specified, the latest report will be fetched from the default linter results location. + +If you are using other Qodana Cloud instance than https://qodana.cloud/, override it with declaring `ENDPOINT` environment variable. + +```shell +qodana send [flags] +``` + +#### Options + +``` + -h, --help help for send + -l, --linter string Override linter to use + -i, --project-dir string Root directory of the inspected project (default ".") + -r, --report-dir string Specify HTML report path (the one with index.html inside) (default "/Users/tv/Library/Caches/JetBrains/Qodana/e3b0c442-250e5c26/results/report") + -o, --results-dir string Override directory to save Qodana inspection results to (default "/Users/tv/Library/Caches/JetBrains/Qodana/e3b0c442-250e5c26/results") + -y, --yaml-name string Override qodana.yaml name +``` + ### view View SARIF files in CLI @@ -245,7 +272,7 @@ View SARIF files in CLI Preview all problems found in SARIF files in CLI. -``` +```shell qodana view [flags] ``` @@ -271,7 +298,7 @@ A command-line helper for Qodana pricing to calculate active contributors* in th ** Ultimate Plus plan currently has a discount, more information can be found on https://www.jetbrains.com/qodana/buy/ -``` +```shell qodana contributors [flags] ``` @@ -289,7 +316,7 @@ A command-line helper for project statistics: languages, lines of code. Powered #### Synopsis -``` +```shell qodana cloc [flags] ``` @@ -316,7 +343,7 @@ as there is some additional configuration tuning required that differs from proj It's easy to try Qodana locally by running a _simple_ command: -```console +```shell docker run --rm -it -p 8080:8080 -v /:/data/project/ -v /:/data/results/ -v /:/data/cache/ jetbrains/qodana- --show-report ``` diff --git a/cloud/cloud.go b/cloud/cloud.go index 9027f7e9..47ee6c43 100644 --- a/cloud/cloud.go +++ b/cloud/cloud.go @@ -19,6 +19,7 @@ package cloud import ( "bytes" "encoding/json" + "fmt" log "github.com/sirupsen/logrus" "io" "net/http" @@ -28,18 +29,26 @@ import ( ) const ( + QodanaEndpoint = "ENDPOINT" DefaultEndpoint = "qodana.cloud" - baseUrl = "https://api.qodana.cloud" maxNumberOfRetries = 3 waitTimeout = time.Second * 30 requestTimeout = time.Second * 30 ) +func getCloudBaseUrl() string { + return fmt.Sprintf("https://%s", GetEnvWithDefault(QodanaEndpoint, DefaultEndpoint)) +} + +func getCloudApiBaseUrl() string { + return fmt.Sprintf("https://api.%s", GetEnvWithDefault(QodanaEndpoint, DefaultEndpoint)) +} + // GetCloudTeamsPageUrl returns the team page URL on Qodana Cloud func GetCloudTeamsPageUrl(origin string, path string) string { name := filepath.Base(path) - return strings.Join([]string{"https://", DefaultEndpoint, "/?origin=", origin, "&name=", name}, "") + return strings.Join([]string{"https://", GetEnvWithDefault(QodanaEndpoint, DefaultEndpoint), "/?origin=", origin, "&name=", name}, "") } type QdClient struct { @@ -92,7 +101,7 @@ func (client *QdClient) getProject() RequestResult { } func (client *QdClient) doRequest(path, method string, headers map[string]string, body []byte) RequestResult { - url := baseUrl + path + url := getCloudApiBaseUrl() + path var resp *http.Response var err error diff --git a/cloud/cloud_test.go b/cloud/cloud_test.go index 46b7c0f8..8e2747c4 100644 --- a/cloud/cloud_test.go +++ b/cloud/cloud_test.go @@ -17,7 +17,10 @@ package cloud import ( + "encoding/json" "net/http" + "os" + "path/filepath" "testing" ) @@ -45,3 +48,43 @@ func TestValidateToken(t *testing.T) { t.Errorf("Problem") } } + +func TestGetReportUrl(t *testing.T) { + for _, tc := range []struct { + name string + jsonData jsonData + reportUrlFile string + expectedReport string + }{ + { + name: "valid json data and url", + jsonData: jsonData{Cloud: cloudInfo{URL: "https://cloud.qodana.com/report/url"}}, + reportUrlFile: "https://raw.qodana.com/report/url", + expectedReport: "https://cloud.qodana.com/report/url", + }, + { + name: "invalid json data, valid url file data", + jsonData: jsonData{Cloud: cloudInfo{URL: ""}}, + reportUrlFile: "https://raw.qodana.com/report/url", + expectedReport: "https://raw.qodana.com/report/url", + }, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + jsonFile := filepath.Join(dir, openInIdeJson) + jsonFileData, _ := json.Marshal(tc.jsonData) + if err := os.WriteFile(jsonFile, jsonFileData, 0644); err != nil { + t.Fatal(err) + } + urlFile := filepath.Join(dir, legacyReportFile) + if err := os.WriteFile(urlFile, []byte(tc.reportUrlFile), 0644); err != nil { + t.Fatal(err) + } + + actual := GetReportUrl(dir) + if actual != tc.expectedReport { + t.Fatalf("Expected \"%s\" but got \"%s\"", tc.expectedReport, actual) + } + }) + } +} diff --git a/cloud/license.go b/cloud/license.go index 0be11993..7e46c0d3 100644 --- a/cloud/license.go +++ b/cloud/license.go @@ -28,7 +28,7 @@ import ( "time" ) -type licenseData struct { +type LicenseData struct { LicenseID string `json:"licenseId"` LicenseKey string `json:"licenseKey"` ExpirationDate string `json:"expirationDate"` @@ -59,23 +59,23 @@ const ( var TokenDeclinedError = errors.New("token was declined by Qodana Cloud server") -const EmptyTokenMessage = `Starting from version 2023.2 release versions of Qodana Linters require connection to Qodana Cloud. +var EmptyTokenMessage = fmt.Sprintf(`Starting from version 2023.2 release versions of Qodana Linters require connection to Qodana Cloud. To continue using Qodana, please ensure you have an access token and provide the token as the QODANA_TOKEN environment variable. -Obtain your token by registering at https://qodana.cloud/ +Obtain your token by registering at %s For more details, please visit: https://www.jetbrains.com/help/qodana/cloud-quickstart.html We also offer Community versions as an alternative. You can find them here: https://www.jetbrains.com/help/qodana/linters.html -` +`, getCloudBaseUrl()) -const EapWarnTokenMessage = ` +var EapWarnTokenMessage = fmt.Sprintf(` Starting from version 2023.2 release versions of Qodana Linters will require connection to Qodana Cloud. -For seamless transition to release versions, obtain your token by registering at https://qodana.cloud/ +For seamless transition to release versions, obtain your token by registering at %s and provide the token as the QODANA_TOKEN environment variable. -For more details, please visit: https://www.jetbrains.com/help/qodana/cloud-quickstart.html` +For more details, please visit: https://www.jetbrains.com/help/qodana/cloud-quickstart.html`, getCloudBaseUrl()) -const GeneralLicenseErrorMessage = ` -Please check if https://qodana.cloud/ is accessible from your environment. +var GeneralLicenseErrorMessage = fmt.Sprintf(` +Please check if %s is accessible from your environment. If you encounter any issues, please contact us at qodana-support@jetbrains.com. -Or use our issue tracker at https://jb.gg/qodana-issue` +Or use our issue tracker at https://jb.gg/qodana-issue`, getCloudBaseUrl()) const InvalidTokenMessage = `QODANA_TOKEN is invalid, please provide a valid token` @@ -94,13 +94,13 @@ func (o *LicenseToken) IsAllowedToSendFUS() bool { return !o.LicenseOnly } -func ExtractLicenseKey(data []byte) string { - var ld licenseData +func DeserializeLicenseData(data []byte) LicenseData { + var ld LicenseData err := json.Unmarshal(data, &ld) if err != nil { log.Fatalf("License deserialization failed. License response data:\n%s\nError: '%v'", string(data), err) } - return ld.LicenseKey + return ld } func RequestLicenseData(endpoint string, token string) ([]byte, error) { @@ -155,7 +155,7 @@ func requestLicenseDataAttempt(endpoint string, token string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("Reading license response failed\n. %w", err) } - if resp.StatusCode == 403 { + if resp.StatusCode == 403 || resp.StatusCode == 404 { return nil, TokenDeclinedError } if resp.StatusCode == 200 { diff --git a/cloud/license_test.go b/cloud/license_test.go index 276d4443..318d1fff 100644 --- a/cloud/license_test.go +++ b/cloud/license_test.go @@ -178,9 +178,9 @@ func TestExtractLicenseKey(t *testing.T) { }, } { t.Run(testData.name, func(t *testing.T) { - key := ExtractLicenseKey([]byte(testData.data)) - if key != testData.expectedKey { - t.Errorf("expected key to be '%s' got '%s'", key, testData.expectedKey) + data := DeserializeLicenseData([]byte(testData.data)) + if data.LicenseKey != testData.expectedKey { + t.Errorf("expected data to be '%s' got '%s'", data, testData.expectedKey) } }) } diff --git a/cloud/report.go b/cloud/report.go new file mode 100644 index 00000000..94fc9fb8 --- /dev/null +++ b/cloud/report.go @@ -0,0 +1,80 @@ +package cloud + +import ( + "encoding/json" + log "github.com/sirupsen/logrus" + "os" + "path/filepath" +) + +const legacyReportFile = "qodana.cloud" +const openInIdeJson = "open-in-ide.json" + +type cloudInfo struct { + URL string `json:"url"` +} +type jsonData struct { + Cloud cloudInfo `json:"cloud"` +} + +// GetReportUrl retrieves the Qodana Cloud report URL from the qodana.sarif.json in the specified results directory. +func GetReportUrl(resultsDir string) string { + reportURL, err := readOpenInIde(resultsDir, openInIdeJson) + if err != nil || reportURL == "" { + reportURL, err = readLegacyReportFile(resultsDir, legacyReportFile) + if err != nil || reportURL == "" { + log.Debugf("Unable to find the report url in %s", filepath.Join(resultsDir, legacyReportFile)) + return "" + } + } + return reportURL +} + +func readOpenInIde(resultsDir, fileName string) (string, error) { + filePath := filepath.Join(resultsDir, fileName) + fileData, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + + data := jsonData{} + err = json.Unmarshal(fileData, &data) + if err != nil || data.Cloud.URL == "" { + return "", err + } + + log.Debugf("Found report URL from (%s): %s", filePath, data.Cloud.URL) + return data.Cloud.URL, nil +} + +func readLegacyReportFile(resultsDir, fileName string) (string, error) { + filePath := filepath.Join(resultsDir, fileName) + fileData, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + + log.Debugf("Found report URL from (%s): %s", filePath, string(fileData)) + return string(fileData), nil +} + +// SaveReportFile saves the report URL to the resultsDir/open-in-ide.json file if it does not exist. +func SaveReportFile(resultsDir, reportUrl string) { + if reportUrl == "" { + return + } + reportFilename := filepath.Join(resultsDir, openInIdeJson) + if _, err := os.Stat(reportFilename); err != nil { + var dataBytes []byte + dataBytes, err = json.Marshal(jsonData{Cloud: cloudInfo{URL: reportUrl}}) + if err != nil { + log.Errorf("Unable to marshal the report URL: %s", err) + return + } + err = os.WriteFile(reportFilename, dataBytes, 0644) + if err != nil { + log.Errorf("Unable to save the report URL: %s", err) + return + } + } +} diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 843c7bbf..1068756a 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -22,18 +22,14 @@ import ( "bytes" "encoding/json" "fmt" - "github.com/JetBrains/qodana-cli/v2023/cloud" "io" "os" "os/exec" "path/filepath" "runtime" - "sort" "strings" "testing" - "github.com/stretchr/testify/assert" - log "github.com/sirupsen/logrus" "github.com/JetBrains/qodana-cli/v2023/core" @@ -53,6 +49,10 @@ func createProject(t *testing.T, name string) string { if err != nil { t.Fatal(err) } + err = os.MkdirAll(location+"/.idea", 0o755) + if err != nil { + t.Fatal(err) + } return location } @@ -252,6 +252,7 @@ func TestAllCommandsWithContainer(t *testing.T) { core.DisableColor() core.CheckForUpdates("0.1.0") projectPath := createProject(t, "qodana_scan_python") + cachePath := createProject(t, "cache") resultsPath := filepath.Join(projectPath, "results") err := os.MkdirAll(resultsPath, 0o755) if err != nil { @@ -268,26 +269,28 @@ func TestAllCommandsWithContainer(t *testing.T) { t.Fatal(err) } - // scan with a container - out = bytes.NewBufferString("") - // set debug log to debug - log.SetLevel(log.DebugLevel) - command = newScanCommand() - command.SetOut(out) - command.SetArgs([]string{ - "-i", projectPath, - "-o", resultsPath, - "--cache-dir", filepath.Join(projectPath, "cache"), - "--fail-threshold", "5", - "--print-problems", - "--apply-fixes", - "-l", linter, - "--property", - "idea.headless.enable.statistics=false", - }) - err = command.Execute() - if err != nil { - t.Fatal(err) + for i := 0; i < 2; i++ { // run scan with a container twice to check the cache + out = bytes.NewBufferString("") + // set debug log to debug + log.SetLevel(log.DebugLevel) + command = newScanCommand() + command.SetOut(out) + command.SetArgs([]string{ + "-i", projectPath, + "-o", resultsPath, + "--cache-dir", cachePath, + "-v", filepath.Join(projectPath, ".idea") + ":/data/some", + "--fail-threshold", "5", + "--print-problems", + "--apply-fixes", + "-l", linter, + "--property", + "idea.headless.enable.statistics=false", + }) + err = command.Execute() + if err != nil { + t.Fatal(err) + } } // view @@ -356,6 +359,10 @@ func TestAllCommandsWithContainer(t *testing.T) { if err != nil { t.Fatal(err) } + err = os.RemoveAll(cachePath) + if err != nil { + t.Fatal(err) + } } func TestScanWithIde(t *testing.T) { @@ -392,145 +399,3 @@ func TestScanWithIde(t *testing.T) { t.Fatal(err) } } - -func propertiesFixture(enableStats bool, additionalProperties []string) []string { - properties := []string{ - "-Dfus.internal.reduce.initial.delay=true", - fmt.Sprintf("-Didea.application.info.value=%s", filepath.Join(core.Prod.IdeBin(), "QodanaAppInfo.xml")), - "-Didea.class.before.app=com.jetbrains.rider.protocol.EarlyBackendStarter", - fmt.Sprintf("-Didea.config.path=%s", filepath.Join(os.TempDir(), "entrypoint")), - fmt.Sprintf("-Didea.headless.enable.statistics=%t", enableStats), - "-Didea.headless.statistics.device.id=FAKE", - "-Didea.headless.statistics.max.files.to.send=5000", - "-Didea.headless.statistics.salt=FAKE", - fmt.Sprintf("-Didea.log.path=%s", filepath.Join(os.TempDir(), "entrypoint", "log")), - "-Didea.parent.prefix=Rider", - "-Didea.platform.prefix=Qodana", - fmt.Sprintf("-Didea.plugins.path=%s", filepath.Join(os.TempDir(), "entrypoint", "plugins", "233")), - "-Didea.qodana.thirdpartyplugins.accept=true", - fmt.Sprintf("-Didea.system.path=%s", filepath.Join(os.TempDir(), "entrypoint", "idea", "233")), - "-Dinspect.save.project.settings=true", - "-Djava.awt.headless=true", - "-Djava.net.useSystemProxies=true", - "-Djdk.attach.allowAttachSelf=true", - `-Djdk.http.auth.tunneling.disabledSchemes=""`, - "-Djdk.module.illegalAccess.silent=true", - "-Dkotlinx.coroutines.debug=off", - "-Dqodana.automation.guid=FAKE", - "-Didea.job.launcher.without.timeout=true", - "-Dqodana.coverage.input=/data/coverage", - "-Dqodana.recommended.profile.resource=qodana-dotnet.recommended.yaml", - "-Dqodana.starter.profile.resource=qodana-dotnet.starter.yaml", - "-Drider.collect.full.container.statistics=true", - "-Drider.suppress.std.redirect=true", - "-Dsun.io.useCanonCaches=false", - "-Dsun.tools.attach.tmp.only=true", - "-XX:+HeapDumpOnOutOfMemoryError", - "-XX:+UseG1GC", - "-XX:-OmitStackTraceInFastThrow", - "-XX:CICompilerCount=2", - "-XX:MaxJavaStackTraceDepth=10000", - "-XX:MaxRAMPercentage=70", - "-XX:ReservedCodeCacheSize=512m", - "-XX:SoftRefLRUPolicyMSPerMB=50", - fmt.Sprintf("-Xlog:gc*:%s", filepath.Join(os.TempDir(), "entrypoint", "log", "gc.log")), - "-ea", - } - properties = append(properties, additionalProperties...) - sort.Strings(properties) - return properties -} - -func Test_Properties(t *testing.T) { - opts := &core.QodanaOptions{} - tmpDir := filepath.Join(os.TempDir(), "entrypoint") - opts.ProjectDir = tmpDir - opts.ResultsDir = opts.ProjectDir - opts.CacheDir = opts.ProjectDir - opts.CoverageDir = "/data/coverage" - opts.AnalysisId = "FAKE" - - core.Prod.BaseScriptName = "rider" - core.Prod.Code = "QDNET" - core.Prod.Version = "2023.3" - core.Prod.Name = "" - core.Prod.IdeScript = "" - core.Prod.Build = "" - core.Prod.Home = "" - core.Prod.EAP = false - - cloud.Token.LicenseOnly = false - cloud.Token.Token = "" - - err := os.Setenv(core.QodanaDistEnv, opts.ProjectDir) - if err != nil { - t.Fatal(err) - } - err = os.Setenv(core.QodanaConfEnv, opts.ProjectDir) - if err != nil { - t.Fatal(err) - } - err = os.Setenv("DEVICEID", "FAKE") - if err != nil { - t.Fatal(err) - } - err = os.Setenv("SALT", "FAKE") - if err != nil { - t.Fatal(err) - } - err = os.MkdirAll(opts.ProjectDir, 0o755) - if err != nil { - t.Fatal(err) - } - - for _, tc := range []struct { - name string - cliProperties []string - qodanaYaml string - expected []string - }{ - { - name: "no overrides, just defaults and .NET project", - cliProperties: []string{}, - qodanaYaml: "dotnet:\n project: project.csproj", - expected: propertiesFixture(true, []string{"-Dqodana.net.project=project.csproj"}), - }, - { - name: "add one CLI property and .NET solution settings", - cliProperties: []string{"-xa", "idea.some.custom.property=1"}, - qodanaYaml: "dotnet:\n solution: solution.sln\n configuration: Release\n platform: x64", - expected: append( - propertiesFixture(true, []string{"-Dqodana.net.solution=solution.sln", "-Dqodana.net.configuration=Release", "-Dqodana.net.platform=x64", "-Didea.some.custom.property=1"}), - "-xa", - ), - }, - { - name: "override options from CLI, YAML should be ignored", - cliProperties: []string{"-Dfus.internal.reduce.initial.delay=false", "-Didea.application.info.value=0", "idea.headless.enable.statistics=false"}, - qodanaYaml: "" + - "version: \"1.0\"\n" + - "properties:\n" + - " fus.internal.reduce.initial.delay: true\n" + - " idea.application.info.value: 0\n", - expected: append([]string{ - "-Dfus.internal.reduce.initial.delay=false", - "-Didea.application.info.value=0", - }, propertiesFixture(false, []string{})[2:]...), - }, - } { - t.Run(tc.name, func(t *testing.T) { - err = os.WriteFile(filepath.Join(opts.ProjectDir, "qodana.yml"), []byte(tc.qodanaYaml), 0o600) - if err != nil { - t.Fatal(err) - } - opts.Property = tc.cliProperties - core.Config = core.GetQodanaYaml(opts.ProjectDir) - actual := core.GetProperties(opts, core.Config.Properties, core.Config.DotNet, []string{}) - assert.Equal(t, tc.expected, actual) - }) - } - err = os.RemoveAll(opts.ProjectDir) - if err != nil { - t.Fatal(err) - } -} diff --git a/cmd/pull.go b/cmd/pull.go index 3ecb8ea8..aa3ad116 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -23,40 +23,18 @@ import ( "github.com/spf13/cobra" ) -// pullOptions represents pull command options. -type pullOptions struct { - Linter string - ProjectDir string - YamlName string -} - // newPullCommand returns a new instance of the show command. func newPullCommand() *cobra.Command { - options := &pullOptions{} + options := &core.QodanaOptions{} cmd := &cobra.Command{ Use: "pull", Short: "Pull latest version of linter", Long: `An alternative to pull an image.`, PreRun: func(cmd *cobra.Command, args []string) { - core.PrepairContainerEnvSettings() + core.PrepareContainerEnvSettings() }, Run: func(cmd *cobra.Command, args []string) { - if options.YamlName == "" { - options.YamlName = core.FindQodanaYaml(options.ProjectDir) - } - if options.Linter == "" { - qodanaYaml := core.LoadQodanaYaml(options.ProjectDir, options.YamlName) - if qodanaYaml.Linter == "" { - core.WarningMessage( - "No valid qodana.yaml found. Have you run %s? Running that for you...", - core.PrimaryBold("qodana init"), - ) - options.Linter = core.GetLinter(options.ProjectDir, options.YamlName) - core.EmptyMessage() - } else { - options.Linter = qodanaYaml.Linter - } - } + options.FetchAnalyzerSettings() containerClient, err := client.NewClientWithOpts() if err != nil { log.Fatal("couldn't connect to container engine ", err) @@ -67,6 +45,6 @@ func newPullCommand() *cobra.Command { flags := cmd.Flags() flags.StringVarP(&options.Linter, "linter", "l", "", "Override linter to use") flags.StringVarP(&options.ProjectDir, "project-dir", "i", ".", "Root directory of the inspected project") - flags.StringVarP(&options.YamlName, "yaml-name", "y", "", "Override qodana.yaml name") + flags.StringVarP(&options.YamlName, "yaml-name", "y", core.FindQodanaYaml(options.ProjectDir), "Override qodana.yaml name") return cmd } diff --git a/cmd/root.go b/cmd/root.go index 4d6bd2d9..11dcc83d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ package cmd import ( + "fmt" "github.com/JetBrains/qodana-cli/v2023/core" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -24,9 +25,34 @@ import ( "os" ) +// isHelp checks if only help was requested. +func isHelp(args []string) bool { + return len(args) == 2 && (args[1] == "--help" || args[1] == "-h") +} + +// isCommandRequested checks if any command is requested. +func isCommandRequested(commands []*cobra.Command, args []string) string { + for _, c := range commands { + for _, a := range args { + if c.Name() == a { + return c.Name() + } + } + } + return "" +} + +// setDefaultCommandIfNeeded sets default scan command if no other command is requested. +func setDefaultCommandIfNeeded(rootCmd *cobra.Command, args []string) { + if !(isHelp(args) || isCommandRequested(rootCmd.Commands(), args[1:]) != "") { + newArgs := append([]string{"scan"}, args[1:]...) + rootCmd.SetArgs(newArgs) + } +} + // Execute is a main CLI entrypoint: handles user interrupt, CLI start and everything else. func Execute() { - if os.Geteuid() == 0 && !core.IsContainer() { + if !core.IsContainer() && os.Geteuid() == 0 { core.WarningMessage("Running the tool as root is dangerous: please run it as a regular user") } go core.CheckForUpdates(core.Version) @@ -34,9 +60,13 @@ func Execute() { core.DisableColor() } + setDefaultCommandIfNeeded(rootCommand, os.Args) if err := rootCommand.Execute(); err != nil { core.CheckForUpdates(core.Version) - log.Fatalf("error running command: %s", err) + _, err = fmt.Fprintf(os.Stderr, "error running command: %s\n", err) + if err != nil { + return + } os.Exit(1) } @@ -82,6 +112,7 @@ func init() { newInitCommand(), newScanCommand(), newShowCommand(), + newSendCommand(), newPullCommand(), newViewCommand(), newContributorsCommand(), diff --git a/cmd/scan.go b/cmd/scan.go index 14ccfa79..4c342071 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -18,6 +18,7 @@ package cmd import ( "fmt" + "github.com/JetBrains/qodana-cli/v2023/cloud" "github.com/google/uuid" "os" "path/filepath" @@ -41,29 +42,11 @@ Note that most options can be configured via qodana.yaml (https://www.jetbrains. But you can always override qodana.yaml options with the following command-line options. `, Run: func(cmd *cobra.Command, args []string) { + reportUrl := cloud.GetReportUrl(options.ResultsDir) + ctx := cmd.Context() checkProjectDir(options.ProjectDir) - if options.YamlName == "" { - options.YamlName = core.FindQodanaYaml(options.ProjectDir) - } - - if options.Linter == "" && options.Ide == "" { - qodanaYaml := core.LoadQodanaYaml(options.ProjectDir, options.YamlName) - if qodanaYaml.Linter == "" && qodanaYaml.Ide == "" { - core.WarningMessage( - "No valid `linter:` field found in %s. Have you run %s? Running that for you...", - core.PrimaryBold(options.YamlName), - core.PrimaryBold("qodana init"), - ) - options.Linter = core.GetLinter(options.ProjectDir, options.YamlName) - core.EmptyMessage() - } else { - options.Linter = qodanaYaml.Linter - } - if options.Ide == "" { - options.Ide = qodanaYaml.Ide - } - } + options.FetchAnalyzerSettings() exitCode := core.RunAnalysis(ctx, options) checkExitCode(exitCode, options.ResultsDir) @@ -72,9 +55,14 @@ But you can always override qodana.yaml options with the following command-line options.ShowReport = core.AskUserConfirm("Do you want to open the latest report") } + newReportUrl := cloud.GetReportUrl(options.ResultsDir) + if newReportUrl != reportUrl { + core.SuccessMessage("Report is successfully uploaded to %s", reportUrl) + } + if options.ShowReport { - core.ShowReport(options.ResultsDir, options.ReportDirPath(), options.Port) - } else { + core.ShowReport(options.ResultsDir, options.ReportDir, options.Port) + } else if !core.IsContainer() && core.IsInteractive() { core.WarningMessage( "To view the Qodana report later, run %s in the current directory or add %s flag to %s", core.PrimaryBold("qodana show"), @@ -100,15 +88,15 @@ But you can always override qodana.yaml options with the following command-line flags.StringVar(&options.Ide, "ide", os.Getenv(core.QodanaDistEnv), fmt.Sprintf("Use to run Qodana without a container. Path to the installed IDE, or a downloaded one: provide direct URL or a product code. Not compatible with --linter option. Available codes are %s, add -EAP part to obtain EAP versions", strings.Join(core.AllSupportedCodes, ", "))) flags.StringVarP(&options.ProjectDir, "project-dir", "i", ".", "Root directory of the inspected project") - flags.StringVarP(&options.ResultsDir, "results-dir", "o", options.ResultsDirPath(), "Override directory to save Qodana inspection results to") - flags.StringVar(&options.CacheDir, "cache-dir", options.CacheDirPath(), "Override cache directory (default /JetBrains//cache)") - flags.StringVarP(&options.ReportDir, "report-dir", "r", options.ReportDirPath(), "Override directory to save Qodana HTML report to") + flags.StringVarP(&options.ResultsDir, "results-dir", "o", "", "Override directory to save Qodana inspection results to (default /JetBrains//results)") + flags.StringVar(&options.CacheDir, "cache-dir", "", "Override cache directory (default /JetBrains//cache)") + flags.StringVarP(&options.ReportDir, "report-dir", "r", "", "Override directory to save Qodana HTML report to (default /JetBrains//results/report)") flags.BoolVar(&options.PrintProblems, "print-problems", false, "Print all found problems by Qodana in the CLI output") flags.BoolVar(&options.ClearCache, "clear-cache", false, "Clear the local Qodana cache before running the analysis") flags.BoolVarP(&options.ShowReport, "show-report", "w", false, "Serve HTML report on port") flags.IntVar(&options.Port, "port", 8080, "Port to serve the report on") - flags.StringVar(&options.YamlName, "yaml-name", "", "Override qodana.yaml name to use: 'qodana.yaml' or 'qodana.yml'") + flags.StringVar(&options.YamlName, "yaml-name", core.FindQodanaYaml(options.ProjectDir), "Override qodana.yaml name to use: 'qodana.yaml' or 'qodana.yml'") flags.StringVarP(&options.AnalysisId, "analysis-id", "a", uuid.New().String(), "Unique report identifier (GUID) to be used by Qodana Cloud") flags.StringVarP(&options.Baseline, "baseline", "b", "", "Provide the path to an existing SARIF report to be used in the baseline state calculation") @@ -123,6 +111,7 @@ But you can always override qodana.yaml options with the following command-line flags.StringVar(&options.RunPromo, "run-promo", "", "Set to 'true' to have the application run the inspections configured by the promo profile; set to 'false' otherwise (default: 'true' only if Qodana is executed with the default profile)") flags.StringVar(&options.Script, "script", "default", "Override the run scenario") flags.StringVar(&options.StubProfile, "stub-profile", "", "Absolute path to the fallback profile file. This option is applied in case the profile was not specified using any available options") + flags.StringVar(&options.CoverageDir, "coverage-dir", "", "Directory with coverage data to process") flags.BoolVar(&options.ApplyFixes, "apply-fixes", false, "Apply all available quick-fixes, including cleanup") flags.BoolVar(&options.Cleanup, "cleanup", false, "Run project cleanup") diff --git a/cmd/send.go b/cmd/send.go new file mode 100644 index 00000000..2100d13d --- /dev/null +++ b/cmd/send.go @@ -0,0 +1,52 @@ +/* + * Copyright 2021-2023 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "fmt" + "github.com/JetBrains/qodana-cli/v2023/cloud" + "github.com/JetBrains/qodana-cli/v2023/core" + "github.com/spf13/cobra" +) + +// newShowCommand returns a new instance of the show command. +func newSendCommand() *cobra.Command { + options := &core.QodanaOptions{} + cmd := &cobra.Command{ + Use: "send", + Short: "Send a Qodana report to Cloud", + Long: fmt.Sprintf(`Send the report (qodana.sarif.json and other analysis results) to Qodana Cloud. + +If report directory is not specified, the latest report will be fetched from the default linter results location. + +If you are using other Qodana Cloud instance than https://qodana.cloud/, override it with declaring %s environment variable.`, core.PrimaryBold(cloud.QodanaEndpoint)), + Run: func(cmd *cobra.Command, args []string) { + options.FetchAnalyzerSettings() + core.SendReport( + options, + options.ValidateToken(false), + ) + }, + } + flags := cmd.Flags() + flags.StringVarP(&options.Linter, "linter", "l", "", "Override linter to use") + flags.StringVarP(&options.ProjectDir, "project-dir", "i", ".", "Root directory of the inspected project") + flags.StringVarP(&options.ResultsDir, "results-dir", "o", "", "Override directory to save Qodana inspection results to (default /JetBrains//results)") + flags.StringVarP(&options.ReportDir, "report-dir", "r", "", "Override directory to save Qodana HTML report to (default /JetBrains//results/report)") + flags.StringVarP(&options.YamlName, "yaml-name", "y", core.FindQodanaYaml(options.ProjectDir), "Override qodana.yaml name") + return cmd +} diff --git a/cmd/show.go b/cmd/show.go index 9b9a8d1f..1d87e375 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -17,9 +17,6 @@ package cmd import ( - "os" - "path/filepath" - "github.com/JetBrains/qodana-cli/v2023/core" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -28,7 +25,6 @@ import ( // newShowCommand returns a new instance of the show command. func newShowCommand() *cobra.Command { options := &core.QodanaOptions{} - reportDir := "" openDir := false cmd := &cobra.Command{ Use: "show", @@ -40,21 +36,7 @@ be viewed via the file:// protocol (by double-clicking the index.html file). https://www.jetbrains.com/help/qodana/html-report.html This command serves the Qodana report locally and opens a browser to it.`, Run: func(cmd *cobra.Command, args []string) { - if options.YamlName == "" { - options.YamlName = core.FindQodanaYaml(options.ProjectDir) - } - if reportDir == "" { - if options.Linter == "" { - options.Linter = core.LoadQodanaYaml(options.ProjectDir, options.YamlName).Linter - } - systemDir := options.GetLinterDir() - if _, err := os.Stat(systemDir); os.IsNotExist(err) { - systemDir = core.LookUpLinterSystemDir(options) - } - - options.ResultsDir = filepath.Join(systemDir, "results") - reportDir = filepath.Join(options.ResultsDir, "report") - } + options.FetchAnalyzerSettings() if openDir { err := core.OpenDir(options.ResultsDir) if err != nil { @@ -63,7 +45,7 @@ This command serves the Qodana report locally and opens a browser to it.`, } else { core.ShowReport( options.ResultsDir, - reportDir, + options.ReportDir, options.Port, ) } @@ -72,10 +54,10 @@ This command serves the Qodana report locally and opens a browser to it.`, flags := cmd.Flags() flags.StringVarP(&options.Linter, "linter", "l", "", "Override linter to use") flags.StringVarP(&options.ProjectDir, "project-dir", "i", ".", "Root directory of the inspected project") - flags.StringVarP(&options.ResultsDir, "results-dir", "o", options.ResultsDirPath(), "Override directory to save Qodana inspection results to") - flags.StringVarP(&options.ReportDir, "report-dir", "r", options.ReportDirPath(), "Specify HTML report path (the one with index.html inside) ") + flags.StringVarP(&options.ResultsDir, "results-dir", "o", "", "Override directory to save Qodana inspection results to (default /JetBrains//results)") + flags.StringVarP(&options.ReportDir, "report-dir", "r", "", "Override directory to save Qodana HTML report to (default /JetBrains//results/report)") flags.IntVarP(&options.Port, "port", "p", 8080, "Specify port to serve report at") flags.BoolVarP(&openDir, "dir-only", "d", false, "Open report directory only, don't serve it") - flags.StringVarP(&options.YamlName, "yaml-name", "y", "", "Override qodana.yaml name") + flags.StringVarP(&options.YamlName, "yaml-name", "y", core.FindQodanaYaml(options.ProjectDir), "Override qodana.yaml name") return cmd } diff --git a/core/common.go b/core/common.go index abe7881f..1e9f55cb 100644 --- a/core/common.go +++ b/core/common.go @@ -18,6 +18,7 @@ package core import ( "fmt" + "github.com/JetBrains/qodana-cli/v2023/cloud" "os" "path/filepath" "strings" @@ -54,7 +55,7 @@ var AllSupportedCodes = []string{QDNET} func Image(code string) string { switch code { - case QDAND: + case QDANDC: return "jetbrains/qodana-jvm-android:" + version case QDPHP: return "jetbrains/qodana-php:" + version @@ -129,7 +130,7 @@ func GetLinter(path string, yamlName string) string { // ShowReport serves the Qodana report func ShowReport(resultsDir string, reportPath string, port int) { - cloudUrl := getReportUrl(resultsDir) + cloudUrl := cloud.GetReportUrl(resultsDir) if cloudUrl != "" { openReport(cloudUrl, reportPath, port) } else { diff --git a/core/configurator.go b/core/configurator.go index f4c57fc9..9eb35f6b 100644 --- a/core/configurator.go +++ b/core/configurator.go @@ -30,17 +30,16 @@ import ( ) const ( - QodanaSarifName = "qodana.sarif.json" - qodanaReportUrlFile = "qodana.cloud" - configName = "qodana" - version = "2023.2" - eap = "-eap" + QodanaSarifName = "qodana.sarif.json" + configName = "qodana" + version = "2023.2" + eap = "-eap" ) // langsLinters is a map of languages to linters. var langsLinters = map[string][]string{ - "Java": {Image(QDJVMC), Image(QDJVM), Image(QDAND)}, - "Kotlin": {Image(QDJVMC), Image(QDJVM), Image(QDAND)}, + "Java": {Image(QDJVMC), Image(QDJVM), Image(QDANDC)}, + "Kotlin": {Image(QDJVMC), Image(QDJVM), Image(QDANDC)}, "PHP": {Image(QDPHP)}, "Python": {Image(QDPYC), Image(QDPY)}, "JavaScript": {Image(QDJS)}, @@ -51,35 +50,21 @@ var langsLinters = map[string][]string{ "Visual Basic .NET": {Image(QDNET)}, } -// allCodes is a list of all supported linters' product codes. -var allCodes = []string{ - QDJVMC, - QDJVM, - QDAND, - QDPHP, - QDPYC, - QDPY, - QDJS, - QDGO, - QDNET, - QDRST, - QDRUBY, +var allSupportedPaidCodes = []string{QDJVM, QDPHP, QDPY, QDJS, QDGO, QDNET} +var allSupportedFreeCodes = []string{QDJVMC, QDPYC} + +func allImages(codes []string) []string { + var images []string + for _, code := range codes { + images = append(images, Image(code)) + } + return images } +var allSupportedFreeImages = allImages(allSupportedFreeCodes) + // AllImages is a list of all supported linters. -var AllImages = []string{ - Image(QDJVMC), - Image(QDJVM), - Image(QDAND), - Image(QDPHP), - Image(QDPY), - Image(QDPYC), - Image(QDJS), - Image(QDGO), - Image(QDNET), - //Image(QDRST), - //Image(QDRUBY), -} +var AllImages = append(allSupportedFreeImages, allImages(allSupportedPaidCodes)...) // ignoredDirectories is a list of directories that should be ignored by the configurator. var ignoredDirectories = []string{ diff --git a/core/container.go b/core/container.go index ffac1456..d6a2ab59 100644 --- a/core/container.go +++ b/core/container.go @@ -24,7 +24,6 @@ import ( "fmt" "github.com/pterm/pterm" "io" - "net/url" "os" "os/exec" "path/filepath" @@ -33,8 +32,6 @@ import ( cliconfig "github.com/docker/cli/cli/config" - "github.com/cucumber/ci-environment/go" - "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" @@ -71,6 +68,8 @@ func runQodanaContainer(ctx context.Context, options *QodanaOptions) int { resetScanStages() docker := getContainerClient() + fixDarwinCaches(options) + for i, stage := range scanStages { scanStages[i] = PrimaryBold("[%d/%d] ", i+1, len(scanStages)+1) + primary(stage) } @@ -93,72 +92,52 @@ func runQodanaContainer(ctx context.Context, options *QodanaOptions) int { exitCode := getContainerExitCode(ctx, docker, dockerConfig.Name) + fixDarwinCaches(options) + if progress != nil { _ = progress.Stop() } return int(exitCode) } -// encodeAuthToBase64 serializes the auth configuration as JSON base64 payload -func encodeAuthToBase64(authConfig types.AuthConfig) (string, error) { - buf, err := json.Marshal(authConfig) - if err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(buf), nil -} - -// extractQodanaEnvironmentForDocker extracts Qodana env variables QODANA_* to the given environment array. -func extractQodanaEnvironmentForDocker(opts *QodanaOptions) { - ci := cienvironment.DetectCIEnvironment() - qEnv := "cli" - if ci != nil { - qEnv = strings.ReplaceAll(strings.ToLower(ci.Name), " ", "-") - opts.setenv(qodanaJobUrl, validateJobUrl(ci.URL, qEnv)) - if ci.Git != nil { - opts.setenv(qodanaRemoteUrl, validateRemoteUrl(ci.Git.Remote)) - opts.setenv(qodanaBranch, validateBranch(ci.Git.Branch, qEnv)) - opts.setenv(qodanaRevision, ci.Git.Revision) +func fixDarwinCaches(options *QodanaOptions) { + if //goland:noinspection GoBoolExpressions + runtime.GOOS == "darwin" { + err := removePortSocket(options.CacheDir) + if err != nil { + log.Warnf("Could not remove .port from %s: %s", options.CacheDir, err) } } - opts.setenv(qodanaEnv, fmt.Sprintf("%s:%s", qEnv, Version)) } -func validateRemoteUrl(remote string) string { - _, err := url.ParseRequestURI(remote) - if remote == "" || err != nil { - log.Warnf("Unable to parse git remote URL, set %s env variable for proper qodana.cloud reporting", qodanaBranch) - return "" +// removePortSocket removes .port from the system dir to resolve QD-7383. +func removePortSocket(systemDir string) error { + ideaDir := filepath.Join(systemDir, "idea") + files, err := os.ReadDir(ideaDir) + if err != nil { + return nil } - return remote -} - -func validateBranch(branch string, env string) string { - if branch == "" { - if env == "github-actions" { - branch = os.Getenv("GITHUB_REF") - } else if env == "azure-pipelines" { - branch = os.Getenv("BUILD_SOURCEBRANCHNAME") - } else if env == "jenkins" { - branch = os.Getenv("GIT_BRANCH") + for _, file := range files { + if file.IsDir() { + dotPort := filepath.Join(ideaDir, file.Name(), ".port") + if _, err = os.Stat(dotPort); err == nil { + err = os.Remove(dotPort) + if err != nil { + return err + } + } } } - if branch == "" { - log.Warnf("Unable to parse git branch, set %s env variable for proper qodana.cloud reporting", qodanaBranch) - return "" - } - return branch + return nil } -func validateJobUrl(ciUrl string, qEnv string) string { - if strings.HasPrefix(qEnv, "azure") { // temporary workaround for Azure Pipelines - return getAzureJobUrl() - } - _, err := url.ParseRequestURI(ciUrl) +// encodeAuthToBase64 serializes the auth configuration as JSON base64 payload +func encodeAuthToBase64(authConfig types.AuthConfig) (string, error) { + buf, err := json.Marshal(authConfig) if err != nil { - return "" + return "", err } - return ciUrl + return base64.URLEncoding.EncodeToString(buf), nil } func checkRequiredToolInstalled(tool string) bool { @@ -166,8 +145,8 @@ func checkRequiredToolInstalled(tool string) bool { return err == nil } -// PrepairContainerEnvSettings checks if the host is ready to run Qodana container images. -func PrepairContainerEnvSettings() { +// PrepareContainerEnvSettings checks if the host is ready to run Qodana container images. +func PrepareContainerEnvSettings() { var tool string if os.Getenv(qodanaCliUsePodman) == "" && checkRequiredToolInstalled("docker") { tool = "docker" @@ -309,7 +288,7 @@ func CheckContainerEngineMemory() { // getDockerOptions returns qodana docker container options. func getDockerOptions(opts *QodanaOptions) *types.ContainerCreateConfig { cmdOpts := getIdeArgs(opts) - extractQodanaEnvironmentForDocker(opts) + ExtractQodanaEnvironment(opts.setenv) cachePath, err := filepath.Abs(opts.CacheDir) if err != nil { log.Fatal("couldn't get abs path for cache", err) diff --git a/core/core_test.go b/core/core_test.go index d1db45ea..2aac42d7 100644 --- a/core/core_test.go +++ b/core/core_test.go @@ -22,6 +22,8 @@ import ( "errors" "fmt" "github.com/JetBrains/qodana-cli/v2023/cloud" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" "net/http" "net/http/httptest" "os" @@ -29,7 +31,7 @@ import ( "path/filepath" "reflect" "runtime" - "strings" + "sort" "testing" "time" @@ -102,39 +104,16 @@ func TestCliArgs(t *testing.T) { } } -func TestCloudUrl(t *testing.T) { - for _, tc := range []struct { - name string - writtenUrl string - expectedUrl string - }{ - { - name: "valid url", - writtenUrl: "https://youtu.be/dQw4w9WgXcQ", - expectedUrl: "https://youtu.be/dQw4w9WgXcQ", - }, - } { - t.Run(tc.name, func(t *testing.T) { - resultsPath := filepath.Join(os.TempDir(), "cloud_url_valid") - err := os.MkdirAll(resultsPath, 0o755) - if err != nil { - return - } - - filePath := resultsPath + "/" + qodanaReportUrlFile - err = os.WriteFile( - filePath, - []byte(tc.writtenUrl), - 0o644, - ) - if err != nil { - t.Fatal(err) - } - actual := getReportUrl(resultsPath) - if actual != tc.expectedUrl { - t.Fatalf("expected \"%s\" got \"%s\"", tc.expectedUrl, actual) - } - }) +func unsetGitHubVariables() { + variables := []string{ + "GITHUB_SERVER_URL", + "GITHUB_REPOSITORY", + "GITHUB_RUN_ID", + "GITHUB_HEAD_REF", + "GITHUB_REF", + } + for _, v := range variables { + _ = os.Unsetenv(v) } } @@ -143,29 +122,22 @@ func Test_ExtractEnvironmentVariables(t *testing.T) { branchExpected := "refs/heads/main" if os.Getenv("GITHUB_ACTIONS") == "true" { - variables := []string{ - "GITHUB_SERVER_URL", - "GITHUB_REPOSITORY", - "GITHUB_RUN_ID", - "GITHUB_HEAD_REF", - "GITHUB_REF", - } - for _, v := range variables { - _ = os.Unsetenv(v) - } + unsetGitHubVariables() } for _, tc := range []struct { - ci string - variables map[string]string - qodanaJobUrlExpected string - qodanaEnvExpected string - qodanaRemoteUrlExpected string + ci string + variables map[string]string + jobUrlExpected string + envExpected string + remoteUrlExpected string + revisionExpected string + branchExpected string }{ { - ci: "no CI detected", - variables: map[string]string{}, - qodanaEnvExpected: "cli:dev", + ci: "no CI detected", + variables: map[string]string{}, + envExpected: "cli:dev", }, { ci: "User defined", @@ -176,9 +148,27 @@ func Test_ExtractEnvironmentVariables(t *testing.T) { qodanaBranch: branchExpected, qodanaRevision: revisionExpected, }, - qodanaEnvExpected: "user-defined", - qodanaRemoteUrlExpected: "https://qodana.jetbrains.com/never-gonna-give-you-up", - qodanaJobUrlExpected: "https://qodana.jetbrains.com/never-gonna-give-you-up", + envExpected: "user-defined", + remoteUrlExpected: "https://qodana.jetbrains.com/never-gonna-give-you-up", + jobUrlExpected: "https://qodana.jetbrains.com/never-gonna-give-you-up", + revisionExpected: revisionExpected, + branchExpected: branchExpected, + }, + { + ci: "Space", + variables: map[string]string{ + "JB_SPACE_EXECUTION_URL": "https://space.jetbrains.com/never-gonna-give-you-up", + "JB_SPACE_GIT_BRANCH": branchExpected, + "JB_SPACE_GIT_REVISION": revisionExpected, + "JB_SPACE_API_URL": "jetbrains.team", + "JB_SPACE_PROJECT_KEY": "sa", + "JB_SPACE_GIT_REPOSITORY_NAME": "entrypoint", + }, + envExpected: fmt.Sprintf("space:%s", Version), + remoteUrlExpected: "ssh://git@git.jetbrains.team/sa/entrypoint.git", + jobUrlExpected: "https://space.jetbrains.com/never-gonna-give-you-up", + revisionExpected: revisionExpected, + branchExpected: branchExpected, }, { ci: "GitLab", @@ -188,9 +178,11 @@ func Test_ExtractEnvironmentVariables(t *testing.T) { "CI_COMMIT_SHA": revisionExpected, "CI_REPOSITORY_URL": "https://gitlab.jetbrains.com/sa/entrypoint.git", }, - qodanaEnvExpected: fmt.Sprintf("gitlab:%s", Version), - qodanaRemoteUrlExpected: "https://gitlab.jetbrains.com/sa/entrypoint.git", - qodanaJobUrlExpected: "https://gitlab.jetbrains.com/never-gonna-give-you-up", + envExpected: fmt.Sprintf("gitlab:%s", Version), + remoteUrlExpected: "https://gitlab.jetbrains.com/sa/entrypoint.git", + jobUrlExpected: "https://gitlab.jetbrains.com/never-gonna-give-you-up", + revisionExpected: revisionExpected, + branchExpected: branchExpected, }, { ci: "Jenkins", @@ -200,9 +192,11 @@ func Test_ExtractEnvironmentVariables(t *testing.T) { "GIT_COMMIT": revisionExpected, "GIT_URL": "https://git.jetbrains.com/sa/entrypoint.git", }, - qodanaEnvExpected: fmt.Sprintf("jenkins:%s", Version), - qodanaJobUrlExpected: "https://jenkins.jetbrains.com/never-gonna-give-you-up", - qodanaRemoteUrlExpected: "https://git.jetbrains.com/sa/entrypoint.git", + envExpected: fmt.Sprintf("jenkins:%s", Version), + jobUrlExpected: "https://jenkins.jetbrains.com/never-gonna-give-you-up", + remoteUrlExpected: "https://git.jetbrains.com/sa/entrypoint.git", + revisionExpected: revisionExpected, + branchExpected: branchExpected, }, { ci: "GitHub", @@ -213,9 +207,11 @@ func Test_ExtractEnvironmentVariables(t *testing.T) { "GITHUB_SHA": revisionExpected, "GITHUB_HEAD_REF": branchExpected, }, - qodanaEnvExpected: fmt.Sprintf("github-actions:%s", Version), - qodanaJobUrlExpected: "https://github.jetbrains.com/sa/entrypoint/actions/runs/123456789", - qodanaRemoteUrlExpected: "https://github.jetbrains.com/sa/entrypoint.git", + envExpected: fmt.Sprintf("github-actions:%s", Version), + jobUrlExpected: "https://github.jetbrains.com/sa/entrypoint/actions/runs/123456789", + remoteUrlExpected: "https://github.jetbrains.com/sa/entrypoint.git", + revisionExpected: revisionExpected, + branchExpected: branchExpected, }, { ci: "GitHub push", @@ -226,9 +222,11 @@ func Test_ExtractEnvironmentVariables(t *testing.T) { "GITHUB_SHA": revisionExpected, "GITHUB_REF": branchExpected, }, - qodanaEnvExpected: fmt.Sprintf("github-actions:%s", Version), - qodanaJobUrlExpected: "https://github.jetbrains.com/sa/entrypoint/actions/runs/123456789", - qodanaRemoteUrlExpected: "https://github.jetbrains.com/sa/entrypoint.git", + envExpected: fmt.Sprintf("github-actions:%s", Version), + jobUrlExpected: "https://github.jetbrains.com/sa/entrypoint/actions/runs/123456789", + remoteUrlExpected: "https://github.jetbrains.com/sa/entrypoint.git", + revisionExpected: revisionExpected, + branchExpected: branchExpected, }, { ci: "CircleCI", @@ -238,9 +236,11 @@ func Test_ExtractEnvironmentVariables(t *testing.T) { "CIRCLE_BRANCH": branchExpected, "CIRCLE_REPOSITORY_URL": "https://circleci.jetbrains.com/sa/entrypoint.git", }, - qodanaEnvExpected: fmt.Sprintf("circleci:%s", Version), - qodanaJobUrlExpected: "https://circleci.jetbrains.com/never-gonna-give-you-up", - qodanaRemoteUrlExpected: "https://circleci.jetbrains.com/sa/entrypoint.git", + envExpected: fmt.Sprintf("circleci:%s", Version), + jobUrlExpected: "https://circleci.jetbrains.com/never-gonna-give-you-up", + remoteUrlExpected: "https://circleci.jetbrains.com/sa/entrypoint.git", + revisionExpected: revisionExpected, + branchExpected: branchExpected, }, { ci: "Azure Pipelines", @@ -253,9 +253,11 @@ func Test_ExtractEnvironmentVariables(t *testing.T) { "BUILD_SOURCEBRANCH": "refs/heads/" + branchExpected, "BUILD_REPOSITORY_URI": "https://dev.azure.com/jetbrains/sa/entrypoint.git", }, - qodanaEnvExpected: fmt.Sprintf("azure-pipelines:%s", Version), - qodanaJobUrlExpected: "https://dev.azure.com/jetbrains/sa/_build/results?buildId=123456789", - qodanaRemoteUrlExpected: "https://dev.azure.com/jetbrains/sa/entrypoint.git", + envExpected: fmt.Sprintf("azure-pipelines:%s", Version), + jobUrlExpected: "https://dev.azure.com/jetbrains/sa/_build/results?buildId=123456789", + remoteUrlExpected: "https://dev.azure.com/jetbrains/sa/entrypoint.git", + revisionExpected: revisionExpected, + branchExpected: branchExpected, }, } { t.Run(tc.ci, func(t *testing.T) { @@ -268,36 +270,50 @@ func Test_ExtractEnvironmentVariables(t *testing.T) { opts.setenv(k, v) } - extractQodanaEnvironmentForDocker(opts) - currentQodanaEnv := opts.getenv(qodanaEnv) - if currentQodanaEnv != tc.qodanaEnvExpected { - t.Errorf("Expected %s, got %s", tc.qodanaEnvExpected, currentQodanaEnv) - } - if !strings.HasPrefix(currentQodanaEnv, "cli:") { - if opts.getenv(qodanaJobUrl) != tc.qodanaJobUrlExpected { - t.Errorf("Expected %s, got %s", tc.qodanaJobUrlExpected, opts.getenv(qodanaJobUrl)) - } - if opts.getenv(qodanaRemoteUrl) != tc.qodanaRemoteUrlExpected { - t.Errorf("Expected %s, got %s", tc.qodanaRemoteUrlExpected, opts.getenv(qodanaRemoteUrl)) - } - if opts.getenv(qodanaRevision) != revisionExpected { - t.Errorf("Expected %s, got %s", revisionExpected, opts.getenv(qodanaRevision)) - } - if opts.getenv(qodanaBranch) != branchExpected { - t.Errorf("Expected %s, got %s", branchExpected, opts.getenv(qodanaBranch)) - } - } - for _, k := range []string{qodanaJobUrl, qodanaEnv, qodanaRemoteUrl, qodanaRevision, qodanaBranch} { - err := os.Unsetenv(k) - if err != nil { - t.Fatal(err) - } + for _, environment := range []struct { + name string + set func(string, string) + unset func(string) + get func(string) string + }{ + { + name: "Container", + set: opts.setenv, + get: opts.getenv, + }, + { + name: "Local", + set: setEnv, + get: os.Getenv, + }, + } { + t.Run(environment.name, func(t *testing.T) { + ExtractQodanaEnvironment(environment.set) + currentQodanaEnv := environment.get(qodanaEnv) + if currentQodanaEnv != tc.envExpected { + t.Errorf("%s: Expected %s, got %s", environment.name, tc.envExpected, currentQodanaEnv) + } + if environment.get(qodanaJobUrl) != tc.jobUrlExpected { + t.Errorf("%s: Expected %s, got %s", environment.name, tc.jobUrlExpected, environment.get(qodanaJobUrl)) + } + if environment.get(qodanaRemoteUrl) != tc.remoteUrlExpected { + t.Errorf("%s: Expected %s, got %s", environment.name, tc.remoteUrlExpected, environment.get(qodanaRemoteUrl)) + } + if environment.get(qodanaRevision) != tc.revisionExpected { + t.Errorf("%s: Expected %s, got %s", environment.name, revisionExpected, environment.get(qodanaRevision)) + } + if environment.get(qodanaBranch) != tc.branchExpected { + t.Errorf("%s: Expected %s, got %s", environment.name, branchExpected, environment.get(qodanaBranch)) + } + }) } - for k := range tc.variables { + + for _, k := range append(maps.Keys(tc.variables), []string{qodanaJobUrl, qodanaEnv, qodanaRemoteUrl, qodanaRevision, qodanaBranch}...) { err := os.Unsetenv(k) if err != nil { t.Fatal(err) } + opts.unsetenv(k) } }) } @@ -1026,6 +1042,7 @@ func TestSetupLicenseToken(t *testing.T) { } func TestQodanaOptions_RequiresToken(t *testing.T) { + log.SetLevel(log.DebugLevel) tests := []struct { name string linter string @@ -1033,35 +1050,56 @@ func TestQodanaOptions_RequiresToken(t *testing.T) { expected bool }{ { - "QDPYC docker", - Image(QDPYC), + QodanaToken, "", - false, + "", + true, }, { - "QDJVMC ide", + QodanaLicense, + "", "", - QDJVMC, false, }, { - QodanaLicense, + "QDPYC docker", + Image(QDPYC), "", + false, + }, + { + "QDJVMC ide", "", + QDJVMC, false, }, } for _, tt := range tests { - token := os.Getenv(QodanaToken) - if token != "" { - err := os.Unsetenv(QodanaToken) - if err != nil { - t.Fatal(err) + var token string + for _, env := range []string{QodanaToken, QodanaLicenseOnlyToken, QodanaLicense} { + if os.Getenv(env) != "" { + token = os.Getenv(env) + err := os.Unsetenv(env) + if err != nil { + t.Fatal(err) + } } } + t.Run(tt.name, func(t *testing.T) { - if tt.name == QodanaLicense { + if tt.name == QodanaToken { + err := os.Setenv(QodanaToken, "test") + if err != nil { + t.Fatal(err) + } + defer func() { + err := os.Unsetenv(QodanaToken) + if err != nil { + t.Fatal(err) + } + }() + } else if tt.name == QodanaLicense { err := os.Setenv(QodanaLicense, "test") if err != nil { t.Fatal(err) @@ -1085,6 +1123,188 @@ func TestQodanaOptions_RequiresToken(t *testing.T) { if err != nil { t.Fatal(err) } + err = os.Setenv(QodanaLicenseOnlyToken, token) + if err != nil { + t.Fatal(err) + } } } } + +func propertiesFixture(enableStats bool, additionalProperties []string) []string { + properties := []string{ + "-Dfus.internal.reduce.initial.delay=true", + "-Dide.warmup.use.predicates=false", + "-Dvcs.log.index.enable=false", + fmt.Sprintf("-Didea.application.info.value=%s", filepath.Join(Prod.IdeBin(), "QodanaAppInfo.xml")), + "-Didea.class.before.app=com.jetbrains.rider.protocol.EarlyBackendStarter", + fmt.Sprintf("-Didea.config.path=%s", filepath.Join(os.TempDir(), "entrypoint")), + fmt.Sprintf("-Didea.headless.enable.statistics=%t", enableStats), + "-Didea.headless.statistics.device.id=FAKE", + "-Didea.headless.statistics.max.files.to.send=5000", + "-Didea.headless.statistics.salt=FAKE", + fmt.Sprintf("-Didea.log.path=%s", filepath.Join(os.TempDir(), "entrypoint", "log")), + "-Didea.parent.prefix=Rider", + "-Didea.platform.prefix=Qodana", + fmt.Sprintf("-Didea.plugins.path=%s", filepath.Join(os.TempDir(), "entrypoint", "plugins", "233")), + "-Didea.qodana.thirdpartyplugins.accept=true", + fmt.Sprintf("-Didea.system.path=%s", filepath.Join(os.TempDir(), "entrypoint", "idea", "233")), + "-Dinspect.save.project.settings=true", + "-Djava.awt.headless=true", + "-Djava.net.useSystemProxies=true", + "-Djdk.attach.allowAttachSelf=true", + `-Djdk.http.auth.tunneling.disabledSchemes=""`, + "-Djdk.module.illegalAccess.silent=true", + "-Dkotlinx.coroutines.debug=off", + "-Dqodana.automation.guid=FAKE", + "-Didea.job.launcher.without.timeout=true", + "-Dqodana.coverage.input=/data/coverage", + "-Dqodana.recommended.profile.resource=qodana-dotnet.recommended.yaml", + "-Dqodana.starter.profile.resource=qodana-dotnet.starter.yaml", + "-Drider.collect.full.container.statistics=true", + "-Drider.suppress.std.redirect=true", + "-Dsun.io.useCanonCaches=false", + "-Dsun.tools.attach.tmp.only=true", + "-XX:+HeapDumpOnOutOfMemoryError", + "-XX:+UseG1GC", + "-XX:-OmitStackTraceInFastThrow", + "-XX:CICompilerCount=2", + "-XX:MaxJavaStackTraceDepth=10000", + "-XX:MaxRAMPercentage=70", + "-XX:ReservedCodeCacheSize=512m", + "-XX:SoftRefLRUPolicyMSPerMB=50", + fmt.Sprintf("-Xlog:gc*:%s", filepath.Join(os.TempDir(), "entrypoint", "log", "gc.log")), + "-ea", + } + properties = append(properties, additionalProperties...) + sort.Strings(properties) + return properties +} + +func Test_Properties(t *testing.T) { + opts := &QodanaOptions{} + tmpDir := filepath.Join(os.TempDir(), "entrypoint") + opts.ProjectDir = tmpDir + opts.ResultsDir = opts.ProjectDir + opts.CacheDir = opts.ProjectDir + opts.CoverageDir = "/data/coverage" + opts.AnalysisId = "FAKE" + + Prod.BaseScriptName = "rider" + Prod.Code = "QDNET" + Prod.Version = "2023.3" + + err := os.Setenv(QodanaDistEnv, opts.ProjectDir) + if err != nil { + t.Fatal(err) + } + err = os.Setenv(QodanaConfEnv, opts.ProjectDir) + if err != nil { + t.Fatal(err) + } + err = os.Setenv(qodanaDockerEnv, "true") + if err != nil { + t.Fatal(err) + } + err = os.Setenv("DEVICEID", "FAKE") + if err != nil { + t.Fatal(err) + } + err = os.Setenv("SALT", "FAKE") + if err != nil { + t.Fatal(err) + } + err = os.MkdirAll(opts.ProjectDir, 0o755) + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + cliProperties []string + qodanaYaml string + isContainer bool + expected []string + }{ + { + name: "no overrides, just defaults and .NET project", + cliProperties: []string{}, + qodanaYaml: "dotnet:\n project: project.csproj", + isContainer: false, + expected: propertiesFixture(true, []string{"-Dqodana.net.project=project.csproj", "-Dqodana.net.targetFrameworks=!net48;!net472;!net471;!net47;!net462;!net461;!net46;!net452;!net451;!net45;!net403;!net40;!net35;!net20;!net11"}), + }, + { + name: "target frameworks set in YAML", + cliProperties: []string{}, + qodanaYaml: "dotnet:\n frameworks: net5.0;net6.0", + isContainer: false, + expected: propertiesFixture(true, []string{"-Dqodana.net.targetFrameworks=net5.0;net6.0"}), + }, + { + name: "target frameworks set in YAML in container", + cliProperties: []string{}, + qodanaYaml: "dotnet:\n frameworks: net5.0;net6.0", + isContainer: true, + expected: propertiesFixture(true, []string{"-Dqodana.net.targetFrameworks=net5.0;net6.0"}), + }, + { + name: "target frameworks not set in container", + cliProperties: []string{}, + qodanaYaml: "", + isContainer: true, + expected: propertiesFixture(true, []string{"-Dqodana.net.targetFrameworks=!net48;!net472;!net471;!net47;!net462;!net461;!net46;!net452;!net451;!net45;!net403;!net40;!net35;!net20;!net11"}), + }, + { + name: "add one CLI property and .NET solution settings", + cliProperties: []string{"-xa", "idea.some.custom.property=1"}, + qodanaYaml: "dotnet:\n solution: solution.sln\n configuration: Release\n platform: x64", + isContainer: false, + expected: append( + propertiesFixture(true, []string{"-Dqodana.net.solution=solution.sln", "-Dqodana.net.configuration=Release", "-Dqodana.net.platform=x64", "-Didea.some.custom.property=1"}), + "-xa", + ), + }, + { + name: "override options from CLI, YAML should be ignored", + cliProperties: []string{"-Dfus.internal.reduce.initial.delay=false", "-Dide.warmup.use.predicates=true", "-Didea.application.info.value=0", "idea.headless.enable.statistics=false"}, + qodanaYaml: "" + + "version: \"1.0\"\n" + + "properties:\n" + + " fus.internal.reduce.initial.delay: true\n" + + " idea.application.info.value: 0\n", + isContainer: false, + expected: append([]string{ + "-Dfus.internal.reduce.initial.delay=false", + "-Dide.warmup.use.predicates=true", + "-Didea.application.info.value=0", + }, propertiesFixture(false, []string{})[3:]...), + }, + } { + t.Run(tc.name, func(t *testing.T) { + err = os.WriteFile(filepath.Join(opts.ProjectDir, "qodana.yml"), []byte(tc.qodanaYaml), 0o600) + if err != nil { + t.Fatal(err) + } + opts.Property = tc.cliProperties + qConfig := GetQodanaYaml(opts.ProjectDir) + if tc.isContainer { + err = os.Setenv(qodanaDockerEnv, "true") + if err != nil { + t.Fatal(err) + } + } + actual := GetProperties(opts, qConfig.Properties, qConfig.DotNet, []string{}) + if tc.isContainer { + err = os.Unsetenv(qodanaDockerEnv) + if err != nil { + t.Fatal(err) + } + } + assert.Equal(t, tc.expected, actual) + }) + } + err = os.RemoveAll(opts.ProjectDir) + if err != nil { + t.Fatal(err) + } +} diff --git a/core/env.go b/core/env.go index b92fff40..645553eb 100644 --- a/core/env.go +++ b/core/env.go @@ -20,6 +20,7 @@ import ( "fmt" cienvironment "github.com/cucumber/ci-environment/go" log "github.com/sirupsen/logrus" + "net/url" "os" "runtime" "strings" @@ -40,29 +41,78 @@ const ( QodanaConfEnv = "QODANA_CONF" QodanaToolEnv = "QODANA_TOOL" QodanaDistEnv = "QODANA_DIST" - QodanaEndpoint = "ENDPOINT" qodanaCorettoSdk = "QODANA_CORETTO_SDK" androidSdkRoot = "ANDROID_SDK_ROOT" QodanaLicenseEndpoint = "LICENSE_ENDPOINT" QodanaLicense = "QODANA_LICENSE" QodanaTreatAsRelease = "QODANA_TREAT_AS_RELEASE" qodanaClearKeyring = "QODANA_CLEAR_KEYRING" + qodanaNugetUrl = "QODANA_NUGET_URL" + qodanaNugetUser = "QODANA_NUGET_USER" + qodanaNugetPassword = "QODANA_NUGET_PASSWORD" + qodanaNugetName = "QODANA_NUGET_NAME" ) -func ExtractQodanaEnvironment() { +// ExtractQodanaEnvironment extracts Qodana environment variables from the current environment. +func ExtractQodanaEnvironment(setEnvironmentFunc func(string, string)) { ci := cienvironment.DetectCIEnvironment() - qEnv := "qodana" + qEnv := "cli" if ci != nil { qEnv = strings.ReplaceAll(strings.ToLower(ci.Name), " ", "-") - setEnv(qodanaJobUrl, validateJobUrl(ci.URL, qEnv)) + setEnvironmentFunc(qodanaJobUrl, validateJobUrl(ci.URL, qEnv)) if ci.Git != nil { - setEnv(qodanaRemoteUrl, validateRemoteUrl(ci.Git.Remote)) - setEnv(qodanaBranch, validateBranch(ci.Git.Branch, qEnv)) - setEnv(qodanaRevision, ci.Git.Revision) + setEnvironmentFunc(qodanaRemoteUrl, validateRemoteUrl(ci.Git.Remote, qEnv)) + setEnvironmentFunc(qodanaBranch, validateBranch(ci.Git.Branch, qEnv)) + setEnvironmentFunc(qodanaRevision, ci.Git.Revision) } + } else if space := os.Getenv("JB_SPACE_API_URL"); space != "" { + qEnv = "space" + setEnvironmentFunc(qodanaJobUrl, os.Getenv("JB_SPACE_EXECUTION_URL")) + setEnvironmentFunc(qodanaRemoteUrl, getSpaceRemoteUrl()) + setEnvironmentFunc(qodanaBranch, os.Getenv("JB_SPACE_GIT_BRANCH")) + setEnvironmentFunc(qodanaRevision, os.Getenv("JB_SPACE_GIT_REVISION")) } - setEnv(qodanaEnv, fmt.Sprintf("%s:%s", qEnv, Prod.Version)) - setEnv(QodanaDistEnv, Prod.Home) + setEnvironmentFunc(qodanaEnv, fmt.Sprintf("%s:%s", qEnv, Version)) +} + +func validateRemoteUrl(remote string, qEnv string) string { + if strings.HasPrefix(qEnv, "space") { + return getSpaceRemoteUrl() + } + _, err := url.ParseRequestURI(remote) + if remote == "" || err != nil { + log.Warnf("Unable to parse git remote URL %s, set %s env variable for proper qodana.cloud reporting", remote, qodanaRemoteUrl) + return "" + } + return remote +} + +func validateBranch(branch string, env string) string { + if branch == "" { + if env == "github-actions" { + branch = os.Getenv("GITHUB_REF") + } else if env == "azure-pipelines" { + branch = os.Getenv("BUILD_SOURCEBRANCHNAME") + } else if env == "jenkins" { + branch = os.Getenv("GIT_BRANCH") + } + } + if branch == "" { + log.Warnf("Unable to parse git branch, set %s env variable for proper qodana.cloud reporting", qodanaBranch) + return "" + } + return branch +} + +func validateJobUrl(ciUrl string, qEnv string) string { + if strings.HasPrefix(qEnv, "azure") { // temporary workaround for Azure Pipelines + return getAzureJobUrl() + } + _, err := url.ParseRequestURI(ciUrl) + if err != nil { + return "" + } + return ciUrl } // bootstrap takes the given command (from CLI or qodana.yaml) and runs it. @@ -88,10 +138,12 @@ func bootstrap(command string, project string) { } func setEnv(key string, value string) { + log.Debugf("Setting %s=%s", key, value) if os.Getenv(key) == "" && value != "" { err := os.Setenv(key, value) if err != nil { return } + log.Debugf("Set %s=%s", key, value) } } diff --git a/core/git.go b/core/git.go index badf6e97..e394acaa 100644 --- a/core/git.go +++ b/core/git.go @@ -25,18 +25,6 @@ import ( log "github.com/sirupsen/logrus" ) -// isGitInstalled checks if git is installed. -func isGitInstalled() bool { - _, err := exec.LookPath("git") - if err != nil { - WarningMessage( - "Unable to find git, refer to https://git-scm.com/downloads for installing it", - ) - return false - } - return true -} - // gitRun runs the git command in the given directory and returns an error if any. func gitRun(cwd string, command []string) error { cmd := exec.Command("git", command...) diff --git a/core/ide.go b/core/ide.go index 89645b69..06a838b4 100644 --- a/core/ide.go +++ b/core/ide.go @@ -68,9 +68,6 @@ func runQodanaLocal(opts *QodanaOptions) int { if opts.SaveReport || opts.ShowReport { saveReport(opts) } - if cloud.Token.IsAllowedToSendReports() && !IsContainer() { - SendReport(opts, cloud.Token.Token) - } postAnalysis(opts) return res } @@ -150,6 +147,10 @@ func getIdeArgs(opts *QodanaOptions) []string { arguments = append(arguments, "--analysis-id", opts.AnalysisId) } + if opts.CoverageDir != "" { + arguments = append(arguments, "--coverage-dir", opts.CoverageDir) + } + for _, property := range opts.Property { arguments = append(arguments, "--property="+property) } @@ -287,7 +288,11 @@ func readAppInfoXml(ideDir string) appInfo { func prepareLocalIdeSettings(opts *QodanaOptions) { guessProduct(opts) - ExtractQodanaEnvironment() + if Prod.BaseScriptName == "" { + log.Fatal("IDE to run is not found") + } + + ExtractQodanaEnvironment(setEnv) SetupLicenseToken(opts) SetupLicense(cloud.Token.Token) prepareDirectories( diff --git a/core/license.go b/core/license.go index b997f0cb..69ac422d 100644 --- a/core/license.go +++ b/core/license.go @@ -22,6 +22,7 @@ import ( "github.com/JetBrains/qodana-cli/v2023/cloud" "log" "os" + "strings" ) func SetupLicense(token string) { @@ -58,16 +59,33 @@ func SetupLicense(token string) { if err != nil { log.Fatalf("License request: %v\n%s", err, cloud.GeneralLicenseErrorMessage) } - licenseKey := cloud.ExtractLicenseKey(licenseDataResponse) - if licenseKey == "" { + licenseData := cloud.DeserializeLicenseData(licenseDataResponse) + if strings.ToLower(licenseData.LicensePlan) == "community" { + log.Fatalf("Your Qodana Cloud organization has Community license that doesn’t support \"%s\" linter, "+ + "please try one of the community linters instead: %s or obtain Ultimate "+ + "or Ultimate Plus license. Read more about licenses and plans at "+ + "https://www.jetbrains.com/help/qodana/pricing.html#pricing-linters-licenses.", + Prod.getProductNameFromCode(), + allCommunityNames(), + ) + } + if licenseData.LicenseKey == "" { log.Fatalf("Response for license request should contain license key\n%s", string(licenseDataResponse)) } - err = os.Setenv(QodanaLicense, licenseKey) + err = os.Setenv(QodanaLicense, licenseData.LicenseKey) if err != nil { log.Fatal(err) } } +func allCommunityNames() string { + var nameList []string + for _, code := range allSupportedFreeCodes { + nameList = append(nameList, "\""+getProductNameFromCode(code)+"\"") + } + return strings.Join(nameList, ", ") +} + func SetupLicenseToken(opts *QodanaOptions) { token := opts.loadToken(false) licenseOnlyToken := os.Getenv(QodanaLicenseOnlyToken) diff --git a/core/options.go b/core/options.go index 161631e6..2ebc13ae 100644 --- a/core/options.go +++ b/core/options.go @@ -64,6 +64,29 @@ type QodanaOptions struct { _id string } +func (o *QodanaOptions) FetchAnalyzerSettings() { + if o.Linter == "" && o.Ide == "" { + qodanaYaml := LoadQodanaYaml(o.ProjectDir, o.YamlName) + if qodanaYaml.Linter == "" && qodanaYaml.Ide == "" { + WarningMessage( + "No valid `linter:` field found in %s. Have you run %s? Running that for you...", + PrimaryBold(o.YamlName), + PrimaryBold("qodana init"), + ) + o.Linter = GetLinter(o.ProjectDir, o.YamlName) + EmptyMessage() + } else { + o.Linter = qodanaYaml.Linter + } + if o.Ide == "" { + o.Ide = qodanaYaml.Ide + } + } + o.ResultsDir = o.resultsDirPath() + o.ReportDir = o.reportDirPath() + o.CacheDir = o.cacheDirPath() +} + // setenv sets the Qodana container environment variables if such variable was not set before. func (o *QodanaOptions) setenv(key string, value string) { for _, e := range o.Env { @@ -98,17 +121,20 @@ func (o *QodanaOptions) unsetenv(key string) { func (o *QodanaOptions) id() string { if o._id == "" { - var linter string + var analyzer string if o.Linter != "" { - linter = o.Linter + analyzer = o.Linter } else if o.Ide != "" { - linter = o.Ide + analyzer = o.Ide + } + if analyzer == "" { + analyzer = LoadQodanaYaml(o.ProjectDir, o.YamlName).Linter } length := 7 projectAbs, _ := filepath.Abs(o.ProjectDir) o._id = fmt.Sprintf( "%s-%s", - getHash(linter)[0:length+1], + getHash(analyzer)[0:length+1], getHash(projectAbs)[0:length+1], ) } @@ -135,7 +161,7 @@ func (o *QodanaOptions) GetLinterDir() string { ) } -func (o *QodanaOptions) ResultsDirPath() string { +func (o *QodanaOptions) resultsDirPath() string { if o.ResultsDir == "" { if IsContainer() { o.ResultsDir = "/data/results" @@ -146,7 +172,7 @@ func (o *QodanaOptions) ResultsDirPath() string { return o.ResultsDir } -func (o *QodanaOptions) CacheDirPath() string { +func (o *QodanaOptions) cacheDirPath() string { if o.CacheDir == "" { if IsContainer() { o.CacheDir = "/data/cache" @@ -157,23 +183,34 @@ func (o *QodanaOptions) CacheDirPath() string { return o.CacheDir } -func (o *QodanaOptions) ReportDirPath() string { +func (o *QodanaOptions) reportDirPath() string { if o.ReportDir == "" { if IsContainer() { o.ReportDir = "/data/results/report" } else { - o.ReportDir = filepath.Join(o.ResultsDirPath(), "report") + o.ReportDir = filepath.Join(o.resultsDirPath(), "report") } } return o.ReportDir } +func (o *QodanaOptions) CoverageDirPath() string { + if o.CoverageDir == "" { + if IsContainer() { + o.CoverageDir = "/data/coverage" + } else { + o.CoverageDir = filepath.Join(o.ProjectDir, ".qodana", "code-coverage") + } + } + return o.CoverageDir +} + func (o *QodanaOptions) ReportResultsPath() string { - return filepath.Join(o.ReportDirPath(), "results") + return filepath.Join(o.reportDirPath(), "results") } func (o *QodanaOptions) logDirPath() string { - return filepath.Join(o.ResultsDirPath(), "log") + return filepath.Join(o.resultsDirPath(), "log") } func (o *QodanaOptions) vmOptionsPath() string { @@ -211,23 +248,31 @@ func (o *QodanaOptions) properties() (map[string]string, []string) { } func (o *QodanaOptions) RequiresToken() bool { - if os.Getenv(QodanaLicense) != "" { - return false + if os.Getenv(QodanaToken) != "" || o.getenv(QodanaLicenseOnlyToken) != "" { + return true } - if os.Getenv(QodanaToken) != "" || o.getenv(QodanaToken) != "" { - return true + var analyzer string + if o.Linter != "" { + analyzer = o.Linter + } else if o.Ide != "" { + analyzer = o.Ide } - if o.Linter == Image(QDPYC) || o.Linter == Image(QDJVMC) { + if os.Getenv(QodanaLicense) != "" || + Contains(append(allSupportedFreeImages, allSupportedFreeCodes...), analyzer) || + strings.Contains(lower(analyzer), "eap") || + Prod.IsCommunity() || Prod.EAP { return false } - if o.Ide == QDJVMC || o.Ide == QDPYC { - return false + for _, e := range allSupportedPaidCodes { + if strings.HasPrefix(Image(e), o.Linter) || strings.HasPrefix(e, o.Ide) { + return true + } } - return !Prod.IsCommunity() && !Prod.EAP + return false } func (o *QodanaOptions) fixesSupported() bool { diff --git a/core/product_info.go b/core/product_info.go index 6232f500..e411a4a9 100644 --- a/core/product_info.go +++ b/core/product_info.go @@ -74,14 +74,20 @@ func (p *product) javaHome() string { } func (p *product) JbrJava() string { - switch runtime.GOOS { - case "darwin": - return filepath.Join(p.javaHome(), "Contents", "Home", "bin", "java") - case "windows": - return filepath.Join(p.javaHome(), "bin", "java.exe") - default: - return filepath.Join(p.javaHome(), "bin", "java") + if p.Home != "" { + switch runtime.GOOS { + case "darwin": + return filepath.Join(p.javaHome(), "Contents", "Home", "bin", "java") + case "windows": + return filepath.Join(p.javaHome(), "bin", "java.exe") + default: + return filepath.Join(p.javaHome(), "bin", "java") + } + } else if isInstalled("java") { + return "java" } + log.Warn("Java is not installed") + return "" } func (p *product) vmOptionsEnv() string { @@ -129,11 +135,21 @@ func (p *product) parentPrefix() string { } func (p *product) IsCommunity() bool { - return p.Code == QDJVMC || p.Code == QDPYC || p.Code == "" + if p.Code == "" { + return true + } + if Contains(allSupportedFreeCodes, p.Code) { + return true + } + return false } func (p *product) getProductNameFromCode() string { - switch p.Code { + return getProductNameFromCode(p.Code) +} + +func getProductNameFromCode(code string) string { + switch code { case QDJVMC: return "Qodana Community for JVM" case QDPYC: @@ -276,6 +292,7 @@ func guessProduct(opts *QodanaOptions) { } log.Debug(Prod) + setEnv(QodanaDistEnv, Prod.Home) } // temporary solution to fix runs in the native mode diff --git a/core/properties.go b/core/properties.go index e73b6651..a3ba44f0 100644 --- a/core/properties.go +++ b/core/properties.go @@ -19,7 +19,7 @@ package core import ( "fmt" "github.com/JetBrains/qodana-cli/v2023/cloud" - "log" + log "github.com/sirupsen/logrus" "os" "path/filepath" "sort" @@ -65,6 +65,8 @@ func getPropertiesMap( "-Didea.log.path": quoteIfSpace(logDir), "-Didea.qodana.thirdpartyplugins.accept": "true", "-Dqodana.automation.guid": quoteIfSpace(analysisId), + "-Dide.warmup.use.predicates": "false", + "-Dvcs.log.index.enable": "false", "-XX:SoftRefLRUPolicyMSPerMB": "50", "-XX:MaxJavaStackTraceDepth": "10000", @@ -106,8 +108,16 @@ func getPropertiesMap( if dotNet.Platform != "" { properties["-Dqodana.net.platform"] = dotNet.Platform } + if dotNet.Frameworks != "" { + properties["-Dqodana.net.targetFrameworks"] = dotNet.Frameworks + } else if IsContainer() { + // We don't want to scan .NET Framework projects in Linux containers + properties["-Dqodana.net.targetFrameworks"] = "!net48;!net472;!net471;!net47;!net462;!net461;!net46;!net452;!net451;!net45;!net403;!net40;!net35;!net20;!net11" + } } + log.Debugf("properties: %v", properties) + return properties } @@ -145,7 +155,7 @@ func GetProperties(opts *QodanaOptions, yamlProps map[string]string, dotNetOptio getDeviceIdSalt(), plugins, opts.AnalysisId, - opts.CoverageDir, + opts.CoverageDirPath(), ) for k, v := range yamlProps { // qodana.yaml – overrides vmoptions if !strings.HasPrefix(k, "-") { diff --git a/core/publisher.go b/core/publisher.go index 67c90e61..eb33ba4c 100644 --- a/core/publisher.go +++ b/core/publisher.go @@ -28,13 +28,15 @@ import ( "encoding/xml" "github.com/JetBrains/qodana-cli/v2023/cloud" cp "github.com/otiai10/copy" + log "github.com/sirupsen/logrus" "io" - "log" "net/http" "os" "path/filepath" ) +const jarName = "publisher.jar" + type metadata struct { Versioning versioning `xml:"versioning"` } @@ -46,14 +48,21 @@ type versioning struct { // SendReport sends report to Qodana Cloud. func SendReport(opts *QodanaOptions, token string) { - path := Prod.IdeBin() - if !IsContainer() { - path = opts.ConfDirPath() - fetchPublisher(path) + var publisherPath string + if IsContainer() { + publisherPath = filepath.Join(Prod.IdeBin(), jarName) + } else { + publisherPath = filepath.Join(opts.ConfDirPath(), jarName) + } + if _, err := os.Stat(publisherPath); os.IsNotExist(err) { + err := os.MkdirAll(filepath.Dir(publisherPath), os.ModePerm) + if err != nil { + log.Fatalf("failed to create directory: %v", err) + } + fetchPublisher(publisherPath) } - publisher := filepath.Join(path, "publisher.jar") - if _, err := os.Stat(publisher); os.IsNotExist(err) { - log.Fatalf("Not able to send the report: %s is missing", publisher) + if _, err := os.Stat(publisherPath); os.IsNotExist(err) { + log.Fatalf("Not able to send the report: %s is missing", publisherPath) } if !IsContainer() { if _, err := os.Stat(opts.ReportResultsPath()); os.IsNotExist(err) { @@ -69,7 +78,7 @@ func SendReport(opts *QodanaOptions, token string) { } } - publisherCommand := getPublisherArgs(Prod.JbrJava(), publisher, opts, token, os.Getenv(cloud.DefaultEndpoint)) + publisherCommand := getPublisherArgs(Prod.JbrJava(), publisherPath, opts, token, cloud.GetEnvWithDefault(cloud.QodanaEndpoint, cloud.DefaultEndpoint)) if res := RunCmd("", publisherCommand...); res > 0 { os.Exit(res) } @@ -131,17 +140,16 @@ func getPublisherUrl(version string) string { return "https://packages.jetbrains.team/maven/p/ij/intellij-dependencies/org/jetbrains/qodana/publisher-cli/" + version + "/publisher-cli-" + version + ".jar" } -func fetchPublisher(directory string) { - version := publisherVersion().Release - path := filepath.Join(directory, "publisher.jar") +func fetchPublisher(path string) { + jarVersion := publisherVersion().Release if _, err := os.Stat(path); err == nil { return } - err := DownloadFile(path, getPublisherUrl(version), nil) + err := DownloadFile(path, getPublisherUrl(jarVersion), nil) if err != nil { log.Fatal(err) } - verifyMd5Hash(version, path) + verifyMd5Hash(jarVersion, path) } func verifyMd5Hash(version string, path string) { @@ -186,6 +194,6 @@ func verifyMd5Hash(version string, path string) { } log.Fatal("The provided file and the file from the link have different md5 hashes") } else { - println("Obtained publisher " + version + " and successfully checked md5 hash") + log.Debug("Obtained publisher " + version + " and successfully checked md5 hash") } } diff --git a/core/publisher_test.go b/core/publisher_test.go index e3dd714d..57bb5a50 100644 --- a/core/publisher_test.go +++ b/core/publisher_test.go @@ -1,6 +1,7 @@ package core import ( + "github.com/JetBrains/qodana-cli/v2023/cloud" "os" "path/filepath" "reflect" @@ -8,7 +9,6 @@ import ( ) func TestFetchPublisher(t *testing.T) { - tempDir, err := os.MkdirTemp("", "test") if err != nil { t.Fatal(err) @@ -20,12 +20,11 @@ func TestFetchPublisher(t *testing.T) { t.Fatal(err) } }(tempDir) // clean up + path := filepath.Join(tempDir, "publisher.jar") + fetchPublisher(path) - fetchPublisher(tempDir) - - expectedPath := filepath.Join(tempDir, "publisher.jar") - if _, err := os.Stat(expectedPath); os.IsNotExist(err) { - t.Fatalf("fetchPublisher() failed, expected %v to exists, got error: %v", expectedPath, err) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Fatalf("fetchPublisher() failed, expected %v to exists, got error: %v", path, err) } } @@ -43,7 +42,7 @@ func TestGetPublisherArgs(t *testing.T) { if err != nil { t.Fatal(err) } - err = os.Setenv(QodanaEndpoint, "test-endpoint") + err = os.Setenv(cloud.QodanaEndpoint, "test-endpoint") if err != nil { t.Fatal(err) } diff --git a/core/system.go b/core/system.go index 7dbbcf14..fdcbb484 100644 --- a/core/system.go +++ b/core/system.go @@ -21,8 +21,8 @@ import ( "context" "encoding/json" "fmt" + "github.com/JetBrains/qodana-cli/v2023/cloud" "io" - "io/fs" "net/http" "os" "os/exec" @@ -189,50 +189,18 @@ func noCache(h http.Handler) http.Handler { return http.HandlerFunc(fn) } -// LookUpLinterSystemDir returns path to the latest modified directory from /JetBrains/Qodana -func LookUpLinterSystemDir(opts *QodanaOptions) string { - parent := opts.getQodanaSystemDir() - - entries, err := os.ReadDir(parent) - if err != nil { - log.Debugf("Failed to read directory %s: %s", parent, err.Error()) - return parent - } - subdirs := make([]fs.FileInfo, 0, len(entries)) - for _, entry := range entries { - info, err := entry.Info() - if err != nil { - continue - } - subdirs = append(subdirs, info) - } - var latestDir string - var latestTime time.Time - for _, subdir := range subdirs { - if subdir.IsDir() { - if subdir.ModTime().After(latestTime) { - latestDir = subdir.Name() - latestTime = subdir.ModTime() - } - } - } - systemDir := filepath.Join(parent, latestDir) - log.Debugf("Found latest linter system dir: %s", systemDir) - return systemDir -} - // prepareHost gets the current user, creates the necessary folders for the analysis. func prepareHost(opts *QodanaOptions) { - if opts.RequiresToken() { - opts.ValidateToken(false) - } - if opts.ClearCache { err := os.RemoveAll(opts.CacheDir) if err != nil { log.Errorf("Could not clear local Qodana cache: %s", err) } } + if isNugetConfigNeeded() { + prepareNugetConfig(os.Getenv("HOME")) + } + unsetNugetVariables() if err := os.MkdirAll(opts.CacheDir, os.ModePerm); err != nil { log.Fatal("couldn't create a directory ", err.Error()) } @@ -240,10 +208,10 @@ func prepareHost(opts *QodanaOptions) { log.Fatal("couldn't create a directory ", err.Error()) } if opts.Linter != "" { - PrepairContainerEnvSettings() + PrepareContainerEnvSettings() } if opts.Ide != "" { - if Contains(allCodes, strings.TrimSuffix(opts.Ide, eapSuffix)) || strings.HasPrefix(opts.Ide, "https://") { + if Contains(AllSupportedCodes, strings.TrimSuffix(opts.Ide, eapSuffix)) || strings.HasPrefix(opts.Ide, "https://") { printProcess(func(spinner *pterm.SpinnerPrinter) { if spinner != nil { spinner.ShowTimer = false // We will update interactive spinner @@ -253,6 +221,37 @@ func prepareHost(opts *QodanaOptions) { } prepareLocalIdeSettings(opts) } + if opts.RequiresToken() { + opts.ValidateToken(false) + } +} + +func unsetNugetVariables() { + variables := []string{qodanaNugetUser, qodanaNugetPassword, qodanaNugetName, qodanaNugetUrl} + for _, variable := range variables { + if err := os.Unsetenv(variable); err != nil { + log.Fatal("couldn't unset env variable ", err.Error()) + } + } +} + +func isNugetConfigNeeded() bool { + return IsContainer() && os.Getenv(qodanaNugetUrl) != "" && os.Getenv(qodanaNugetUser) != "" && os.Getenv(qodanaNugetPassword) != "" +} + +func prepareNugetConfig(userPath string) { + nugetConfig := filepath.Join(userPath, ".nuget", "NuGet") + if _, err := os.Stat(nugetConfig); err != nil { + // mkdir -p ~/.nuget/NuGet + if err := os.MkdirAll(nugetConfig, os.ModePerm); err != nil { + log.Fatal("couldn't create a directory ", err.Error()) + } + } + nugetConfig = filepath.Join(nugetConfig, "NuGet.Config") + config := nugetWithPrivateFeed(cloud.GetEnvWithDefault(qodanaNugetName, "qodana"), os.Getenv(qodanaNugetUrl), os.Getenv(qodanaNugetUser), os.Getenv(qodanaNugetPassword)) + if err := os.WriteFile(nugetConfig, []byte(config), 0644); err != nil { + log.Fatal("couldn't create a file ", err.Error()) + } } func GetDefaultUser() string { @@ -298,7 +297,7 @@ func RunAnalysis(ctx context.Context, options *QodanaOptions) int { var exitCode int - if options.FullHistory && isGitInstalled() { + if options.FullHistory && isInstalled("git") { remoteUrl := gitRemoteUrl(options.ProjectDir) branch := gitBranch(options.ProjectDir) if remoteUrl == "" && branch == "" { @@ -341,7 +340,7 @@ func RunAnalysis(ctx context.Context, options *QodanaOptions) int { if err != nil { log.Fatal(err) } - } else if options.Commit != "" && isGitInstalled() { + } else if options.Commit != "" && isInstalled("git") { options.GitReset = false err := gitReset(options.ProjectDir, options.Commit) if err != nil { @@ -414,7 +413,8 @@ func followLinter(client *client.Client, containerName string, progress *pterm.S } if strings.Contains(line, "Report is successfully uploaded to ") { reportUrl := strings.TrimPrefix(line, "Report is successfully uploaded to ") - saveReportUrl(resultsDir, reportUrl) + cloud.SaveReportFile(resultsDir, reportUrl) // TODO: stop after 2023.3 CLI release + continue } printLinterLog(line) } @@ -427,34 +427,6 @@ func followLinter(client *client.Client, containerName string, progress *pterm.S } } -// getReportUrl get Qodana Cloud report URL from the given qodana.sarif.json -func getReportUrl(resultsDir string) string { - filePath := filepath.Join(resultsDir, qodanaReportUrlFile) - log.Debugf("Looking for report URL in %s", filePath) - if _, err := os.Stat(filePath); err == nil { - url, err := os.ReadFile(filePath) - log.Debugf("Found report URL: %s", string(url)) - if err != nil { - log.Debug(err) - return "" - } - return string(url) - } - return "" -} - -// saveReportUrl saves the report URL to the resultsDir/qodana.cloud file. -func saveReportUrl(resultsDir, reportUrl string) { - if reportUrl == "" { - return - } - resultsDir = filepath.Join(resultsDir, "qodana.cloud") - err := os.WriteFile(resultsDir, []byte(reportUrl), 0o644) - if err != nil { - log.Errorf("Could not save the report URL to the results directory: %s", err) - } -} - func resetScanStages() { scanStages = []string{ "Preparing Qodana Docker images", diff --git a/core/system_test.go b/core/system_test.go new file mode 100644 index 00000000..6c8aebfa --- /dev/null +++ b/core/system_test.go @@ -0,0 +1,67 @@ +package core + +import ( + "bufio" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrepareNugetConfig(t *testing.T) { + _ = os.Setenv(qodanaNugetName, "qdn") + _ = os.Setenv(qodanaNugetUrl, "test_url") + _ = os.Setenv(qodanaNugetUser, "test_user") + _ = os.Setenv(qodanaNugetPassword, "test_password") + + // create temp dir + tmpDir, _ := os.MkdirTemp("", "test") + defer func(tmpDir string) { + err := os.RemoveAll(tmpDir) + if err != nil { + t.Fatal(err) + } + }(tmpDir) + + prepareNugetConfig(tmpDir) + + expected := ` + + + + + + + + + + + + +` + + file, err := os.Open(filepath.Join(tmpDir, ".nuget", "NuGet", "NuGet.Config")) + if err != nil { + t.Fatal(err) + } + defer func(file *os.File) { + err := file.Close() + if err != nil { + t.Fatal(err) + } + }(file) + + scanner := bufio.NewScanner(file) + var text string + for scanner.Scan() { + text += scanner.Text() + "\n" + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + + text = strings.TrimSuffix(text, "\n") + if text != expected { + t.Fatalf("got:\n%s\n\nwant:\n%s", text, expected) + } +} diff --git a/core/token.go b/core/token.go index e36b9de4..d3598d7d 100644 --- a/core/token.go +++ b/core/token.go @@ -113,7 +113,7 @@ func (o *QodanaOptions) ValidateToken(refresh bool) string { os.Exit(1) } } else { - SuccessMessage("Linked qodana.cloud project: %s", projectName) + SuccessMessage("Linked %s project: %s", cloud.GetEnvWithDefault(cloud.QodanaEndpoint, cloud.DefaultEndpoint), projectName) o.setenv(QodanaToken, token) } } diff --git a/core/utils.go b/core/utils.go index 71a5e907..8ae62934 100644 --- a/core/utils.go +++ b/core/utils.go @@ -116,6 +116,22 @@ func getAzureJobUrl() string { return "" } +// getSpaceJobUrl returns the Space job URL. +func getSpaceRemoteUrl() string { + if server := os.Getenv("JB_SPACE_API_URL"); server != "" { + return strings.Join([]string{ + "ssh://git@git.", + server, + "/", + os.Getenv("JB_SPACE_PROJECT_KEY"), + "/", + os.Getenv("JB_SPACE_GIT_REPOSITORY_NAME"), + ".git", + }, "") + } + return "" +} + // findProcess using gopsutil to find process by name. func findProcess(processName string) bool { if IsContainer() { @@ -227,6 +243,24 @@ func IsContainer() bool { return os.Getenv(qodanaDockerEnv) != "" } +// isInstalled checks if git is installed. +func isInstalled(what string) bool { + help := "" + if what == "git" { + help = ", refer to https://git-scm.com/downloads for installing it" + } + + _, err := exec.LookPath(what) + if err != nil { + WarningMessage( + "Unable to find %s"+help, + what, + ) + return false + } + return true +} + // createUser will make dynamic uid as a valid user `idea`, needed for gradle cache. func createUser(fn string) { if //goland:noinspection ALL diff --git a/core/xml.go b/core/xml.go index 699a599f..14275803 100644 --- a/core/xml.go +++ b/core/xml.go @@ -174,3 +174,26 @@ const userPrefsXml = `?xml version="1.0" encoding="UTF-8" standalone="no"?> ` + +func nugetWithPrivateFeed(nugetSourceName string, nugetUrl string, nugetUser string, nugetPassword string) string { + return fmt.Sprintf(` + + + + + + + + <%s> + + + + +`, + nugetSourceName, + nugetUrl, + nugetSourceName, + nugetUser, + nugetPassword, + nugetSourceName) +} diff --git a/core/yaml.go b/core/yaml.go index b148bf2d..dd361a09 100644 --- a/core/yaml.go +++ b/core/yaml.go @@ -237,6 +237,9 @@ type DotNet struct { // Platform is the target platform in which .NET project should be opened by Qodana. Platform string `yaml:"platform,omitempty"` + + // Frameworks is a semicolon-separated list of target framework monikers (TFM) to be analyzed. + Frameworks string `yaml:"frameworks,omitempty"` } // IsEmpty checks whether the .NET configuration is empty or not. diff --git a/go.mod b/go.mod index 862dda8a..222756f2 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 + require ( atomicgo.dev/cursor v0.2.0 // indirect atomicgo.dev/keyboard v0.2.9 // indirect diff --git a/qodana.yaml b/qodana.yaml index 2b2e5c1a..44b50d9b 100644 --- a/qodana.yaml +++ b/qodana.yaml @@ -1,5 +1,5 @@ version: "1.0" -linter: jetbrains/qodana-go:latest +linter: registry.jetbrains.team/p/sa/containers/qodana-go:latest include: - name: CheckDependencyLicenses licenseRules: