Skip to content

Commit 78eae40

Browse files
authored
Allow optionally aggregating all fixes into one pull request (#314)
1 parent 5a7f297 commit 78eae40

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+476
-57
lines changed

commands/createfixpullrequests.go

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ type CreateFixPullRequestsCmd struct {
3131
projectWorkingDir string
3232
// The git client the command performs git operations with
3333
gitManager *utils.GitManager
34+
// Determines whether to open a pull request for each vulnerability fix or to aggregate all fixes into one pull request.
35+
aggregateFixes bool
3436
}
3537

3638
func (cfp *CreateFixPullRequestsCmd) Run(configAggregator utils.FrogbotConfigAggregator, client vcsclient.VcsClient) error {
@@ -63,6 +65,7 @@ func (cfp *CreateFixPullRequestsCmd) scanAndFixRepository(repository *utils.Frog
6365
}
6466
for _, project := range repository.Projects {
6567
cfp.details.Project = project
68+
cfp.aggregateFixes = repository.Git.AggregateFixes
6669
projectFullPathWorkingDirs := getFullPathWorkingDirs(project.WorkingDirs, baseWd)
6770
for _, fullPathWd := range projectFullPathWorkingDirs {
6871
scanResults, isMultipleRoots, err := cfp.scan(cfp.details, fullPathWd)
@@ -134,9 +137,17 @@ func (cfp *CreateFixPullRequestsCmd) fixVulnerablePackages(fixVersionsMap map[st
134137
}
135138
}()
136139

137-
// Fix all impacted packages
140+
if cfp.aggregateFixes {
141+
err = cfp.fixIssuesSinglePR(fixVersionsMap)
142+
} else {
143+
err = cfp.fixIssuesSeparatePRs(fixVersionsMap)
144+
}
145+
return
146+
}
147+
148+
func (cfp *CreateFixPullRequestsCmd) fixIssuesSeparatePRs(fixVersionsMap map[string]*utils.FixVersionInfo) (err error) {
138149
for impactedPackage, fixVersionInfo := range fixVersionsMap {
139-
if err = cfp.fixSinglePackage(impactedPackage, fixVersionInfo); err != nil {
150+
if err = cfp.fixSinglePackageAndCreatePR(impactedPackage, fixVersionInfo); err != nil {
140151
log.Warn(err)
141152
}
142153
// After finishing to work on the current vulnerability, we go back to the base branch to start the next vulnerability fix
@@ -148,10 +159,41 @@ func (cfp *CreateFixPullRequestsCmd) fixVulnerablePackages(fixVersionsMap map[st
148159
return
149160
}
150161

151-
func (cfp *CreateFixPullRequestsCmd) fixSinglePackage(impactedPackage string, fixVersionInfo *utils.FixVersionInfo) (err error) {
162+
func (cfp *CreateFixPullRequestsCmd) fixIssuesSinglePR(fixVersionsMap map[string]*utils.FixVersionInfo) (err error) {
163+
successfullyFixedPackages := make(map[string]*utils.FixVersionInfo)
164+
log.Info("-----------------------------------------------------------------")
165+
log.Info("Start aggregated packages fix")
166+
aggregatedFixBranchName, err := cfp.gitManager.GenerateAggregatedFixBranchName(fixVersionsMap)
167+
if err != nil {
168+
return
169+
}
170+
if err = cfp.createFixBranch(aggregatedFixBranchName, false); err != nil {
171+
return
172+
}
173+
// Fix all packages in the same branch
174+
for impactedPackage, fixVersionInfo := range fixVersionsMap {
175+
if err = cfp.updatePackageToFixedVersion(impactedPackage, fixVersionInfo); err != nil {
176+
log.Error("Could not fix impacted package", impactedPackage, "as part of the PR. Skipping it. Cause:", err.Error())
177+
} else {
178+
log.Info("Successfully fixed", impactedPackage)
179+
successfullyFixedPackages[impactedPackage] = fixVersionInfo
180+
}
181+
}
182+
183+
if err = cfp.openAggregatedPullRequest(aggregatedFixBranchName, successfullyFixedPackages); err != nil {
184+
return fmt.Errorf("failed while creating aggreagted pull request. Error: \n%s", err.Error())
185+
}
186+
return
187+
}
188+
189+
func (cfp *CreateFixPullRequestsCmd) fixSinglePackageAndCreatePR(impactedPackage string, fixVersionInfo *utils.FixVersionInfo) (err error) {
152190
log.Info("-----------------------------------------------------------------")
153191
log.Info("Start fixing", impactedPackage, "with", fixVersionInfo.FixVersion)
154-
fixBranchName, err := cfp.createFixingBranch(impactedPackage, fixVersionInfo)
192+
fixBranchName, err := cfp.gitManager.GenerateFixBranchName(cfp.details.Branch, impactedPackage, fixVersionInfo.FixVersion)
193+
if err != nil {
194+
return
195+
}
196+
err = cfp.createFixBranch(fixBranchName, true)
155197
if err != nil {
156198
return fmt.Errorf("failed while creating new branch: \n%s", err.Error())
157199
}
@@ -179,14 +221,12 @@ func (cfp *CreateFixPullRequestsCmd) openFixingPullRequest(impactedPackage, fixB
179221

180222
commitMessage := cfp.gitManager.GenerateCommitMessage(impactedPackage, fixVersionInfo.FixVersion)
181223
log.Info("Running git add all and commit...")
182-
err = cfp.gitManager.AddAllAndCommit(commitMessage)
183-
if err != nil {
224+
if err = cfp.gitManager.AddAllAndCommit(commitMessage); err != nil {
184225
return
185226
}
186227

187-
log.Info("Pushing fix branch:", fixBranchName, "...")
188-
err = cfp.gitManager.Push()
189-
if err != nil {
228+
log.Info("Pushing branch:", fixBranchName, "...")
229+
if err = cfp.gitManager.Push(false); err != nil {
190230
return
191231
}
192232

@@ -196,22 +236,49 @@ func (cfp *CreateFixPullRequestsCmd) openFixingPullRequest(impactedPackage, fixB
196236
return cfp.details.Client.CreatePullRequest(context.Background(), cfp.details.RepoOwner, cfp.details.RepoName, fixBranchName, cfp.details.Branch, pullRequestTitle, prBody)
197237
}
198238

199-
func (cfp *CreateFixPullRequestsCmd) createFixingBranch(impactedPackage string, fixVersionInfo *utils.FixVersionInfo) (fixBranchName string, err error) {
200-
fixBranchName, err = cfp.gitManager.GenerateFixBranchName(cfp.details.Branch, impactedPackage, fixVersionInfo.FixVersion)
239+
// When aggregate mode is active, there can be only one updated pull request to contain all the available fixes.
240+
// In case of an already opened pull request, Frogbot will only update the branch.
241+
func (cfp *CreateFixPullRequestsCmd) openAggregatedPullRequest(fixBranchName string, versionsMap map[string]*utils.FixVersionInfo) (err error) {
242+
log.Info("Checking if there are changes to commit")
243+
isClean, err := cfp.gitManager.IsClean()
201244
if err != nil {
202245
return
203246
}
247+
if isClean {
248+
return fmt.Errorf("there were no changes in the fix branch '%s'", fixBranchName)
249+
}
250+
commitMessage := cfp.gitManager.GenerateAggregatedCommitMessage()
251+
log.Info("Running git add all and commit...")
252+
if err = cfp.gitManager.AddAllAndCommit(commitMessage); err != nil {
253+
return
254+
}
255+
exists, err := cfp.gitManager.BranchExistsInRemote(fixBranchName)
256+
if err != nil {
257+
return
258+
}
259+
log.Info("Pushing branch:", fixBranchName, "...")
260+
if err = cfp.gitManager.Push(true); err != nil {
261+
return
262+
}
263+
if !exists {
264+
log.Info("Creating Pull Request form:", fixBranchName, " to:", cfp.details.Branch)
265+
prBody := commitMessage + "\n\n" + utils.WhatIsFrogbotMd
266+
return cfp.details.Client.CreatePullRequest(context.Background(), cfp.details.RepoOwner, cfp.details.RepoName, fixBranchName, cfp.details.Branch, utils.AggregatedPullRequestTitleTemplate, prBody)
267+
}
268+
log.Info("Pull Request branch:", fixBranchName, "has been updated")
269+
return
270+
}
204271

272+
func (cfp *CreateFixPullRequestsCmd) createFixBranch(fixBranchName string, failOnExists bool) (err error) {
205273
exists, err := cfp.gitManager.BranchExistsInRemote(fixBranchName)
206274
if err != nil {
207275
return
208276
}
209277
log.Info("Creating branch", fixBranchName, "...")
210-
if exists {
211-
return "", fmt.Errorf("branch %s already exists in the remote git repository", fixBranchName)
278+
if failOnExists && exists {
279+
return fmt.Errorf("branch %s already exists in the remote git repository", fixBranchName)
212280
}
213-
214-
return fixBranchName, cfp.gitManager.CreateBranchAndCheckout(fixBranchName)
281+
return cfp.gitManager.CreateBranchAndCheckout(fixBranchName)
215282
}
216283

217284
func (cfp *CreateFixPullRequestsCmd) cloneRepository() (tempWd string, restoreDir func() error, err error) {
@@ -275,21 +342,6 @@ func (cfp *CreateFixPullRequestsCmd) addVulnerabilityToFixVersionsMap(vulnerabil
275342
return nil
276343
}
277344

278-
// getMinimalFixVersion that fixes the current impactedPackage
279-
// fixVersions array is sorted, so we take the first index, unless it's version is older than what we have now.
280-
func getMinimalFixVersion(impactedPackageVersion string, fixVersions []string) string {
281-
// Trim 'v' prefix in case of Go package
282-
currVersionStr := strings.TrimPrefix(impactedPackageVersion, "v")
283-
currVersion := version.NewVersion(currVersionStr)
284-
for _, fixVersion := range fixVersions {
285-
fixVersionCandidate := parseVersionChangeString(fixVersion)
286-
if currVersion.Compare(fixVersionCandidate) > 0 {
287-
return fixVersionCandidate
288-
}
289-
}
290-
return ""
291-
}
292-
293345
func (cfp *CreateFixPullRequestsCmd) updatePackageToFixedVersion(impactedPackage string, fixVersionInfo *utils.FixVersionInfo) (err error) {
294346
// 'CD' into the relevant working directory
295347
if cfp.projectWorkingDir != "" {
@@ -317,6 +369,21 @@ func (cfp *CreateFixPullRequestsCmd) updatePackageToFixedVersion(impactedPackage
317369
return packageHandler.UpdateImpactedPackage(impactedPackage, fixVersionInfo)
318370
}
319371

372+
// getMinimalFixVersion find the minimal version that fixes the current impactedPackage;
373+
// fixVersions is a sorted array. The function returns the first version in the array, that is larger than impactedPackageVersion.
374+
func getMinimalFixVersion(impactedPackageVersion string, fixVersions []string) string {
375+
// Trim 'v' prefix in case of Go package
376+
currVersionStr := strings.TrimPrefix(impactedPackageVersion, "v")
377+
currVersion := version.NewVersion(currVersionStr)
378+
for _, fixVersion := range fixVersions {
379+
fixVersionCandidate := parseVersionChangeString(fixVersion)
380+
if currVersion.Compare(fixVersionCandidate) > 0 {
381+
return fixVersionCandidate
382+
}
383+
}
384+
return ""
385+
}
386+
320387
// 1.0 --> 1.0 ≤ x
321388
// (,1.0] --> x ≤ 1.0
322389
// (,1.0) --> x < 1.0

commands/testdata/config/frogbot-config-test-params.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
branchNameTemplate: "this is my branch ${BRANCH_NAME_HASH}"
77
commitMessageTemplate: "custom commit title"
88
pullRequestTitleTemplate: "myPullRequests"
9+
aggregateFixes: true
910
scan:
1011
includeAllVulnerabilities: true
1112
failOnSecurityIssues: true

commands/utils/consts.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,11 @@ const (
6161
WatchesDelimiter = ","
6262

6363
//#nosec G101 -- False positive - no hardcoded credentials.
64-
GitTokenEnv = "JF_GIT_TOKEN"
65-
GitBaseBranchEnv = "JF_GIT_BASE_BRANCH"
66-
GitPullRequestIDEnv = "JF_GIT_PULL_REQUEST_ID"
67-
GitApiEndpointEnv = "JF_GIT_API_ENDPOINT"
64+
GitTokenEnv = "JF_GIT_TOKEN"
65+
GitBaseBranchEnv = "JF_GIT_BASE_BRANCH"
66+
GitPullRequestIDEnv = "JF_GIT_PULL_REQUEST_ID"
67+
GitApiEndpointEnv = "JF_GIT_API_ENDPOINT"
68+
GitAggregateFixesEnv = "JF_GIT_AGGREGATE_FIXES"
6869

6970
// Comment
7071
tableHeader = "\n| SEVERITY | DIRECT DEPENDENCIES | DIRECT DEPENDENCIES VERSIONS | IMPACTED DEPENDENCY NAME | IMPACTED DEPENDENCY VERSION | FIXED VERSIONS | CVE\n" +
@@ -93,7 +94,12 @@ const (
9394
BranchHashPlaceHolder = "${BRANCH_NAME_HASH}"
9495

9596
// Default naming templates
96-
BranchNameTemplate = "frogbot-" + PackagePlaceHolder + "-" + BranchHashPlaceHolder
97-
CommitMessageTemplate = "Upgrade " + PackagePlaceHolder + " to " + FixVersionPlaceHolder
98-
PullRequestTitleTemplate = "[🐸 Frogbot] Upgrade " + PackagePlaceHolder + " to " + FixVersionPlaceHolder
97+
BranchNameTemplate = "frogbot-" + PackagePlaceHolder + "-" + BranchHashPlaceHolder
98+
AggregatedBranchNameTemplate = "frogobt-" + BranchHashPlaceHolder
99+
CommitMessageTemplate = "Upgrade " + PackagePlaceHolder + " to " + FixVersionPlaceHolder
100+
AggregatedPullRequestTitleTemplate = "[🐸 Frogbot] Update dependencies versions"
101+
PullRequestTitleTemplate = "[🐸 Frogbot] Update version of " + PackagePlaceHolder + " to " + FixVersionPlaceHolder
102+
// Frogbot Git author details showed in commits
103+
frogbotAuthorName = "JFrog-Frogbot"
104+
frogbotAuthorEmail = "[email protected]"
99105
)

commands/utils/git.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ func (gm *GitManager) commit(commitMessage string) error {
171171
}
172172
_, err = worktree.Commit(commitMessage, &git.CommitOptions{
173173
Author: &object.Signature{
174-
Name: "JFrog-Frogbot",
175-
174+
Name: frogbotAuthorName,
175+
Email: frogbotAuthorEmail,
176176
When: time.Now(),
177177
},
178178
})
@@ -200,7 +200,7 @@ func (gm *GitManager) BranchExistsInRemote(branchName string) (bool, error) {
200200
return false, nil
201201
}
202202

203-
func (gm *GitManager) Push() error {
203+
func (gm *GitManager) Push(force bool) error {
204204
if gm.dryRun {
205205
// On dry run do not push to any remote
206206
return nil
@@ -209,6 +209,7 @@ func (gm *GitManager) Push() error {
209209
err := gm.repository.Push(&git.PushOptions{
210210
RemoteName: gm.remoteName,
211211
Auth: gm.auth,
212+
Force: force,
212213
})
213214
if err != nil {
214215
err = fmt.Errorf("git push failed with error: %s", err.Error())
@@ -238,6 +239,14 @@ func (gm *GitManager) GenerateCommitMessage(impactedPackage string, fixVersion s
238239
return formatStringWithPlaceHolders(template, impactedPackage, fixVersion, "", true)
239240
}
240241

242+
func (gm *GitManager) GenerateAggregatedCommitMessage() string {
243+
template := gm.customTemplates.commitMessageTemplate
244+
if template == "" {
245+
template = AggregatedPullRequestTitleTemplate
246+
}
247+
return formatStringWithPlaceHolders(template, "", "", "", true)
248+
}
249+
241250
func formatStringWithPlaceHolders(str, impactedPackage, fixVersion, hash string, allowSpaces bool) string {
242251
replacements := []struct {
243252
placeholder string
@@ -280,6 +289,19 @@ func (gm *GitManager) GeneratePullRequestTitle(impactedPackage string, version s
280289
return formatStringWithPlaceHolders(template, impactedPackage, version, "", true)
281290
}
282291

292+
// Generates unique branch name constructed by all the vulnerable packages.
293+
func (gm *GitManager) GenerateAggregatedFixBranchName(versionsMap map[string]*FixVersionInfo) (fixBranchName string, err error) {
294+
hash, err := fixVersionsMapToMd5Hash(versionsMap)
295+
if err != nil {
296+
return
297+
}
298+
branchFormat := gm.customTemplates.branchNameTemplate
299+
if branchFormat == "" {
300+
branchFormat = AggregatedBranchNameTemplate
301+
}
302+
return formatStringWithPlaceHolders(branchFormat, "", "", hash, false), nil
303+
}
304+
283305
// dryRunClone clones an existing repository from our testdata folder into the destination folder for testing purposes.
284306
// We should call this function when the current working directory is the repository we want to clone.
285307
func (gm *GitManager) dryRunClone(destination string) error {

commands/utils/git_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package utils
22

33
import (
4+
"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
45
"github.com/stretchr/testify/assert"
56
"testing"
67
)
@@ -115,3 +116,74 @@ func TestGitManager_GeneratePullRequestTitle(t *testing.T) {
115116
})
116117
}
117118
}
119+
120+
func TestGitManager_GenerateAggregatedFixBranchName(t *testing.T) {
121+
tests := []struct {
122+
fixVersionMapFirst map[string]*FixVersionInfo
123+
fixVersionMapSecond map[string]*FixVersionInfo
124+
gitManager GitManager
125+
equal bool
126+
desc string
127+
}{
128+
{
129+
fixVersionMapFirst: map[string]*FixVersionInfo{
130+
"pkg": {FixVersion: "1.2.3", PackageType: coreutils.Npm, DirectDependency: false},
131+
"pkg2": {FixVersion: "1.5.3", PackageType: coreutils.Npm, DirectDependency: false}},
132+
fixVersionMapSecond: map[string]*FixVersionInfo{
133+
"pkg": {FixVersion: "1.2.3", PackageType: coreutils.Npm, DirectDependency: false},
134+
"pkg2": {FixVersion: "1.5.3", PackageType: coreutils.Npm, DirectDependency: false}},
135+
equal: true, desc: "should be equal",
136+
gitManager: GitManager{},
137+
},
138+
{
139+
fixVersionMapFirst: map[string]*FixVersionInfo{
140+
"pkg": {FixVersion: "1.2.3", PackageType: coreutils.Npm, DirectDependency: false},
141+
"pkg2": {FixVersion: "1.5.3", PackageType: coreutils.Npm, DirectDependency: false},
142+
},
143+
fixVersionMapSecond: map[string]*FixVersionInfo{
144+
"pkgOther": {FixVersion: "1.2.3", PackageType: coreutils.Npm, DirectDependency: false},
145+
"pkg2": {FixVersion: "1.5.3", PackageType: coreutils.Npm, DirectDependency: false}},
146+
equal: false,
147+
desc: "should not be equal",
148+
gitManager: GitManager{},
149+
},
150+
{
151+
fixVersionMapFirst: map[string]*FixVersionInfo{
152+
"pkg": {FixVersion: "1.2.3", PackageType: coreutils.Npm, DirectDependency: false},
153+
"pkg2": {FixVersion: "1.5.3", PackageType: coreutils.Npm, DirectDependency: false},
154+
},
155+
fixVersionMapSecond: map[string]*FixVersionInfo{
156+
"pkgOther": {FixVersion: "1.2.3", PackageType: coreutils.Npm, DirectDependency: false},
157+
"pkg2": {FixVersion: "1.5.3", PackageType: coreutils.Npm, DirectDependency: false}},
158+
equal: true,
159+
desc: "should be equal with template",
160+
gitManager: GitManager{customTemplates: CustomTemplates{branchNameTemplate: "custom"}},
161+
},
162+
}
163+
for _, test := range tests {
164+
t.Run(test.desc, func(t *testing.T) {
165+
titleOutput1, err := test.gitManager.GenerateAggregatedFixBranchName(test.fixVersionMapFirst)
166+
assert.NoError(t, err)
167+
titleOutput2, err := test.gitManager.GenerateAggregatedFixBranchName(test.fixVersionMapSecond)
168+
assert.NoError(t, err)
169+
equal := titleOutput1 == titleOutput2
170+
assert.Equal(t, test.equal, equal)
171+
})
172+
}
173+
}
174+
175+
func TestGitManager_GenerateAggregatedCommitMessage(t *testing.T) {
176+
tests := []struct {
177+
gitManager GitManager
178+
expected string
179+
}{
180+
{gitManager: GitManager{}, expected: AggregatedPullRequestTitleTemplate},
181+
{gitManager: GitManager{customTemplates: CustomTemplates{commitMessageTemplate: "custom_template"}}, expected: "custom_template"},
182+
}
183+
for _, test := range tests {
184+
t.Run(test.expected, func(t *testing.T) {
185+
commit := test.gitManager.GenerateAggregatedCommitMessage()
186+
assert.Equal(t, commit, test.expected)
187+
})
188+
}
189+
}

0 commit comments

Comments
 (0)