internal/core/incremental_analysis.go (295 lines of code) (raw):
/*
* Copyright 2021-2024 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 core
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/JetBrains/qodana-cli/internal/core/corescan"
"github.com/JetBrains/qodana-cli/internal/core/startup"
"github.com/JetBrains/qodana-cli/internal/platform"
"github.com/JetBrains/qodana-cli/internal/platform/effectiveconfig"
"github.com/JetBrains/qodana-cli/internal/platform/git"
"github.com/JetBrains/qodana-cli/internal/platform/qdyaml"
"github.com/JetBrains/qodana-cli/internal/platform/utils"
log "github.com/sirupsen/logrus"
)
var defaultRunner = &defaultAnalysisRunner{}
// AnalysisRunner defines the interface for logic on running analysis on commits
type AnalysisRunner interface {
RunFunc(hash string, ctx context.Context, c corescan.Context) (bool, int)
}
// DefaultAnalysisRunner is the production implementation of RunFunc
type defaultAnalysisRunner struct{}
// SequenceRunner defines the interface for different sequence analysis strategies
type SequenceRunner interface {
RunSequence(scopeFile string, runner AnalysisRunner) int
GetParams() (corescan.Context, string, string)
ComputeEndHash() string
}
// ScopedAnalyzer contains the common logic and coordinates the analysis
type ScopedAnalyzer struct {
runner AnalysisRunner
sequenceRunner SequenceRunner
}
type SequenceRunnerBase struct {
ctx context.Context
c corescan.Context
startHash, endHash string
}
// ScopeSequenceRunner implements the original scope analysis (old commit comes first, new commit comes last)
type ScopeSequenceRunner struct {
SequenceRunnerBase
}
// ReverseScopeSequenceRunner implements the reverse scope analysis
type ReverseScopeSequenceRunner struct {
SequenceRunnerBase
}
// NewScopedAnalyzer creates a new ScopedAnalyzer with the original sequence runner
func NewScopedAnalyzer(
ctx context.Context,
c corescan.Context,
startHash, endHash string,
runner AnalysisRunner,
) *ScopedAnalyzer {
return &ScopedAnalyzer{
runner: runner,
sequenceRunner: &ScopeSequenceRunner{
SequenceRunnerBase: SequenceRunnerBase{
ctx: ctx,
c: c,
startHash: startHash,
endHash: endHash,
},
},
}
}
// NewReverseScopedAnalyzer creates a new ScopedAnalyzer with the reversed sequence runner
func NewReverseScopedAnalyzer(
ctx context.Context,
c corescan.Context,
startHash, endHash string,
runner AnalysisRunner,
) *ScopedAnalyzer {
var err error
if endHash == "" {
endHash, err = git.CurrentRevision(c.RepositoryRoot(), c.LogDir())
if err != nil {
log.Fatal(err)
}
}
return &ScopedAnalyzer{
runner: runner,
sequenceRunner: &ReverseScopeSequenceRunner{
SequenceRunnerBase: SequenceRunnerBase{
ctx: ctx,
c: c,
startHash: startHash,
endHash: endHash,
},
},
}
}
func (sa *ScopedAnalyzer) RunAnalysis() int {
c, startRef, endRef := sa.sequenceRunner.GetParams()
var err error
if startRef == "" || endRef == "" {
log.Fatal("No commits given. Consider passing --commit or --diff-start and --diff-end (optional) with the range of commits to analyze.")
}
startSha, err := git.RevParse(c.RepositoryRoot(), startRef, c.LogDir())
if err != nil {
log.Fatalf("Failed to calculate analysis scope: %q is not a valid commit ref.", startRef)
}
endSha, err := git.RevParse(c.RepositoryRoot(), endRef, c.LogDir())
if err != nil {
log.Fatalf("Failed to calculate analysis scope: %q is not a valid commit ref.", endRef)
}
changedFiles, err := git.ComputeChangedFiles(c.RepositoryRoot(), startSha, endSha, c.LogDir())
if err != nil {
log.Fatal(err)
}
if len(changedFiles.Files) == 0 {
log.Warnf("Nothing to compare between %s and %s", startRef, endRef)
return utils.QodanaEmptyChangesetExitCodePlaceholder
}
scopeFile, err := writeChangesFile(c, changedFiles)
if err != nil {
log.Fatal("Failed to prepare diff run ", err)
}
defer func() {
_ = os.Remove(scopeFile)
}()
return sa.sequenceRunner.RunSequence(scopeFile, sa.runner)
}
func (r *defaultAnalysisRunner) RunFunc(hash string, ctx context.Context, c corescan.Context) (bool, int) {
e := git.CheckoutAndUpdateSubmodule(c.RepositoryRoot(), hash, true, c.LogDir())
if e != nil {
log.Fatalf("Cannot checkout commit %s: %v", hash, e)
}
log.Infof("Analysing %s", hash)
// for CLI, we use only bootstrap from this effective yaml
// all other fields are used from the one (effective aswell) obtained at the start
localQodanaYamlFullPath := qdyaml.GetLocalNotEffectiveQodanaYamlFullPath(
c.ProjectDir(),
c.CustomLocalQodanaYamlPath(),
)
effectiveConfigDir, cleanup, err := utils.CreateTempDir("qd-effective-config-")
if err != nil {
log.Fatalf("Failed to create Qodana effective config directory: %v", err)
}
defer cleanup()
effectiveConfigFiles, err := effectiveconfig.CreateEffectiveConfigFiles(
localQodanaYamlFullPath,
c.GlobalConfigurationsDir(),
c.GlobalConfigurationId(),
c.Prod().JbrJava(),
effectiveConfigDir,
c.LogDir(),
)
if err != nil {
log.Fatalf("Failed to load Qodana configuration during analysis of commit %s: %v", hash, err)
}
// if local qodana yaml doesn't exist on revision, for bootstrap fallback to the one constructed at the start
var bootstrap string
if c.LocalQodanaYamlExists() {
yaml := qdyaml.LoadQodanaYamlByFullPath(effectiveConfigFiles.EffectiveQodanaYamlPath)
bootstrap = yaml.Bootstrap
} else {
bootstrap = c.QodanaYamlConfig().Bootstrap
}
// TODO: mention that bootstrap should be relative to the project path
utils.Bootstrap(bootstrap, c.ProjectDir())
contextForAnalysis := c.WithEffectiveConfigurationDirOnRevision(effectiveConfigFiles.ConfigDir)
exitCode := runQodana(ctx, contextForAnalysis)
if exitCode != 0 && exitCode != 255 {
log.Errorf("Qodana analysis on %s exited with code %d. Aborting", hash, exitCode)
return true, exitCode
}
return false, exitCode
}
func (r *ScopeSequenceRunner) RunSequence(
scopeFile string,
runner AnalysisRunner,
) int {
ctx, c, startHash, endHash := computeSequenceParams(&r.SequenceRunnerBase)
startRunContext := c.FirstStageOfScopedScript(scopeFile)
stop, code := runner.RunFunc(startHash, ctx, startRunContext)
if stop {
return code
}
startSarif := platform.GetSarifPath(startRunContext.ResultsDir())
endRunContext := c.SecondStageOfScopedScript(scopeFile, startSarif)
stop, code = runner.RunFunc(endHash, ctx, endRunContext)
if stop {
return code
}
copyAndSaveReport(endRunContext, c)
return code
}
func (r *ReverseScopeSequenceRunner) RunSequence(
scopeFile string,
runner AnalysisRunner,
) int {
var code int
var stop bool
ctx, c, startHash, endHash := computeSequenceParams(&r.SequenceRunnerBase)
newCodeContext := c.FirstStageOfReverseScopedScript(scopeFile)
if stop, code = runner.RunFunc(endHash, ctx, newCodeContext); stop {
return code
}
currentContext := newCodeContext
if shouldProceedToNextStage(currentContext) {
copyAndSaveReport(currentContext, c)
return code
}
scopeFile, coverageArtifactsPath, newCodeSarif := prepareArtifactPaths(newCodeContext, scopeFile)
startRunContext := c.SecondStageOfReverseScopedScript(scopeFile, newCodeSarif)
copyCoverageFromNewStage(coverageArtifactsPath, startRunContext.ResultsDir())
if stop, code = runner.RunFunc(startHash, ctx, startRunContext); stop {
return code
}
currentContext = startRunContext
if shouldProceedToNextStage(currentContext) {
copyAndSaveReport(currentContext, c)
return code
}
if shouldApplyFixes := c.ApplyFixes() || c.Cleanup(); shouldApplyFixes {
fixesContext := c.ThirdStageOfReverseScopedScript(scopeFile, newCodeSarif)
copyCoverageFromNewStage(coverageArtifactsPath, fixesContext.ResultsDir())
if stop, code = runner.RunFunc(endHash, ctx, fixesContext); stop {
return code
}
currentContext = fixesContext
}
copyAndSaveReport(currentContext, c)
return code
}
func computeSequenceParams(r *SequenceRunnerBase) (context.Context, corescan.Context, string, string) {
return r.ctx, r.c, r.startHash, r.ComputeEndHash()
}
func (r *SequenceRunnerBase) GetParams() (corescan.Context, string, string) {
return r.c, r.startHash, r.ComputeEndHash()
}
func (r *SequenceRunnerBase) ComputeEndHash() string {
endHash := r.endHash
var err error
if endHash == "" {
endHash, err = git.CurrentRevision(r.c.RepositoryRoot(), r.c.LogDir())
if err != nil {
log.Fatal(err)
}
r.endHash = endHash
}
return endHash
}
func prepareArtifactPaths(
newCodeContext corescan.Context,
originalScopeFile string,
) (scopeFile, coverageArtifactsPath, newCodeSarif string) {
scopeFile = originalScopeFile
if reducedPath := newCodeContext.ReducedScopePath(); reducedPath != "" {
scopeFile = reducedPath
}
coverageArtifactsPath = platform.GetCoverageArtifactsPath(newCodeContext.ResultsDir())
newCodeSarif = platform.GetSarifPath(newCodeContext.ResultsDir())
return scopeFile, coverageArtifactsPath, newCodeSarif
}
func shouldProceedToNextStage(ctx corescan.Context) bool {
value := getInvocationProperties(ctx.ResultsDir()).AdditionalProperties["qodana.result.skipped"]
if strValue, ok := value.(string); ok {
return strValue == "false"
}
if boolValue, ok := value.(bool); ok {
return !boolValue
}
return false
}
func copyAndSaveReport(lastContext corescan.Context, c corescan.Context) {
err := utils.CopyDir(lastContext.ResultsDir(), c.ResultsDir())
if err != nil {
log.Fatal(err)
}
saveReport(c)
}
// writeChangesFile creates a temp file containing the changes between diffStart and diffEnd
func writeChangesFile(c corescan.Context, changedFiles git.ChangedFiles) (string, error) {
file, err := os.CreateTemp("", "diff-scope.txt")
if err != nil {
return "", err
}
defer func() {
err := file.Close()
if err != nil {
log.Warn("Failed to close scope file ", err)
}
}()
jsonChanges, err := json.MarshalIndent(changedFiles, "", " ")
if err != nil {
return "", err
}
_, err = file.WriteString(string(jsonChanges))
if err != nil {
return "", fmt.Errorf("failed to write scope file: %w", err)
}
err = utils.CopyFile(file.Name(), filepath.Join(c.LogDir(), "changes.json"))
if err != nil {
return "", err
}
return file.Name(), nil
}
func copyCoverageFromNewStage(coverageDataPath string, resultsDir string) {
if info, err := os.Stat(coverageDataPath); err == nil && info.IsDir() {
startup.MakeDirAll(resultsDir)
targetCoveragePath := filepath.Join(resultsDir, "coverage")
if err := utils.CopyDir(coverageDataPath, targetCoveragePath); err != nil {
log.Fatalf("Failed to copy coverage data from %s to %s: %v", coverageDataPath, targetCoveragePath, err)
}
}
}