Skip to content

Commit 9dc7087

Browse files
committed
feat(amazonq): grouping options for code issues
1 parent 5e1a94b commit 9dc7087

File tree

9 files changed

+249
-3
lines changed

9 files changed

+249
-3
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "/review: Code issues can be grouped by file location or severity"
4+
}

packages/amazonq/package.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,10 +370,15 @@
370370
"when": "view == aws.AmazonQChatView || view == aws.amazonq.AmazonCommonAuth",
371371
"group": "y_toolkitMeta@2"
372372
},
373+
{
374+
"command": "aws.amazonq.codescan.showGroupingStrategy",
375+
"when": "view == aws.amazonq.SecurityIssuesTree",
376+
"group": "navigation@1"
377+
},
373378
{
374379
"command": "aws.amazonq.security.showFilters",
375380
"when": "view == aws.amazonq.SecurityIssuesTree",
376-
"group": "navigation"
381+
"group": "navigation@2"
377382
}
378383
],
379384
"view/item/context": [
@@ -729,6 +734,12 @@
729734
{
730735
"command": "aws.amazonq.security.showFilters",
731736
"title": "%AWS.command.amazonq.filterIssues%",
737+
"icon": "$(filter)",
738+
"enablement": "view == aws.amazonq.SecurityIssuesTree"
739+
},
740+
{
741+
"command": "aws.amazonq.codescan.showGroupingStrategy",
742+
"title": "%AWS.command.amazonq.groupIssues%",
732743
"icon": "$(list-filter)",
733744
"enablement": "view == aws.amazonq.SecurityIssuesTree"
734745
},

packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import {
1010
SecurityTreeViewFilterState,
1111
SecurityIssueProvider,
1212
SeverityItem,
13+
CodeIssueGroupingStrategyState,
14+
CodeIssueGroupingStrategy,
1315
} from 'aws-core-vscode/codewhisperer'
1416
import { createCodeScanIssue } from 'aws-core-vscode/test'
1517
import assert from 'assert'
1618
import sinon from 'sinon'
19+
import path from 'path'
1720

1821
describe('SecurityIssueTreeViewProvider', function () {
1922
let securityIssueProvider: SecurityIssueProvider
@@ -102,5 +105,88 @@ describe('SecurityIssueTreeViewProvider', function () {
102105
const result = securityIssueTreeViewProvider.getChildren(element) as IssueItem[]
103106
assert.strictEqual(result.length, 0)
104107
})
108+
109+
it('should return severity-grouped items when grouping strategy is Severity', function () {
110+
sinon.stub(CodeIssueGroupingStrategyState.instance, 'getState').returns(CodeIssueGroupingStrategy.Severity)
111+
securityIssueProvider.issues = [
112+
{
113+
filePath: 'file/path/c',
114+
issues: [
115+
createCodeScanIssue({ severity: 'Critical' }),
116+
createCodeScanIssue({ severity: 'Critical' }),
117+
],
118+
},
119+
{
120+
filePath: 'file/path/d',
121+
issues: [createCodeScanIssue({ severity: 'Critical' })],
122+
},
123+
{
124+
filePath: 'file/path/a',
125+
issues: [createCodeScanIssue({ severity: 'Critical' }), createCodeScanIssue({ severity: 'High' })],
126+
},
127+
]
128+
129+
const severityItems = securityIssueTreeViewProvider.getChildren() as SeverityItem[]
130+
for (const [index, [severity, expectedIssueCount]] of [
131+
['Critical', 4],
132+
['High', 1],
133+
['Medium', 0],
134+
['Low', 0],
135+
['Info', 0],
136+
].entries()) {
137+
const currentSeverityItem = severityItems[index]
138+
assert.strictEqual(currentSeverityItem.label, severity)
139+
assert.strictEqual(currentSeverityItem.issues.length, expectedIssueCount)
140+
141+
const issueItems = securityIssueTreeViewProvider.getChildren(currentSeverityItem) as IssueItem[]
142+
assert.ok(issueItems.every((item) => item.iconPath === undefined))
143+
assert.ok(
144+
issueItems.every((item) => item.description?.toString().startsWith(path.basename(item.filePath)))
145+
)
146+
}
147+
})
148+
149+
it('should return file-grouped items when grouping strategy is FileLocation', function () {
150+
sinon
151+
.stub(CodeIssueGroupingStrategyState.instance, 'getState')
152+
.returns(CodeIssueGroupingStrategy.FileLocation)
153+
securityIssueProvider.issues = [
154+
{
155+
filePath: 'file/path/c',
156+
issues: [
157+
createCodeScanIssue({ severity: 'Critical' }),
158+
createCodeScanIssue({ severity: 'Critical' }),
159+
],
160+
},
161+
{
162+
filePath: 'file/path/d',
163+
issues: [createCodeScanIssue({ severity: 'Critical' })],
164+
},
165+
{
166+
filePath: 'file/path/a',
167+
issues: [createCodeScanIssue({ severity: 'Critical' }), createCodeScanIssue({ severity: 'High' })],
168+
},
169+
]
170+
171+
const result = securityIssueTreeViewProvider.getChildren() as FileItem[]
172+
for (const [index, [fileName, expectedIssueCount]] of [
173+
['a', 2],
174+
['c', 2],
175+
['d', 1],
176+
].entries()) {
177+
const currentFileItem = result[index]
178+
assert.strictEqual(currentFileItem.label, fileName)
179+
assert.strictEqual(currentFileItem.issues.length, expectedIssueCount)
180+
assert.strictEqual(currentFileItem.description, 'file/path')
181+
182+
const issueItems = securityIssueTreeViewProvider.getChildren(currentFileItem) as IssueItem[]
183+
assert.ok(
184+
issueItems.every((item) =>
185+
item.iconPath?.toString().includes(`${item.issue.severity.toLowerCase()}.svg`)
186+
)
187+
)
188+
assert.ok(issueItems.every((item) => item.description?.toString().startsWith('[Ln ')))
189+
}
190+
})
105191
})
106192
})

packages/core/package.nls.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
"AWS.command.amazonq.acceptFix": "Accept Fix",
134134
"AWS.command.amazonq.regenerateFix": "Regenerate Fix",
135135
"AWS.command.amazonq.filterIssues": "Filter Issues",
136+
"AWS.command.amazonq.groupIssues": "Group Issues",
136137
"AWS.command.deploySamApplication": "Deploy SAM Application",
137138
"AWS.command.aboutToolkit": "About",
138139
"AWS.command.downloadLambda": "Download...",
@@ -309,6 +310,8 @@
309310
"AWS.amazonq.scans.projectScanInProgress": "Workspace review is in progress...",
310311
"AWS.amazonq.scans.fileScanInProgress": "File review is in progress...",
311312
"AWS.amazonq.scans.noGitRepo": "Your workspace is not in a git repository. I'll review your project files for security issues, and your in-flight changes for code quality issues.",
313+
"AWS.amazonq.scans.severity": "Severity",
314+
"AWS.amazonq.scans.fileLocation": "File Location",
312315
"AWS.amazonq.featureDev.error.conversationIdNotFoundError": "Conversation id must exist before starting code generation",
313316
"AWS.amazonq.featureDev.error.contentLengthError": "The folder you selected is too large for me to use as context. Please choose a smaller folder to work on. For more information on quotas, see the <a href=\"https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/software-dev.html#quotas\" target=\"_blank\">Amazon Q Developer documentation.</a>",
314317
"AWS.amazonq.featureDev.error.illegalStateTransition": "Illegal transition between states, restart the conversation",

packages/core/src/codewhisperer/activation.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
SecurityTreeViewFilterState,
1919
AggregatedCodeScanIssue,
2020
CodeScanIssue,
21+
CodeIssueGroupingStrategyState,
2122
} from './models/model'
2223
import { invokeRecommendation } from './commands/invokeRecommendation'
2324
import { acceptSuggestion } from './commands/onInlineAcceptance'
@@ -60,6 +61,7 @@ import {
6061
ignoreAllIssues,
6162
focusIssue,
6263
showExploreAgentsView,
64+
showCodeIssueGroupingQuickPick,
6365
} from './commands/basicCommands'
6466
import { sleep } from '../shared/utilities/timeoutUtils'
6567
import { ReferenceLogViewProvider } from './service/referenceLogViewProvider'
@@ -288,6 +290,8 @@ export async function activate(context: ExtContext): Promise<void> {
288290
listCodeWhispererCommands.register(),
289291
// quick pick with security issues tree filters
290292
showSecurityIssueFilters.register(),
293+
// quick pick code issue grouping strategy
294+
showCodeIssueGroupingQuickPick.register(),
291295
// reset security issue filters
292296
clearFilters.register(),
293297
// handle security issues tree item clicked
@@ -296,6 +300,10 @@ export async function activate(context: ExtContext): Promise<void> {
296300
SecurityTreeViewFilterState.instance.onDidChangeState((e) => {
297301
SecurityIssueTreeViewProvider.instance.refresh()
298302
}),
303+
// refresh treeview when grouping strategy changes
304+
CodeIssueGroupingStrategyState.instance.onDidChangeState((e) => {
305+
SecurityIssueTreeViewProvider.instance.refresh()
306+
}),
299307
// show a no match state
300308
SecurityIssueTreeViewProvider.instance.onDidChangeTreeData((e) => {
301309
const noMatches =

packages/core/src/codewhisperer/commands/basicCommands.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { confirmStopSecurityScan, startSecurityScan } from './startSecurityScan'
1313
import { SecurityPanelViewProvider } from '../views/securityPanelViewProvider'
1414
import {
1515
codeFixState,
16+
codeIssueGroupingStrategies,
17+
codeIssueGroupingStrategyLabel,
18+
CodeIssueGroupingStrategyState,
1619
CodeScanIssue,
1720
CodeScansState,
1821
codeScanState,
@@ -884,6 +887,33 @@ export const showSecurityIssueFilters = Commands.declare({ id: 'aws.amazonq.secu
884887
}
885888
})
886889

890+
export const showCodeIssueGroupingQuickPick = Commands.declare(
891+
{ id: 'aws.amazonq.codescan.showGroupingStrategy' },
892+
() => async () => {
893+
const quickPick = vscode.window.createQuickPick()
894+
quickPick.title = localize('aws.commands.amazonq.groupIssues', 'Group Issues')
895+
quickPick.placeholder = localize(
896+
'aws.amazonq.codescan.groupIssues.placeholder',
897+
'Select how to group code issues'
898+
)
899+
quickPick.items = codeIssueGroupingStrategies.map((strategy) => ({
900+
label: codeIssueGroupingStrategyLabel[strategy],
901+
}))
902+
const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState()
903+
quickPick.activeItems = quickPick.items.filter(
904+
(item) => item.label === codeIssueGroupingStrategyLabel[groupingStrategy]
905+
)
906+
quickPick.show()
907+
quickPick.onDidChangeSelection(async (items) => {
908+
const [item] = items
909+
await CodeIssueGroupingStrategyState.instance.setState(
910+
Object.entries(codeIssueGroupingStrategyLabel).find(([, label]) => label === item.label)?.[0]
911+
)
912+
quickPick.hide()
913+
})
914+
}
915+
)
916+
887917
export const focusIssue = Commands.declare(
888918
{ id: 'aws.amazonq.security.focusIssue' },
889919
() => async (issue: CodeScanIssue, filePath: string) => {

packages/core/src/codewhisperer/models/model.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { TransformationSteps } from '../client/codewhispereruserclient'
2020
import { Messenger } from '../../amazonqGumby/chat/controller/messenger/messenger'
2121
import { TestChatControllerEventEmitters } from '../../amazonqTest/chat/controller/controller'
2222
import { ScanChatControllerEventEmitters } from '../../amazonqScan/controller'
23+
import { localize } from '../../shared/utilities/vsCodeUtils'
2324

2425
// unavoidable global variables
2526
interface VsCodeState {
@@ -564,6 +565,52 @@ export class SecurityTreeViewFilterState {
564565
}
565566
}
566567

568+
export enum CodeIssueGroupingStrategy {
569+
Severity = 'Severity',
570+
FileLocation = 'FileLocation',
571+
}
572+
const defaultCodeIssueGroupingStrategy = CodeIssueGroupingStrategy.Severity
573+
574+
export const codeIssueGroupingStrategies = Object.values(CodeIssueGroupingStrategy)
575+
export const codeIssueGroupingStrategyLabel: Record<CodeIssueGroupingStrategy, string> = {
576+
[CodeIssueGroupingStrategy.Severity]: localize('AWS.amazonq.scans.severity', 'Severity'),
577+
[CodeIssueGroupingStrategy.FileLocation]: localize('AWS.amazonq.scans.fileLocation', 'File Location'),
578+
}
579+
580+
export class CodeIssueGroupingStrategyState {
581+
#fallback: CodeIssueGroupingStrategy
582+
#onDidChangeState = new vscode.EventEmitter<CodeIssueGroupingStrategy>()
583+
onDidChangeState = this.#onDidChangeState.event
584+
585+
static #instance: CodeIssueGroupingStrategyState
586+
static get instance() {
587+
return (this.#instance ??= new this())
588+
}
589+
590+
protected constructor(fallback: CodeIssueGroupingStrategy = defaultCodeIssueGroupingStrategy) {
591+
this.#fallback = fallback
592+
}
593+
594+
public getState(): CodeIssueGroupingStrategy {
595+
const state = globals.globalState.tryGet('aws.amazonq.codescan.groupingStrategy', String)
596+
return this.isValidGroupingStrategy(state) ? state : this.#fallback
597+
}
598+
599+
public async setState(_state: unknown) {
600+
const state = this.isValidGroupingStrategy(_state) ? _state : this.#fallback
601+
await globals.globalState.update('aws.amazonq.codescan.groupingStrategy', state)
602+
this.#onDidChangeState.fire(state)
603+
}
604+
605+
private isValidGroupingStrategy(strategy: unknown): strategy is CodeIssueGroupingStrategy {
606+
return Object.values(CodeIssueGroupingStrategy).includes(strategy as CodeIssueGroupingStrategy)
607+
}
608+
609+
public reset() {
610+
return this.setState(this.#fallback)
611+
}
612+
}
613+
567614
/**
568615
* Q - Transform
569616
*/

packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
*/
55
import * as vscode from 'vscode'
66
import path from 'path'
7-
import { CodeScanIssue, SecurityTreeViewFilterState, severities, Severity } from '../models/model'
7+
import {
8+
CodeIssueGroupingStrategy,
9+
CodeIssueGroupingStrategyState,
10+
CodeScanIssue,
11+
SecurityTreeViewFilterState,
12+
severities,
13+
Severity,
14+
} from '../models/model'
815
import globals from '../../shared/extensionGlobals'
916
import { getLogger } from '../../shared/logger'
1017
import { SecurityIssueProvider } from './securityIssueProvider'
@@ -34,6 +41,17 @@ export class SecurityIssueTreeViewProvider implements vscode.TreeDataProvider<Se
3441
}
3542

3643
public getChildren(element?: SecurityViewTreeItem | undefined): vscode.ProviderResult<SecurityViewTreeItem[]> {
44+
const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState()
45+
switch (groupingStrategy) {
46+
case CodeIssueGroupingStrategy.FileLocation:
47+
return this.getChildrenGroupedByFileLocation(element)
48+
case CodeIssueGroupingStrategy.Severity:
49+
default:
50+
return this.getChildrenGroupedBySeverity(element)
51+
}
52+
}
53+
54+
private getChildrenGroupedBySeverity(element: SecurityViewTreeItem | undefined) {
3755
const filterHiddenSeverities = (severity: Severity) =>
3856
!SecurityTreeViewFilterState.instance.getHiddenSeverities().includes(severity)
3957

@@ -64,6 +82,27 @@ export class SecurityIssueTreeViewProvider implements vscode.TreeDataProvider<Se
6482
return result
6583
}
6684

85+
private getChildrenGroupedByFileLocation(element: SecurityViewTreeItem | undefined) {
86+
const filterHiddenSeverities = (issue: CodeScanIssue) =>
87+
!SecurityTreeViewFilterState.instance.getHiddenSeverities().includes(issue.severity)
88+
89+
if (element instanceof FileItem) {
90+
return element.issues
91+
.filter(filterHiddenSeverities)
92+
.filter((issue) => issue.visible)
93+
.sort((a, b) => a.startLine - b.startLine)
94+
.map((issue) => new IssueItem(element.filePath, issue))
95+
}
96+
97+
const result = this.issueProvider.issues
98+
.filter((group) => group.issues.some(filterHiddenSeverities))
99+
.filter((group) => group.issues.some((issue) => issue.visible))
100+
.sort((a, b) => a.filePath.localeCompare(b.filePath))
101+
.map((group) => new FileItem(group.filePath, group.issues.filter(filterHiddenSeverities)))
102+
this._onDidChangeTreeData.fire(result)
103+
return result
104+
}
105+
67106
public refresh(): void {
68107
this._onDidChangeTreeData.fire()
69108
}
@@ -118,7 +157,8 @@ export class IssueItem extends vscode.TreeItem {
118157
public readonly issue: CodeScanIssue
119158
) {
120159
super(issue.title, vscode.TreeItemCollapsibleState.None)
121-
this.description = `${path.basename(this.filePath)} [Ln ${this.issue.startLine + 1}, Col 1]`
160+
this.description = this.getDescription()
161+
this.iconPath = this.getSeverityIcon()
122162
this.tooltip = this.getTooltipMarkdown()
123163
this.command = {
124164
title: 'Focus Issue',
@@ -132,6 +172,22 @@ export class IssueItem extends vscode.TreeItem {
132172
return globals.context.asAbsolutePath(`resources/images/severity-${this.issue.severity.toLowerCase()}.svg`)
133173
}
134174

175+
private getSeverityIcon() {
176+
const iconPath = globals.context.asAbsolutePath(
177+
`resources/icons/aws/amazonq/severity-${this.issue.severity.toLowerCase()}.svg`
178+
)
179+
const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState()
180+
return groupingStrategy !== CodeIssueGroupingStrategy.Severity ? iconPath : undefined
181+
}
182+
183+
private getDescription() {
184+
const positionStr = `[Ln ${this.issue.startLine + 1}, Col 1]`
185+
const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState()
186+
return groupingStrategy !== CodeIssueGroupingStrategy.FileLocation
187+
? `${path.basename(this.filePath)} ${positionStr}`
188+
: positionStr
189+
}
190+
135191
private getContextValue() {
136192
return this.issue.suggestedFixes.length === 0 || !this.issue.suggestedFixes[0].code
137193
? ContextValue.ISSUE_WITHOUT_FIX

packages/core/src/shared/globalState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type globalKey =
3232
| 'aws.amazonq.hasShownWalkthrough'
3333
| 'aws.amazonq.showTryChatCodeLens'
3434
| 'aws.amazonq.securityIssueFilters'
35+
| 'aws.amazonq.codescan.groupingStrategy'
3536
| 'aws.amazonq.notifications'
3637
| 'aws.amazonq.welcomeChatShowCount'
3738
| 'aws.amazonq.disclaimerAcknowledged'

0 commit comments

Comments
 (0)