diff --git a/.eslintrc.js b/.eslintrc.js index 5d196c59541..edfcaa2cb72 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,7 +12,7 @@ module.exports = { mocha: true, es2024: true, }, - plugins: ['@typescript-eslint', 'unicorn', 'header', 'security-node', 'aws-toolkits'], + plugins: ['@typescript-eslint', '@stylistic', 'unicorn', 'header', 'security-node', 'aws-toolkits'], extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/eslint-recommended', @@ -113,6 +113,20 @@ module.exports = { 'no-constant-condition': ['error', { checkLoops: false }], 'no-empty': 'off', + // https://eslint.style/rules/default/spaced-comment + // Require space after // comment. + '@stylistic/spaced-comment': [ + 'error', + 'always', + { + block: { + markers: ['!'], // Allow the /*!…*/ license header. + // exceptions: ['*'], + // balanced: true + }, + }, + ], + // Rules from https://github.com/sindresorhus/eslint-plugin-unicorn // TODO: 'unicorn/no-useless-promise-resolve-reject': 'error', // TODO: 'unicorn/prefer-at': 'error', @@ -144,6 +158,8 @@ module.exports = { 'unicorn/prefer-reflect-apply': 'error', 'unicorn/prefer-string-trim-start-end': 'error', 'unicorn/prefer-type-error': 'error', + // Discourage `.forEach` because it can lead to accidental, incorrect use of async callbacks. + 'unicorn/no-array-for-each': 'error', 'security-node/detect-child-process': 'error', 'header/header': [ @@ -185,8 +201,20 @@ module.exports = { name: 'fs', message: 'Avoid node:fs and use shared/fs/fs.ts when possible.', }, + { + name: 'child_process', + message: + 'Avoid child_process, use ChildProcess from `shared/utilities/processUtils.ts` instead.', + }, + { + name: '..', + message: + 'Avoid importing from index.ts files as it can lead to circular dependencies. Import from the module directly instead.', + }, ], }, ], + + 'prettier/prettier': ['error', { endOfLine: 'auto' }], }, } diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 929388bc246..3b891b24003 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,5 +8,4 @@ - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - -License: I confirm that my contribution is made under the terms of the Apache 2.0 license. +- License: I confirm that my contribution is made under the terms of the Apache 2.0 license. diff --git a/.github/workflows/copyPasteDetection.yml b/.github/workflows/copyPasteDetection.yml deleted file mode 100644 index ad78e409fca..00000000000 --- a/.github/workflows/copyPasteDetection.yml +++ /dev/null @@ -1,86 +0,0 @@ -# # github actions: https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-nodejs -# # setup-node: https://github.com/actions/setup-node - -# name: Copy-Paste Detection - -# on: -# pull_request: -# branches: [master, feature/*, staging] - -# jobs: -# jscpd: -# runs-on: ubuntu-latest -# strategy: -# matrix: -# node-version: [18.x] -# env: -# NODE_OPTIONS: '--max-old-space-size=8192' - -# steps: -# - uses: actions/checkout@v4 -# with: -# fetch-depth: 0 - -# - name: Use Node.js ${{ matrix.node-version }} -# uses: actions/setup-node@v4 -# with: -# node-version: ${{ matrix.node-version }} - -# - name: Fetch fork upstream -# run: | -# git remote add forkUpstream https://github.com/${{ github.event.pull_request.head.repo.full_name }} # URL of the fork -# git fetch forkUpstream # Fetch fork - -# - name: Determine base and target branches for comparison. -# run: | -# echo "CURRENT_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV -# echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV -# - run: git diff --name-only origin/$TARGET_BRANCH forkUpstream/$CURRENT_BRANCH > diff_output.txt -# - run: | -# npm install -g jscpd - -# - run: jscpd --config "$GITHUB_WORKSPACE/.github/workflows/jscpd.json" - -# - if: always() -# uses: actions/upload-artifact@v4 -# with: -# name: unfiltered-jscpd-report -# path: ./jscpd-report.json - -# - name: Filter jscpd report for changed files -# run: | -# if [ ! -f ./jscpd-report.json ]; then -# echo "jscpd-report.json not found" -# exit 1 -# fi -# echo "Filtering jscpd report for changed files..." -# CHANGED_FILES=$(jq -R -s -c 'split("\n")[:-1]' diff_output.txt) -# echo "Changed files: $CHANGED_FILES" -# jq --argjson changed_files "$CHANGED_FILES" ' -# .duplicates | map(select( -# (.firstFile?.name as $fname | $changed_files | any(. == $fname)) or -# (.secondFile?.name as $sname | $changed_files | any(. == $sname)) -# )) -# ' ./jscpd-report.json > filtered-jscpd-report.json -# cat filtered-jscpd-report.json - -# - name: Check for duplicates -# run: | -# if [ $(wc -l < ./filtered-jscpd-report.json) -gt 1 ]; then -# echo "filtered_report_exists=true" >> $GITHUB_ENV -# else -# echo "filtered_report_exists=false" >> $GITHUB_ENV -# fi -# - name: upload filtered report (if applicable) -# if: env.filtered_report_exists == 'true' -# uses: actions/upload-artifact@v4 -# with: -# name: filtered-jscpd-report -# path: ./filtered-jscpd-report.json - -# - name: Fail and log found duplicates. -# if: env.filtered_report_exists == 'true' -# run: | -# cat ./filtered-jscpd-report.json -# echo "Duplications found, failing the check." -# exit 1 diff --git a/.github/workflows/filterDuplicates.js b/.github/workflows/filterDuplicates.js new file mode 100644 index 00000000000..2bb9d440cb5 --- /dev/null +++ b/.github/workflows/filterDuplicates.js @@ -0,0 +1,294 @@ +/** + * Filters the report produced by jscpd to only include clones that involve changes from the given git diff. + * If the filtered report is non-empty, i.e. there exists a clone in the changes, + * the program exits with an error and logs the filtered report to console. + * + * Usage: + * node filterDuplicates.js run [path_to_git_diff] [path_to_jscpd_report] [commit_hash] [repo_name] + * + * Tests: + * node filterDuplicates.js test + */ + +const fs = require('fs/promises') +const path = require('path') + +function parseDiffFilePath(filePathLine) { + return filePathLine.split(' ')[2].split('/').slice(1).join('/') +} + +function parseDiffRange(rangeLine) { + const [_fromRange, toRange] = rangeLine.split(' ').slice(1, 3) + const [startLine, numLines] = toRange.slice(1).split(',').map(Number) + const range = [startLine, startLine + numLines] + return range +} + +async function parseDiff(diffPath) { + const diff = await fs.readFile(diffPath, 'utf8') + const lines = diff.split('\n') + let currentFile = null + let currentFileChanges = [] + const fileChanges = new Map() + + for (const line of lines) { + if (line.startsWith('diff')) { + if (currentFile) { + fileChanges.set(currentFile, currentFileChanges) + } + currentFile = parseDiffFilePath(line) + currentFileChanges = [] + } + if (line.startsWith('@@')) { + currentFileChanges.push(parseDiffRange(line)) + } + } + + fileChanges.set(currentFile, currentFileChanges) + + return fileChanges +} + +function doesOverlap(range1, range2) { + const [start1, end1] = range1 + const [start2, end2] = range2 + return ( + (start1 >= start2 && start1 <= end2) || (end1 >= start2 && end1 <= end2) || (start2 >= start1 && end2 <= end1) + ) +} + +function isCloneInChanges(changes, cloneInstance) { + const fileName = cloneInstance.name + const cloneStart = cloneInstance.start + const cloneEnd = cloneInstance.end + const lineChangeRanges = changes.get(fileName) + + if (!lineChangeRanges) { + return false + } + + return lineChangeRanges.some((range) => doesOverlap([cloneStart, cloneEnd], range)) +} + +function isInChanges(changes, dupe) { + return isCloneInChanges(changes, dupe.firstFile) || isCloneInChanges(changes, dupe.secondFile) +} + +function filterDuplicates(report, changes) { + duplicates = [] + for (const dupe of report.duplicates) { + if (isInChanges(changes, dupe)) { + duplicates.push(dupe) + } + } + return duplicates +} + +function formatDuplicates(duplicates, commitHash, repoName) { + const baseUrl = `https://github.com/${repoName}` + return duplicates.map((dupe) => { + return { + first: formUrl(dupe.firstFile, commitHash), + second: formUrl(dupe.secondFile, commitHash), + numberOfLines: dupe.lines, + } + }) + function formUrl(file, commitHash) { + return `${baseUrl}/blob/${commitHash}/${file.name}#L${file.start}-L${file.end}` + } +} + +async function run() { + const rawDiffPath = process.argv[3] + const jscpdReportPath = process.argv[4] + const commitHash = process.argv[5] + const repoName = process.argv[6] + const changes = await parseDiff(rawDiffPath) + const jscpdReport = JSON.parse(await fs.readFile(jscpdReportPath, 'utf8')) + const filteredDuplicates = filterDuplicates(jscpdReport, changes) + + console.log('%s files changes', changes.size) + console.log('%s duplicates found', filteredDuplicates.length) + if (filteredDuplicates.length > 0) { + console.log(formatDuplicates(filteredDuplicates, commitHash, repoName)) + process.exit(1) + } +} + +/** + * Mini-test Suite + */ +const testDiffFile = path.resolve(__dirname, 'test/test_diff.txt') +let testCounter = 0 +function assertEqual(actual, expected) { + if (actual !== expected) { + throw new Error(`Expected ${expected} but got ${actual}`) + } + testCounter += 1 +} + +async function test() { + test_parseDiffFilePath() + test_parseDiffRange() + test_doesOverlap() + await test_parseDiff() + await test_isCloneInChanges() + await test_isInChanges() + await test_filterDuplicates() + console.log('All tests passed (%s)', testCounter) +} + +function test_parseDiffFilePath() { + assertEqual( + parseDiffFilePath( + 'diff --git a/.github/workflows/copyPasteDetection.yml b/.github/workflows/copyPasteDetection.yml' + ), + '.github/workflows/copyPasteDetection.yml' + ) + assertEqual( + parseDiffFilePath('diff --git a/.github/workflows/filterDuplicates.js b/.github/workflows/filterDuplicates.js'), + '.github/workflows/filterDuplicates.js' + ) +} + +function test_parseDiffRange() { + assertEqual(parseDiffRange('@@ -1,4 +1,4 @@').join(','), '1,5') + assertEqual(parseDiffRange('@@ -10,4 +10,4 @@').join(','), '10,14') + assertEqual(parseDiffRange('@@ -10,4 +10,5 @@').join(','), '10,15') +} + +function test_doesOverlap() { + assertEqual(doesOverlap([1, 5], [2, 4]), true) + assertEqual(doesOverlap([2, 3], [2, 4]), true) + assertEqual(doesOverlap([2, 3], [1, 4]), true) + assertEqual(doesOverlap([1, 5], [5, 6]), true) + assertEqual(doesOverlap([1, 5], [6, 7]), false) + assertEqual(doesOverlap([6, 7], [1, 5]), false) + assertEqual(doesOverlap([2, 5], [4, 5]), true) +} + +async function test_parseDiff() { + const changes = await parseDiff(testDiffFile) + assertEqual(changes.size, 2) + assertEqual(changes.get('.github/workflows/copyPasteDetection.yml').length, 1) + assertEqual(changes.get('.github/workflows/filterDuplicates.js').length, 1) + assertEqual(changes.get('.github/workflows/filterDuplicates.js')[0].join(','), '1,86') + assertEqual(changes.get('.github/workflows/copyPasteDetection.yml')[0].join(','), '26,73') +} + +async function test_isCloneInChanges() { + const changes = await parseDiff(testDiffFile) + assertEqual( + isCloneInChanges(changes, { + name: '.github/workflows/filterDuplicates.js', + start: 1, + end: 86, + }), + true + ) + assertEqual( + isCloneInChanges(changes, { + name: '.github/workflows/filterDuplicates.js', + start: 80, + end: 95, + }), + true + ) + assertEqual( + isCloneInChanges(changes, { + name: '.github/workflows/filterDuplicates.js', + start: 87, + end: 95, + }), + false + ) + assertEqual( + isCloneInChanges(changes, { + name: 'some-fake-file', + start: 1, + end: 100, + }), + false + ) +} + +async function test_isInChanges() { + const changes = await parseDiff(testDiffFile) + const dupe = { + firstFile: { + name: '.github/workflows/filterDuplicates.js', + start: 1, + end: 86, + }, + secondFile: { + name: '.github/workflows/filterDuplicates.js', + start: 80, + end: 95, + }, + } + assertEqual(isInChanges(changes, dupe), true) + dupe.secondFile.start = 87 + assertEqual(isInChanges(changes, dupe), true) + dupe.firstFile.name = 'some-fake-file' + assertEqual(isInChanges(changes, dupe), false) +} + +async function test_filterDuplicates() { + assertEqual( + filterDuplicates( + { + duplicates: [ + { + firstFile: { + name: '.github/workflows/filterDuplicates.js', + start: 1, + end: 86, + }, + secondFile: { + name: '.github/workflows/filterDuplicates.js', + start: 80, + end: 95, + }, + }, + ], + }, + await parseDiff(testDiffFile) + ).length, + 1 + ) + assertEqual( + filterDuplicates( + { + duplicates: [ + { + firstFile: { + name: 'some-other-file', + start: 1, + end: 86, + }, + secondFile: { + name: '.github/workflows/filterDuplicates.js', + start: 90, + end: 95, + }, + }, + ], + }, + await parseDiff(testDiffFile) + ).length, + 0 + ) +} + +async function main() { + const mode = process.argv[2] + if (mode === 'run') { + await run() + } else if (mode === 'test') { + await test() + } else { + throw new Error('Invalid mode') + } +} + +void main() diff --git a/.github/workflows/lintbranch.js b/.github/workflows/lintbranch.js new file mode 100644 index 00000000000..05fc677dac5 --- /dev/null +++ b/.github/workflows/lintbranch.js @@ -0,0 +1,67 @@ +// Check that branch name conforms to GitHub naming convention: +// https://docs.github.com/en/get-started/using-git/dealing-with-special-characters-in-branch-and-tag-names#naming-branches-and-tags + +// To run self-tests, +// node lintbranch.js test +// TODO: deduplicate code from lintbranch.js and lintcommit.js. + +function isValid(branchName) { + const branchNameRegex = /^[a-zA-Z][a-zA-Z0-9._/-]*$/ + + return branchNameRegex.test(branchName) +} + +function run(branchName) { + if (isValid(branchName)) { + console.log(`Branch name "${branchName}" is valid.`) + process.exit(0) + } else { + const helpUrl = + 'https://docs.github.com/en/get-started/using-git/dealing-with-special-characters-in-branch-and-tag-names#naming-branches-and-tags' + console.log(`Branch name "${branchName}" is invalid see ${helpUrl} for more information.`) + process.exit(1) + } +} + +function _test() { + const tests = { + 'feature/branch-name': true, + feature_123: true, + 'my-branch': true, + '123invalid-start': false, + '!invalid@start': false, + '': false, + 'another/valid-name134': true, + 'feature/123";id;{echo,Y2F0IC9ldGMvcGFzc3dk}|{base64,-d}|{bash,-i};#': false, + } + + let passed = 0 + let failed = 0 + + for (const [branchName, expected] of Object.entries(tests)) { + const result = isValid(branchName) + if (result === expected) { + console.log(`✅ Test passed for "${branchName}"`) + passed++ + } else { + console.log(`❌ Test failed for "${branchName}" (expected "${expected}", got "${result}")`) + failed++ + } + } + + console.log(`\n${passed} tests passed, ${failed} tests failed`) +} + +function main() { + const mode = process.argv[2] + + if (mode === 'test') { + _test() + } else if (mode === 'run') { + run(process.argv[3]) + } else { + throw new Error(`Unknown mode: ${mode}`) + } +} + +main() diff --git a/.github/workflows/lintcommit.js b/.github/workflows/lintcommit.js index 9e3f8b40406..4f329223eef 100644 --- a/.github/workflows/lintcommit.js +++ b/.github/workflows/lintcommit.js @@ -130,6 +130,7 @@ Invalid pull request title: \`${title}\` * scope: lowercase, <30 chars * subject: must be <100 chars * documentation: https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#pull-request-title +* Hint: *close and re-open the PR* to re-trigger CI (after fixing the PR title). ` : `Pull request title matches the [expected format](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#pull-request-title).` diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 7a8f5c4323e..0cc8025125d 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -7,15 +7,10 @@ on: push: branches: [master, staging] pull_request: - branches: [master, feature/*, staging] - # Default = opened + synchronize + reopened. - # We also want "edited" so that lint-commits runs when PR title is updated. + # By default, CI will trigger on opened/synchronize/reopened event types. # https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request - types: - - edited - - opened - - reopened - - synchronize + # Note: To re-run `lint-commits` after fixing the PR title, close-and-reopen the PR. + branches: [master, feature/*, staging] # Cancel old jobs when a pull request is updated. concurrency: @@ -24,6 +19,7 @@ concurrency: jobs: lint-commits: + # Note: To re-run `lint-commits` after fixing the PR title, close-and-reopen the PR. runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -32,6 +28,12 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '20' + - name: Validate Branch name + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref != ''}} + env: + BRANCH_NAME: ${{ github.event.pull_request.head.ref }} + run: | + node "$GITHUB_WORKSPACE/.github/workflows/lintbranch.js" run "$BRANCH_NAME" - name: Check PR title run: | node "$GITHUB_WORKSPACE/.github/workflows/lintcommit.js" @@ -55,6 +57,55 @@ jobs: - run: npm run testCompile - run: npm run lint + jscpd: + needs: lint-commits + if: ${{ github.event_name == 'pull_request'}} + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x] + env: + NODE_OPTIONS: '--max-old-space-size=8192' + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Fetch fork upstream + env: + REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }} + run: | + git remote add forkUpstream https://github.com/$REPO_NAME # URL of the fork + git fetch forkUpstream # Fetch fork + + - name: Compute git diff + env: + CURRENT_BRANCH: ${{ github.head_ref }} + TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} + run: git diff origin/$TARGET_BRANCH forkUpstream/$CURRENT_BRANCH > diff_output.txt + + - run: npm install -g jscpd + + - run: jscpd --config "$GITHUB_WORKSPACE/.github/workflows/jscpd.json" + + - if: always() + uses: actions/upload-artifact@v4 + with: + name: unfiltered-jscpd-report + path: ./jscpd-report.json + + - name: Check for Duplicates + env: + COMMIT_HASH: ${{ github.sha}} + REPO_NAME: ${{ github.repository }} + run: node "$GITHUB_WORKSPACE/.github/workflows/filterDuplicates.js" run diff_output.txt jscpd-report.json $COMMIT_HASH $REPO_NAME + macos: needs: lint-commits name: test macOS diff --git a/.github/workflows/notification.yml b/.github/workflows/notification.yml index ea37b04e0d5..17f0327e5c1 100644 --- a/.github/workflows/notification.yml +++ b/.github/workflows/notification.yml @@ -6,15 +6,10 @@ name: Notifications on: # `pull_request_target` (as opposed to `pull_request`) gives permissions to comment on PRs. pull_request_target: - branches: [master, feature/*, staging] - # Default = opened + synchronize + reopened. - # We also want "edited" so that changelog notifications runs when PR title is updated. + # By default, CI will trigger on opened/synchronize/reopened event types. # https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request - types: - - edited - - opened - - reopened - - synchronize + # Note: To re-run `lint-commits` after fixing the PR title, close-and-reopen the PR. + branches: [master, feature/*, staging] # Cancel old jobs when a pull request is updated. concurrency: diff --git a/.github/workflows/test/test_diff.txt b/.github/workflows/test/test_diff.txt new file mode 100644 index 00000000000..9614e902a5e --- /dev/null +++ b/.github/workflows/test/test_diff.txt @@ -0,0 +1,185 @@ +diff --git a/.github/workflows/copyPasteDetection.yml b/.github/workflows/copyPasteDetection.yml +index 793337de5..746b3cecd 100644 +--- a/.github/workflows/copyPasteDetection.yml ++++ b/.github/workflows/copyPasteDetection.yml +@@ -26,61 +26,47 @@ jobs: + with: + node-version: ${{ matrix.node-version }} + ++ - name: Determine if local ++ run: echo "IS_LOCAL=false" >> $GITHUB_ENV ++ + - name: Fetch fork upstream ++ if: ${{ env.IS_LOCAL == 'false' }} + run: | + git remote add forkUpstream https://github.com/${{ github.event.pull_request.head.repo.full_name }} # URL of the fork + git fetch forkUpstream # Fetch fork + + - name: Determine base and target branches for comparison. + run: | +- echo "CURRENT_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV +- echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV +- - run: git diff --name-only origin/$TARGET_BRANCH forkUpstream/$CURRENT_BRANCH > diff_output.txt +- - run: | +- npm install -g jscpd ++ if [[ $IS_LOCAL == 'false' ]]; then ++ echo "CURRENT_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV ++ echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV ++ else ++ echo "CURRENT_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV ++ echo "TARGET_BRANCH=master" >> $GITHUB_ENV ++ fi ++ ++ - name: Print base and target branches for comparison. ++ run: | ++ echo "CURRENT_BRANCH=$CURRENT_BRANCH" ++ echo "TARGET_BRANCH=$TARGET_BRANCH" ++ ++ - name: Compare target and current branches. ++ run: | ++ if [[ $IS_LOCAL == 'false' ]]; then ++ git diff origin/$TARGET_BRANCH forkUpstream/$CURRENT_BRANCH > diff_output.txt ++ else ++ git diff origin/$TARGET_BRANCH $CURRENT_BRANCH > diff_output.txt ++ fi ++ ++ - run: npm install -g jscpd + + - run: jscpd --config "$GITHUB_WORKSPACE/.github/workflows/jscpd.json" + +- - if: always() ++ - if: ${{ env.IS_LOCAL == 'false' }} + uses: actions/upload-artifact@v4 + with: + name: unfiltered-jscpd-report + path: ./jscpd-report.json + +- - name: Filter jscpd report for changed files +- run: | +- if [ ! -f ./jscpd-report.json ]; then +- echo "jscpd-report.json not found" +- exit 1 +- fi +- echo "Filtering jscpd report for changed files..." +- CHANGED_FILES=$(jq -R -s -c 'split("\n")[:-1]' diff_output.txt) +- echo "Changed files: $CHANGED_FILES" +- jq --argjson changed_files "$CHANGED_FILES" ' +- .duplicates | map(select( +- (.firstFile?.name as $fname | $changed_files | any(. == $fname)) or +- (.secondFile?.name as $sname | $changed_files | any(. == $sname)) +- )) +- ' ./jscpd-report.json > filtered-jscpd-report.json +- cat filtered-jscpd-report.json +- + - name: Check for duplicates +- run: | +- if [ $(wc -l < ./filtered-jscpd-report.json) -gt 1 ]; then +- echo "filtered_report_exists=true" >> $GITHUB_ENV +- else +- echo "filtered_report_exists=false" >> $GITHUB_ENV +- fi +- - name: upload filtered report (if applicable) +- if: env.filtered_report_exists == 'true' +- uses: actions/upload-artifact@v4 +- with: +- name: filtered-jscpd-report +- path: ./filtered-jscpd-report.json +- +- - name: Fail and log found duplicates. +- if: env.filtered_report_exists == 'true' +- run: | +- cat ./filtered-jscpd-report.json +- echo "Duplications found, failing the check." +- exit 1 ++ run: node "$GITHUB_WORKSPACE/.github/workflows/filterDuplicates.js" diff_output.txt jscpd-report.json +diff --git a/.github/workflows/filterDuplicates.js b/.github/workflows/filterDuplicates.js +new file mode 100644 +index 000000000..b2f1e913e +--- /dev/null ++++ b/.github/workflows/filterDuplicates.js +@@ -0,0 +1,85 @@ ++const fs = require('fs/promises') ++ ++function parseDiffFilePath(filePathLine) { ++ return filePathLine.split(' ')[2].split('/').slice(1).join('/') ++} ++ ++function parseDiffRange(rangeLine) { ++ const [_fromRange, toRange] = rangeLine.split(' ').slice(1, 3) ++ const [startLine, numLines] = toRange.slice(1).split(',').map(Number) ++ const range = [startLine, startLine + numLines] ++ return range ++} ++ ++async function parseDiff(diffPath) { ++ const diff = await fs.readFile(diffPath, 'utf8') ++ const lines = diff.split('\n') ++ let currentFile = null ++ let currentFileChanges = [] ++ const fileChanges = new Map() ++ ++ for (const line of lines) { ++ if (line.startsWith('diff')) { ++ if (currentFile) { ++ fileChanges.set(currentFile, currentFileChanges) ++ } ++ currentFile = parseDiffFilePath(line) ++ currentFileChanges = [] ++ } ++ if (line.startsWith('@@')) { ++ currentFileChanges.push(parseDiffRange(line)) ++ } ++ } ++ ++ return fileChanges ++} ++ ++function doesOverlap(range1, range2) { ++ const [start1, end1] = range1 ++ const [start2, end2] = range2 ++ return (start1 >= start2 && start1 <= end2) || (end1 >= start2 && end1 <= end2) ++} ++ ++function isCloneInChanges(changes, cloneInstance) { ++ const fileName = cloneInstance.name ++ const cloneStart = cloneInstance.start ++ const cloneEnd = cloneInstance.end ++ const lineChangeRanges = changes.get(fileName) ++ ++ if (!lineChangeRanges) { ++ return false ++ } ++ ++ return lineChangeRanges.some((range) => doesOverlap([cloneStart, cloneEnd], range)) ++} ++ ++function isInChanges(changes, dupe) { ++ return isCloneInChanges(changes, dupe.firstFile) || isCloneInChanges(changes, dupe.secondFile) ++} ++ ++function filterDuplicates(report, changes) { ++ duplicates = [] ++ for (const dupe of report.duplicates) { ++ if (isInChanges(changes, dupe)) { ++ duplicates.push(dupe) ++ } ++ } ++ return duplicates ++} ++ ++async function main() { ++ const rawDiffPath = process.argv[2] ++ const jscpdReportPath = process.argv[3] ++ const changes = await parseDiff(rawDiffPath) ++ const jscpdReport = JSON.parse(await fs.readFile(jscpdReportPath, 'utf8')) ++ const filteredDuplicates = filterDuplicates(jscpdReport, changes) ++ ++ console.log(filteredDuplicates) ++ console.log('%s files changes', changes.size) ++ console.log('%s duplicates found', filteredDuplicates.length) ++ if (filteredDuplicates.length > 0) { ++ process.exit(1) ++ } ++} ++ ++void main() diff --git a/.gitignore b/.gitignore index eeb136eea4a..596af538b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -47,7 +47,6 @@ packages/amazonq/package.nls.json packages/amazonq/resources # Icons -packages/*/resources/icons/cloud9/generated/** packages/*/resources/fonts/aws-toolkit-icons.woff packages/*/resources/css/icons.css diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4dba3042e2..087d6e838ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -633,7 +633,7 @@ If you are contribuing visual assets from other open source repos, the source re ## Using new vscode APIs The minimum required vscode version specified in [package.json](https://github.com/aws/aws-toolkit-vscode/blob/07119655109bb06105a3f53bbcd86b812b32cdbe/package.json#L16) -is decided by the version of vscode running in Cloud9 and other vscode-compatible targets. +is decided by the version of vscode running in other supported vscode-compatible targets (e.g. web). But you can still use the latest vscode APIs, by checking the current running vscode version. For example, to use a vscode 1.64 API: diff --git a/README.md b/README.md index c53bfa121f7..bd9f82fa04d 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,11 @@ AWS Toolkit is a [VS Code extension](https://marketplace.visualstudio.com/itemde - Connect with [IAM credentials](https://docs.aws.amazon.com/sdkref/latest/guide/access-users.html), [IAM Identity Center (SSO)](https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html), or [AWS Builder ID](https://docs.aws.amazon.com/signin/latest/userguide/differences-aws_builder_id.html) +- Connect VSCode to your EC2 instances - Connect to your [CodeCatalyst](https://codecatalyst.aws/) Dev Environments - Debug your Lambda functions using [SAM CLI](https://github.com/aws/aws-sam-cli) - Check and autocomplete code in SAM/CFN (CloudFormation) `template.yaml` files -- `Open Terminal` on your ECS tasks +- `Open Terminal` on your EC2 instances or ECS tasks - `Search Log Group` on your CloudWatch logs - Browse your AWS resources diff --git a/buildspec/linuxE2ETests.yml b/buildspec/linuxE2ETests.yml index e5e984e220b..def6dfd1cd0 100644 --- a/buildspec/linuxE2ETests.yml +++ b/buildspec/linuxE2ETests.yml @@ -37,7 +37,7 @@ phases: commands: - export HOME=/home/codebuild-user # Ignore failure until throttling issues are fixed. - - xvfb-run npm run testE2E || true + - xvfb-run npm run testE2E; npm run mergeReports -- "$?" - VCS_COMMIT_ID="${CODEBUILD_RESOLVED_SOURCE_VERSION}" - CI_BUILD_URL=$(echo $CODEBUILD_BUILD_URL | sed 's/#/%23/g') - CI_BUILD_ID="${CODEBUILD_BUILD_ID}" diff --git a/buildspec/linuxIntegrationTests.yml b/buildspec/linuxIntegrationTests.yml index 49481c5f8f0..dacab125b89 100644 --- a/buildspec/linuxIntegrationTests.yml +++ b/buildspec/linuxIntegrationTests.yml @@ -51,6 +51,27 @@ phases: # Ensure that "docker" group has permissions to the socket. # - chown codebuild-user /var/run/docker.sock - chmod 666 /var/run/docker.sock + # Pull Docker Images for SAM tests + + # Nodejs + - | + docker pull public.ecr.aws/sam/build-nodejs18.x:latest + docker pull public.ecr.aws/sam/build-nodejs20.x:latest + docker pull public.ecr.aws/sam/build-nodejs22.x:latest + # Java + - | + docker pull public.ecr.aws/sam/build-java8.al2:latest + docker pull public.ecr.aws/sam/build-java11:latest + docker pull public.ecr.aws/sam/build-java17:latest + # Python + - | + docker pull public.ecr.aws/sam/build-python3.10:latest + docker pull public.ecr.aws/sam/build-python3.11:latest + docker pull public.ecr.aws/sam/build-python3.12:latest + docker pull public.ecr.aws/sam/build-python3.13:latest + # Dotnet + - | + docker pull public.ecr.aws/sam/build-dotnet6:latest pre_build: commands: @@ -71,7 +92,7 @@ phases: build: commands: - export HOME=/home/codebuild-user - - xvfb-run npm run testInteg + - xvfb-run npm run testInteg; npm run mergeReports -- "$?" - VCS_COMMIT_ID="${CODEBUILD_RESOLVED_SOURCE_VERSION}" - CI_BUILD_URL=$(echo $CODEBUILD_BUILD_URL | sed 's/#/%23/g') - CI_BUILD_ID="${CODEBUILD_BUILD_ID}" diff --git a/buildspec/linuxTests.yml b/buildspec/linuxTests.yml index d8fff088c2f..900b720e61a 100644 --- a/buildspec/linuxTests.yml +++ b/buildspec/linuxTests.yml @@ -41,7 +41,7 @@ phases: # Ensure that "foo | run_and_report" fails correctly. set -o pipefail . buildspec/shared/common.sh - 2>&1 xvfb-run npm test --silent | run_and_report 2 \ + { 2>&1 xvfb-run npm test --silent; npm run mergeReports -- "$?"; } | run_and_report 2 \ 'rejected promise not handled' \ 'This typically indicates a bug. Read https://developer.mozilla.org/docs/Web/JavaScript/Guide/Using_promises#error_handling' } diff --git a/buildspec/shared/common.sh b/buildspec/shared/common.sh index cce614e4406..a0dbc2e5837 100644 --- a/buildspec/shared/common.sh +++ b/buildspec/shared/common.sh @@ -8,7 +8,7 @@ # - "waiting for browser": from `ssoAccessTokenProvider.test.ts`, unclear how to fix it. # - "HTTPError: Response code …": caused by github rate-limiting. # - "npm WARN deprecated querystring": transitive dep of aws sdk v2 (check `npm ls querystring`), so that's blocked until we migrate to v3. -_ignore_pat='Timed-out waiting for browser login flow\|HTTPError: Response code 403\|HTTPError: Response code 404\|npm WARN deprecated querystring\|npm WARN deprecated' +_ignore_pat='HTTPError: Response code 403\|HTTPError: Response code 404\|npm WARN deprecated querystring\|npm WARN deprecated' # Do not print (noisy) lines matching these patterns. # - "ERROR:bus… Failed to connect to the bus": noise related to "xvfb". https://github.com/cypress-io/cypress/issues/19299 diff --git a/cloud9-toolkit-install.sh b/cloud9-toolkit-install.sh deleted file mode 100644 index 8b85a56a63a..00000000000 --- a/cloud9-toolkit-install.sh +++ /dev/null @@ -1,139 +0,0 @@ -#!/bin/env bash - -# By default, this script gets the latest VSIX from: -# https://github.com/aws/aws-toolkit-vscode/releases/ -# else the first argument must be a URL or file pointing to a toolkit VSIX or -# ZIP (containing a VSIX). -# -# USAGE: -# cloud9-toolkit-install.sh [URL|FILE] -# curl -LO https://raw.githubusercontent.com/aws/aws-toolkit-vscode/master/cloud9-toolkit-install.sh && bash cloud9-toolkit-install.sh -# EXAMPLES: -# cloud9-toolkit-install.sh https://github.com/aws/aws-toolkit-vscode/releases/download/v1.24.0/aws-toolkit-vscode-1.24.0.vsix -# cloud9-toolkit-install.sh toolkit.zip - -set -eu - -# Script currently depends on $HOME so running as root is not supported. -if [ "$(whoami)" = root ]; then - echo "cannot run as root" - exit 1 -fi - -_log() { - echo >&2 "$@" -} - -# Runs whatever is passed. -# -# On failure: -# - prints the command output -# - exits the script -_run() { - local out - if ! out="$("$@" 2>&1)"; then - _log "Command failed (output below): '${*}'" - echo "$out" | sed 's/^/ /' - _log "Command failed (output above): '${*}'" - exit 1 - fi -} - -# Gets the first existing directory, or exits if none are found. -_any_dir() { - for d in "$@"; do - if test -d "$d"; then - echo "$d" - return - fi - done - - _log "error: None of the expected dirs exist:" - for d in "$@"; do - _log " $d" - done - exit 1 -} - -_setglobals() { - # Example: - # https://github.com/aws/aws-toolkit-vscode/releases/tag/v1.24.0 - TOOLKIT_LATEST_RELEASE_URL="$(curl -Ls -o /dev/null -w '%{url_effective}' 'https://github.com/aws/aws-toolkit-vscode/releases/latest')" - # Example: - # 1.24.0 - TOOLKIT_LATEST_VERSION="$(echo "$TOOLKIT_LATEST_RELEASE_URL" | grep -oh '[0-9]\+\.[0-9]\+\.[0-9]\+$')" - # Example: - # https://github.com/aws/aws-toolkit-vscode/releases/download/v1.24.0/aws-toolkit-vscode-1.24.0.vsix - TOOLKIT_LATEST_ARTIFACT_URL="https://github.com/aws/aws-toolkit-vscode/releases/download/v${TOOLKIT_LATEST_VERSION}/aws-toolkit-vscode-${TOOLKIT_LATEST_VERSION}.vsix" - # URL or local filepath pointing to toolkit VSIX or ZIP (containing a VSIX). - TOOLKIT_FILE=${1:-} - TOOLKIT_INSTALL_PARENT="$(_any_dir "/projects/.c9/aws-toolkit-vscode" "$HOME/.c9/dependencies/aws-toolkit-vscode" "$HOME/environment/.c9/extensions" "$HOME/.c9/aws-toolkit-vscode")" - # Hash name is 128 chars long. - TOOLKIT_INSTALL_DIR="$(_any_dir "$(realpath ${TOOLKIT_INSTALL_PARENT}/????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????)" "$(realpath ${TOOLKIT_INSTALL_PARENT}/extension)")" - SCRIPT_WORKDIR="$HOME/toolkit" -} - -_main() { - ( - if test -f "$TOOLKIT_FILE"; then - # Ensure full path (before `cd` below). - TOOLKIT_FILE="$(readlink -f "$TOOLKIT_FILE")" - fi - - echo "Script will DELETE these directories:" - echo " ${TOOLKIT_INSTALL_DIR}" - echo " ${SCRIPT_WORKDIR}" - read -n1 -r -p "ENTER to continue, CTRL-c to cancel" - rm -rf "${TOOLKIT_INSTALL_DIR}.old" - mv "$TOOLKIT_INSTALL_DIR" "${TOOLKIT_INSTALL_DIR}.old" - rm -rf "$SCRIPT_WORKDIR" - - cd "$HOME/" - mkdir -p "$SCRIPT_WORKDIR" - mkdir -p "$TOOLKIT_INSTALL_PARENT" - cd "${SCRIPT_WORKDIR}" - - # Set default URL if no argument was provided. - if test -z "$TOOLKIT_FILE"; then - _log "Latest release:" - _log " URL : $TOOLKIT_LATEST_RELEASE_URL" - _log " version: $TOOLKIT_LATEST_VERSION" - _log " VSIX : $TOOLKIT_LATEST_ARTIFACT_URL" - TOOLKIT_FILE="$TOOLKIT_LATEST_ARTIFACT_URL" - fi - - TOOLKIT_FILE_EXTENSION="${TOOLKIT_FILE: -4}" - if test -f "$TOOLKIT_FILE"; then - _log "Local file (not URL): ${TOOLKIT_FILE}" - if [ "$TOOLKIT_FILE_EXTENSION" = ".zip" ] || [ "$TOOLKIT_FILE_EXTENSION" = ".ZIP" ]; then - _log 'File is a .zip file' - _run unzip -- "$TOOLKIT_FILE" - _run unzip -- *.vsix - else - _log 'File is not .zip file, assuming .vsix' - _run unzip -- "$TOOLKIT_FILE" - fi - else - _log "URL: ${TOOLKIT_FILE}" - _log 'Deleting toolkit.zip' - rm -rf toolkit.zip - _log 'Downloading...' - curl -o toolkit.zip -L "$TOOLKIT_FILE" - if [ "$TOOLKIT_FILE_EXTENSION" = ".zip" ] || [ "$TOOLKIT_FILE_EXTENSION" = ".ZIP" ]; then - _log 'File is a .zip file' - _run unzip -- toolkit.zip - _run unzip -- *.vsix - else - _log 'File is not .zip file, assuming .vsix' - _run unzip -- toolkit.zip - fi - fi - - mv extension "$TOOLKIT_INSTALL_DIR" - _log "Toolkit installed to: $TOOLKIT_INSTALL_DIR" - _log "Refresh Cloud9 to load it" - ) -} - -_setglobals "$@" -_main diff --git a/codecov.yml b/codecov.yml index 6da7d75aec2..1e348859f11 100644 --- a/codecov.yml +++ b/codecov.yml @@ -81,11 +81,6 @@ coverage: target: 80 threshold: 5 only_pulls: true - tests: - # Most code in test/ should always be "covered"! - target: 95 - paths: - - '**/test/**' patch: default: # Note: `default` measures the entire project. diff --git a/docs/CODE_GUIDELINES.md b/docs/CODE_GUIDELINES.md index 50471b3d286..90153905b12 100644 --- a/docs/CODE_GUIDELINES.md +++ b/docs/CODE_GUIDELINES.md @@ -81,13 +81,10 @@ that is a net cost. - Telemetry: "active" vs "passive" - Active (`passive:false`) metrics are those intended to appear in DAU count. - Icons: - - Where possible, use IDE-specific standard icons (e.g. standard VSCode - standard icons in VSCode, and Cloud9 standard icons in Cloud9). The typical - (maintainable) way to do this is to use _named_ icons (what VSCode calls + - Where possible, use standard VSCode icons. The typical (maintainable) + way to do this is to use _named_ icons (what VSCode calls [codicons](https://microsoft.github.io/vscode-codicons/)) as opposed to icons shipped with the Toolkit build and referenced by _path_. - - For cases where icons must be statically defined (package.json), if Cloud9 - does not support the VSCode standard icon, use the Cloud9 icon. - Changelog guidelines - Prefer active voice: "You can do X" instead of "X can be done" - Avoid unnecessary use of `lodash` (which we may remove in the future). @@ -240,11 +237,11 @@ _See also [arch_develop.md](./arch_develop.md#exceptions)._ - PREFER: ```ts - things.filter(o => o.isFoo) + things.filter((o) => o.isFoo) ``` - INSTEAD OF: ```ts - things.filter(thing => thing.isFoo) + things.filter((thing) => thing.isFoo) ``` ## User settings diff --git a/docs/arch_develop.md b/docs/arch_develop.md index 4880d5493c5..a3ad998d3e1 100644 --- a/docs/arch_develop.md +++ b/docs/arch_develop.md @@ -444,7 +444,7 @@ await tester.result(items[0].data) // Execute the actions, asserting the final r Abstractly, a 'wizard' is a collection of discrete, linear steps (subroutines), where each step can potentially be dependent on prior steps, that results in some final state. Wizards are extremely common in top-level flows such as creating a new resource, deployments, or confirmation messages. For these kinds of flows, we have a shared `Wizard` class that handles the bulk of control flow and state management logic for us. -### Creating a Wizard (Quick Picks) +### 1. `Wizard` Class Create a new wizard by extending the base `Wizard` class, using the template type to specify the shape of the wizard state. All wizards have an internal `form` property that is used to assign @@ -482,6 +482,41 @@ class ExampleWizard extends Wizard { } ``` +### 2. `CompositeWizard` Class + +`CompositeWizard` extends `Wizard` to create and manage a collection of nested/child wizards. + +Extend this class to create a wizard that contains other wizards as part of a prompter flow. +Use `this.createWizardPrompter()` to use a wizard as a prompter in the `CompositeWizard`. + +Example: + +```ts + +// Child wizard +class ChildWizard extends Wizard {...} + + +// Composite wizard +interface SingleNestedWizardForm { + ... + singleNestedWizardNestedProp: string + ... +} + +class SingleNestedWizard extends CompositeWizard { + constructor() { + super() + ... + this.form.singleNestedWizardNestedProp.bindPrompter(() => + this.createWizardPrompter(ChildWizard) + ) + ... + } +} + +``` + ### Executing Wizards can be ran by calling the async `run` method: @@ -495,6 +530,8 @@ Note that all wizards can potentially return `undefined` if the workflow was can ### Testing +#### Using `WizardTester` + Use `createWizardTester` on an instance of a wizard. Tests can then be constructed by asserting both the user-defined and internal state. Using the above `ExampleWizard`: ```ts @@ -505,6 +542,70 @@ tester.foo.applyInput('Hello, world!') // Manipulate 'user' state tester.bar.assertShow() // True since 'foo' has a defined value ``` +#### Using `PrompterTester` + +Use `PrompterTester` to simulate user behavior (click, input and selection) on prompters to test end-to-end flow of a wizard. + +Example: + +```ts +// 1. Register PrompterTester handlers +const prompterTester = PrompterTester.init() + .handleInputBox('Input Prompter title 1', (inputBox) => { + // Register Input Prompter handler + inputBox.acceptValue('my-source-bucket-name') + }) + .handleQuickPick('Quick Pick Prompter title 2', (quickPick) => { + // Register Quick Pick Prompter handler + + // Optional assertion can be added as part of the handler function + assert.strictEqual(quickPick.items.length, 2) + assert.strictEqual(quickPick.items[0].label, 'Specify required parameters and save as defaults') + assert.strictEqual(quickPick.items[1].label, 'Specify required parameters') + // Choose item + quickPick.acceptItem(quickPick.items[0]) + }) + .handleQuickPick( + 'Quick Pick Prompter with various handler behavior title 3', + (() => { + // Register handler with dynamic behavior + const generator = (function* () { + // First call, choose '**' + yield async (picker: TestQuickPick) => { + await picker.untilReady() + assert.strictEqual(picker.items[1].label, '**') + picker.acceptItem(picker.items[1]) + } + // Second call, choose BACK button + yield async (picker: TestQuickPick) => { + await picker.untilReady() + picker.pressButton(vscode.QuickInputButtons.Back) + } + // Third and subsequent call + while (true) { + yield async (picker: TestQuickPick) => { + await picker.untilReady() + picker.acceptItem(picker.items[1]) + } + } + })() + + return (picker: TestQuickPick) => { + const next = generator.next().value + return next(picker) + } + })() + ) + .build() + +// 2. Run your wizard class +const result = await wizard.run() + +// 3. Assert your tests +prompterTester.assertCallAll() +prompterTester.assertCallOrder('Input Prompter title 1', 1) +``` + ## Module path debugging Node has an environment variable `NODE_DEBUG=module` that helps to debug module imports. This can be helpful on windows, which can load node modules into uppercase or lower case drive letters, depending on the drive letter of the parent module. diff --git a/docs/arch_features.md b/docs/arch_features.md index c76874f7b6e..7a9c239d584 100644 --- a/docs/arch_features.md +++ b/docs/arch_features.md @@ -41,8 +41,11 @@ For connecting a new VSCode _terminal_, remote connect works like this: For EC2 specifically, there are a few additional steps: +1. Remote window connections are only supported for EC2 instances running a linux based OS such as Amazon Linux or Ubuntu. However, the terminal option is supported by all OS, and will open a Powershell-based terminal for Windows instances. 1. If connecting to EC2 instance via remote window, the toolkit generates temporary SSH keys (30 second lifetime), with the public key sent to the remote instance. - Key type is ed25519 if supported, or RSA otherwise. + - Lines in `.ssh/authorized_keys` marked with the comment `#AWSToolkitForVSCode` will be removed by AWS Toolkit. + - Assumes `.sss/authorized_keys` can be found under `/home/ec2-user/` on Amazon Linux and `/home/ubuntu/` on Ubuntu. 1. If insufficient permissions are detected on the attached IAM role, toolkit will prompt to add an inline policy with the necessary actions. 1. If SSM sessions remain open after closing the window/terminal, the toolkit will terminate them on-shutdown, or when starting another session to the same instance. diff --git a/docs/arch_runtime.md b/docs/arch_runtime.md index d2c5902c333..e1cbfd01661 100644 --- a/docs/arch_runtime.md +++ b/docs/arch_runtime.md @@ -31,8 +31,6 @@ If you must define a new key (is it _really_ necessary?), follow these guideline These keys are currently set by the core/ package, but many of them may eventually be migrated to toolkit/ or amazonq/ if appropriate. -- `isCloud9`: This is hardcoded by Cloud9 itself, not the Toolkit. - - Cloud9 _does not support setContext_. So this is the only usable key there. - `aws.codecatalyst.connected`: CodeCatalyst connection is active. - `aws.codewhisperer.connected`: CodeWhisperer connection is active. - `aws.codewhisperer.connectionExpired`: CodeWhisperer connection is active, but the connection is expired. diff --git a/docs/icons.md b/docs/icons.md index d5a2144085f..46e252d5dac 100644 --- a/docs/icons.md +++ b/docs/icons.md @@ -4,7 +4,6 @@ All icons that are used in the extensions can be found in `resources/icons`. A [build script](../scripts/generateIcons.ts) generates extension artifacts in [core/](../packages/core/): -- `resources/icons/cloud9/generated` - `resources/fonts/aws-toolkit-icons.woff` - `resources/css/icons.css` - `contributes.icons` in [amazonq package.json](../packages/amazonq/package.json) and [toolkit package.json](../packages/toolkit/package.json) @@ -31,7 +30,7 @@ If your desired icon does not work well as a font, see [Theme Overrides](#theme- ## Identifiers -Icons (except those in `cloud9`) can be referenced within the Toolkit by concatenating the icon path with hyphens, omitting the 'theme' if applicable. +Icons can be referenced within the Toolkit by concatenating the icon path with hyphens, omitting the 'theme' if applicable. Examples: @@ -50,11 +49,6 @@ For example, if I wanted to use a special App Runner service icon, then I need t - `resources/icons/aws/dark/apprunner-service.svg` - `resources/icons/aws/light/apprunner-service.svg` -A similar format is used for overriding icons only on Cloud9: - -- `resources/icons/cloud9/dark/aws-apprunner-service.svg` -- `resources/icons/cloud9/light/aws-apprunner-service.svg` - These icons will **not** be usuable as Codicons or as font icons. ## Third Party diff --git a/package-lock.json b/package-lock.json index 8bf434177b1..1427e2f0716 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,8 +20,9 @@ "vscode-nls-dev": "^4.0.4" }, "devDependencies": { - "@aws-toolkits/telemetry": "^1.0.284", + "@aws-toolkits/telemetry": "^1.0.293", "@playwright/browser-chromium": "^1.43.1", + "@stylistic/eslint-plugin": "^2.11.0", "@types/he": "^1.2.3", "@types/vscode": "^1.68.0", "@types/vscode-webview": "^1.57.1", @@ -1090,6 +1091,917 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-sdk/client-cloudwatch-logs": { + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.682.0.tgz", + "integrity": "sha512-fodsKdamwVpc9wLRrly4Ccur9TZdfDjrlDY2V0pZT+B4XS9PKhPaQ/8JY4/5ysJoUCe3NxFEpQxkediKEbu2uQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.682.0", + "@aws-sdk/client-sts": "3.682.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/credential-provider-node": "3.682.0", + "@aws-sdk/middleware-host-header": "3.679.0", + "@aws-sdk/middleware-logger": "3.679.0", + "@aws-sdk/middleware-recursion-detection": "3.679.0", + "@aws-sdk/middleware-user-agent": "3.682.0", + "@aws-sdk/region-config-resolver": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-endpoints": "3.679.0", + "@aws-sdk/util-user-agent-browser": "3.679.0", + "@aws-sdk/util-user-agent-node": "3.682.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.8", + "@smithy/eventstream-serde-browser": "^3.0.10", + "@smithy/eventstream-serde-config-resolver": "^3.0.7", + "@smithy/eventstream-serde-node": "^3.0.9", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.23", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.23", + "@smithy/util-defaults-mode-node": "^3.0.23", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/client-sso": { + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.682.0.tgz", + "integrity": "sha512-PYH9RFUMYLFl66HSBq4tIx6fHViMLkhJHTYJoJONpBs+Td+NwVJ895AdLtDsBIhMS0YseCbPpuyjUCJgsUrwUw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/middleware-host-header": "3.679.0", + "@aws-sdk/middleware-logger": "3.679.0", + "@aws-sdk/middleware-recursion-detection": "3.679.0", + "@aws-sdk/middleware-user-agent": "3.682.0", + "@aws-sdk/region-config-resolver": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-endpoints": "3.679.0", + "@aws-sdk/util-user-agent-browser": "3.679.0", + "@aws-sdk/util-user-agent-node": "3.682.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.8", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.23", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.23", + "@smithy/util-defaults-mode-node": "^3.0.23", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.682.0.tgz", + "integrity": "sha512-ZPZ7Y/r/w3nx/xpPzGSqSQsB090Xk5aZZOH+WBhTDn/pBEuim09BYXCLzvvxb7R7NnuoQdrTJiwimdJAhHl7ZQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/credential-provider-node": "3.682.0", + "@aws-sdk/middleware-host-header": "3.679.0", + "@aws-sdk/middleware-logger": "3.679.0", + "@aws-sdk/middleware-recursion-detection": "3.679.0", + "@aws-sdk/middleware-user-agent": "3.682.0", + "@aws-sdk/region-config-resolver": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-endpoints": "3.679.0", + "@aws-sdk/util-user-agent-browser": "3.679.0", + "@aws-sdk/util-user-agent-node": "3.682.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.8", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.23", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.23", + "@smithy/util-defaults-mode-node": "^3.0.23", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.682.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/client-sts": { + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.682.0.tgz", + "integrity": "sha512-xKuo4HksZ+F8m9DOfx/ZuWNhaPuqZFPwwy0xqcBT6sWH7OAuBjv/fnpOTzyQhpVTWddlf+ECtMAMrxjxuOExGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.682.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/credential-provider-node": "3.682.0", + "@aws-sdk/middleware-host-header": "3.679.0", + "@aws-sdk/middleware-logger": "3.679.0", + "@aws-sdk/middleware-recursion-detection": "3.679.0", + "@aws-sdk/middleware-user-agent": "3.682.0", + "@aws-sdk/region-config-resolver": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-endpoints": "3.679.0", + "@aws-sdk/util-user-agent-browser": "3.679.0", + "@aws-sdk/util-user-agent-node": "3.682.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.8", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.23", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.23", + "@smithy/util-defaults-mode-node": "^3.0.23", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/core": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.679.0.tgz", + "integrity": "sha512-CS6PWGX8l4v/xyvX8RtXnBisdCa5+URzKd0L6GvHChype9qKUVxO/Gg6N/y43Hvg7MNWJt9FBPNWIxUB+byJwg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.679.0", + "@smithy/core": "^2.4.8", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/property-provider": "^3.1.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/signature-v4": "^4.2.0", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/util-middleware": "^3.0.7", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.679.0.tgz", + "integrity": "sha512-EdlTYbzMm3G7VUNAMxr9S1nC1qUNqhKlAxFU8E7cKsAe8Bp29CD5HAs3POc56AVo9GC4yRIS+/mtlZSmrckzUA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.679.0.tgz", + "integrity": "sha512-ZoKLubW5DqqV1/2a3TSn+9sSKg0T8SsYMt1JeirnuLJF0mCoYFUaWMyvxxKuxPoqvUsaycxKru4GkpJ10ltNBw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/util-stream": "^3.1.9", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.682.0.tgz", + "integrity": "sha512-6eqWeHdK6EegAxqDdiCi215nT3QZPwukgWAYuVxNfJ/5m0/P7fAzF+D5kKVgByUvGJEbq/FEL8Fw7OBe64AA+g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.679.0", + "@aws-sdk/credential-provider-env": "3.679.0", + "@aws-sdk/credential-provider-http": "3.679.0", + "@aws-sdk/credential-provider-process": "3.679.0", + "@aws-sdk/credential-provider-sso": "3.682.0", + "@aws-sdk/credential-provider-web-identity": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.682.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.682.0.tgz", + "integrity": "sha512-HSmDqZcBVZrTctHCT9m++vdlDfJ1ARI218qmZa+TZzzOFNpKWy6QyHMEra45GB9GnkkMmV6unoDSPMuN0AqcMg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.679.0", + "@aws-sdk/credential-provider-http": "3.679.0", + "@aws-sdk/credential-provider-ini": "3.682.0", + "@aws-sdk/credential-provider-process": "3.679.0", + "@aws-sdk/credential-provider-sso": "3.682.0", + "@aws-sdk/credential-provider-web-identity": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.679.0.tgz", + "integrity": "sha512-u/p4TV8kQ0zJWDdZD4+vdQFTMhkDEJFws040Gm113VHa/Xo1SYOjbpvqeuFoz6VmM0bLvoOWjxB9MxnSQbwKpQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.682.0.tgz", + "integrity": "sha512-h7IH1VsWgV6YAJSWWV6y8uaRjGqLY3iBpGZlXuTH/c236NMLaNv+WqCBLeBxkFGUb2WeQ+FUPEJDCD69rgLIkg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.682.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/token-providers": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.679.0.tgz", + "integrity": "sha512-a74tLccVznXCaBefWPSysUcLXYJiSkeUmQGtalNgJ1vGkE36W5l/8czFiiowdWdKWz7+x6xf0w+Kjkjlj42Ung==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.679.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.679.0.tgz", + "integrity": "sha512-y176HuQ8JRY3hGX8rQzHDSbCl9P5Ny9l16z4xmaiLo+Qfte7ee4Yr3yaAKd7GFoJ3/Mhud2XZ37fR015MfYl2w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.679.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/middleware-logger": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.679.0.tgz", + "integrity": "sha512-0vet8InEj7nvIvGKk+ch7bEF5SyZ7Us9U7YTEgXPrBNStKeRUsgwRm0ijPWWd0a3oz2okaEwXsFl7G/vI0XiEA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.679.0", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.679.0.tgz", + "integrity": "sha512-sQoAZFsQiW/LL3DfKMYwBoGjYDEnMbA9WslWN8xneCmBAwKo6IcSksvYs23PP8XMIoBGe2I2J9BSr654XWygTQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.679.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.682.0.tgz", + "integrity": "sha512-7TyvYR9HdGH1/Nq0eeApUTM4izB6rExiw87khVYuJwZHr6FmvIL1FsOVFro/4WlXa0lg4LiYOm/8H8dHv+fXTg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-endpoints": "3.679.0", + "@smithy/core": "^2.4.8", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.679.0.tgz", + "integrity": "sha512-Ybx54P8Tg6KKq5ck7uwdjiKif7n/8g1x+V0V9uTjBjRWqaIgiqzXwKWoPj6NCNkE7tJNtqI4JrNxp/3S3HvmRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.679.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/token-providers": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.679.0.tgz", + "integrity": "sha512-1/+Zso/x2jqgutKixYFQEGli0FELTgah6bm7aB+m2FAWH4Hz7+iMUsazg6nSWm714sG9G3h5u42Dmpvi9X6/hA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.679.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sso-oidc": "^3.679.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/types": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.679.0.tgz", + "integrity": "sha512-NwVq8YvInxQdJ47+zz4fH3BRRLC6lL+WLkvr242PVBbUOLRyK/lkwHlfiKUoeVIMyK5NF+up6TRg71t/8Bny6Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/util-endpoints": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.679.0.tgz", + "integrity": "sha512-YL6s4Y/1zC45OvddvgE139fjeWSKKPgLlnfrvhVL7alNyY9n7beR4uhoDpNrt5mI6sn9qiBF17790o+xLAXjjg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.679.0", + "@smithy/types": "^3.5.0", + "@smithy/util-endpoints": "^2.1.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.679.0.tgz", + "integrity": "sha512-CusSm2bTBG1kFypcsqU8COhnYc6zltobsqs3nRrvYqYaOqtMnuE46K4XTWpnzKgwDejgZGOE+WYyprtAxrPvmQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.679.0", + "@smithy/types": "^3.5.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.682.0.tgz", + "integrity": "sha512-so5s+j0gPoTS0HM4HPL+G0ajk0T6cQAg8JXzRgvyiQAxqie+zGCZAV3VuVeMNWMVbzsgZl0pYZaatPFTLG/AxA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.682.0", + "@aws-sdk/types": "3.679.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/abort-controller": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.1.6.tgz", + "integrity": "sha512-0XuhuHQlEqbNQZp7QxxrFTdVWdwxch4vjxYgfInF91hZFkPxf9QDrdQka0KfxFMPqLNzSw0b95uGTrLliQUavQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/fetch-http-handler": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-3.2.9.tgz", + "integrity": "sha512-hYNVQOqhFQ6vOpenifFME546f0GfJn2OiQ3M0FDmuUu8V/Uiwy2wej7ZXxFBNqdx0R5DZAqWM1l6VRhGz8oE6A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^4.1.4", + "@smithy/querystring-builder": "^3.0.7", + "@smithy/types": "^3.5.0", + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/is-array-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-3.0.0.tgz", + "integrity": "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/middleware-endpoint": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-3.2.1.tgz", + "integrity": "sha512-wWO3xYmFm6WRW8VsEJ5oU6h7aosFXfszlz3Dj176pTij6o21oZnzkCLzShfmRaaCHDkBXWBdO0c4sQAvLFP6zA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^2.5.1", + "@smithy/middleware-serde": "^3.0.8", + "@smithy/node-config-provider": "^3.1.9", + "@smithy/shared-ini-file-loader": "^3.1.9", + "@smithy/types": "^3.6.0", + "@smithy/url-parser": "^3.0.8", + "@smithy/util-middleware": "^3.0.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/middleware-retry": { + "version": "3.0.25", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-3.0.25.tgz", + "integrity": "sha512-m1F70cPaMBML4HiTgCw5I+jFNtjgz5z5UdGnUbG37vw6kh4UvizFYjqJGHvicfgKMkDL6mXwyPp5mhZg02g5sg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^3.1.9", + "@smithy/protocol-http": "^4.1.5", + "@smithy/service-error-classification": "^3.0.8", + "@smithy/smithy-client": "^3.4.2", + "@smithy/types": "^3.6.0", + "@smithy/util-middleware": "^3.0.8", + "@smithy/util-retry": "^3.0.8", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/middleware-serde": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-3.0.8.tgz", + "integrity": "sha512-Xg2jK9Wc/1g/MBMP/EUn2DLspN8LNt+GMe7cgF+Ty3vl+Zvu+VeZU5nmhveU+H8pxyTsjrAkci8NqY6OuvZnjA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/middleware-stack": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-3.0.8.tgz", + "integrity": "sha512-d7ZuwvYgp1+3682Nx0MD3D/HtkmZd49N3JUndYWQXfRZrYEnCWYc8BHcNmVsPAp9gKvlurdg/mubE6b/rPS9MA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/node-config-provider": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-3.1.9.tgz", + "integrity": "sha512-qRHoah49QJ71eemjuS/WhUXB+mpNtwHRWQr77J/m40ewBVVwvo52kYAmb7iuaECgGTTcYxHS4Wmewfwy++ueew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^3.1.8", + "@smithy/shared-ini-file-loader": "^3.1.9", + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/node-http-handler": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-3.2.5.tgz", + "integrity": "sha512-PkOwPNeKdvX/jCpn0A8n9/TyoxjGZB8WVoJmm9YzsnAgggTj4CrjpRHlTQw7dlLZ320n1mY1y+nTRUDViKi/3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^3.1.6", + "@smithy/protocol-http": "^4.1.5", + "@smithy/querystring-builder": "^3.0.8", + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/property-provider": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-3.1.8.tgz", + "integrity": "sha512-ukNUyo6rHmusG64lmkjFeXemwYuKge1BJ8CtpVKmrxQxc6rhUX0vebcptFA9MmrGsnLhwnnqeH83VTU9hwOpjA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/protocol-http": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-4.1.5.tgz", + "integrity": "sha512-hsjtwpIemmCkm3ZV5fd/T0bPIugW1gJXwZ/hpuVubt2hEUApIoUTrf6qIdh9MAWlw0vjMrA1ztJLAwtNaZogvg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/querystring-builder": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-3.0.8.tgz", + "integrity": "sha512-btYxGVqFUARbUrN6VhL9c3dnSviIwBYD9Rz1jHuN1hgh28Fpv2xjU1HeCeDJX68xctz7r4l1PBnFhGg1WBBPuA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.6.0", + "@smithy/util-uri-escape": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/querystring-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-3.0.8.tgz", + "integrity": "sha512-BtEk3FG7Ks64GAbt+JnKqwuobJNX8VmFLBsKIwWr1D60T426fGrV2L3YS5siOcUhhp6/Y6yhBw1PSPxA5p7qGg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/service-error-classification": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-3.0.8.tgz", + "integrity": "sha512-uEC/kCCFto83bz5ZzapcrgGqHOh/0r69sZ2ZuHlgoD5kYgXJEThCoTuw/y1Ub3cE7aaKdznb+jD9xRPIfIwD7g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.6.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/shared-ini-file-loader": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-3.1.9.tgz", + "integrity": "sha512-/+OsJRNtoRbtsX0UpSgWVxFZLsJHo/4sTr+kBg/J78sr7iC+tHeOvOJrS5hCpVQ6sWBbhWLp1UNiuMyZhE6pmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/smithy-client": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-3.4.2.tgz", + "integrity": "sha512-dxw1BDxJiY9/zI3cBqfVrInij6ShjpV4fmGHesGZZUiP9OSE/EVfdwdRz0PgvkEvrZHpsj2htRaHJfftE8giBA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^2.5.1", + "@smithy/middleware-endpoint": "^3.2.1", + "@smithy/middleware-stack": "^3.0.8", + "@smithy/protocol-http": "^4.1.5", + "@smithy/types": "^3.6.0", + "@smithy/util-stream": "^3.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/types": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-3.6.0.tgz", + "integrity": "sha512-8VXK/KzOHefoC65yRgCn5vG1cysPJjHnOVt9d0ybFQSmJgQj152vMn4EkYhGuaOmnnZvCPav/KnYyE6/KsNZ2w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/url-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-3.0.8.tgz", + "integrity": "sha512-4FdOhwpTW7jtSFWm7SpfLGKIBC9ZaTKG5nBF0wK24aoQKQyDIKUw3+KFWCQ9maMzrgTJIuOvOnsV2lLGW5XjTg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^3.0.8", + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/util-base64": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-3.0.0.tgz", + "integrity": "sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/util-buffer-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-3.0.0.tgz", + "integrity": "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/util-middleware": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-3.0.8.tgz", + "integrity": "sha512-p7iYAPaQjoeM+AKABpYWeDdtwQNxasr4aXQEA/OmbOaug9V0odRVDy3Wx4ci8soljE/JXQo+abV0qZpW8NX0yA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/util-retry": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-3.0.8.tgz", + "integrity": "sha512-TCEhLnY581YJ+g1x0hapPz13JFqzmh/pMWL2KEFASC51qCfw3+Y47MrTmea4bUE5vsdxQ4F6/KFbUeSz22Q1ow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^3.0.8", + "@smithy/types": "^3.6.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/util-stream": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-3.2.1.tgz", + "integrity": "sha512-R3ufuzJRxSJbE58K9AEnL/uSZyVdHzud9wLS8tIbXclxKzoe09CRohj2xV8wpx5tj7ZbiJaKYcutMm1eYgz/0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^4.0.0", + "@smithy/node-http-handler": "^3.2.5", + "@smithy/types": "^3.6.0", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-hex-encoding": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/util-stream/node_modules/@smithy/fetch-http-handler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-4.0.0.tgz", + "integrity": "sha512-MLb1f5tbBO2X6K4lMEKJvxeLooyg7guq48C2zKr4qM7F2Gpkz4dc+hdSgu77pCJ76jVqFBjZczHYAs6dp15N+g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^4.1.5", + "@smithy/querystring-builder": "^3.0.8", + "@smithy/types": "^3.6.0", + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/util-uri-escape": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-3.0.0.tgz", + "integrity": "sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@aws-sdk/client-cognito-identity": { "version": "3.637.0", "license": "Apache-2.0", @@ -5135,11 +6047,10 @@ } }, "node_modules/@aws-toolkits/telemetry": { - "version": "1.0.285", - "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.285.tgz", - "integrity": "sha512-O5/kbCE9cXF8scL5XmeDjMX9ojmCLvXg6cwcBayTS4URypI6XFat6drmaIF/QoDqxAfnHLHs0zypOdqSWCDr8w==", + "version": "1.0.294", + "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.294.tgz", + "integrity": "sha512-Hy/yj93pFuHhKCqAA9FgNjdJHRi4huUnyl13dZLzzICDlFVl/AHlm9iWmm9LR22KOuXUyu3uX40VtXLdExIHqw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "ajv": "^6.12.6", "cross-spawn": "^7.0.6", @@ -5172,23 +6083,24 @@ } }, "node_modules/@aws/mynah-ui": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.21.0.tgz", - "integrity": "sha512-hEo9T1FeDoh79xA2nXaz3Le0SJES8YTuAOlleR8UKfbmzyfWzvvgO/yDIQ6dK39NO0cuQw+6XYGlIVDFpKfEkA==", + "version": "4.21.5", + "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.21.5.tgz", + "integrity": "sha512-Ge7/XADBx/Phm9k2pVgjtYRoB5UOsNcTwZ0VOsWOc2JBGblEIasiT4pNNfHGKgMkLf79AKYUKRSH5IAuQRKpaQ==", "hasInstallScript": true, + "license": "Apache License 2.0", "dependencies": { "escape-html": "^1.0.3", + "highlight.js": "^11.11.0", "just-clone": "^6.2.0", "marked": "^14.1.0", - "prismjs": "1.29.0", "sanitize-html": "^2.12.1", "unescape-html": "^1.1.0" }, "peerDependencies": { "escape-html": "^1.0.3", + "highlight.js": "^11.11.0", "just-clone": "^6.2.0", - "marked": "^12.0.2", - "prismjs": "1.29.0", + "marked": "^14.1.0", "sanitize-html": "^2.12.1", "unescape-html": "^1.1.0" } @@ -7736,6 +8648,213 @@ "node": ">=16.0.0" } }, + "node_modules/@stylistic/eslint-plugin": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.11.0.tgz", + "integrity": "sha512-PNRHbydNG5EH8NK4c+izdJlxajIR6GxcUhzsYNRsn6Myep4dsZt0qFCz3rCPnkvgO5FYibDcMqgNHUT+zvjYZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.13.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.16.0.tgz", + "integrity": "sha512-mwsZWubQvBki2t5565uxF0EYvG+FwdFb8bMtDuGQLdCCnGPrDEDvm1gtfynuKlnpzeBRqdFCkMf9jg1fnAK8sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.16.0", + "@typescript-eslint/visitor-keys": "8.16.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.16.0.tgz", + "integrity": "sha512-NzrHj6thBAOSE4d9bsuRNMvk+BvaQvmY4dDglgkgGC0EW/tB3Kelnp3tAKH87GEwzoxgeQn9fNGRyFJM/xd+GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.16.0.tgz", + "integrity": "sha512-E2+9IzzXMc1iaBy9zmo+UYvluE3TW7bCGWSF41hVWUE01o8nzr1rvOQYSxelxr6StUvRcTMe633eY8mXASMaNw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.16.0", + "@typescript-eslint/visitor-keys": "8.16.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.16.0.tgz", + "integrity": "sha512-C1zRy/mOL8Pj157GiX4kaw7iyRLKfJXBR3L82hk5kS/GyHcOFmy4YUq/zfZti72I9wnuQtA/+xzft4wCC8PJdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.16.0", + "@typescript-eslint/types": "8.16.0", + "@typescript-eslint/typescript-estree": "8.16.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.16.0.tgz", + "integrity": "sha512-pq19gbaMOmFE3CbL0ZB8J8BFCo2ckfHBfaIsaOZgBIF4EoISJIdLX5xRhd0FGB0LlHReNRuzoJoMGpTjq8F2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.16.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "license": "MIT", @@ -9046,7 +10165,9 @@ } }, "node_modules/acorn": { - "version": "8.12.0", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", "bin": { @@ -9162,8 +10283,9 @@ "link": true }, "node_modules/amazon-states-language-service": { - "version": "1.11.0", - "license": "MIT", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/amazon-states-language-service/-/amazon-states-language-service-1.13.0.tgz", + "integrity": "sha512-XT/7LL9+TRCB8H3t0kM6h2uivHa7Pn2lZGpvHKujH1MM+lQ7aaprAKrnZkfSk9++VFNbFJBAnKW+5NN2xVcvlA==", "dependencies": { "js-yaml": "^4.1.0", "vscode-json-languageservice": "5.3.5", @@ -9359,8 +10481,10 @@ "link": true }, "node_modules/aws-sdk": { - "version": "2.1384.0", - "license": "Apache-2.0", + "version": "2.1692.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1692.0.tgz", + "integrity": "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==", + "hasInstallScript": true, "dependencies": { "buffer": "4.9.2", "events": "1.1.1", @@ -9371,7 +10495,7 @@ "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", - "xml2js": "0.5.0" + "xml2js": "0.6.2" }, "engines": { "node": ">= 10.0.0" @@ -9384,17 +10508,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/aws-sdk/node_modules/xml2js": { - "version": "0.5.0", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/aws-ssm-document-language-service": { "version": "1.0.0", "license": "Apache-2.0", @@ -12134,9 +13247,9 @@ } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "dev": true, "dependencies": { "accepts": "~1.3.8", @@ -12158,7 +13271,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -12173,6 +13286,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/array-flatten": { @@ -12210,9 +13327,10 @@ "license": "MIT" }, "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.10", - "dev": true, - "license": "MIT" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true }, "node_modules/express/node_modules/statuses": { "version": "2.0.1", @@ -12976,8 +14094,9 @@ } }, "node_modules/highlight.js": { - "version": "11.9.0", - "license": "BSD-3-Clause", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "engines": { "node": ">=12.0.0" } @@ -16206,13 +17325,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/prismjs": { - "version": "1.29.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/private": { "version": "0.1.8", "dev": true, @@ -19700,8 +20812,9 @@ } }, "node_modules/xml2js": { - "version": "0.6.1", - "license": "MIT", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" @@ -20006,7 +21119,7 @@ }, "packages/amazonq": { "name": "amazon-q-vscode", - "version": "1.40.0-SNAPSHOT", + "version": "1.45.0-SNAPSHOT", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" @@ -20025,6 +21138,7 @@ "@amzn/amazon-q-developer-streaming-client": "file:../../src.gen/@amzn/amazon-q-developer-streaming-client", "@amzn/codewhisperer-streaming": "file:../../src.gen/@amzn/codewhisperer-streaming", "@aws-sdk/client-cloudformation": "^3.667.0", + "@aws-sdk/client-cloudwatch-logs": "^3.666.0", "@aws-sdk/client-cognito-identity": "^3.637.0", "@aws-sdk/client-lambda": "^3.637.0", "@aws-sdk/client-sso": "^3.342.0", @@ -20035,7 +21149,7 @@ "@aws-sdk/property-provider": "3.46.0", "@aws-sdk/smithy-client": "^3.46.0", "@aws-sdk/util-arn-parser": "^3.46.0", - "@aws/mynah-ui": "^4.21.0", + "@aws/mynah-ui": "^4.21.5", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "@iarna/toml": "^2.2.5", "@smithy/middleware-retry": "^2.3.1", @@ -20046,13 +21160,13 @@ "@vscode/debugprotocol": "^1.57.0", "@zip.js/zip.js": "^2.7.41", "adm-zip": "^0.5.10", - "amazon-states-language-service": "^1.11.0", + "amazon-states-language-service": "^1.13.0", "async-lock": "^1.4.0", - "aws-sdk": "^2.1384.0", + "aws-sdk": "^2.1692.0", "aws-ssm-document-language-service": "^1.0.0", "bytes": "^3.1.2", "cross-fetch": "^4.0.0", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "diff": "^5.1.0", "fast-json-patch": "^3.1.1", "glob": "^10.3.10", @@ -20165,7 +21279,7 @@ }, "packages/toolkit": { "name": "aws-toolkit-vscode", - "version": "3.38.0-SNAPSHOT", + "version": "3.44.0-SNAPSHOT", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/package.json b/package.json index bc03c2b8395..e14641a45c4 100644 --- a/package.json +++ b/package.json @@ -36,11 +36,13 @@ "lintfix": "eslint -c .eslintrc.js --ignore-path .gitignore --ignore-pattern '**/*.json' --ignore-pattern '**/*.gen.ts' --ignore-pattern '**/types/*.d.ts' --ignore-pattern '**/src/testFixtures/**' --ignore-pattern '**/resources/js/graphStateMachine.js' --fix --ext .ts packages plugins", "clean": "npm run clean -w packages/ -w plugins/", "reset": "npm run clean && ts-node ./scripts/clean.ts node_modules && npm install", - "generateNonCodeFiles": "npm run generateNonCodeFiles -w packages/ --if-present" + "generateNonCodeFiles": "npm run generateNonCodeFiles -w packages/ --if-present", + "mergeReports": "ts-node ./scripts/mergeReports.ts" }, "devDependencies": { - "@aws-toolkits/telemetry": "^1.0.284", + "@aws-toolkits/telemetry": "^1.0.293", "@playwright/browser-chromium": "^1.43.1", + "@stylistic/eslint-plugin": "^2.11.0", "@types/he": "^1.2.3", "@types/vscode": "^1.68.0", "@types/vscode-webview": "^1.57.1", diff --git a/packages/amazonq/.changes/1.32.0.json b/packages/amazonq/.changes/1.32.0.json index 5d5a61c848a..cfa6f4796bf 100644 --- a/packages/amazonq/.changes/1.32.0.json +++ b/packages/amazonq/.changes/1.32.0.json @@ -20,7 +20,7 @@ }, { "type": "Feature", - "description": "Enable Free Tier Chat for IAM users" + "description": "Amazon SageMaker Studio: Enable Free Tier Chat for IAM users" } ] -} \ No newline at end of file +} diff --git a/packages/amazonq/.changes/1.40.0.json b/packages/amazonq/.changes/1.40.0.json new file mode 100644 index 00000000000..ea251bdd8fe --- /dev/null +++ b/packages/amazonq/.changes/1.40.0.json @@ -0,0 +1,66 @@ +{ + "date": "2024-12-10", + "version": "1.40.0", + "entries": [ + { + "type": "Bug Fix", + "description": "Improved LLM code review for file review." + }, + { + "type": "Bug Fix", + "description": "@workspace is missing from the welcome to q chat tab" + }, + { + "type": "Bug Fix", + "description": "Fix chat syntax highlighting when using several different themes" + }, + { + "type": "Bug Fix", + "description": "Amazon Q /doc: progress bar persists after cancelling README creation" + }, + { + "type": "Bug Fix", + "description": "Code Review: Fixed a bug where some issues are missing from the code issues view for workspaces with custom names" + }, + { + "type": "Bug Fix", + "description": "Amazon Q /doc: Prompt user to choose a folder in chat" + }, + { + "type": "Bug Fix", + "description": "Amazon Q /dev not adding Dockerfiles in nested folders" + }, + { + "type": "Bug Fix", + "description": "Improved Code Fix generation for code review issues" + }, + { + "type": "Bug Fix", + "description": "Fix the quick start buttons on the explore page to show amazon q colours on hover" + }, + { + "type": "Feature", + "description": "Q feature dev: recognize .bms, .pli code files" + }, + { + "type": "Feature", + "description": "Amazon Q Code Transformation: show job ID in Transformation Hub" + }, + { + "type": "Feature", + "description": "UI improvements to Amazon Q Chat: New splash loader animation, initial streaming card animation, improved button colours" + }, + { + "type": "Feature", + "description": "Add acknowledgement button for amazon q chat disclaimer" + }, + { + "type": "Feature", + "description": "Navigate through prompt history by using the up/down arrows" + }, + { + "type": "Feature", + "description": "Amazon Q: Simplify log channel" + } + ] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/1.41.0.json b/packages/amazonq/.changes/1.41.0.json new file mode 100644 index 00000000000..d0ca09cb476 --- /dev/null +++ b/packages/amazonq/.changes/1.41.0.json @@ -0,0 +1,62 @@ +{ + "date": "2024-12-17", + "version": "1.41.0", + "entries": [ + { + "type": "Bug Fix", + "description": "/review: Apply fix removes other issues in the same file." + }, + { + "type": "Bug Fix", + "description": "Fix(Amazon Q Code Transformation): show correct diff when running consecutive transformations" + }, + { + "type": "Bug Fix", + "description": "Improve when the welcome page is shown in amazon q chat" + }, + { + "type": "Bug Fix", + "description": "Code Review: Cleaned up output logs when running /review" + }, + { + "type": "Bug Fix", + "description": "Code Review: Fixed a bug where applying a fix did not update the positions of other issues in the same file." + }, + { + "type": "Bug Fix", + "description": "Chat: When navigating to previous prompts, code attachments are sometimes displayed incorrectly" + }, + { + "type": "Bug Fix", + "description": "/review: Diagnostics in the problems panel are mapped to the wrong code" + }, + { + "type": "Bug Fix", + "description": "Fix opentabs context possibly timeout due to race condition of misuse of different timeout functionalities" + }, + { + "type": "Bug Fix", + "description": "Auth: SSO session was bad, but no reauth prompt given" + }, + { + "type": "Bug Fix", + "description": "Reduce frequency of system status poll" + }, + { + "type": "Bug Fix", + "description": "Chat: When writing a prompt without sending it, navigating via up/down arrows sometimes deletes the unsent prompt." + }, + { + "type": "Bug Fix", + "description": "Code Review: Fixed a bug where projects with repeated path names did not scan properly." + }, + { + "type": "Feature", + "description": "/review: Code fix automatically scrolls into view after generation." + }, + { + "type": "Feature", + "description": "Chat: improve font size and line-height in footer (below prompt input field)" + } + ] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/1.42.0.json b/packages/amazonq/.changes/1.42.0.json new file mode 100644 index 00000000000..7327ed0c9c4 --- /dev/null +++ b/packages/amazonq/.changes/1.42.0.json @@ -0,0 +1,58 @@ +{ + "date": "2025-01-09", + "version": "1.42.0", + "entries": [ + { + "type": "Bug Fix", + "description": "Amazon Q /doc: Improve button text phrasing" + }, + { + "type": "Bug Fix", + "description": "Amazon Q /dev: Fix issue when files are deleted while preparing context" + }, + { + "type": "Bug Fix", + "description": "Amazon Q Code Transformation: allow POSTGRESQL as target DB for SQL conversions" + }, + { + "type": "Bug Fix", + "description": "Fix context menu displaying when typing @, even though input is disallowed" + }, + { + "type": "Bug Fix", + "description": "Amazon Q can update mvn and gradle build files" + }, + { + "type": "Bug Fix", + "description": "/transform: use correct documentation link in SQL conversion help message" + }, + { + "type": "Bug Fix", + "description": "Up/down history navigation only triggering on first/last line of prompt input" + }, + { + "type": "Bug Fix", + "description": "Amazon Q /test: Fix to redirect /test to generate tests in chat for external files out of workspace scope." + }, + { + "type": "Bug Fix", + "description": "/review: Code block extends beyond page margins in code issue detail view" + }, + { + "type": "Bug Fix", + "description": "Amazon Q Code Transformation: retry project upload up to 3 times" + }, + { + "type": "Feature", + "description": "Amazon Q Code Transformation: add view summary button in chat" + }, + { + "type": "Feature", + "description": "Amazon Q: new code syntax highlighter for improved accuracy" + }, + { + "type": "Removal", + "description": "Settings: No longer migrate old CodeWhisperer settings or initialize telemetry setting from AWS Toolkit." + } + ] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/1.43.0.json b/packages/amazonq/.changes/1.43.0.json new file mode 100644 index 00000000000..a4f2376f2e6 --- /dev/null +++ b/packages/amazonq/.changes/1.43.0.json @@ -0,0 +1,42 @@ +{ + "date": "2025-01-15", + "version": "1.43.0", + "entries": [ + { + "type": "Bug Fix", + "description": "Auth: Valid StartURL not accepted at login" + }, + { + "type": "Bug Fix", + "description": "Fix inline completion supplementalContext length exceeding maximum in certain cases" + }, + { + "type": "Bug Fix", + "description": "Amazon Q /test: Unit test generation completed message shows after accept/reject action" + }, + { + "type": "Bug Fix", + "description": "/test: for unsupported languages was sometimes unreliable" + }, + { + "type": "Bug Fix", + "description": "User-selected customizations are sometimes not being persisted." + }, + { + "type": "Bug Fix", + "description": "Amazon q /dev: Remove hard-coded limits and instead rely server-side data to communicate number of code generations remaining" + }, + { + "type": "Feature", + "description": "Adds capability to send new context commands to AB groups" + }, + { + "type": "Feature", + "description": "feat(amazonq): Add error message for updated README too large" + }, + { + "type": "Feature", + "description": "Enhance Q inline completion context fetching for better suggestion quality" + } + ] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/1.44.0.json b/packages/amazonq/.changes/1.44.0.json new file mode 100644 index 00000000000..7593e9a7af3 --- /dev/null +++ b/packages/amazonq/.changes/1.44.0.json @@ -0,0 +1,50 @@ +{ + "date": "2025-01-23", + "version": "1.44.0", + "entries": [ + { + "type": "Bug Fix", + "description": "Amazon Q: word duplication when pressing tab on context selector fixed" + }, + { + "type": "Bug Fix", + "description": "Amazon Q /doc: Prevent users from requesting changes if no iterations remain" + }, + { + "type": "Bug Fix", + "description": "`/test`: view diffs by clicking files in the file tree, aligning the behavior with the 'View Diff' button." + }, + { + "type": "Bug Fix", + "description": "/review: Improved error handling for code fix operations" + }, + { + "type": "Bug Fix", + "description": "Amazon Q: cursor no longer jumps after navigating prompt history" + }, + { + "type": "Bug Fix", + "description": "Improve the text description of workspace index settings" + }, + { + "type": "Bug Fix", + "description": "Notifications: 'Dismiss' command visible in command palette." + }, + { + "type": "Bug Fix", + "description": "/transform: replace icons in Transformation Hub with text" + }, + { + "type": "Bug Fix", + "description": "Amazon Q /doc: Ask for user prompt if error occurs while updating documentation" + }, + { + "type": "Feature", + "description": "Amazon Q: increase chat current active file context char limit to 40k" + }, + { + "type": "Feature", + "description": "/review: Code issues can be grouped by file location or severity" + } + ] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/next-release/Bug Fix-0e68107f-cd6c-488d-a457-d765b37a49c3.json b/packages/amazonq/.changes/next-release/Bug Fix-0e68107f-cd6c-488d-a457-d765b37a49c3.json deleted file mode 100644 index 18b94f4225c..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-0e68107f-cd6c-488d-a457-d765b37a49c3.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "@workspace is missing from the welcome to q chat tab" -} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-17772b9d-7f10-4e16-8b53-0561d6b08f4a.json b/packages/amazonq/.changes/next-release/Bug Fix-17772b9d-7f10-4e16-8b53-0561d6b08f4a.json deleted file mode 100644 index 02b38b02eb5..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-17772b9d-7f10-4e16-8b53-0561d6b08f4a.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Fix chat syntax highlighting when using several different themes" -} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-357cef5f-a2e7-43c4-b4ca-0e89bc8fccd7.json b/packages/amazonq/.changes/next-release/Bug Fix-357cef5f-a2e7-43c4-b4ca-0e89bc8fccd7.json deleted file mode 100644 index 85b6ac5c070..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-357cef5f-a2e7-43c4-b4ca-0e89bc8fccd7.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Amazon Q /doc: progress bar persists after cancelling README creation" -} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-75ff3f7a-3cfb-491c-9557-ce8c04f54ccc.json b/packages/amazonq/.changes/next-release/Bug Fix-75ff3f7a-3cfb-491c-9557-ce8c04f54ccc.json deleted file mode 100644 index b79066d4070..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-75ff3f7a-3cfb-491c-9557-ce8c04f54ccc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Improved Code Fix generation for code review issues" -} diff --git a/packages/amazonq/.changes/next-release/Feature-2e0fcda2-695f-4646-8ccd-20e9e56647a6.json b/packages/amazonq/.changes/next-release/Feature-2e0fcda2-695f-4646-8ccd-20e9e56647a6.json deleted file mode 100644 index 7aaf8b7077f..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-2e0fcda2-695f-4646-8ccd-20e9e56647a6.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Amazon Q Code Transformation: show job ID in Transformation Hub" -} diff --git a/packages/amazonq/.changes/next-release/Feature-3e91ab38-b708-4fcc-888e-7a532c210a69.json b/packages/amazonq/.changes/next-release/Feature-3e91ab38-b708-4fcc-888e-7a532c210a69.json deleted file mode 100644 index 5e592eca298..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-3e91ab38-b708-4fcc-888e-7a532c210a69.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "UI improvements to Amazon Q Chat: New splash loader animation, initial streaming card animation, improved button colours" -} diff --git a/packages/amazonq/.changes/next-release/Feature-b92f1e8b-78a0-46a6-ba9b-2300c884fcbc.json b/packages/amazonq/.changes/next-release/Feature-b92f1e8b-78a0-46a6-ba9b-2300c884fcbc.json deleted file mode 100644 index 20664e7f542..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-b92f1e8b-78a0-46a6-ba9b-2300c884fcbc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Add acknowledgement button for amazon q chat disclaimer" -} diff --git a/packages/amazonq/.changes/next-release/Feature-c0fdc8fe-e0e5-422c-a76a-1d7461460e81.json b/packages/amazonq/.changes/next-release/Feature-c0fdc8fe-e0e5-422c-a76a-1d7461460e81.json deleted file mode 100644 index 33bc4142322..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-c0fdc8fe-e0e5-422c-a76a-1d7461460e81.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Navigate through prompt history by using the up/down arrows" -} diff --git a/packages/amazonq/CHANGELOG.md b/packages/amazonq/CHANGELOG.md index 4d40556f351..668eb5726ac 100644 --- a/packages/amazonq/CHANGELOG.md +++ b/packages/amazonq/CHANGELOG.md @@ -1,3 +1,80 @@ +## 1.44.0 2025-01-23 + +- **Bug Fix** Amazon Q: word duplication when pressing tab on context selector fixed +- **Bug Fix** Amazon Q /doc: Prevent users from requesting changes if no iterations remain +- **Bug Fix** `/test`: view diffs by clicking files in the file tree, aligning the behavior with the 'View Diff' button. +- **Bug Fix** /review: Improved error handling for code fix operations +- **Bug Fix** Amazon Q: cursor no longer jumps after navigating prompt history +- **Bug Fix** Improve the text description of workspace index settings +- **Bug Fix** Notifications: 'Dismiss' command visible in command palette. +- **Bug Fix** /transform: replace icons in Transformation Hub with text +- **Bug Fix** Amazon Q /doc: Ask for user prompt if error occurs while updating documentation +- **Feature** Amazon Q: increase chat current active file context char limit to 40k +- **Feature** /review: Code issues can be grouped by file location or severity + +## 1.43.0 2025-01-15 + +- **Bug Fix** Auth: Valid StartURL not accepted at login +- **Bug Fix** Fix inline completion supplementalContext length exceeding maximum in certain cases +- **Bug Fix** Amazon Q /test: Unit test generation completed message shows after accept/reject action +- **Bug Fix** /test: for unsupported languages was sometimes unreliable +- **Bug Fix** User-selected customizations are sometimes not being persisted. +- **Bug Fix** Amazon q /dev: Remove hard-coded limits and instead rely server-side data to communicate number of code generations remaining +- **Feature** Adds capability to send new context commands to AB groups +- **Feature** feat(amazonq): Add error message for updated README too large +- **Feature** Enhance Q inline completion context fetching for better suggestion quality + +## 1.42.0 2025-01-09 + +- **Bug Fix** Amazon Q /doc: Improve button text phrasing +- **Bug Fix** Amazon Q /dev: Fix issue when files are deleted while preparing context +- **Bug Fix** Amazon Q Code Transformation: allow POSTGRESQL as target DB for SQL conversions +- **Bug Fix** Fix context menu displaying when typing @, even though input is disallowed +- **Bug Fix** Amazon Q can update mvn and gradle build files +- **Bug Fix** /transform: use correct documentation link in SQL conversion help message +- **Bug Fix** Up/down history navigation only triggering on first/last line of prompt input +- **Bug Fix** Amazon Q /test: Fix to redirect /test to generate tests in chat for external files out of workspace scope. +- **Bug Fix** /review: Code block extends beyond page margins in code issue detail view +- **Bug Fix** Amazon Q Code Transformation: retry project upload up to 3 times +- **Feature** Amazon Q Code Transformation: add view summary button in chat +- **Feature** Amazon Q: new code syntax highlighter for improved accuracy +- **Removal** Settings: No longer migrate old CodeWhisperer settings or initialize telemetry setting from AWS Toolkit. + +## 1.41.0 2024-12-17 + +- **Bug Fix** /review: Apply fix removes other issues in the same file. +- **Bug Fix** Fix(Amazon Q Code Transformation): show correct diff when running consecutive transformations +- **Bug Fix** Improve when the welcome page is shown in amazon q chat +- **Bug Fix** Code Review: Cleaned up output logs when running /review +- **Bug Fix** Code Review: Fixed a bug where applying a fix did not update the positions of other issues in the same file. +- **Bug Fix** Chat: When navigating to previous prompts, code attachments are sometimes displayed incorrectly +- **Bug Fix** /review: Diagnostics in the problems panel are mapped to the wrong code +- **Bug Fix** Fix opentabs context possibly timeout due to race condition of misuse of different timeout functionalities +- **Bug Fix** Auth: SSO session was bad, but no reauth prompt given +- **Bug Fix** Reduce frequency of system status poll +- **Bug Fix** Chat: When writing a prompt without sending it, navigating via up/down arrows sometimes deletes the unsent prompt. +- **Bug Fix** Code Review: Fixed a bug where projects with repeated path names did not scan properly. +- **Feature** /review: Code fix automatically scrolls into view after generation. +- **Feature** Chat: improve font size and line-height in footer (below prompt input field) + +## 1.40.0 2024-12-10 + +- **Bug Fix** Improved LLM code review for file review. +- **Bug Fix** @workspace is missing from the welcome to q chat tab +- **Bug Fix** Fix chat syntax highlighting when using several different themes +- **Bug Fix** Amazon Q /doc: progress bar persists after cancelling README creation +- **Bug Fix** Code Review: Fixed a bug where some issues are missing from the code issues view for workspaces with custom names +- **Bug Fix** Amazon Q /doc: Prompt user to choose a folder in chat +- **Bug Fix** Amazon Q /dev not adding Dockerfiles in nested folders +- **Bug Fix** Improved Code Fix generation for code review issues +- **Bug Fix** Fix the quick start buttons on the explore page to show amazon q colours on hover +- **Feature** Q feature dev: recognize .bms, .pli code files +- **Feature** Amazon Q Code Transformation: show job ID in Transformation Hub +- **Feature** UI improvements to Amazon Q Chat: New splash loader animation, initial streaming card animation, improved button colours +- **Feature** Add acknowledgement button for amazon q chat disclaimer +- **Feature** Navigate through prompt history by using the up/down arrows +- **Feature** Amazon Q: Simplify log channel + ## 1.39.0 2024-12-03 - **Feature** Added a getting started page for exploring amazon q agents @@ -58,7 +135,7 @@ - **Bug Fix** Use Sagemaker environment IAM Credentials for Code Completion when they're available - **Bug Fix** Inline: Code completion not working for Sagemaker Pro Tier users. - **Bug Fix** Disable /transform and /dev commands for sagemaker users as they're not supported -- **Feature** Enable Free Tier Chat for IAM users +- **Feature** Amazon SageMaker Studio: Enable Free Tier Chat for IAM users ## 1.31.0 2024-10-29 diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 7ff568a7619..4061ca02a4b 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -2,7 +2,7 @@ "name": "amazon-q-vscode", "displayName": "Amazon Q", "description": "The most capable generative AI-powered assistant for building, operating, and transforming software, with advanced capabilities for managing data and AI", - "version": "1.40.0-SNAPSHOT", + "version": "1.45.0-SNAPSHOT", "extensionKind": [ "workspace" ], @@ -203,7 +203,7 @@ { "id": "aws.amazonq.notifications", "name": "%AWS.notifications.title%", - "when": "!isCloud9 && !aws.isSageMaker && aws.amazonq.notifications.show" + "when": "!(isCloud9 || aws.isSageMaker) && aws.amazonq.notifications.show" }, { "type": "webview", @@ -215,18 +215,14 @@ "type": "tree", "id": "aws.amazonq.SecurityIssuesTree", "name": "%AWS.amazonq.security%", - "when": "!aws.isSageMaker && !aws.isWebExtHost && !aws.amazonq.showLoginView" + "when": "!aws.isSageMaker && !aws.isWebExtHost && !aws.amazonq.showLoginView", + "visibility": "collapsed" }, { "type": "webview", "id": "aws.AmazonQChatView", "name": "%AWS.amazonq.chat%", "when": "!aws.isWebExtHost && !aws.amazonq.showLoginView" - }, - { - "id": "aws.AmazonQNeverShowBadge", - "name": "", - "when": "false" } ], "aws-codewhisperer-reference-log": [ @@ -323,16 +319,14 @@ "group": "navigation@3" }, { - "command": "aws.amazonq.transformationHub.summary.reveal", - "when": "view == aws.amazonq.transformationHub" - }, - { - "command": "aws.amazonq.transformationHub.reviewChanges.reveal", - "when": "view == aws.amazonq.transformationHub" + "command": "aws.amazonq.showTransformationPlanInHub", + "when": "view == aws.amazonq.transformationHub", + "group": "navigation@4" }, { - "command": "aws.amazonq.showTransformationPlanInHub", - "when": "view == aws.amazonq.transformationHub" + "command": "aws.amazonq.transformationHub.summary.reveal", + "when": "view == aws.amazonq.transformationHub", + "group": "navigation@5" }, { "command": "aws.amazonq.transformationHub.reviewChanges.acceptChanges", @@ -374,10 +368,15 @@ "when": "view == aws.AmazonQChatView || view == aws.amazonq.AmazonCommonAuth", "group": "y_toolkitMeta@2" }, + { + "command": "aws.amazonq.codescan.showGroupingStrategy", + "when": "view == aws.amazonq.SecurityIssuesTree", + "group": "navigation@1" + }, { "command": "aws.amazonq.security.showFilters", "when": "view == aws.amazonq.SecurityIssuesTree", - "group": "navigation" + "group": "navigation@2" } ], "view/item/context": [ @@ -502,7 +501,7 @@ "command": "_aws.amazonq.notifications.dismiss", "title": "%AWS.generic.dismiss%", "category": "%AWS.amazonq.title%", - "enablement": "isCloud9 || !aws.isWebExtHost", + "enablement": "view == aws.amazonq.notifications", "icon": "$(remove-close)" }, { @@ -572,11 +571,6 @@ "title": "%AWS.command.q.transform.showChangeSummary%", "enablement": "gumby.isSummaryAvailable" }, - { - "command": "aws.amazonq.transformationHub.reviewChanges.reveal", - "title": "%AWS.command.q.transform.showChanges%", - "enablement": "gumby.reviewState == InReview" - }, { "command": "aws.amazonq.showTransformationPlanInHub", "title": "%AWS.command.q.transform.showTransformationPlan%", @@ -647,19 +641,16 @@ }, { "command": "aws.amazonq.stopTransformationInHub", - "title": "Stop Transformation", - "icon": "$(stop)", + "title": "%AWS.command.q.transform.stopJobInHub%", "enablement": "gumby.isStopButtonAvailable" }, { "command": "aws.amazonq.showPlanProgressInHub", - "title": "Show Transformation Status", - "icon": "$(checklist)" + "title": "%AWS.command.q.transform.viewJobProgress%" }, { "command": "aws.amazonq.showHistoryInHub", - "title": "Show Job Status", - "icon": "$(history)" + "title": "%AWS.command.q.transform.viewJobStatus%" }, { "command": "aws.amazonq.selectCustomization", @@ -733,6 +724,12 @@ { "command": "aws.amazonq.security.showFilters", "title": "%AWS.command.amazonq.filterIssues%", + "icon": "$(filter)", + "enablement": "view == aws.amazonq.SecurityIssuesTree" + }, + { + "command": "aws.amazonq.codescan.showGroupingStrategy", + "title": "%AWS.command.amazonq.groupIssues%", "icon": "$(list-filter)", "enablement": "view == aws.amazonq.SecurityIssuesTree" }, diff --git a/packages/amazonq/src/app/amazonqScan/chat/controller/messenger/messengerUtils.ts b/packages/amazonq/src/app/amazonqScan/chat/controller/messenger/messengerUtils.ts index 67351c3eb6e..455a4ebf4af 100644 --- a/packages/amazonq/src/app/amazonqScan/chat/controller/messenger/messengerUtils.ts +++ b/packages/amazonq/src/app/amazonqScan/chat/controller/messenger/messengerUtils.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 * */ -//TODO: Refactor the common functionality between Transform, FeatureDev, CWSPRChat, Scan and UTG to a new Folder. +// TODO: Refactor the common functionality between Transform, FeatureDev, CWSPRChat, Scan and UTG to a new Folder. export default class MessengerUtils { static stringToEnumValue = ( diff --git a/packages/amazonq/src/app/chat/activation.ts b/packages/amazonq/src/app/chat/activation.ts index f7b3f9a0fa5..49205c75c7d 100644 --- a/packages/amazonq/src/app/chat/activation.ts +++ b/packages/amazonq/src/app/chat/activation.ts @@ -61,7 +61,6 @@ export async function activate(context: ExtensionContext) { void vscode.env.openExternal(vscode.Uri.parse(amazonq.amazonQHelpUrl)) }) - await amazonq.activateBadge() void setupLsp() void setupAuthNotification() } diff --git a/packages/amazonq/src/extension.ts b/packages/amazonq/src/extension.ts index add823b5a9e..a4b53dbf66d 100644 --- a/packages/amazonq/src/extension.ts +++ b/packages/amazonq/src/extension.ts @@ -53,7 +53,7 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is errors.init(fs.getUsername(), env.isAutomation()) await initializeComputeRegion() - globals.contextPrefix = 'amazonq.' //todo: disconnect from above line + globals.contextPrefix = 'amazonq.' // todo: disconnect from above line // Avoid activation if older toolkit is installed // Amazon Q is only compatible with AWS Toolkit >= 3.0.0 @@ -97,10 +97,8 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is globals.manifestPaths.endpoints = context.asAbsolutePath(join('resources', 'endpoints.json')) globals.regionProvider = RegionProvider.fromEndpointsProvider(makeEndpointsProvider()) - const qOutputChannel = vscode.window.createOutputChannel('Amazon Q', { log: true }) const qLogChannel = vscode.window.createOutputChannel('Amazon Q Logs', { log: true }) - await activateLogger(context, amazonQContextPrefix, qOutputChannel, qLogChannel) - globals.outputChannel = qOutputChannel + await activateLogger(context, amazonQContextPrefix, qLogChannel) globals.logOutputChannel = qLogChannel globals.loginManager = new LoginManager(globals.awsContext, new CredentialsStore()) diff --git a/packages/amazonq/src/inlineChat/controller/inlineChatController.ts b/packages/amazonq/src/inlineChat/controller/inlineChatController.ts index ce0df6a0878..7ace8d0095e 100644 --- a/packages/amazonq/src/inlineChat/controller/inlineChatController.ts +++ b/packages/amazonq/src/inlineChat/controller/inlineChatController.ts @@ -13,6 +13,7 @@ import { computeDecorations } from '../decorations/computeDecorations' import { CodelensProvider } from '../codeLenses/codeLenseProvider' import { PromptMessage, ReferenceLogController } from 'aws-core-vscode/codewhispererChat' import { CodeWhispererSettings } from 'aws-core-vscode/codewhisperer' +import { UserWrittenCodeTracker } from 'aws-core-vscode/codewhisperer' import { codicon, getIcon, @@ -84,6 +85,7 @@ export class InlineChatController { await this.updateTaskAndLenses(task) this.referenceLogController.addReferenceLog(task.codeReferences, task.replacement ? task.replacement : '') await this.reset() + UserWrittenCodeTracker.instance.onQFinishesEdits() } public async rejectAllChanges(task = this.task, userInvoked: boolean): Promise { @@ -156,7 +158,9 @@ export class InlineChatController { } private async reset() { - this.listeners.forEach((listener) => listener.dispose()) + for (const listener of this.listeners) { + listener.dispose() + } this.listeners = [] this.task = undefined @@ -197,7 +201,7 @@ export class InlineChatController { getLogger().info('inlineQuickPick query is empty') return } - + UserWrittenCodeTracker.instance.onQStartsMakingEdits() this.userQuery = query await textDocumentUtil.addEofNewline(editor) this.task = await this.createTask(query, editor.document, editor.selection) diff --git a/packages/amazonq/src/inlineChat/output/computeDiff.ts b/packages/amazonq/src/inlineChat/output/computeDiff.ts index 9b599b2eff3..a17a7914cf6 100644 --- a/packages/amazonq/src/inlineChat/output/computeDiff.ts +++ b/packages/amazonq/src/inlineChat/output/computeDiff.ts @@ -2,7 +2,7 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ -import { type LinesOptions, diffLines, Change } from 'diff' +import { type LinesOptions, diffLines } from 'diff' import * as vscode from 'vscode' import { InlineTask, TextDiff } from '../controller/inlineTask' @@ -24,8 +24,7 @@ export function computeDiff(response: string, inlineTask: InlineTask, isPartialD const textDiff: TextDiff[] = [] let startLine = selectedRange.start.line - - diffs.forEach((part: Change) => { + for (const part of diffs) { const count = part.count ?? 0 if (part.removed) { if (part.value !== '\n') { @@ -49,7 +48,7 @@ export function computeDiff(response: string, inlineTask: InlineTask, isPartialD } } startLine += count - }) + } inlineTask.diff = textDiff return textDiff } diff --git a/packages/amazonq/test/e2e/amazonq/assert.ts b/packages/amazonq/test/e2e/amazonq/assert.ts new file mode 100644 index 00000000000..5bcec3fc0b4 --- /dev/null +++ b/packages/amazonq/test/e2e/amazonq/assert.ts @@ -0,0 +1,41 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { Messenger } from './framework/messenger' + +export function assertQuickActions(tab: Messenger, commands: string[]) { + const commandGroup = tab + .getCommands() + .map((groups) => groups.commands) + .flat() + if (!commandGroup) { + assert.fail(`Could not find commands for ${tab.tabID}`) + } + + const commandNames = commandGroup.map((cmd) => cmd.command) + + const missingCommands = [] + for (const command of commands) { + if (!commandNames.includes(command)) { + missingCommands.push(command) + } + } + + if (missingCommands.length > 0) { + assert.fail(`Could not find commands: ${missingCommands.join(', ')} for ${tab.tabID}`) + } +} + +export function assertContextCommands(tab: Messenger, contextCommands: string[]) { + assert.deepStrictEqual( + tab + .getStore() + .contextCommands?.map((x) => x.commands) + .flat() + .map((x) => x.command), + contextCommands + ) +} diff --git a/packages/amazonq/test/e2e/amazonq/chat.test.ts b/packages/amazonq/test/e2e/amazonq/chat.test.ts new file mode 100644 index 00000000000..3021be28782 --- /dev/null +++ b/packages/amazonq/test/e2e/amazonq/chat.test.ts @@ -0,0 +1,85 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { qTestingFramework } from './framework/framework' +import sinon from 'sinon' +import { Messenger } from './framework/messenger' +import { MynahUIDataModel } from '@aws/mynah-ui' +import { assertContextCommands, assertQuickActions } from './assert' +import { registerAuthHook, using } from 'aws-core-vscode/test' +import { loginToIdC } from './utils/setup' +import { webviewConstants } from 'aws-core-vscode/amazonq' + +describe('Amazon Q Chat', function () { + let framework: qTestingFramework + let tab: Messenger + let store: MynahUIDataModel + + const availableCommands: string[] = ['/dev', '/test', '/review', '/doc', '/transform'] + + before(async function () { + /** + * Login to the amazonq-test-account. When running in CI this has unlimited + * calls to the backend api + */ + await using(registerAuthHook('amazonq-test-account'), async () => { + await loginToIdC() + }) + }) + + // jscpd:ignore-start + beforeEach(() => { + // Make sure you're logged in before every test + registerAuthHook('amazonq-test-account') + framework = new qTestingFramework('cwc', true, []) + tab = framework.createTab() + store = tab.getStore() + }) + + afterEach(() => { + framework.removeTab(tab.tabID) + framework.dispose() + sinon.restore() + }) + + it(`Shows quick actions: ${availableCommands.join(', ')}`, async () => { + assertQuickActions(tab, availableCommands) + }) + + it('Shows @workspace', () => { + assertContextCommands(tab, ['@workspace']) + }) + + // jscpd:ignore-end + + it('Shows title', () => { + assert.deepStrictEqual(store.tabTitle, 'Chat') + }) + + it('Shows placeholder', () => { + assert.deepStrictEqual(store.promptInputPlaceholder, 'Ask a question or enter "/" for quick actions') + }) + + it('Sends message', async () => { + tab.addChatMessage({ + prompt: 'What is a lambda', + }) + await tab.waitForChatFinishesLoading() + const chatItems = tab.getChatItems() + // the last item should be an answer + assert.deepStrictEqual(chatItems[4].type, 'answer') + }) + + describe('Clicks examples', () => { + it('Click help', async () => { + tab.clickButton('help') + await tab.waitForText(webviewConstants.helpMessage) + const chatItems = tab.getChatItems() + assert.deepStrictEqual(chatItems[4].type, 'answer') + assert.deepStrictEqual(chatItems[4].body, webviewConstants.helpMessage) + }) + }) +}) diff --git a/packages/amazonq/test/e2e/amazonq/doc.test.ts b/packages/amazonq/test/e2e/amazonq/doc.test.ts new file mode 100644 index 00000000000..343d228c261 --- /dev/null +++ b/packages/amazonq/test/e2e/amazonq/doc.test.ts @@ -0,0 +1,178 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { qTestingFramework } from './framework/framework' +import sinon from 'sinon' +import { registerAuthHook, using } from 'aws-core-vscode/test' +import { loginToIdC } from './utils/setup' +import { Messenger } from './framework/messenger' +import { FollowUpTypes } from 'aws-core-vscode/amazonq' +import { i18n } from 'aws-core-vscode/shared' +import { docGenerationProgressMessage, DocGenerationStep, Mode } from 'aws-core-vscode/amazonqDoc' + +describe('Amazon Q Doc', async function () { + let framework: qTestingFramework + let tab: Messenger + + before(async function () { + /** + * The tests are getting throttled, only run them on stable for now + * + * TODO: Re-enable for all versions once the backend can handle them + */ + const testVersion = process.env['VSCODE_TEST_VERSION'] + if (testVersion && testVersion !== 'stable') { + this.skip() + } + + await using(registerAuthHook('amazonq-test-account'), async () => { + await loginToIdC() + }) + }) + + beforeEach(() => { + registerAuthHook('amazonq-test-account') + framework = new qTestingFramework('doc', true, []) + tab = framework.createTab() + }) + + afterEach(() => { + framework.removeTab(tab.tabID) + framework.dispose() + sinon.restore() + }) + + describe('Quick action availability', () => { + it('Shows /doc when doc generation is enabled', async () => { + const command = tab.findCommand('/doc') + if (!command.length) { + assert.fail('Could not find command') + } + + if (command.length > 1) { + assert.fail('Found too many commands with the name /doc') + } + }) + + it('Does NOT show /doc when doc generation is NOT enabled', () => { + // The beforeEach registers a framework which accepts requests. If we don't dispose before building a new one we have duplicate messages + framework.dispose() + framework = new qTestingFramework('doc', false, []) + const tab = framework.createTab() + const command = tab.findCommand('/doc') + if (command.length > 0) { + assert.fail('Found command when it should not have been found') + } + }) + }) + + describe('/doc entry', () => { + beforeEach(async function () { + tab.addChatMessage({ command: '/doc' }) + await tab.waitForChatFinishesLoading() + }) + + it('Checks for initial follow ups', async () => { + await tab.waitForButtons([FollowUpTypes.CreateDocumentation, FollowUpTypes.UpdateDocumentation]) + }) + }) + + describe('Creates a README', () => { + beforeEach(async function () { + tab.addChatMessage({ command: '/doc' }) + await tab.waitForChatFinishesLoading() + }) + + it('Creates a README for root folder', async () => { + await tab.waitForButtons([FollowUpTypes.CreateDocumentation]) + + tab.clickButton(FollowUpTypes.CreateDocumentation) + + await tab.waitForText(i18n('AWS.amazonq.doc.answer.createReadme')) + + await tab.waitForButtons([FollowUpTypes.ProceedFolderSelection]) + + tab.clickButton(FollowUpTypes.ProceedFolderSelection) + + await tab.waitForText(docGenerationProgressMessage(DocGenerationStep.SUMMARIZING_FILES, Mode.CREATE)) + + await tab.waitForText( + `${i18n('AWS.amazonq.doc.answer.readmeCreated')} ${i18n('AWS.amazonq.doc.answer.codeResult')}` + ) + + await tab.waitForButtons([ + FollowUpTypes.AcceptChanges, + FollowUpTypes.MakeChanges, + FollowUpTypes.RejectChanges, + ]) + + tab.clickButton(FollowUpTypes.AcceptChanges) + + await tab.waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) + }) + }) + + describe('Edits a README', () => { + beforeEach(async function () { + tab.addChatMessage({ command: '/doc' }) + await tab.waitForChatFinishesLoading() + }) + + it('Make specific change in README', async () => { + await tab.waitForButtons([FollowUpTypes.UpdateDocumentation]) + + tab.clickButton(FollowUpTypes.UpdateDocumentation) + + await tab.waitForButtons([FollowUpTypes.SynchronizeDocumentation, FollowUpTypes.EditDocumentation]) + + tab.clickButton(FollowUpTypes.EditDocumentation) + + await tab.waitForButtons([FollowUpTypes.ProceedFolderSelection]) + + tab.clickButton(FollowUpTypes.ProceedFolderSelection) + + tab.addChatMessage({ prompt: 'remove the repository structure section' }) + + await tab.waitForText( + `${i18n('AWS.amazonq.doc.answer.readmeUpdated')} ${i18n('AWS.amazonq.doc.answer.codeResult')}` + ) + + await tab.waitForButtons([ + FollowUpTypes.AcceptChanges, + FollowUpTypes.MakeChanges, + FollowUpTypes.RejectChanges, + ]) + }) + + it('Handle unrelated prompt error', async () => { + await tab.waitForButtons([FollowUpTypes.UpdateDocumentation]) + + tab.clickButton(FollowUpTypes.UpdateDocumentation) + + await tab.waitForButtons([FollowUpTypes.SynchronizeDocumentation, FollowUpTypes.EditDocumentation]) + + tab.clickButton(FollowUpTypes.EditDocumentation) + + await tab.waitForButtons([FollowUpTypes.ProceedFolderSelection]) + + tab.clickButton(FollowUpTypes.ProceedFolderSelection) + + tab.addChatMessage({ prompt: 'tell me about the weather' }) + + await tab.waitForEvent(() => + tab.getChatItems().some(({ body }) => body?.startsWith(i18n('AWS.amazonq.doc.error.promptUnrelated'))) + ) + + await tab.waitForEvent(() => { + const store = tab.getStore() + return ( + !store.promptInputDisabledState && + store.promptInputPlaceholder === i18n('AWS.amazonq.doc.placeholder.editReadme') + ) + }) + }) + }) +}) diff --git a/packages/amazonq/test/e2e/amazonq/explore.test.ts b/packages/amazonq/test/e2e/amazonq/explore.test.ts new file mode 100644 index 00000000000..970d93d00bb --- /dev/null +++ b/packages/amazonq/test/e2e/amazonq/explore.test.ts @@ -0,0 +1,45 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import { qTestingFramework } from './framework/framework' +import { Messenger } from './framework/messenger' + +describe('Amazon Q Explore page', function () { + let framework: qTestingFramework + let tab: Messenger + + beforeEach(() => { + framework = new qTestingFramework('agentWalkthrough', true, [], 0) + const welcomeTab = framework.getTabs()[0] + welcomeTab.clickInBodyButton('explore') + + // Find the new explore tab + const exploreTab = framework.findTab('Explore') + if (!exploreTab) { + assert.fail('Explore tab not found') + } + tab = exploreTab + }) + + afterEach(() => { + framework.removeTab(tab.tabID) + framework.dispose() + sinon.restore() + }) + + // TODO refactor page objects so we can associate clicking user guides with actual urls + // TODO test that clicking quick start changes the tab title, etc + it('should have correct button IDs', async () => { + const features = ['featuredev', 'testgen', 'doc', 'review', 'gumby'] + + for (const [index, feature] of features.entries()) { + const buttons = (tab.getStore().chatItems ?? [])[index].buttons ?? [] + assert.deepStrictEqual(buttons[0].id, `user-guide-${feature}`) + assert.deepStrictEqual(buttons[1].id, `quick-start-${feature}`) + } + }) +}) diff --git a/packages/amazonq/test/e2e/amazonq/featureDev.test.ts b/packages/amazonq/test/e2e/amazonq/featureDev.test.ts index 5b830834743..cc1670ced8f 100644 --- a/packages/amazonq/test/e2e/amazonq/featureDev.test.ts +++ b/packages/amazonq/test/e2e/amazonq/featureDev.test.ts @@ -22,22 +22,11 @@ describe('Amazon Q Feature Dev', function () { const fileLevelAcceptPrompt = `${prompt} and add a license, and a contributing file` const tooManyRequestsWaitTime = 100000 - function waitForButtons(buttons: FollowUpTypes[]) { - return tab.waitForEvent(() => { - return buttons.every((value) => tab.hasButton(value)) - }) - } - async function waitForText(text: string) { - await tab.waitForEvent( - () => { - return tab.getChatItems().some((chatItem) => chatItem.body === text) - }, - { - waitIntervalInMs: 250, - waitTimeoutInMs: 2000, - } - ) + await tab.waitForText(text, { + waitIntervalInMs: 250, + waitTimeoutInMs: 2000, + }) } async function iterate(prompt: string) { @@ -201,12 +190,12 @@ describe('Amazon Q Feature Dev', function () { it('Clicks accept code and click new task', async () => { await retryIfRequired(async () => { await Promise.any([ - waitForButtons([FollowUpTypes.InsertCode, FollowUpTypes.ProvideFeedbackAndRegenerateCode]), - waitForButtons([FollowUpTypes.Retry]), + tab.waitForButtons([FollowUpTypes.InsertCode, FollowUpTypes.ProvideFeedbackAndRegenerateCode]), + tab.waitForButtons([FollowUpTypes.Retry]), ]) }) tab.clickButton(FollowUpTypes.InsertCode) - await waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) + await tab.waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) tab.clickButton(FollowUpTypes.NewTask) await waitForText('What new task would you like to work on?') assert.deepStrictEqual(tab.getChatItems().pop()?.body, 'What new task would you like to work on?') @@ -215,15 +204,15 @@ describe('Amazon Q Feature Dev', function () { it('Iterates on codegen', async () => { await retryIfRequired(async () => { await Promise.any([ - waitForButtons([FollowUpTypes.InsertCode, FollowUpTypes.ProvideFeedbackAndRegenerateCode]), - waitForButtons([FollowUpTypes.Retry]), + tab.waitForButtons([FollowUpTypes.InsertCode, FollowUpTypes.ProvideFeedbackAndRegenerateCode]), + tab.waitForButtons([FollowUpTypes.Retry]), ]) }) tab.clickButton(FollowUpTypes.ProvideFeedbackAndRegenerateCode) await tab.waitForChatFinishesLoading() await iterate(codegenApproachPrompt) tab.clickButton(FollowUpTypes.InsertCode) - await waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) + await tab.waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) }) }) @@ -240,8 +229,8 @@ describe('Amazon Q Feature Dev', function () { ) await retryIfRequired(async () => { await Promise.any([ - waitForButtons([FollowUpTypes.InsertCode, FollowUpTypes.ProvideFeedbackAndRegenerateCode]), - waitForButtons([FollowUpTypes.Retry]), + tab.waitForButtons([FollowUpTypes.InsertCode, FollowUpTypes.ProvideFeedbackAndRegenerateCode]), + tab.waitForButtons([FollowUpTypes.Retry]), ]) }) }) @@ -271,7 +260,7 @@ describe('Amazon Q Feature Dev', function () { it('disables all action buttons when new task is clicked', async () => { tab.clickButton(FollowUpTypes.InsertCode) - await waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) + await tab.waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) tab.clickButton(FollowUpTypes.NewTask) await waitForText('What new task would you like to work on?') @@ -283,7 +272,7 @@ describe('Amazon Q Feature Dev', function () { it('disables all action buttons when close session is clicked', async () => { tab.clickButton(FollowUpTypes.InsertCode) - await waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) + await tab.waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) tab.clickButton(FollowUpTypes.CloseSession) await waitForText( "Okay, I've ended this chat session. You can open a new tab to chat or start another workflow." @@ -335,7 +324,7 @@ describe('Amazon Q Feature Dev', function () { for (const filePath of filePaths) { await clickActionButton(filePath, 'accept-change') } - await waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) + await tab.waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) assert.ok(tab.hasButton(FollowUpTypes.InsertCode) === false) assert.ok(tab.hasButton(FollowUpTypes.ProvideFeedbackAndRegenerateCode) === false) diff --git a/packages/amazonq/test/e2e/amazonq/framework/framework.ts b/packages/amazonq/test/e2e/amazonq/framework/framework.ts index b65e8b184f7..6a29015c06f 100644 --- a/packages/amazonq/test/e2e/amazonq/framework/framework.ts +++ b/packages/amazonq/test/e2e/amazonq/framework/framework.ts @@ -8,6 +8,7 @@ import { injectJSDOM } from './jsdomInjector' // This needs to be ran before all other imports so that mynah ui gets loaded inside of jsdom injectJSDOM() +import assert from 'assert' import * as vscode from 'vscode' import { MynahUI, MynahUIProps } from '@aws/mynah-ui' import { DefaultAmazonQAppInitContext, TabType, createMynahUI } from 'aws-core-vscode/amazonq' @@ -24,7 +25,12 @@ export class qTestingFramework { lastEventId: string = '' - constructor(featureName: TabType, amazonQEnabled: boolean, featureConfigsSerialized: [string, FeatureContext][]) { + constructor( + featureName: TabType, + amazonQEnabled: boolean, + featureConfigsSerialized: [string, FeatureContext][], + welcomeCount = Number.MAX_VALUE // by default don't show the welcome page + ) { /** * Instantiate the UI and override the postMessage to publish using the app message * publishers directly. @@ -44,7 +50,8 @@ export class qTestingFramework { }, }, amazonQEnabled, - featureConfigsSerialized + featureConfigsSerialized, + welcomeCount ) this.mynahUI = ui.mynahUI this.mynahUIProps = (this.mynahUI as any).props @@ -79,18 +86,35 @@ export class qTestingFramework { * functionality against a specific tab */ public createTab(options?: MessengerOptions) { - const newTabID = this.mynahUI.updateStore('', {}) + const oldTabs = Object.keys(this.mynahUI.getAllTabs()) + + // simulate pressing the new tab button + ;(document.querySelectorAll('.mynah-nav-tabs-wrapper > button.mynah-button')[0] as HTMLButtonElement).click() + const newTabs = Object.keys(this.mynahUI.getAllTabs()) + + const newTabID = newTabs.find((tab) => !oldTabs.includes(tab)) if (!newTabID) { - throw new Error('Could not create tab id') + assert.fail('Could not find new tab') } + return new Messenger(newTabID, this.mynahUIProps, this.mynahUI, options) } + public getTabs() { + const tabs = this.mynahUI.getAllTabs() + return Object.entries(tabs).map(([tabId]) => new Messenger(tabId, this.mynahUIProps, this.mynahUI)) + } + + public findTab(title: string) { + return Object.values(this.getTabs()).find((tab) => tab.getStore().tabTitle === title) + } + public removeTab(tabId: string) { this.mynahUI.removeTab(tabId, this.lastEventId) } public dispose() { vscode.Disposable.from(...this.disposables).dispose() + this.mynahUI.destroy() } } diff --git a/packages/amazonq/test/e2e/amazonq/framework/jsdomInjector.ts b/packages/amazonq/test/e2e/amazonq/framework/jsdomInjector.ts index f5d2d1c2770..ce8309c1039 100644 --- a/packages/amazonq/test/e2e/amazonq/framework/jsdomInjector.ts +++ b/packages/amazonq/test/e2e/amazonq/framework/jsdomInjector.ts @@ -3,15 +3,24 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { JSDOM } from 'jsdom' +import { JSDOM, VirtualConsole } from 'jsdom' +/** + * JSDOM is used to help hoist MynahUI to running in a node environment vs in the browser (which is what it's made for) + */ export function injectJSDOM() { - /** - * JSDOM is used to help hoist MynahUI to running in a node environment vs in the browser (which is what it's made for) - */ + const virtualConsole = new VirtualConsole() + virtualConsole.on('error', (error) => { + // JSDOM can't load scss from mynah UI, just skip it + if (!error.includes('Could not parse CSS stylesheet')) { + console.error(error) + } + }) + const dom = new JSDOM(undefined, { pretendToBeVisual: true, includeNodeLocations: true, + virtualConsole, }) global.window = dom.window as unknown as Window & typeof globalThis global.document = dom.window.document diff --git a/packages/amazonq/test/e2e/amazonq/framework/messenger.ts b/packages/amazonq/test/e2e/amazonq/framework/messenger.ts index 353bd3b4a9c..0aa363d2e3d 100644 --- a/packages/amazonq/test/e2e/amazonq/framework/messenger.ts +++ b/packages/amazonq/test/e2e/amazonq/framework/messenger.ts @@ -51,12 +51,36 @@ export class Messenger { } const lastChatItem = this.getChatItems().pop() - const option = lastChatItem?.followUp?.options?.filter((option) => option.type === type) - if (!option || option.length > 1) { - assert.fail('Could not find follow up option') + const followupOption = lastChatItem?.followUp?.options?.filter((option) => option.type === type) + if (followupOption && followupOption.length > 0) { + this.mynahUIProps.onFollowUpClicked(this.tabID, lastChatItem?.messageId ?? '', followupOption[0]) + return } - this.mynahUIProps.onFollowUpClicked(this.tabID, lastChatItem?.messageId ?? '', option[0]) + assert.fail(`Could not find a button with id ${type} on tabID: ${this.tabID}`) + } + + clickInBodyButton(type: string) { + if (!this.mynahUIProps.onInBodyButtonClicked) { + assert.fail('onInBodyButtonClicked must be defined to use it in the tests') + } + + const lastChatItem = this.getChatItems().pop() + const followupButton = lastChatItem?.buttons?.filter((option) => option.id === type) + if (followupButton && followupButton.length > 0) { + this.mynahUIProps.onInBodyButtonClicked(this.tabID, lastChatItem?.messageId ?? '', followupButton[0]) + return + } + + assert.fail(`Could not find a button with id ${type} on tabID: ${this.tabID}`) + } + + clickCustomFormButton(action: { id: string; text?: string; formItemValues?: Record }) { + if (!this.mynahUIProps.onCustomFormAction) { + assert.fail('onCustomFormAction must be defined to use it in the tests') + } + + this.mynahUIProps.onCustomFormAction(this.tabID, action) } clickFileActionButton(filePath: string, actionName: string) { @@ -145,17 +169,23 @@ export class Messenger { return this.getActionsByFilePath(filePath).some((action) => action.name === actionName) } + async waitForText(text: string, waitOverrides?: MessengerOptions) { + await this.waitForEvent(() => { + return this.getChatItems().some((chatItem) => chatItem.body === text) + }, waitOverrides) + } + + async waitForButtons(buttons: FollowUpTypes[]) { + return this.waitForEvent(() => { + return buttons.every((value) => this.hasButton(value)) + }) + } + async waitForChatFinishesLoading() { return this.waitForEvent(() => this.getStore().loadingChat === false || this.hasButton(FollowUpTypes.Retry)) } - async waitForEvent( - event: () => boolean, - waitOverrides?: { - waitIntervalInMs: number - waitTimeoutInMs: number - } - ) { + async waitForEvent(event: () => boolean, waitOverrides?: MessengerOptions) { /** * Wait until the chat has finished loading. This happens when a backend request * has finished and responded in the chat @@ -173,11 +203,13 @@ export class Messenger { // Do another check just in case the waitUntil time'd out if (!event()) { - assert.fail(`Event has not finished loading in: ${this.waitTimeoutInMs} ms`) + assert.fail( + `Event has not finished loading in: ${waitOverrides ? waitOverrides.waitTimeoutInMs : this.waitTimeoutInMs} ms` + ) } } - private getStore(): MynahUIDataModel { + getStore(): MynahUIDataModel { const store = this.mynahUI.getAllTabs()[this.tabID].store if (!store) { assert.fail(`${this.tabID} does not have a store`) diff --git a/packages/amazonq/test/e2e/amazonq/template.test.ts b/packages/amazonq/test/e2e/amazonq/template.test.ts new file mode 100644 index 00000000000..42857575583 --- /dev/null +++ b/packages/amazonq/test/e2e/amazonq/template.test.ts @@ -0,0 +1,67 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// jscpd:ignore-start +import assert from 'assert' +import { qTestingFramework } from './framework/framework' +import sinon from 'sinon' +import { Messenger } from './framework/messenger' +import { MynahUIDataModel } from '@aws/mynah-ui' +import { assertQuickActions } from './assert' +import { registerAuthHook, using } from 'aws-core-vscode/test' +import { loginToIdC } from './utils/setup' + +describe.skip('Amazon Q Test Template', function () { + let framework: qTestingFramework + let tab: Messenger + let store: MynahUIDataModel + + const availableCommands: string[] = [] + + before(async function () { + /** + * Login to the amazonq-test-account. When running in CI this has unlimited + * calls to the backend api + */ + await using(registerAuthHook('amazonq-test-account'), async () => { + await loginToIdC() + }) + }) + + beforeEach(() => { + // Make sure you're logged in before every test + registerAuthHook('amazonq-test-account') + + // TODO change unknown to the tab type you want to test + framework = new qTestingFramework('unknown', true, []) + tab = framework.getTabs()[0] // use the default tab that gets created + framework.createTab() // alternatively you can create a new tab + store = tab.getStore() + }) + + afterEach(() => { + framework.removeTab(tab.tabID) + framework.dispose() + sinon.restore() + }) + + it(`Shows quick actions: ${availableCommands.join(', ')}`, async () => { + assertQuickActions(tab, availableCommands) + }) + + it('Shows title', () => { + assert.deepStrictEqual(store.tabTitle, '') + }) + + it('Shows placeholder', () => { + assert.deepStrictEqual(store.promptInputPlaceholder, '') + }) + + describe('clicks examples', () => {}) + + describe('sends message', async () => {}) +}) + +// jscpd:ignore-end diff --git a/packages/amazonq/test/e2e/amazonq/transformByQ.test.ts b/packages/amazonq/test/e2e/amazonq/transformByQ.test.ts new file mode 100644 index 00000000000..b28368e1e7f --- /dev/null +++ b/packages/amazonq/test/e2e/amazonq/transformByQ.test.ts @@ -0,0 +1,373 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { qTestingFramework } from './framework/framework' +import sinon from 'sinon' +import { Messenger } from './framework/messenger' +import { + CodeWhispererConstants, + JDKVersion, + TransformationType, + transformByQState, +} from 'aws-core-vscode/codewhisperer' +import { GumbyController, setMaven, startTransformByQ, TabsStorage } from 'aws-core-vscode/amazonqGumby' +import { using, registerAuthHook, TestFolder } from 'aws-core-vscode/test' +import { loginToIdC } from './utils/setup' +import { fs } from 'aws-core-vscode/shared' +import path from 'path' + +describe('Amazon Q Code Transformation', function () { + let framework: qTestingFramework + let tab: Messenger + + before(async function () { + await using(registerAuthHook('amazonq-test-account'), async () => { + await loginToIdC() + }) + }) + + beforeEach(() => { + registerAuthHook('amazonq-test-account') + framework = new qTestingFramework('gumby', true, []) + tab = framework.createTab() + }) + + afterEach(() => { + framework.removeTab(tab.tabID) + framework.dispose() + sinon.restore() + }) + + describe('Quick action availability', () => { + it('Can invoke /transform when QCT is enabled', async () => { + const command = tab.findCommand('/transform') + if (!command) { + assert.fail('Could not find command') + } + + if (command.length > 1) { + assert.fail('Found too many commands with the name /transform') + } + }) + + it('CANNOT invoke /transform when QCT is NOT enabled', () => { + framework.dispose() + framework = new qTestingFramework('gumby', false, []) + const tab = framework.createTab() + const command = tab.findCommand('/transform') + if (command.length > 0) { + assert.fail('Found command when it should not have been found') + } + }) + }) + + describe('Starting a transformation from chat', () => { + it('Can click through all user input forms for a Java upgrade', async () => { + sinon.stub(startTransformByQ, 'getValidSQLConversionCandidateProjects').resolves([]) + sinon.stub(GumbyController.prototype, 'validateLanguageUpgradeProjects' as keyof GumbyController).resolves([ + { + name: 'qct-sample-java-8-app-main', + path: '/Users/alias/Desktop/qct-sample-java-8-app-main', + JDKVersion: JDKVersion.JDK8, + }, + ]) + + tab.addChatMessage({ command: '/transform' }) + + // wait for /transform to respond with some intro messages and the first user input form + await tab.waitForEvent(() => tab.getChatItems().length > 3, { + waitTimeoutInMs: 5000, + waitIntervalInMs: 1000, + }) + const projectForm = tab.getChatItems().pop() + assert.strictEqual(projectForm?.formItems?.[0]?.id ?? undefined, 'GumbyTransformLanguageUpgradeProjectForm') + + const projectFormItemValues = { + GumbyTransformLanguageUpgradeProjectForm: '/Users/alias/Desktop/qct-sample-java-8-app-main', + GumbyTransformJdkFromForm: '8', + GumbyTransformJdkToForm: '17', + } + const projectFormValues: Record = { ...projectFormItemValues } + // TODO: instead of stubbing, can we create a tab in qTestingFramework with tabType passed in? + // Mynah-UI updates tab type like this: this.tabsStorage.updateTabTypeFromUnknown(affectedTabId, 'gumby') + sinon + .stub(TabsStorage.prototype, 'getTab') + .returns({ id: tab.tabID, status: 'free', type: 'gumby', isSelected: true }) + tab.clickCustomFormButton({ + id: 'gumbyLanguageUpgradeTransformFormConfirm', + text: 'Confirm', + formItemValues: projectFormValues, + }) + + // 3 additional chat messages (including message with 2nd form) get sent after 1st form submitted; wait for all of them + await tab.waitForEvent(() => tab.getChatItems().length > 6, { + waitTimeoutInMs: 5000, + waitIntervalInMs: 1000, + }) + const skipTestsForm = tab.getChatItems().pop() + assert.strictEqual(skipTestsForm?.formItems?.[0]?.id ?? undefined, 'GumbyTransformSkipTestsForm') + + const skipTestsFormItemValues = { + GumbyTransformSkipTestsForm: 'Run unit tests', + } + const skipTestsFormValues: Record = { ...skipTestsFormItemValues } + tab.clickCustomFormButton({ + id: 'gumbyTransformSkipTestsFormConfirm', + text: 'Confirm', + formItemValues: skipTestsFormValues, + }) + + // 3 additional chat messages (including message with 3rd form) get sent after 2nd form submitted; wait for all of them + await tab.waitForEvent(() => tab.getChatItems().length > 9, { + waitTimeoutInMs: 5000, + waitIntervalInMs: 1000, + }) + const multipleDiffsForm = tab.getChatItems().pop() + assert.strictEqual( + multipleDiffsForm?.formItems?.[0]?.id ?? undefined, + 'GumbyTransformOneOrMultipleDiffsForm' + ) + + const oneOrMultipleDiffsFormItemValues = { + GumbyTransformOneOrMultipleDiffsForm: 'One diff', + } + const oneOrMultipleDiffsFormValues: Record = { ...oneOrMultipleDiffsFormItemValues } + tab.clickCustomFormButton({ + id: 'gumbyTransformOneOrMultipleDiffsFormConfirm', + text: 'Confirm', + formItemValues: oneOrMultipleDiffsFormValues, + }) + + // 2 additional chat messages (including message with 4th form) get sent after 3rd form submitted; wait for both of them + await tab.waitForEvent(() => tab.getChatItems().length > 11, { + waitTimeoutInMs: 5000, + waitIntervalInMs: 1000, + }) + const jdkPathPrompt = tab.getChatItems().pop() + assert.strictEqual(jdkPathPrompt?.body?.includes('Enter the path to JDK'), true) + + // 2 additional chat messages get sent after 4th form submitted; wait for both of them + tab.addChatMessage({ prompt: '/dummy/path/to/jdk8' }) + await tab.waitForEvent(() => tab.getChatItems().length > 13, { + waitTimeoutInMs: 5000, + waitIntervalInMs: 1000, + }) + const jdkPathResponse = tab.getChatItems().pop() + // this 'Sorry' message is OK - just making sure that the UI components are working correctly + assert.strictEqual(jdkPathResponse?.body?.includes("Sorry, I couldn't locate your Java installation"), true) + + const tmpDir = (await TestFolder.create()).path + + transformByQState.setSummaryFilePath(path.join(tmpDir, 'summary.md')) + + transformByQState + .getChatMessenger() + ?.sendJobFinishedMessage(tab.tabID, CodeWhispererConstants.viewProposedChangesChatMessage) + + tab.clickCustomFormButton({ + id: 'gumbyViewSummary', + text: 'View summary', + }) + + await tab.waitForEvent(() => tab.getChatItems().length > 14, { + waitTimeoutInMs: 5000, + waitIntervalInMs: 1000, + }) + + const viewSummaryChatItem = tab.getChatItems().pop() + assert.strictEqual(viewSummaryChatItem?.body?.includes('view a summary'), true) + }) + + it('Can provide metadata file for a SQL conversion', async () => { + sinon.stub(startTransformByQ, 'getValidSQLConversionCandidateProjects').resolves([ + { + name: 'OracleExample', + path: '/Users/alias/Desktop/OracleExample', + JDKVersion: JDKVersion.JDK17, + }, + ]) + sinon.stub(startTransformByQ, 'getValidLanguageUpgradeCandidateProjects').resolves([]) + sinon.stub(GumbyController.prototype, 'validateSQLConversionProjects' as keyof GumbyController).resolves([ + { + name: 'OracleExample', + path: '/Users/alias/Desktop/OracleExample', + JDKVersion: JDKVersion.JDK17, + }, + ]) + + tab.addChatMessage({ command: '/transform' }) + + // wait for /transform to respond with some intro messages and the first user input message + await tab.waitForEvent(() => tab.getChatItems().length > 3, { + waitTimeoutInMs: 5000, + waitIntervalInMs: 1000, + }) + const selectMetadataMessage = tab.getChatItems().pop() + assert.strictEqual( + selectMetadataMessage?.body?.includes('I can convert the embedded SQL') ?? undefined, + true + ) + + // verify that we processed the metadata file + const processMetadataFileStub = sinon.stub( + GumbyController.prototype, + 'processMetadataFile' as keyof GumbyController + ) + tab.clickCustomFormButton({ + id: 'gumbySQLConversionMetadataTransformFormConfirm', + text: 'Select metadata file', + }) + sinon.assert.calledOnce(processMetadataFileStub) + }) + + it('Can choose "language upgrade" when eligible for a Java upgrade AND SQL conversion', async () => { + sinon.stub(startTransformByQ, 'getValidSQLConversionCandidateProjects').resolves([ + { + name: 'OracleExample', + path: '/Users/alias/Desktop/OracleExample', + JDKVersion: JDKVersion.JDK17, + }, + ]) + sinon.stub(startTransformByQ, 'getValidLanguageUpgradeCandidateProjects').resolves([ + { + name: 'qct-sample-java-8-app-main', + path: '/Users/alias/Desktop/qct-sample-java-8-app-main', + JDKVersion: JDKVersion.JDK8, + }, + ]) + + tab.addChatMessage({ command: '/transform' }) + + // wait for /transform to respond with some intro messages and a prompt asking user what they want to do + await tab.waitForEvent(() => tab.getChatItems().length > 2, { + waitTimeoutInMs: 5000, + waitIntervalInMs: 1000, + }) + const prompt = tab.getChatItems().pop() + assert.strictEqual( + prompt?.body?.includes('You can enter "language upgrade" or "sql conversion"') ?? undefined, + true + ) + + // 3 additional chat messages get sent after user enters a choice; wait for all of them + tab.addChatMessage({ prompt: 'language upgrade' }) + await tab.waitForEvent(() => tab.getChatItems().length > 5, { + waitTimeoutInMs: 5000, + waitIntervalInMs: 1000, + }) + const projectForm = tab.getChatItems().pop() + assert.strictEqual(projectForm?.formItems?.[0]?.id ?? undefined, 'GumbyTransformLanguageUpgradeProjectForm') + }) + + it('Can choose "sql conversion" when eligible for a Java upgrade AND SQL conversion', async () => { + sinon.stub(startTransformByQ, 'getValidSQLConversionCandidateProjects').resolves([ + { + name: 'OracleExample', + path: '/Users/alias/Desktop/OracleExample', + JDKVersion: JDKVersion.JDK17, + }, + ]) + sinon.stub(startTransformByQ, 'getValidLanguageUpgradeCandidateProjects').resolves([ + { + name: 'qct-sample-java-8-app-main', + path: '/Users/alias/Desktop/qct-sample-java-8-app-main', + JDKVersion: JDKVersion.JDK8, + }, + ]) + + tab.addChatMessage({ command: '/transform' }) + + // wait for /transform to respond with some intro messages and a prompt asking user what they want to do + await tab.waitForEvent(() => tab.getChatItems().length > 2, { + waitTimeoutInMs: 5000, + waitIntervalInMs: 1000, + }) + const prompt = tab.getChatItems().pop() + assert.strictEqual( + prompt?.body?.includes('You can enter "language upgrade" or "sql conversion"') ?? undefined, + true + ) + + // 3 additional chat messages get sent after user enters a choice; wait for all of them + tab.addChatMessage({ prompt: 'sql conversion' }) + await tab.waitForEvent(() => tab.getChatItems().length > 5, { + waitTimeoutInMs: 5000, + waitIntervalInMs: 1000, + }) + const selectMetadataMessage = tab.getChatItems().pop() + assert.strictEqual( + selectMetadataMessage?.body?.includes('I can convert the embedded SQL') ?? undefined, + true + ) + }) + }) + + // TODO: enable when we no longer get throttled on CreateUploadUrl and other APIs + describe.skip('Running a Java upgrade from start to finish', async function () { + let tempDir = '' + let tempFileName = '' + let tempFilePath = '' + + const javaFileContents = `public class MyApp { + public static void main(String[] args) { + Integer temp = new Integer("1234"); + } + }` + + const pomXmlContents = ` + + 4.0.0 + + com.example + basic-java-app + 1.0-SNAPSHOT + + + 1.8 + 1.8 + UTF-8 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 1.8 + 1.8 + + + + + ` + + before(async function () { + tempDir = path.join((await TestFolder.create()).path, 'qct-java-upgrade-test') + tempFileName = 'MyApp.java' + tempFilePath = path.join(tempDir, tempFileName) + await fs.writeFile(tempFilePath, javaFileContents) + tempFileName = 'pom.xml' + tempFilePath = path.join(tempDir, tempFileName) + await fs.writeFile(tempFilePath, pomXmlContents) + }) + + after(async function () { + await fs.delete(tempDir, { recursive: true }) + }) + + it('WHEN transforming a Java 8 project E2E THEN job is successful', async function () { + transformByQState.setTransformationType(TransformationType.LANGUAGE_UPGRADE) + await setMaven() + await startTransformByQ.processLanguageUpgradeTransformFormInput(tempDir, JDKVersion.JDK8, JDKVersion.JDK17) + await startTransformByQ.startTransformByQ() + assert.strictEqual(transformByQState.getPolledJobStatus(), 'COMPLETED') + }) + }) +}) diff --git a/packages/amazonq/test/e2e/amazonq/welcome.test.ts b/packages/amazonq/test/e2e/amazonq/welcome.test.ts new file mode 100644 index 00000000000..d9f0ccd66bf --- /dev/null +++ b/packages/amazonq/test/e2e/amazonq/welcome.test.ts @@ -0,0 +1,103 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { qTestingFramework } from './framework/framework' +import sinon from 'sinon' +import { Messenger } from './framework/messenger' +import { MynahUIDataModel } from '@aws/mynah-ui' +import { FeatureContext } from 'aws-core-vscode/shared' +import { assertContextCommands, assertQuickActions } from './assert' + +describe('Amazon Q Welcome page', function () { + let framework: qTestingFramework + let tab: Messenger + let store: MynahUIDataModel + + const availableCommands = ['/dev', '/test', '/review', '/doc', '/transform'] + + const highlightCommand: FeatureContext = { + name: 'highlightCommand', + value: { + stringValue: '@highlight', + }, + variation: 'highlight command desc', + } + beforeEach(() => { + framework = new qTestingFramework('welcome', true, [['highlightCommand', highlightCommand]], 0) + tab = framework.getTabs()[0] // use the default tab that gets created + store = tab.getStore() + }) + + afterEach(() => { + framework.removeTab(tab.tabID) + framework.dispose() + sinon.restore() + }) + + it(`Shows quick actions: ${availableCommands.join(', ')}`, async () => { + assertQuickActions(tab, availableCommands) + }) + + it('Shows context commands', async () => { + assertContextCommands(tab, ['@workspace', '@highlight']) + }) + + describe('shows 3 times', async () => { + it('new tabs', () => { + framework.createTab() + framework.createTab() + framework.createTab() + framework.createTab() + + let welcomeCount = 0 + for (const tab of framework.getTabs()) { + if (tab.getStore().tabTitle === 'Welcome to Q') { + welcomeCount++ + } + } + // 3 welcome tabs + assert.deepStrictEqual(welcomeCount, 3) + + // 2 normal tabs + assert.deepStrictEqual(framework.getTabs().length - welcomeCount, 2) + }) + + it('new windows', () => { + // check the initial window + assert.deepStrictEqual(store.tabTitle, 'Welcome to Q') + framework.dispose() + + // check when theres already been two welcome tabs shown + framework = new qTestingFramework('welcome', true, [], 2) + const secondStore = framework.getTabs()[0].getStore() + assert.deepStrictEqual(secondStore.tabTitle, 'Welcome to Q') + framework.dispose() + + // check when theres already been three welcome tabs shown + framework = new qTestingFramework('welcome', true, [], 3) + const thirdStore = framework.getTabs()[0].getStore() + assert.deepStrictEqual(thirdStore.tabTitle, 'Chat') + framework.dispose() + }) + }) + + describe('Welcome actions', () => { + it('explore', () => { + tab.clickInBodyButton('explore') + + // explore opens in a new tab + const exploreTabStore = framework.findTab('Explore')?.getStore() + assert.strictEqual(exploreTabStore?.tabTitle, 'Explore') + }) + + it('quick-start', async () => { + tab.clickInBodyButton('quick-start') + + // clicking quick start opens in the current tab and changes the compact mode + assert.deepStrictEqual(tab.getStore().compactMode, false) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/models/model.test.ts b/packages/amazonq/test/unit/codewhisperer/models/model.test.ts index ae7114a22c8..7b0888521f4 100644 --- a/packages/amazonq/test/unit/codewhisperer/models/model.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/models/model.test.ts @@ -4,7 +4,12 @@ */ import assert from 'assert' import sinon from 'sinon' -import { SecurityIssueFilters, SecurityTreeViewFilterState } from 'aws-core-vscode/codewhisperer' +import { + CodeIssueGroupingStrategy, + CodeIssueGroupingStrategyState, + SecurityIssueFilters, + SecurityTreeViewFilterState, +} from 'aws-core-vscode/codewhisperer' import { globals } from 'aws-core-vscode/shared' describe('model', function () { @@ -70,4 +75,100 @@ describe('model', function () { assert.deepStrictEqual(hiddenSeverities, ['High', 'Low']) }) }) + + describe('CodeIssueGroupingStrategyState', function () { + let sandbox: sinon.SinonSandbox + let state: CodeIssueGroupingStrategyState + + beforeEach(function () { + sandbox = sinon.createSandbox() + state = CodeIssueGroupingStrategyState.instance + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('instance', function () { + it('should return the same instance when called multiple times', function () { + const instance1 = CodeIssueGroupingStrategyState.instance + const instance2 = CodeIssueGroupingStrategyState.instance + assert.strictEqual(instance1, instance2) + }) + }) + + describe('getState', function () { + it('should return fallback when no state is stored', function () { + const result = state.getState() + + assert.equal(result, CodeIssueGroupingStrategy.Severity) + }) + + it('should return stored state when valid', async function () { + const validStrategy = CodeIssueGroupingStrategy.FileLocation + await state.setState(validStrategy) + + const result = state.getState() + + assert.equal(result, validStrategy) + }) + + it('should return fallback when stored state is invalid', async function () { + const invalidStrategy = 'invalid' + await state.setState(invalidStrategy) + + const result = state.getState() + + assert.equal(result, CodeIssueGroupingStrategy.Severity) + }) + }) + + describe('setState', function () { + it('should update state and fire change event for valid strategy', async function () { + const validStrategy = CodeIssueGroupingStrategy.FileLocation + + // Create a spy to watch for event emissions + const eventSpy = sandbox.spy() + state.onDidChangeState(eventSpy) + + await state.setState(validStrategy) + + sinon.assert.calledWith(eventSpy, validStrategy) + }) + + it('should use fallback and fire change event for invalid strategy', async function () { + const invalidStrategy = 'invalid' + + // Create a spy to watch for event emissions + const eventSpy = sandbox.spy() + state.onDidChangeState(eventSpy) + + await state.setState(invalidStrategy) + + sinon.assert.calledWith(eventSpy, CodeIssueGroupingStrategy.Severity) + }) + }) + + describe('reset', function () { + it('should set state to fallback value', async function () { + const setStateStub = sandbox.stub(state, 'setState').resolves() + + await state.reset() + + sinon.assert.calledWith(setStateStub, CodeIssueGroupingStrategy.Severity) + }) + }) + + describe('onDidChangeState', function () { + it('should allow subscribing to state changes', async function () { + const listener = sandbox.spy() + const disposable = state.onDidChangeState(listener) + + await state.setState(CodeIssueGroupingStrategy.Severity) + + sinon.assert.calledWith(listener, CodeIssueGroupingStrategy.Severity) + disposable.dispose() + }) + }) + }) }) diff --git a/packages/amazonq/test/unit/codewhisperer/service/diagnosticsProvider.test.ts b/packages/amazonq/test/unit/codewhisperer/service/diagnosticsProvider.test.ts index 4bf076eb23c..910aadb02b9 100644 --- a/packages/amazonq/test/unit/codewhisperer/service/diagnosticsProvider.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/service/diagnosticsProvider.test.ts @@ -12,6 +12,8 @@ import { removeDiagnostic, disposeSecurityDiagnostic, SecurityDiagnostic, + createSecurityDiagnostic, + codewhispererDiagnosticSourceLabel, } from 'aws-core-vscode/codewhisperer' import { createCodeScanIssue, createMockDocument, createTextDocumentChangeEvent } from 'aws-core-vscode/test' @@ -83,4 +85,16 @@ describe('diagnosticsProvider', function () { assert.strictEqual(actual[1].range.start.line, 5) assert.strictEqual(actual[1].range.end.line, 6) }) + + it('should create securityDiagnostic from codeScanIssue', function () { + const codeScanIssue = createCodeScanIssue() + const securityDiagnostic = createSecurityDiagnostic(codeScanIssue) + assert.strictEqual(securityDiagnostic.findingId, codeScanIssue.findingId) + assert.strictEqual(securityDiagnostic.message, codeScanIssue.title) + assert.strictEqual(securityDiagnostic.range.start.line, codeScanIssue.startLine) + assert.strictEqual(securityDiagnostic.range.end.line, codeScanIssue.endLine) + assert.strictEqual(securityDiagnostic.severity, vscode.DiagnosticSeverity.Warning) + assert.strictEqual(securityDiagnostic.source, codewhispererDiagnosticSourceLabel) + assert.strictEqual(securityDiagnostic.code, codeScanIssue.ruleId) + }) }) diff --git a/packages/amazonq/test/unit/codewhisperer/service/keyStrokeHandler.test.ts b/packages/amazonq/test/unit/codewhisperer/service/keyStrokeHandler.test.ts index 075dc769b0e..4b6a5291f22 100644 --- a/packages/amazonq/test/unit/codewhisperer/service/keyStrokeHandler.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/service/keyStrokeHandler.test.ts @@ -212,7 +212,7 @@ describe('keyStrokeHandler', function () { ['function suggestedByIntelliSense():', DocumentChangedSource.Unknown], ] - cases.forEach((tuple) => { + for (const tuple of cases) { const input = tuple[0] const expected = tuple[1] it(`test input ${input} should return ${expected}`, function () { @@ -221,7 +221,7 @@ describe('keyStrokeHandler', function () { ).checkChangeSource() assert.strictEqual(actual, expected) }) - }) + } function createFakeDocumentChangeEvent(str: string): ReadonlyArray { return [ diff --git a/packages/amazonq/test/unit/codewhisperer/service/recommendationHandler.test.ts b/packages/amazonq/test/unit/codewhisperer/service/recommendationHandler.test.ts index 4bc10329f81..d8855796df0 100644 --- a/packages/amazonq/test/unit/codewhisperer/service/recommendationHandler.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/service/recommendationHandler.test.ts @@ -125,7 +125,7 @@ describe('recommendationHandler', function () { supplementalContextItems: [], contentsLength: 100, latency: 0, - strategy: 'Empty', + strategy: 'empty', }) sinon.stub(performance, 'now').returns(0.0) session.startPos = new vscode.Position(1, 0) diff --git a/packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts b/packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts index bd7c3aab8de..4d973735c9f 100644 --- a/packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts @@ -10,17 +10,24 @@ import { SecurityTreeViewFilterState, SecurityIssueProvider, SeverityItem, + CodeIssueGroupingStrategyState, + CodeIssueGroupingStrategy, } from 'aws-core-vscode/codewhisperer' import { createCodeScanIssue } from 'aws-core-vscode/test' import assert from 'assert' import sinon from 'sinon' +import path from 'path' describe('SecurityIssueTreeViewProvider', function () { - let securityIssueProvider: SecurityIssueProvider let securityIssueTreeViewProvider: SecurityIssueTreeViewProvider beforeEach(function () { - securityIssueProvider = SecurityIssueProvider.instance + SecurityIssueProvider.instance.issues = [ + { filePath: 'file/path/a', issues: [createCodeScanIssue(), createCodeScanIssue()] }, + { filePath: 'file/path/b', issues: [createCodeScanIssue(), createCodeScanIssue()] }, + { filePath: 'file/path/c', issues: [createCodeScanIssue(), createCodeScanIssue()] }, + { filePath: 'file/path/d', issues: [createCodeScanIssue(), createCodeScanIssue()] }, + ] securityIssueTreeViewProvider = new SecurityIssueTreeViewProvider() }) @@ -44,13 +51,6 @@ describe('SecurityIssueTreeViewProvider', function () { describe('getChildren', function () { it('should return sorted list of severities if element is undefined', function () { - securityIssueProvider.issues = [ - { filePath: 'file/path/c', issues: [createCodeScanIssue(), createCodeScanIssue()] }, - { filePath: 'file/path/d', issues: [createCodeScanIssue(), createCodeScanIssue()] }, - { filePath: 'file/path/a', issues: [createCodeScanIssue(), createCodeScanIssue()] }, - { filePath: 'file/path/b', issues: [createCodeScanIssue(), createCodeScanIssue()] }, - ] - const element = undefined const result = securityIssueTreeViewProvider.getChildren(element) as SeverityItem[] assert.strictEqual(result.length, 5) @@ -102,5 +102,55 @@ describe('SecurityIssueTreeViewProvider', function () { const result = securityIssueTreeViewProvider.getChildren(element) as IssueItem[] assert.strictEqual(result.length, 0) }) + + it('should return severity-grouped items when grouping strategy is Severity', function () { + sinon.stub(CodeIssueGroupingStrategyState.instance, 'getState').returns(CodeIssueGroupingStrategy.Severity) + + const severityItems = securityIssueTreeViewProvider.getChildren() as SeverityItem[] + for (const [index, [severity, expectedIssueCount]] of [ + ['Critical', 0], + ['High', 8], + ['Medium', 0], + ['Low', 0], + ['Info', 0], + ].entries()) { + const currentSeverityItem = severityItems[index] + assert.strictEqual(currentSeverityItem.label, severity) + assert.strictEqual(currentSeverityItem.issues.length, expectedIssueCount) + + const issueItems = securityIssueTreeViewProvider.getChildren(currentSeverityItem) as IssueItem[] + assert.ok(issueItems.every((item) => item.iconPath === undefined)) + assert.ok( + issueItems.every((item) => item.description?.toString().startsWith(path.basename(item.filePath))) + ) + } + }) + + it('should return file-grouped items when grouping strategy is FileLocation', function () { + sinon + .stub(CodeIssueGroupingStrategyState.instance, 'getState') + .returns(CodeIssueGroupingStrategy.FileLocation) + + const result = securityIssueTreeViewProvider.getChildren() as FileItem[] + for (const [index, [fileName, expectedIssueCount]] of [ + ['a', 2], + ['b', 2], + ['c', 2], + ['d', 2], + ].entries()) { + const currentFileItem = result[index] + assert.strictEqual(currentFileItem.label, fileName) + assert.strictEqual(currentFileItem.issues.length, expectedIssueCount) + assert.strictEqual(currentFileItem.description, 'file/path') + + const issueItems = securityIssueTreeViewProvider.getChildren(currentFileItem) as IssueItem[] + assert.ok( + issueItems.every((item) => + item.iconPath?.toString().includes(`${item.issue.severity.toLowerCase()}.svg`) + ) + ) + assert.ok(issueItems.every((item) => item.description?.toString().startsWith('[Ln '))) + } + }) }) }) diff --git a/packages/amazonq/test/unit/codewhisperer/service/securityScanHandler.test.ts b/packages/amazonq/test/unit/codewhisperer/service/securityScanHandler.test.ts index b0086b2a205..e297dfb82b3 100644 --- a/packages/amazonq/test/unit/codewhisperer/service/securityScanHandler.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/service/securityScanHandler.test.ts @@ -13,7 +13,10 @@ import { mapToAggregatedList, DefaultCodeWhispererClient, ListCodeScanFindingsResponse, + pollScanJobStatus, + SecurityScanTimedOutError, } from 'aws-core-vscode/codewhisperer' +import { timeoutUtils } from 'aws-core-vscode/shared' import assert from 'assert' import sinon from 'sinon' import * as vscode from 'vscode' @@ -50,7 +53,7 @@ const buildMockListCodeScanFindingsResponse = ( ): Awaited>> => ({ $response: { hasNextPage: () => false, - nextPage: () => undefined, + nextPage: () => null, // eslint-disable-line unicorn/no-null data: undefined, error: undefined, requestId: '', @@ -239,4 +242,57 @@ describe('securityScanHandler', function () { assert.strictEqual(codeScanIssueMap.get('file1.ts')?.length, 1) }) }) + + describe('pollScanJobStatus', function () { + let mockClient: Stub + let clock: sinon.SinonFakeTimers + const mockJobId = 'test-job-id' + const mockStartTime = Date.now() + + beforeEach(function () { + mockClient = stub(DefaultCodeWhispererClient) + clock = sinon.useFakeTimers({ + shouldAdvanceTime: true, + }) + sinon.stub(timeoutUtils, 'sleep').resolves() + }) + + afterEach(function () { + sinon.restore() + clock.restore() + }) + + it('should return status when scan completes successfully', async function () { + mockClient.getCodeScan + .onFirstCall() + .resolves({ status: 'Pending', $response: { requestId: 'req1' } }) + .onSecondCall() + .resolves({ status: 'Completed', $response: { requestId: 'req2' } }) + + const result = await pollScanJobStatus(mockClient, mockJobId, CodeAnalysisScope.FILE_AUTO, mockStartTime) + assert.strictEqual(result, 'Completed') + }) + + it('should throw SecurityScanTimedOutError when polling exceeds timeout for express scans', async function () { + mockClient.getCodeScan.resolves({ status: 'Pending', $response: { requestId: 'req1' } }) + + const pollPromise = pollScanJobStatus(mockClient, mockJobId, CodeAnalysisScope.FILE_AUTO, mockStartTime) + + const expectedTimeoutMs = 60_000 + clock.tick(expectedTimeoutMs + 1000) + + await assert.rejects(() => pollPromise, SecurityScanTimedOutError) + }) + + it('should throw SecurityScanTimedOutError when polling exceeds timeout for standard scans', async function () { + mockClient.getCodeScan.resolves({ status: 'Pending', $response: { requestId: 'req1' } }) + + const pollPromise = pollScanJobStatus(mockClient, mockJobId, CodeAnalysisScope.PROJECT, mockStartTime) + + const expectedTimeoutMs = 600_000 + clock.tick(expectedTimeoutMs + 1000) + + await assert.rejects(() => pollPromise, SecurityScanTimedOutError) + }) + }) }) diff --git a/packages/amazonq/test/unit/codewhisperer/tracker/userWrittenCodeTracker.test.ts b/packages/amazonq/test/unit/codewhisperer/tracker/userWrittenCodeTracker.test.ts new file mode 100644 index 00000000000..1d9b878133f --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/tracker/userWrittenCodeTracker.test.ts @@ -0,0 +1,194 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import { UserWrittenCodeTracker, TelemetryHelper, AuthUtil } from 'aws-core-vscode/codewhisperer' +import { createMockDocument, resetCodeWhispererGlobalVariables } from 'aws-core-vscode/test' + +describe('userWrittenCodeTracker', function () { + describe('isActive()', function () { + afterEach(async function () { + await resetCodeWhispererGlobalVariables() + UserWrittenCodeTracker.instance.reset() + sinon.restore() + }) + + it('inactive case: telemetryEnable = true, isConnected = false', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(true) + sinon.stub(AuthUtil.instance, 'isConnected').returns(false) + assert.strictEqual(UserWrittenCodeTracker.instance.isActive(), false) + }) + + it('inactive case: telemetryEnabled = false, isConnected = false', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(false) + sinon.stub(AuthUtil.instance, 'isConnected').returns(false) + assert.strictEqual(UserWrittenCodeTracker.instance.isActive(), false) + }) + + it('active case: telemetryEnabled = true, isConnected = true', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(true) + sinon.stub(AuthUtil.instance, 'isConnected').returns(true) + assert.strictEqual(UserWrittenCodeTracker.instance.isActive(), true) + }) + }) + + describe('onDocumentChange', function () { + let tracker: UserWrittenCodeTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = UserWrittenCodeTracker.instance + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + UserWrittenCodeTracker.instance.reset() + }) + + it('Should skip when content change size is more than 50', function () { + if (!tracker) { + assert.fail() + } + tracker.onQFeatureInvoked() + tracker.onTextDocumentChange({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 600), + rangeOffset: 0, + rangeLength: 600, + text: 'def twoSum(nums, target):\nfor '.repeat(20), + }, + ], + }) + assert.strictEqual(tracker.getUserWrittenCharacters('python'), 0) + assert.strictEqual(tracker.getUserWrittenLines('python'), 0) + }) + + it('Should not skip when content change size is less than 50', function () { + if (!tracker) { + assert.fail() + } + tracker.onQFeatureInvoked() + tracker.onTextDocumentChange({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 49), + rangeOffset: 0, + rangeLength: 49, + text: 'a = 123'.repeat(7), + }, + ], + }) + tracker.onTextDocumentChange({ + reason: undefined, + document: createMockDocument('', 'test.java', 'java'), + contentChanges: [ + { + range: new vscode.Range(0, 0, 1, 3), + rangeOffset: 0, + rangeLength: 11, + text: 'a = 123\nbcd', + }, + ], + }) + assert.strictEqual(tracker.getUserWrittenCharacters('python'), 49) + assert.strictEqual(tracker.getUserWrittenLines('python'), 0) + assert.strictEqual(tracker.getUserWrittenCharacters('java'), 11) + assert.strictEqual(tracker.getUserWrittenLines('java'), 1) + assert.strictEqual(tracker.getUserWrittenLines('cpp'), 0) + }) + + it('Should skip when Q is editing', function () { + if (!tracker) { + assert.fail() + } + tracker.onQFeatureInvoked() + tracker.onQStartsMakingEdits() + tracker.onTextDocumentChange({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 30), + rangeOffset: 0, + rangeLength: 30, + text: 'def twoSum(nums, target):\nfor', + }, + ], + }) + tracker.onQFinishesEdits() + tracker.onTextDocumentChange({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 2), + rangeOffset: 0, + rangeLength: 2, + text: '\na', + }, + ], + }) + assert.strictEqual(tracker.getUserWrittenCharacters('python'), 2) + assert.strictEqual(tracker.getUserWrittenLines('python'), 1) + }) + + it('Should not reduce tokens when delete', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('import math', 'test.py', 'python') + + tracker.onQFeatureInvoked() + tracker.onTextDocumentChange({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 0, + rangeLength: 0, + text: 'a', + }, + ], + }) + tracker.onTextDocumentChange({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 0, + rangeLength: 0, + text: 'b', + }, + ], + }) + assert.strictEqual(tracker.getUserWrittenCharacters('python'), 2) + tracker.onTextDocumentChange({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 1, + rangeLength: 1, + text: '', + }, + ], + }) + assert.strictEqual(tracker.getUserWrittenCharacters('python'), 2) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/ui/prompters.test.ts b/packages/amazonq/test/unit/codewhisperer/ui/prompters.test.ts new file mode 100644 index 00000000000..9c5e00cd6f7 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/ui/prompters.test.ts @@ -0,0 +1,45 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { createQuickPickPrompterTester, QuickPickPrompterTester } from 'aws-core-vscode/test' +import { + CodeIssueGroupingStrategy, + CodeIssueGroupingStrategyState, + createCodeIssueGroupingStrategyPrompter, +} from 'aws-core-vscode/codewhisperer' +import sinon from 'sinon' +import assert from 'assert' +import vscode from 'vscode' + +const severity = { data: CodeIssueGroupingStrategy.Severity, label: 'Severity' } +const fileLocation = { data: CodeIssueGroupingStrategy.FileLocation, label: 'File Location' } + +describe('createCodeIssueGroupingStrategyPrompter', function () { + let tester: QuickPickPrompterTester + + beforeEach(function () { + tester = createQuickPickPrompterTester(createCodeIssueGroupingStrategyPrompter()) + }) + + afterEach(function () { + sinon.restore() + }) + + it('should list grouping strategies', async function () { + tester.assertItems([severity, fileLocation]) + tester.hide() + await tester.result() + }) + + it('should update state on selection', async function () { + const originalState = CodeIssueGroupingStrategyState.instance.getState() + assert.equal(originalState, CodeIssueGroupingStrategy.Severity) + + tester.selectItems(fileLocation) + tester.addCallback(() => vscode.commands.executeCommand('workbench.action.acceptSelectedQuickOpenItem')) + + await tester.result() + assert.equal(CodeIssueGroupingStrategyState.instance.getState(), fileLocation.data) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/crossFileContextUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/crossFileContextUtil.test.ts index 748483a4dc0..91e26e36111 100644 --- a/packages/amazonq/test/unit/codewhisperer/util/crossFileContextUtil.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/util/crossFileContextUtil.test.ts @@ -4,10 +4,16 @@ */ import assert from 'assert' +import * as FakeTimers from '@sinonjs/fake-timers' import * as vscode from 'vscode' import * as sinon from 'sinon' import * as crossFile from 'aws-core-vscode/codewhisperer' -import { aStringWithLineCount, createMockTextEditor } from 'aws-core-vscode/test' +import { + aLongStringWithLineCount, + aStringWithLineCount, + createMockTextEditor, + installFakeClock, +} from 'aws-core-vscode/test' import { FeatureConfigProvider, crossFileContextConfig } from 'aws-core-vscode/codewhisperer' import { assertTabCount, @@ -30,6 +36,15 @@ describe('crossFileContextUtil', function () { } let mockEditor: vscode.TextEditor + let clock: FakeTimers.InstalledClock + + before(function () { + clock = installFakeClock() + }) + + after(function () { + clock.uninstall() + }) afterEach(function () { sinon.restore() @@ -44,7 +59,7 @@ describe('crossFileContextUtil', function () { sinon.restore() }) - it('for control group, should return opentabs context where there will be 3 chunks and each chunk should contains 50 lines', async function () { + it.skip('for control group, should return opentabs context where there will be 3 chunks and each chunk should contains 50 lines', async function () { sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('control') await toTextEditor(aStringWithLineCount(200), 'CrossFile.java', tempFolder, { preview: false }) const myCurrentEditor = await toTextEditor('', 'TargetFile.java', tempFolder, { @@ -61,8 +76,8 @@ describe('crossFileContextUtil', function () { assert.strictEqual(actual.supplementalContextItems[2].content.split('\n').length, 50) }) - it.skip('for t1 group, should return repomap + opentabs context', async function () { - await toTextEditor(aStringWithLineCount(200), 'CrossFile.java', tempFolder, { preview: false }) + it('for t1 group, should return repomap + opentabs context, should not exceed 20k total length', async function () { + await toTextEditor(aLongStringWithLineCount(200), 'CrossFile.java', tempFolder, { preview: false }) const myCurrentEditor = await toTextEditor('', 'TargetFile.java', tempFolder, { preview: false, }) @@ -75,7 +90,7 @@ describe('crossFileContextUtil', function () { .withArgs(sinon.match.any, sinon.match.any, 'codemap') .resolves([ { - content: 'foo', + content: 'foo'.repeat(3000), score: 0, filePath: 'q-inline', }, @@ -83,20 +98,18 @@ describe('crossFileContextUtil', function () { const actual = await crossFile.fetchSupplementalContextForSrc(myCurrentEditor, fakeCancellationToken) assert.ok(actual) - assert.strictEqual(actual.supplementalContextItems.length, 4) + assert.strictEqual(actual.supplementalContextItems.length, 3) assert.strictEqual(actual?.strategy, 'codemap') assert.deepEqual(actual?.supplementalContextItems[0], { - content: 'foo', + content: 'foo'.repeat(3000), score: 0, filePath: 'q-inline', }) - assert.strictEqual(actual.supplementalContextItems[1].content.split('\n').length, 50) assert.strictEqual(actual.supplementalContextItems[2].content.split('\n').length, 50) - assert.strictEqual(actual.supplementalContextItems[3].content.split('\n').length, 50) }) - it('for t2 group, should return global bm25 context and no repomap', async function () { + it.skip('for t2 group, should return global bm25 context and no repomap', async function () { await toTextEditor(aStringWithLineCount(200), 'CrossFile.java', tempFolder, { preview: false }) const myCurrentEditor = await toTextEditor('', 'TargetFile.java', tempFolder, { preview: false, @@ -241,11 +254,11 @@ describe('crossFileContextUtil', function () { const actual = await crossFile.getCrossFileCandidates(editor) assert.ok(actual.length === 5) - actual.forEach((actualFile, index) => { + for (const [index, actualFile] of actual.entries()) { const expectedFile = path.join(tempFolder, expectedFilePaths[index]) assert.strictEqual(normalize(expectedFile), normalize(actualFile)) assert.ok(areEqual(tempFolder, actualFile, expectedFile)) - }) + } }) }) @@ -264,7 +277,7 @@ describe('crossFileContextUtil', function () { await closeAllEditors() }) - fileExtLists.forEach((fileExt) => { + for (const fileExt of fileExtLists) { it('should be empty if userGroup is control', async function () { const editor = await toTextEditor('content-1', `file-1.${fileExt}`, tempFolder) await toTextEditor('content-2', `file-2.${fileExt}`, tempFolder, { preview: false }) @@ -277,7 +290,7 @@ describe('crossFileContextUtil', function () { assert.ok(actual && actual.supplementalContextItems.length === 0) }) - }) + } }) describe.skip('partial support - crossfile group', function () { @@ -295,7 +308,7 @@ describe('crossFileContextUtil', function () { await closeAllEditors() }) - fileExtLists.forEach((fileExt) => { + for (const fileExt of fileExtLists) { it('should be non empty if usergroup is Crossfile', async function () { const editor = await toTextEditor('content-1', `file-1.${fileExt}`, tempFolder) await toTextEditor('content-2', `file-2.${fileExt}`, tempFolder, { preview: false }) @@ -308,7 +321,7 @@ describe('crossFileContextUtil', function () { assert.ok(actual && actual.supplementalContextItems.length !== 0) }) - }) + } }) describe('full support', function () { @@ -327,9 +340,19 @@ describe('crossFileContextUtil', function () { await closeAllEditors() }) - fileExtLists.forEach((fileExt) => { - it('should be non empty', async function () { + for (const fileExt of fileExtLists) { + it(`supplemental context for file ${fileExt} should be non empty`, async function () { sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('control') + sinon + .stub(LspController.instance, 'queryInlineProjectContext') + .withArgs(sinon.match.any, sinon.match.any, 'codemap') + .resolves([ + { + content: 'foo', + score: 0, + filePath: 'q-inline', + }, + ]) const editor = await toTextEditor('content-1', `file-1.${fileExt}`, tempFolder) await toTextEditor('content-2', `file-2.${fileExt}`, tempFolder, { preview: false }) await toTextEditor('content-3', `file-3.${fileExt}`, tempFolder, { preview: false }) @@ -341,7 +364,7 @@ describe('crossFileContextUtil', function () { assert.ok(actual && actual.supplementalContextItems.length !== 0) }) - }) + } }) describe('splitFileToChunks', function () { diff --git a/packages/amazonq/test/unit/codewhisperer/util/editorContext.test.ts b/packages/amazonq/test/unit/codewhisperer/util/editorContext.test.ts index 22be4199375..d5085e4db0c 100644 --- a/packages/amazonq/test/unit/codewhisperer/util/editorContext.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/util/editorContext.test.ts @@ -95,12 +95,12 @@ describe('editorContext', function () { ['c', 'c'], ]) - languageToExtension.forEach((extension, language) => { + for (const [language, extension] of languageToExtension.entries()) { const editor = createMockTextEditor('', 'test.ipynb', language, 1, 17) const actual = EditorContext.getFileRelativePath(editor) const expected = 'test.' + extension assert.strictEqual(actual, expected) - }) + } }) it('Should return relative path', async function () { diff --git a/packages/amazonq/test/unit/codewhisperer/util/runtimeLanguageContext.test.ts b/packages/amazonq/test/unit/codewhisperer/util/runtimeLanguageContext.test.ts index 653505d6cf9..9cf61920861 100644 --- a/packages/amazonq/test/unit/codewhisperer/util/runtimeLanguageContext.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/util/runtimeLanguageContext.test.ts @@ -52,7 +52,7 @@ describe('runtimeLanguageContext', function () { await resetCodeWhispererGlobalVariables() }) - cases.forEach((tuple) => { + for (const tuple of cases) { const languageId = tuple[0] const expected = tuple[1] @@ -60,7 +60,7 @@ describe('runtimeLanguageContext', function () { const actual = languageContext.isLanguageSupported(languageId) assert.strictEqual(actual, expected) }) - }) + } describe('test isLanguageSupported with document as the argument', function () { const cases: [string, boolean][] = [ @@ -105,7 +105,7 @@ describe('runtimeLanguageContext', function () { ['helloFoo.foo', false], ] - cases.forEach((tuple) => { + for (const tuple of cases) { const fileName = tuple[0] const expected = tuple[1] @@ -114,7 +114,7 @@ describe('runtimeLanguageContext', function () { const actual = languageContext.isLanguageSupported(doc) assert.strictEqual(actual, expected) }) - }) + } }) }) @@ -148,14 +148,14 @@ describe('runtimeLanguageContext', function () { [undefined, 'plaintext'], ] - cases.forEach((tuple) => { + for (const tuple of cases) { const vscLanguageId = tuple[0] const expectedCwsprLanguageId = tuple[1] it(`given vscLanguage ${vscLanguageId} should return ${expectedCwsprLanguageId}`, function () { const result = runtimeLanguageContext.getLanguageContext(vscLanguageId) assert.strictEqual(result.language as string, expectedCwsprLanguageId) }) - }) + } }) describe('normalizeLanguage', function () { diff --git a/packages/amazonq/test/unit/codewhisperer/util/securityScanLanguageContext.test.ts b/packages/amazonq/test/unit/codewhisperer/util/securityScanLanguageContext.test.ts index cb0a51fdad8..e1d2cf91189 100644 --- a/packages/amazonq/test/unit/codewhisperer/util/securityScanLanguageContext.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/util/securityScanLanguageContext.test.ts @@ -51,7 +51,7 @@ describe('securityScanLanguageContext', function () { await resetCodeWhispererGlobalVariables() }) - cases.forEach((tuple) => { + for (const tuple of cases) { const languageId = tuple[0] const expected = tuple[1] @@ -59,7 +59,7 @@ describe('securityScanLanguageContext', function () { const actual = languageContext.isLanguageSupported(languageId) assert.strictEqual(actual, expected) }) - }) + } }) describe('normalizeLanguage', function () { diff --git a/packages/amazonq/test/unit/codewhisperer/util/supplemetalContextUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/supplemetalContextUtil.test.ts index 25f3ce1a585..051ac65bee1 100644 --- a/packages/amazonq/test/unit/codewhisperer/util/supplemetalContextUtil.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/util/supplemetalContextUtil.test.ts @@ -4,21 +4,32 @@ */ import assert from 'assert' +import * as FakeTimers from '@sinonjs/fake-timers' import * as vscode from 'vscode' import * as sinon from 'sinon' import * as crossFile from 'aws-core-vscode/codewhisperer' -import { TestFolder, assertTabCount } from 'aws-core-vscode/test' +import { TestFolder, assertTabCount, installFakeClock } from 'aws-core-vscode/test' import { FeatureConfigProvider } from 'aws-core-vscode/codewhisperer' import { toTextEditor } from 'aws-core-vscode/test' +import { LspController } from 'aws-core-vscode/amazonq' describe('supplementalContextUtil', function () { let testFolder: TestFolder + let clock: FakeTimers.InstalledClock const fakeCancellationToken: vscode.CancellationToken = { isCancellationRequested: false, onCancellationRequested: sinon.spy(), } + before(function () { + clock = installFakeClock() + }) + + after(function () { + clock.uninstall() + }) + beforeEach(async function () { testFolder = await TestFolder.create() sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('control') @@ -31,6 +42,16 @@ describe('supplementalContextUtil', function () { describe('fetchSupplementalContext', function () { describe('openTabsContext', function () { it('opentabContext should include chunks if non empty', async function () { + sinon + .stub(LspController.instance, 'queryInlineProjectContext') + .withArgs(sinon.match.any, sinon.match.any, 'codemap') + .resolves([ + { + content: 'foo', + score: 0, + filePath: 'q-inline', + }, + ]) await toTextEditor('class Foo', 'Foo.java', testFolder.path, { preview: false }) await toTextEditor('class Bar', 'Bar.java', testFolder.path, { preview: false }) await toTextEditor('class Baz', 'Baz.java', testFolder.path, { preview: false }) @@ -42,7 +63,7 @@ describe('supplementalContextUtil', function () { await assertTabCount(4) const actual = await crossFile.fetchSupplementalContext(editor, fakeCancellationToken) - assert.ok(actual?.supplementalContextItems.length === 3) + assert.ok(actual?.supplementalContextItems.length === 4) }) it('opentabsContext should filter out empty chunks', async function () { diff --git a/packages/amazonq/test/unit/codewhisperer/util/telemetryHelper.test.ts b/packages/amazonq/test/unit/codewhisperer/util/telemetryHelper.test.ts index d6190a6c0fd..e042b1d43a2 100644 --- a/packages/amazonq/test/unit/codewhisperer/util/telemetryHelper.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/util/telemetryHelper.test.ts @@ -11,6 +11,7 @@ import { CodewhispererSuggestionState, CodewhispererUserDecision, } from 'aws-core-vscode/shared' +import sinon from 'sinon' // TODO: improve and move the following test utils to codewhisperer/testUtils.ts function aUserDecision( @@ -38,6 +39,47 @@ function aCompletion(): Completion { } describe('telemetryHelper', function () { + describe('clientComponentLatency', function () { + let sut: TelemetryHelper + + beforeEach(function () { + sut = new TelemetryHelper() + }) + + afterEach(function () { + sinon.restore() + }) + + it('resetClientComponentLatencyTime should reset state variables', function () { + session.invokeSuggestionStartTime = 100 + session.preprocessEndTime = 200 + session.sdkApiCallStartTime = 300 + session.fetchCredentialStartTime = 400 + session.firstSuggestionShowTime = 500 + + sut.setSdkApiCallEndTime() + sut.setAllPaginationEndTime() + sut.setFirstResponseRequestId('aFakeRequestId') + + sut.resetClientComponentLatencyTime() + + assert.strictEqual(session.invokeSuggestionStartTime, 0) + assert.strictEqual(session.preprocessEndTime, 0) + assert.strictEqual(session.sdkApiCallStartTime, 0) + assert.strictEqual(session.fetchCredentialStartTime, 0) + assert.strictEqual(session.firstSuggestionShowTime, 0) + assert.strictEqual(sut.sdkApiCallEndTime, 0) + assert.strictEqual(sut.allPaginationEndTime, 0) + assert.strictEqual(sut.firstResponseRequestId, '') + }) + + it('setInvocationSuggestionStartTime should call resetClientComponentLatencyTime', function () { + const resetStub = sinon.stub(sut, 'resetClientComponentLatencyTime') + sut.setInvokeSuggestionStartTime() + assert.ok(resetStub.calledOnce) + }) + }) + describe('aggregateUserDecisionByRequest', function () { let sut: TelemetryHelper diff --git a/packages/amazonq/test/unit/codewhisperer/util/zipUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/zipUtil.test.ts index a4a03a5236a..4729d65d416 100644 --- a/packages/amazonq/test/unit/codewhisperer/util/zipUtil.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/util/zipUtil.test.ts @@ -8,17 +8,19 @@ import vscode from 'vscode' import sinon from 'sinon' import { join } from 'path' import { getTestWorkspaceFolder } from 'aws-core-vscode/test' -import { CodeAnalysisScope, ZipUtil } from 'aws-core-vscode/codewhisperer' +import { CodeAnalysisScope, CodeWhispererConstants, ZipUtil } from 'aws-core-vscode/codewhisperer' import { codeScanTruncDirPrefix } from 'aws-core-vscode/codewhisperer' -import { ToolkitError } from 'aws-core-vscode/shared' +import { tempDirPath, ToolkitError } from 'aws-core-vscode/shared' import { LspClient } from 'aws-core-vscode/amazonq' import { fs } from 'aws-core-vscode/shared' import path from 'path' +import JSZip from 'jszip' describe('zipUtil', function () { const workspaceFolder = getTestWorkspaceFolder() const appRoot = join(workspaceFolder, 'java11-plain-maven-sam-app') const appCodePath = join(appRoot, 'HelloWorldFunction', 'src', 'main', 'java', 'helloworld', 'App.java') + const appCodePathWithRepeatedProjectName = join(workspaceFolder, 'workspaceFolder', 'App.java') describe('getProjectPaths', function () { it('Should return the correct project paths', function () { @@ -112,101 +114,86 @@ describe('zipUtil', function () { ) assert.equal(zipMetadata2.lines, zipMetadata.lines + 1) }) + + it('should handle path with repeated project name for file scan', async function () { + const zipMetadata = await zipUtil.generateZip( + vscode.Uri.file(appCodePathWithRepeatedProjectName), + CodeAnalysisScope.FILE_ON_DEMAND + ) + + const zipFileData = await fs.readFileBytes(zipMetadata.zipFilePath) + const zip = await JSZip.loadAsync(zipFileData) + const files = Object.keys(zip.files) + assert.ok(files.includes(join('workspaceFolder', 'workspaceFolder', 'App.java'))) + }) + + it('should handle path with repeated project name for project scan', async function () { + const zipMetadata = await zipUtil.generateZip( + vscode.Uri.file(appCodePathWithRepeatedProjectName), + CodeAnalysisScope.PROJECT + ) + + const zipFileData = await fs.readFileBytes(zipMetadata.zipFilePath) + const zip = await JSZip.loadAsync(zipFileData) + const files = Object.keys(zip.files) + assert.ok(files.includes(join('workspaceFolder', 'workspaceFolder', 'App.java'))) + }) }) describe('generateZipTestGen', function () { let zipUtil: ZipUtil - let mockFs: sinon.SinonStubbedInstance - const projectPath = '/test/project' - const zipDirPath = '/test/zip' - const zipFilePath = '/test/zip/test.zip' + let getZipDirPathStub: sinon.SinonStub + let testTempDirPath: string beforeEach(function () { zipUtil = new ZipUtil() - mockFs = sinon.stub(fs) - - const mockRepoMapPath = '/path/to/repoMapData.json' - mockFs.exists.withArgs(mockRepoMapPath).resolves(true) - sinon.stub(LspClient, 'instance').get(() => ({ - getRepoMapJSON: sinon.stub().resolves(mockRepoMapPath), - })) - - sinon.stub(zipUtil, 'getZipDirPath').returns(zipDirPath) - sinon.stub(zipUtil as any, 'zipProject').resolves(zipFilePath) + testTempDirPath = path.join(tempDirPath, CodeWhispererConstants.TestGenerationTruncDirPrefix) + getZipDirPathStub = sinon.stub(zipUtil, 'getZipDirPath') + getZipDirPathStub.callsFake(() => testTempDirPath) }) afterEach(function () { sinon.restore() }) - it('Should generate zip for test generation successfully', async function () { - mockFs.stat.resolves({ - type: vscode.FileType.File, - size: 1000, - ctime: Date.now(), - mtime: Date.now(), - } as vscode.FileStat) - - mockFs.readFileBytes.resolves(Buffer.from('test content')) + it('should generate zip for test generation successfully', async function () { + const mkdirSpy = sinon.spy(fs, 'mkdir') - // Fix: Create a Set from the array - zipUtil['_totalSize'] = 500 - zipUtil['_totalBuildSize'] = 200 - zipUtil['_totalLines'] = 100 - zipUtil['_language'] = 'typescript' - zipUtil['_pickedSourceFiles'] = new Set(['file1.ts', 'file2.ts']) + const result = await zipUtil.generateZipTestGen(appRoot, false) - const result = await zipUtil.generateZipTestGen(projectPath, false) - - assert.ok(mockFs.mkdir.calledWith(path.join(zipDirPath, 'utgRequiredArtifactsDir'))) + assert.ok(mkdirSpy.calledWith(path.join(testTempDirPath, 'utgRequiredArtifactsDir'))) assert.ok( - mockFs.mkdir.calledWith(path.join(zipDirPath, 'utgRequiredArtifactsDir', 'buildAndExecuteLogDir')) + mkdirSpy.calledWith(path.join(testTempDirPath, 'utgRequiredArtifactsDir', 'buildAndExecuteLogDir')) ) - assert.ok(mockFs.mkdir.calledWith(path.join(zipDirPath, 'utgRequiredArtifactsDir', 'repoMapData'))) - assert.ok(mockFs.mkdir.calledWith(path.join(zipDirPath, 'utgRequiredArtifactsDir', 'testCoverageDir'))) - - // assert.ok( - // mockFs.copy.calledWith( - // '/path/to/repoMapData.json', - // path.join(zipDirPath, 'utgRequiredArtifactsDir', 'repoMapData', 'repoMapData.json') - // ) - // ) - - assert.strictEqual(result.rootDir, zipDirPath) - assert.strictEqual(result.zipFilePath, zipFilePath) - assert.strictEqual(result.srcPayloadSizeInBytes, 500) - assert.strictEqual(result.buildPayloadSizeInBytes, 200) - assert.strictEqual(result.zipFileSizeInBytes, 1000) - assert.strictEqual(result.lines, 100) - assert.strictEqual(result.language, 'typescript') - assert.deepStrictEqual(Array.from(result.scannedFiles), ['file1.ts', 'file2.ts']) - }) - - // it('Should handle LSP client error', async function () { - // // Override the default stub with one that rejects - // sinon.stub(LspClient, 'instance').get(() => ({ - // getRepoMapJSON: sinon.stub().rejects(new Error('LSP error')), - // })) - - // await assert.rejects(() => zipUtil.generateZipTestGen(projectPath), /LSP error/) - // }) + assert.ok(mkdirSpy.calledWith(path.join(testTempDirPath, 'utgRequiredArtifactsDir', 'repoMapData'))) + assert.ok(mkdirSpy.calledWith(path.join(testTempDirPath, 'utgRequiredArtifactsDir', 'testCoverageDir'))) + + assert.strictEqual(result.rootDir, testTempDirPath) + assert.strictEqual(result.zipFilePath, testTempDirPath + CodeWhispererConstants.codeScanZipExt) + assert.ok(result.srcPayloadSizeInBytes > 0) + assert.strictEqual(result.buildPayloadSizeInBytes, 0) + assert.ok(result.zipFileSizeInBytes > 0) + assert.strictEqual(result.lines, 150) + assert.strictEqual(result.language, 'java') + assert.strictEqual(result.scannedFiles.size, 4) + }) it('Should handle file system errors during directory creation', async function () { sinon.stub(LspClient, 'instance').get(() => ({ getRepoMapJSON: sinon.stub().resolves('{"mock": "data"}'), })) - mockFs.mkdir.rejects(new Error('Directory creation failed')) + sinon.stub(fs, 'mkdir').rejects(new Error('Directory creation failed')) - await assert.rejects(() => zipUtil.generateZipTestGen(projectPath, false), /Directory creation failed/) + await assert.rejects(() => zipUtil.generateZipTestGen(appRoot, false), /Directory creation failed/) }) it('Should handle zip project errors', async function () { sinon.stub(LspClient, 'instance').get(() => ({ getRepoMapJSON: sinon.stub().resolves('{"mock": "data"}'), })) - ;(zipUtil as any).zipProject.rejects(new Error('Zip failed')) + sinon.stub(zipUtil, 'zipProject' as keyof ZipUtil).rejects(new Error('Zip failed')) - await assert.rejects(() => zipUtil.generateZipTestGen(projectPath, false), /Zip failed/) + await assert.rejects(() => zipUtil.generateZipTestGen(appRoot, false), /Zip failed/) }) it('Should handle file copy to downloads folder error', async function () { @@ -215,31 +202,15 @@ describe('zipUtil', function () { getRepoMapJSON: sinon.stub().resolves('{"mock": "data"}'), })) - // Mock file operations - const mockFs = { - mkdir: sinon.stub().resolves(), - copy: sinon.stub().rejects(new Error('Copy failed')), - exists: sinon.stub().resolves(true), - stat: sinon.stub().resolves({ - type: vscode.FileType.File, - size: 1000, - ctime: Date.now(), - mtime: Date.now(), - } as vscode.FileStat), - } - - // Since the function now uses Promise.all for directory creation and file operations, - // we need to ensure the mkdir succeeds but the copy fails - fs.mkdir = mockFs.mkdir - fs.copy = mockFs.copy - fs.exists = mockFs.exists - fs.stat = mockFs.stat - - await assert.rejects(() => zipUtil.generateZipTestGen(projectPath, false), /Copy failed/) + const mkdirSpy = sinon.spy(fs, 'mkdir') + sinon.stub(fs, 'exists').resolves(true) + sinon.stub(fs, 'copy').rejects(new Error('Copy failed')) + + await assert.rejects(() => zipUtil.generateZipTestGen(appRoot, false), /Copy failed/) // Verify mkdir was called for all directories - assert(mockFs.mkdir.called, 'mkdir should have been called') - assert.strictEqual(mockFs.mkdir.callCount, 4, 'mkdir should have been called 4 times') + assert(mkdirSpy.called, 'mkdir should have been called') + assert.strictEqual(mkdirSpy.callCount, 4, 'mkdir should have been called 4 times') }) }) }) diff --git a/packages/amazonq/test/web/testRunner.ts b/packages/amazonq/test/web/testRunner.ts index a2c8f8e90cc..1d0726be98b 100644 --- a/packages/amazonq/test/web/testRunner.ts +++ b/packages/amazonq/test/web/testRunner.ts @@ -35,6 +35,7 @@ function setupMocha() { function gatherTestFiles() { // Bundles all files in the current directory matching `*.test` + // eslint-disable-next-line unicorn/no-array-for-each const importAll = (r: __WebpackModuleApi.RequireContext) => r.keys().forEach(r) importAll(require.context('.', true, /\.test$/)) } diff --git a/packages/core/package.json b/packages/core/package.json index 3a3fc432a9d..7eca4863a77 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,6 +20,7 @@ "./auth": "./dist/src/auth/index.js", "./amazonqGumby": "./dist/src/amazonqGumby/index.js", "./amazonqFeatureDev": "./dist/src/amazonqFeatureDev/index.js", + "./amazonqDoc": "./dist/src/amazonqDoc/index.js", "./amazonqScan": "./dist/src/amazonqScan/index.js", "./amazonqTest": "./dist/src/amazonqTest/index.js", "./codewhispererChat": "./dist/src/codewhispererChat/index.js", @@ -496,6 +497,7 @@ "@amzn/amazon-q-developer-streaming-client": "file:../../src.gen/@amzn/amazon-q-developer-streaming-client", "@amzn/codewhisperer-streaming": "file:../../src.gen/@amzn/codewhisperer-streaming", "@aws-sdk/client-cloudformation": "^3.667.0", + "@aws-sdk/client-cloudwatch-logs": "^3.666.0", "@aws-sdk/client-cognito-identity": "^3.637.0", "@aws-sdk/client-lambda": "^3.637.0", "@aws-sdk/client-sso": "^3.342.0", @@ -506,7 +508,7 @@ "@aws-sdk/property-provider": "3.46.0", "@aws-sdk/smithy-client": "^3.46.0", "@aws-sdk/util-arn-parser": "^3.46.0", - "@aws/mynah-ui": "^4.21.0", + "@aws/mynah-ui": "^4.21.5", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "@iarna/toml": "^2.2.5", "@smithy/middleware-retry": "^2.3.1", @@ -517,13 +519,13 @@ "@vscode/debugprotocol": "^1.57.0", "@zip.js/zip.js": "^2.7.41", "adm-zip": "^0.5.10", - "amazon-states-language-service": "^1.11.0", + "amazon-states-language-service": "^1.13.0", "async-lock": "^1.4.0", - "aws-sdk": "^2.1384.0", + "aws-sdk": "^2.1692.0", "aws-ssm-document-language-service": "^1.0.0", "bytes": "^3.1.2", "cross-fetch": "^4.0.0", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "diff": "^5.1.0", "fast-json-patch": "^3.1.1", "glob": "^10.3.10", diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index 4a7a08aa1d5..2b15a28eb58 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -20,8 +20,8 @@ "AWS.configuration.description.suppressPrompts": "Prompts which ask for confirmation. Checking an item suppresses the prompt.", "AWS.configuration.enableCodeLenses": "Enable SAM hints in source code and template.yaml files", "AWS.configuration.description.resources.enabledResources": "AWS resources to display in the 'Resources' portion of the explorer.", - "AWS.configuration.description.experiments": "Try experimental features and give feedback. Note that experimental features may be removed at any time.\n * `jsonResourceModification` - Enables basic create, update, and delete support for cloud resources via the JSON Resources explorer component.\n * `samSyncCode` - Adds an additional code-only option when synchronizing SAM applications. Code-only synchronizations are faster but can cause drift in the CloudFormation stack. Does nothing when using the legacy SAM deploy feature.\n * `iamPolicyChecks` - Enables IAM Policy Checks feature, allowing users to validate IAM policies against IAM policy grammar, AWS best practices, and specified security standards.", "AWS.configuration.description.devCommandWorkspaceConfigurations": "Amazon Q: Allow Q /dev to run code and test commands", + "AWS.configuration.description.experiments": "Try experimental features and give feedback. Note that experimental features may be removed at any time.\n * `jsonResourceModification` - Enables basic create, update, and delete support for cloud resources via the JSON Resources explorer component.\n * `ec2RemoteConnect` - Allows interfacing with EC2 instances with options to start, stop, and establish remote connections. Remote connections are done over SSM and can be through a terminal or a remote VSCode window.", "AWS.stepFunctions.asl.format.enable.desc": "Enables the default formatter used with Amazon States Language files", "AWS.stepFunctions.asl.maxItemsComputed.desc": "The maximum number of outline symbols and folding regions computed (limited for performance reasons).", "AWS.configuration.description.awssam.debug.api": "API Gateway configuration", @@ -75,7 +75,7 @@ "AWS.configuration.description.amazonq": "Amazon Q creates a code reference when you insert a code suggestion from Amazon Q that is similar to training data. When unchecked, Amazon Q will not show code suggestions that have code references. If you authenticate through IAM Identity Center, this setting is controlled by your Amazon Q administrator. [Learn More](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/code-reference.html)", "AWS.configuration.description.amazonq.shareContentWithAWS": "When checked, your content processed by Amazon Q may be used for service improvement (except for content processed for users with the Amazon Q Developer Pro Tier). Unchecking this box will cause AWS to delete any of your content used for that purpose. The information used to provide the Amazon Q service to you will not be affected. See the [Service Terms](https://aws.amazon.com/service-terms) for more details.", "AWS.configuration.description.amazonq.importRecommendation": "Amazon Q will add import statements with inline code suggestions when necessary.", - "AWS.configuration.description.amazonq.workspaceIndex": "When you add @workspace to your question in Amazon Q chat, Amazon Q will index your open workspace files locally to use as context for its response. Extra CPU usage is expected while indexing a workspace. This will not impact Amazon Q features or your IDE, but you may manage CPU usage by setting the number of local threads in 'Local Workspace Index Threads'.", + "AWS.configuration.description.amazonq.workspaceIndex": "When you add @workspace to your question in Amazon Q chat, Amazon Q will index your workspace files locally to use as context for its response. Extra CPU usage is expected while indexing a workspace. This will not impact Amazon Q features or your IDE, but you may manage CPU usage by setting the number of local threads in 'Local Workspace Index Threads'.", "AWS.configuration.description.amazonq.workspaceIndexWorkerThreads": "Number of worker threads of Amazon Q local index process. '0' will use the system default worker threads for balance performance. You may increase this number to more quickly index your workspace, but only up to your hardware's number of CPU cores. Please restart VS Code or reload the VS Code window after changing worker threads.", "AWS.configuration.description.amazonq.workspaceIndexUseGPU": "Enable GPU to help index your local workspace files. Only applies to Linux and Windows.", "AWS.configuration.description.amazonq.workspaceIndexMaxSize": "The maximum size of local workspace files to be indexed in MB", @@ -134,6 +134,7 @@ "AWS.command.amazonq.acceptFix": "Accept Fix", "AWS.command.amazonq.regenerateFix": "Regenerate Fix", "AWS.command.amazonq.filterIssues": "Filter Issues", + "AWS.command.amazonq.groupIssues": "Group Issues", "AWS.command.deploySamApplication": "Deploy SAM Application", "AWS.command.aboutToolkit": "About", "AWS.command.downloadLambda": "Download...", @@ -143,8 +144,8 @@ "AWS.command.refreshAwsExplorer": "Refresh Explorer", "AWS.command.refreshCdkExplorer": "Refresh CDK Explorer", "AWS.command.cdk.help": "View CDK Documentation", - "AWS.command.ec2.openTerminal": "Open terminal to EC2 instance...", - "AWS.command.ec2.openRemoteConnection": "Connect to EC2 instance in New Window...", + "AWS.command.ec2.openTerminal": "Open Terminal to EC2 Instance...", + "AWS.command.ec2.openRemoteConnection": "Connect VS Code to EC2 Instance...", "AWS.command.ec2.startInstance": "Start EC2 Instance", "AWS.command.ec2.linkToLaunch": "Launch EC2 Instance", "AWS.command.ec2.stopInstance": "Stop EC2 Instance", @@ -178,6 +179,7 @@ "AWS.command.downloadSchemaItemCode": "Download Code Bindings", "AWS.command.viewLogs": "View Logs", "AWS.command.cloudWatchLogs.searchLogGroup": "Search Log Group", + "AWS.command.cloudWatchLogs.tailLogGroup": "Tail Log Group", "AWS.command.sam.newTemplate": "Create new SAM Template", "AWS.command.cloudFormation.newTemplate": "Create new CloudFormation Template", "AWS.command.quickStart": "View Quick Start", @@ -239,9 +241,11 @@ "AWS.command.codewhisperer.openReferencePanel": "Open Code Reference Log", "AWS.command.q.transform.acceptChanges": "Accept", "AWS.command.q.transform.rejectChanges": "Reject", - "AWS.command.q.transform.showChanges": "Show Proposed Changes", - "AWS.command.q.transform.showChangeSummary": "Show Transformation Summary", - "AWS.command.q.transform.showTransformationPlan": "Show Transformation Plan", + "AWS.command.q.transform.stopJobInHub": "Stop job", + "AWS.command.q.transform.viewJobProgress": "View job progress", + "AWS.command.q.transform.viewJobStatus": "View job status", + "AWS.command.q.transform.showTransformationPlan": "View plan", + "AWS.command.q.transform.showChangeSummary": "View summary", "AWS.command.threatComposer.createNew": "Create New Threat Composer File", "AWS.command.threatComposer.newFile": "Threat Composer File", "AWS.threatComposer.page.title": "{0} (Threat Composer)", @@ -254,7 +258,7 @@ "AWS.appcomposer.explorerTitle": "Infrastructure Composer", "AWS.cdk.explorerTitle": "CDK", "AWS.codecatalyst.explorerTitle": "CodeCatalyst", - "AWS.cwl.limit.desc": "Maximum amount of log entries pulled per request from CloudWatch Logs (max 10000)", + "AWS.cwl.limit.desc": "Maximum amount of log entries pulled per request from CloudWatch Logs. For LiveTail, when the limit is reached, the oldest events will be removed to accomodate new events. (max 10000)", "AWS.samcli.deploy.bucket.recentlyUsed": "Buckets recently used for SAM deployments", "AWS.submenu.amazonqEditorContextSubmenu.title": "Amazon Q", "AWS.submenu.auth.title": "Authentication", @@ -309,6 +313,11 @@ "AWS.amazonq.scans.projectScanInProgress": "Workspace review is in progress...", "AWS.amazonq.scans.fileScanInProgress": "File review is in progress...", "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.", + "AWS.amazonq.codefix.error.monthlyLimitReached": "Maximum code fix count reached for this month.", + "AWS.amazonq.scans.severity": "Severity", + "AWS.amazonq.scans.fileLocation": "File Location", + "AWS.amazonq.scans.groupIssues": "Group Issues", + "AWS.amazonq.scans.groupIssues.placeholder": "Select how to group code issues", "AWS.amazonq.featureDev.error.conversationIdNotFoundError": "Conversation id must exist before starting code generation", "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 Amazon Q Developer documentation.", "AWS.amazonq.featureDev.error.illegalStateTransition": "Illegal transition between states, restart the conversation", @@ -376,21 +385,31 @@ "AWS.amazonq.doc.answer.readmeCreated": "I've created a README for your code.", "AWS.amazonq.doc.answer.readmeUpdated": "I've updated your README.", "AWS.amazonq.doc.answer.codeResult": "You can accept the changes to your files, or describe any additional changes you'd like me to make.", + "AWS.amazonq.doc.answer.acceptOrReject": "You can accept or reject the changes to your files.", "AWS.amazonq.doc.answer.scanning": "Scanning source files", "AWS.amazonq.doc.answer.summarizing": "Summarizing source files", "AWS.amazonq.doc.answer.generating": "Generating documentation", - "AWS.amazonq.doc.answer.creating": "Okay, I'm creating a README for your project. This may take a few minutes.", - "AWS.amazonq.doc.answer.updating": "Okay, I'm updating the README to reflect your code changes. This may take a few minutes.", + "AWS.amazonq.doc.answer.creating": "Okay, I'm creating a README for your project. This might take a few minutes.", + "AWS.amazonq.doc.answer.updating": "Okay, I'm updating the README. This might take a few minutes.", + "AWS.amazonq.doc.answer.chooseFolder": "Choose a folder to continue.", + "AWS.amazonq.doc.error.noFolderSelected": "It looks like you didn't choose a folder. Choose a folder to continue.", "AWS.amazonq.doc.error.contentLengthError": "Your workspace is too large for me to review. Your workspace must be within the quota, even if you choose a smaller folder. For more information on quotas, see the Amazon Q Developer documentation.", "AWS.amazonq.doc.error.readmeTooLarge": "The README in your folder is too large for me to review. Try reducing the size of your README, or choose a folder with a smaller README. For more information on quotas, see the Amazon Q Developer documentation.", + "AWS.amazonq.doc.error.readmeUpdateTooLarge": "The updated README exceeds document size limits. Try reducing the size of your current README or working on a smaller task that won't produce as much content. For more information on quotas, see the Amazon Q Developer documentation.", "AWS.amazonq.doc.error.workspaceEmpty": "The folder you chose did not contain any source files in a supported language. Choose another folder and try again. For more information on supported languages, see the Amazon Q Developer documentation.", "AWS.amazonq.doc.error.promptTooVague": "I need more information to make changes to your README. Try providing some of the following details:\n- Which sections you want to modify\n- The content you want to add or remove\n- Specific issues that need correcting\n\nFor more information on prompt best practices, see the Amazon Q Developer documentation.", "AWS.amazonq.doc.error.promptUnrelated": "These changes don't seem related to documentation. Try describing your changes again, using the following best practices:\n- Changes should relate to how project functionality is reflected in the README\n- Content you refer to should be available in your codebase\n\n For more information on prompt best practices, see the Amazon Q Developer documentation.", "AWS.amazonq.doc.error.docGen.default": "I'm sorry, I ran into an issue while trying to generate your documentation. Please try again.", "AWS.amazonq.doc.error.noChangeRequiredException": "I couldn't find any code changes to update in the README. Try another documentation task.", - "AWS.amazonq.doc.error.promptRefusal": "I'm sorry, I can't generate documentation for this folder. Please make sure your message and code files comply with the Please make sure your message and code files comply with the AWS Responsible AI Policy.", + "AWS.amazonq.doc.error.promptRefusal": "I'm sorry, I can't generate documentation for this folder. Please make sure your message and code files comply with the AWS Responsible AI Policy.", "AWS.amazonq.doc.placeholder.editReadme": "Describe documentation changes", "AWS.amazonq.doc.pillText.closeSession": "End session", + "AWS.amazonq.doc.pillText.newTask": "Start a new documentation task", + "AWS.amazonq.doc.pillText.update": "Update README to reflect code", + "AWS.amazonq.doc.pillText.makeChange": "Make a specific change", + "AWS.amazonq.doc.pillText.accept": "Accept", + "AWS.amazonq.doc.pillText.reject": "Reject", + "AWS.amazonq.doc.pillText.makeChanges": "Make changes", "AWS.amazonq.inline.invokeChat": "Inline chat", "AWS.toolkit.lambda.walkthrough.quickpickTitle": "Application Builder Walkthrough", "AWS.toolkit.lambda.walkthrough.title": "Get started building your application", diff --git a/packages/core/resources/css/base-cloud9.css b/packages/core/resources/css/base-cloud9.css deleted file mode 100644 index ec1e30468c6..00000000000 --- a/packages/core/resources/css/base-cloud9.css +++ /dev/null @@ -1,68 +0,0 @@ -/* TODO: remove this when Cloud9 injects the correct styling information into webviews */ -@import url('./base.css'); - -body { - /* Temporary variables for C9 to shade/tint elements. Best-effort styling based off current theme. */ - /* Since these are applied as rgba, it's very easy to make things look 'washed-out' or too dark */ - --tint: 255, 255, 255; - --shade: 0, 0, 0; -} - -input[type='text'][data-invalid='true'], -input[type='number'][data-invalid='true'] { - border: 1px solid var(--vscode-inputValidation-errorBorder); - border-bottom: 0; - background: none; -} - -/* "Cloud9 gray" in input boxes (not buttons/radios). */ -body.vscode-dark input:not([type='submit']):not([type='radio']) { - background-color: rgba(var(--shade), 0.1); -} - -input:disabled { - filter: none; -} - -body.vscode-dark select { - background: rgba(var(--shade), 0.1); -} - -body.vscode-dark .header { - background-color: rgba(var(--tint), 0.02); -} -body.vscode-light .header { - background-color: rgba(var(--shade), 0.02); -} - -body.vscode-dark .notification { - background-color: #2a2a2a; -} -body.vscode-light .notification { - background-color: #f7f7f7; - box-shadow: 2px 2px 8px #aaa; -} - -button:disabled { - filter: none; -} - -/* Text area */ -textarea { - background: none; -} -body.vscode-dark textarea { - background: rgba(var(--shade), 0.1); -} - -/* Overrides */ -body.vscode-dark .settings-panel { - background: rgba(var(--tint), 0.02) !important; -} -body.vscode-light .settings-panel { - background: rgba(var(--shade), 0.02) !important; -} - -.button-container h1 { - margin: 0px; -} diff --git a/packages/core/resources/css/securityIssue.css b/packages/core/resources/css/securityIssue.css index e102f5bc0f6..5cd64211ae8 100644 --- a/packages/core/resources/css/securityIssue.css +++ b/packages/core/resources/css/securityIssue.css @@ -524,6 +524,13 @@ pre.center { pre.error { color: var(--vscode-diffEditorOverview-removedForeground); + background-color: var(--vscode-diffEditor-removedTextBackground); + white-space: initial; +} + +a.cursor { + cursor: pointer; + text-decoration: none; } .dot-typing { @@ -586,11 +593,6 @@ pre.error { } } -.code-block { - max-width: fit-content; - min-width: 500px; -} - .code-block pre { border-radius: 3px 3px 0 0; } diff --git a/packages/core/resources/ec2_connect b/packages/core/resources/ec2_connect index 518a5e86f58..06ae8d39686 100755 --- a/packages/core/resources/ec2_connect +++ b/packages/core/resources/ec2_connect @@ -2,7 +2,7 @@ # Usage: # When connecting to a dev environment -# AWS_REGION=… AWS_SSM_CLI=… STREAM_URL=… TOKEN=… LOG_FILE_LOCATION==… ./ec2_connect +# AWS_REGION=… AWS_SSM_CLI=… STREAM_URL=… TOKEN=… LOG_FILE_LOCATION==… DEBUG_LOG=… ./ec2_connect set -e set -u @@ -44,13 +44,21 @@ _ec2() { _main() { _log "==============================================================================" - - _require AWS_SSM_CLI "${AWS_SSM_CLI:-}" + _require DEBUG_LOG "${DEBUG_LOG:-}" _require AWS_REGION "${AWS_REGION:-}" - _require STREAM_URL "${STREAM_URL:-}" - _require TOKEN "${TOKEN:-}" + _require SESSION_ID "${SESSION_ID:-}" - _require LOG_FILE_LOCATION "${LOG_FILE_LOCATION:-}" + _require_nolog STREAM_URL "${STREAM_URL:-}" + _require_nolog TOKEN "${TOKEN:-}" + + # Only log file paths when debug level is enabled. + if [ "${DEBUG_LOG:-}" -eq 1 ]; then + _require AWS_SSM_CLI "${AWS_SSM_CLI:-}" + _require LOG_FILE_LOCATION "${LOG_FILE_LOCATION:-}" + else + _require_nolog AWS_SSM_CLI "${AWS_SSM_CLI:-}" + _require_nolog LOG_FILE_LOCATION "${LOG_FILE_LOCATION:-}" + fi _ec2 "$AWS_SSM_CLI" "$AWS_REGION" "$STREAM_URL" "$TOKEN" "$SESSION_ID" } diff --git a/packages/core/resources/icons/cloud9/dark/vscode-help.svg b/packages/core/resources/icons/cloud9/dark/vscode-help.svg deleted file mode 100644 index 94b17dfd8b4..00000000000 --- a/packages/core/resources/icons/cloud9/dark/vscode-help.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/packages/core/resources/icons/cloud9/light/vscode-help.svg b/packages/core/resources/icons/cloud9/light/vscode-help.svg deleted file mode 100644 index 81c89a3b963..00000000000 --- a/packages/core/resources/icons/cloud9/light/vscode-help.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/packages/core/resources/js/graphStateMachine.js b/packages/core/resources/js/graphStateMachine.js index d2ec7ca11ab..9ccff2145fa 100644 --- a/packages/core/resources/js/graphStateMachine.js +++ b/packages/core/resources/js/graphStateMachine.js @@ -108,7 +108,7 @@ zoomoutBtn.addEventListener('click', () => { // Message passing from extension to webview. // Capture state machine definition -window.addEventListener('message', event => { +window.addEventListener('message', (event) => { // event.data is object passed in from postMessage from vscode const message = event.data switch (message.command) { diff --git a/packages/core/scripts/build/generateServiceClient.ts b/packages/core/scripts/build/generateServiceClient.ts index c095fe5ed54..7ef217be21b 100644 --- a/packages/core/scripts/build/generateServiceClient.ts +++ b/packages/core/scripts/build/generateServiceClient.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as proc from 'child_process' +import * as proc from 'child_process' // eslint-disable-line no-restricted-imports import * as nodefs from 'fs' // eslint-disable-line no-restricted-imports import * as path from 'path' @@ -106,7 +106,7 @@ async function insertServiceClientsIntoJsSdk( jsSdkPath: string, serviceClientDefinitions: ServiceClientDefinition[] ): Promise { - serviceClientDefinitions.forEach((serviceClientDefinition) => { + for (const serviceClientDefinition of serviceClientDefinitions) { const apiVersion = getApiVersion(serviceClientDefinition.serviceJsonPath) // Copy the Service Json into the JS SDK for generation @@ -116,7 +116,7 @@ async function insertServiceClientsIntoJsSdk( `${serviceClientDefinition.serviceName.toLowerCase()}-${apiVersion}.normal.json` ) nodefs.copyFileSync(serviceClientDefinition.serviceJsonPath, jsSdkServiceJsonPath) - }) + } const apiMetadataPath = path.join(jsSdkPath, 'apis', 'metadata.json') await patchServicesIntoApiMetadata( @@ -151,9 +151,9 @@ async function patchServicesIntoApiMetadata(apiMetadataPath: string, serviceName const apiMetadataJson = nodefs.readFileSync(apiMetadataPath).toString() const apiMetadata = JSON.parse(apiMetadataJson) as ApiMetadata - serviceNames.forEach((serviceName) => { + for (const serviceName of serviceNames) { apiMetadata[serviceName.toLowerCase()] = { name: serviceName } - }) + } nodefs.writeFileSync(apiMetadataPath, JSON.stringify(apiMetadata, undefined, 4)) } diff --git a/packages/core/scripts/lint/testLint.ts b/packages/core/scripts/lint/testLint.ts index 43bafb6ae00..d215e57f675 100644 --- a/packages/core/scripts/lint/testLint.ts +++ b/packages/core/scripts/lint/testLint.ts @@ -12,9 +12,9 @@ void (async () => { const mocha = new Mocha() const testFiles = await glob('dist/src/testLint/**/*.test.js') - testFiles.forEach((file) => { + for (const file of testFiles) { mocha.addFile(file) - }) + } mocha.run((failures) => { const exitCode = failures ? 1 : 0 diff --git a/packages/core/scripts/test/launchTestUtilities.ts b/packages/core/scripts/test/launchTestUtilities.ts index bf3fd6614b3..92afb769275 100644 --- a/packages/core/scripts/test/launchTestUtilities.ts +++ b/packages/core/scripts/test/launchTestUtilities.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as proc from 'child_process' +import * as proc from 'child_process' // eslint-disable-line no-restricted-imports import packageJson from '../../package.json' import { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } from '@vscode/test-electron' import { join, resolve } from 'path' diff --git a/packages/core/src/amazonq/commons/connector/baseMessenger.ts b/packages/core/src/amazonq/commons/connector/baseMessenger.ts index ab053333432..4c29f005557 100644 --- a/packages/core/src/amazonq/commons/connector/baseMessenger.ts +++ b/packages/core/src/amazonq/commons/connector/baseMessenger.ts @@ -85,6 +85,7 @@ export class Messenger { type: 'answer', tabID: tabID, message: i18n('AWS.amazonq.featureDev.error.monthlyLimitReached'), + disableChatInput: true, }) this.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.placeholder.chatInputDisabled')) } diff --git a/packages/core/src/amazonq/commons/controllers/contentController.ts b/packages/core/src/amazonq/commons/controllers/contentController.ts index 0af3b317025..1380253f8eb 100644 --- a/packages/core/src/amazonq/commons/controllers/contentController.ts +++ b/packages/core/src/amazonq/commons/controllers/contentController.ts @@ -16,6 +16,7 @@ import { getSelectionFromRange, } from '../../../shared/utilities/textDocumentUtilities' import { extractFileAndCodeSelectionFromMessage, fs, getErrorMsg, ToolkitError } from '../../../shared' +import { UserWrittenCodeTracker } from '../../../codewhisperer/tracker/userWrittenCodeTracker' export class ContentProvider implements vscode.TextDocumentContentProvider { constructor(private uri: vscode.Uri) {} @@ -41,6 +42,7 @@ export class EditorContentController { ) { const editor = window.activeTextEditor if (editor) { + UserWrittenCodeTracker.instance.onQStartsMakingEdits() const cursorStart = editor.selection.active const indentRange = new vscode.Range(new vscode.Position(cursorStart.line, 0), cursorStart) // use the user editor intent if the position to the left of cursor is just space or tab @@ -50,13 +52,13 @@ export class EditorContentController { indent = ' '.repeat(indent.length - indent.trimStart().length) } let textWithIndent = '' - text.split('\n').forEach((line, index) => { + for (const [index, line] of text.split('\n').entries()) { if (index === 0) { textWithIndent += line } else { textWithIndent += '\n' + indent + line } - }) + } editor .edit((editBuilder) => { editBuilder.insert(cursorStart, textWithIndent) @@ -66,9 +68,11 @@ export class EditorContentController { if (appliedEdits) { trackCodeEdit(editor, cursorStart) } + UserWrittenCodeTracker.instance.onQFinishesEdits() }, (e) => { getLogger().error('TextEditor.edit failed: %s', (e as Error).message) + UserWrittenCodeTracker.instance.onQFinishesEdits() } ) } @@ -97,6 +101,7 @@ export class EditorContentController { if (filePath && message?.code?.trim().length > 0 && selection) { try { + UserWrittenCodeTracker.instance.onQStartsMakingEdits() const doc = await vscode.workspace.openTextDocument(filePath) const code = getIndentedCode(message, doc, selection) @@ -130,6 +135,8 @@ export class EditorContentController { const wrappedError = ChatDiffError.chain(error, `Failed to Accept Diff`, { code: chatDiffCode }) getLogger().error('%s: Failed to open diff view %s', chatDiffCode, getErrorMsg(wrappedError, true)) throw wrappedError + } finally { + UserWrittenCodeTracker.instance.onQFinishesEdits() } } } diff --git a/packages/core/src/amazonq/commons/diff.ts b/packages/core/src/amazonq/commons/diff.ts index beb45d88096..ed6642b9192 100644 --- a/packages/core/src/amazonq/commons/diff.ts +++ b/packages/core/src/amazonq/commons/diff.ts @@ -46,7 +46,7 @@ export async function computeDiff(leftPath: string, rightPath: string, tabId: st let charsRemoved = 0 let linesAdded = 0 let linesRemoved = 0 - changes.forEach((change) => { + for (const change of changes) { const lines = change.value.split('\n') const charCount = lines.reduce((sum, line) => sum + line.length, 0) const lineCount = change.count ?? lines.length - 1 // ignoring end-of-file empty line @@ -57,6 +57,6 @@ export async function computeDiff(leftPath: string, rightPath: string, tabId: st charsRemoved += charCount linesRemoved += lineCount } - }) + } return { changes, charsAdded, linesAdded, charsRemoved, linesRemoved } } diff --git a/packages/core/src/amazonq/commons/types.ts b/packages/core/src/amazonq/commons/types.ts index b9d87155364..da6503b262f 100644 --- a/packages/core/src/amazonq/commons/types.ts +++ b/packages/core/src/amazonq/commons/types.ts @@ -4,7 +4,7 @@ */ export enum FollowUpTypes { - //UnitTestGeneration + // UnitTestGeneration ViewDiff = 'ViewDiff', AcceptCode = 'AcceptCode', RejectCode = 'RejectCode', @@ -14,7 +14,7 @@ export enum FollowUpTypes { InstallDependenciesAndContinue = 'InstallDependenciesAndContinue', ContinueBuildAndExecute = 'ContinueBuildAndExecute', ViewCodeDiffAfterIteration = 'ViewCodeDiffAfterIteration', - //FeatureDev + // FeatureDev GenerateCode = 'GenerateCode', InsertCode = 'InsertCode', ProvideFeedbackAndRegenerateCode = 'ProvideFeedbackAndRegenerateCode', diff --git a/packages/core/src/amazonq/explorer/amazonQTreeNode.ts b/packages/core/src/amazonq/explorer/amazonQTreeNode.ts index 4ea7970e84b..bbfd1bc1ff2 100644 --- a/packages/core/src/amazonq/explorer/amazonQTreeNode.ts +++ b/packages/core/src/amazonq/explorer/amazonQTreeNode.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode' import { ResourceTreeDataProvider, TreeNode } from '../../shared/treeview/resourceTreeDataProvider' -import { AuthState, isPreviousQUser } from '../../codewhisperer/util/authUtil' +import { AuthState } from '../../codewhisperer/util/authUtil' import { createLearnMoreNode, createInstallQNode, createDismissNode } from './amazonQChildrenNodes' import { Commands } from '../../shared/vscode/commands2' @@ -40,10 +40,7 @@ export class AmazonQNode implements TreeNode { } public getChildren() { - const children = [createInstallQNode(), createLearnMoreNode()] - if (!isPreviousQUser()) { - children.push(createDismissNode()) - } + const children = [createInstallQNode(), createLearnMoreNode(), createDismissNode()] return children } diff --git a/packages/core/src/amazonq/index.ts b/packages/core/src/amazonq/index.ts index 5bd20e4dfd0..9ca9af7687c 100644 --- a/packages/core/src/amazonq/index.ts +++ b/packages/core/src/amazonq/index.ts @@ -24,8 +24,8 @@ export { init as featureDevChatAppInit } from '../amazonqFeatureDev/app' export { init as gumbyChatAppInit } from '../amazonqGumby/app' export { init as testChatAppInit } from '../amazonqTest/app' export { init as docChatAppInit } from '../amazonqDoc/app' -export { activateBadge } from './util/viewBadgeHandler' export { amazonQHelpUrl } from '../shared/constants' +export * as webviewConstants from './webview/ui/texts/constants' export { listCodeWhispererCommandsWalkthrough } from '../codewhisperer/ui/statusBarMenu' export { focusAmazonQPanel, focusAmazonQPanelKeybinding } from '../codewhispererChat/commands/registerCommands' export { TryChatCodeLensProvider, tryChatCodeLensCommand } from '../codewhispererChat/editor/codelens' @@ -55,11 +55,12 @@ export function createMynahUI( ideApi: any, amazonQEnabled: boolean, featureConfigsSerialized: [string, FeatureContext][], + welcomeCount: number, disabledCommands?: string[] ) { if (typeof window !== 'undefined') { const mynahUI = require('./webview/ui/main') - return mynahUI.createMynahUI(ideApi, amazonQEnabled, featureConfigsSerialized, true, disabledCommands) + return mynahUI.createMynahUI(ideApi, amazonQEnabled, featureConfigsSerialized, welcomeCount, disabledCommands) } throw new Error('Not implemented for node') } diff --git a/packages/core/src/amazonq/lsp/lspClient.ts b/packages/core/src/amazonq/lsp/lspClient.ts index 1d3dd2743e9..3969a3313e9 100644 --- a/packages/core/src/amazonq/lsp/lspClient.ts +++ b/packages/core/src/amazonq/lsp/lspClient.ts @@ -10,7 +10,7 @@ import * as vscode from 'vscode' import * as path from 'path' import * as nls from 'vscode-nls' -import * as cp from 'child_process' +import { spawn } from 'child_process' // eslint-disable-line no-restricted-imports import * as crypto from 'crypto' import * as jose from 'jose' @@ -32,7 +32,7 @@ import { } from './types' import { Writable } from 'stream' import { CodeWhispererSettings } from '../../codewhisperer/util/codewhispererSettings' -import { fs, getLogger } from '../../shared' +import { fs, getLogger, globals } from '../../shared' const localize = nls.loadMessageBundle() @@ -199,7 +199,7 @@ export async function activate(extensionContext: ExtensionContext) { const nodename = process.platform === 'win32' ? 'node.exe' : 'node' - const child = cp.spawn(extensionContext.asAbsolutePath(path.join('resources', nodename)), [ + const child = spawn(extensionContext.asAbsolutePath(path.join('resources', nodename)), [ serverModule, ...debugOptions.execArgv, ]) @@ -228,6 +228,9 @@ export async function activate(extensionContext: ExtensionContext) { // this is used by LSP to determine index cache path, move to this folder so that when extension updates index is not deleted. extensionPath: path.join(fs.getUserHomeDir(), '.aws', 'amazonq', 'cache'), }, + // Log to the Amazon Q Logs so everything is in a single channel + // TODO: Add prefix to the language server logs so it is easier to search + outputChannel: globals.logOutputChannel, } // Create the language client and start the client. diff --git a/packages/core/src/amazonq/lsp/lspController.ts b/packages/core/src/amazonq/lsp/lspController.ts index 7a74318dd14..d3f8960d1fc 100644 --- a/packages/core/src/amazonq/lsp/lspController.ts +++ b/packages/core/src/amazonq/lsp/lspController.ts @@ -58,7 +58,7 @@ export interface Manifest { } const manifestUrl = 'https://aws-toolkit-language-servers.amazonaws.com/q-context/manifest.json' // this LSP client in Q extension is only going to work with these LSP server versions -const supportedLspServerVersions = ['0.1.29'] +const supportedLspServerVersions = ['0.1.32'] const nodeBinName = process.platform === 'win32' ? 'node.exe' : 'node' @@ -280,23 +280,25 @@ export class LspController { async query(s: string): Promise { const chunks: Chunk[] | undefined = await LspClient.instance.queryVectorIndex(s) const resp: RelevantTextDocument[] = [] - chunks?.forEach((chunk) => { - const text = chunk.context ? chunk.context : chunk.content - if (chunk.programmingLanguage && chunk.programmingLanguage !== 'unknown') { - resp.push({ - text: text, - relativeFilePath: chunk.relativePath ? chunk.relativePath : path.basename(chunk.filePath), - programmingLanguage: { - languageName: chunk.programmingLanguage, - }, - }) - } else { - resp.push({ - text: text, - relativeFilePath: chunk.relativePath ? chunk.relativePath : path.basename(chunk.filePath), - }) + if (chunks) { + for (const chunk of chunks) { + const text = chunk.context ? chunk.context : chunk.content + if (chunk.programmingLanguage && chunk.programmingLanguage !== 'unknown') { + resp.push({ + text: text, + relativeFilePath: chunk.relativePath ? chunk.relativePath : path.basename(chunk.filePath), + programmingLanguage: { + languageName: chunk.programmingLanguage, + }, + }) + } else { + resp.push({ + text: text, + relativeFilePath: chunk.relativePath ? chunk.relativePath : path.basename(chunk.filePath), + }) + } } - }) + } return resp } @@ -360,7 +362,7 @@ export class LspController { }) } } catch (error) { - //TODO: use telemetry.run() + // TODO: use telemetry.run() getLogger().error(`LspController: Failed to build index of project`) telemetry.amazonq_indexWorkspace.emit({ duration: performance.now() - start, @@ -372,9 +374,6 @@ export class LspController { }) } finally { this._isIndexingInProgress = false - const repomapFile = await LspClient.instance.getRepoMapJSON() - // console.log(repomapFile) - getLogger().info(`File path ${repomapFile}`) } } diff --git a/packages/core/src/amazonq/lsp/types.ts b/packages/core/src/amazonq/lsp/types.ts index fe1df5ed3bc..3af943cb97d 100644 --- a/packages/core/src/amazonq/lsp/types.ts +++ b/packages/core/src/amazonq/lsp/types.ts @@ -66,7 +66,7 @@ export const QueryVectorIndexRequestType: RequestType = new RequestType( diff --git a/packages/core/src/amazonq/util/viewBadgeHandler.ts b/packages/core/src/amazonq/util/viewBadgeHandler.ts deleted file mode 100644 index 3d066b18472..00000000000 --- a/packages/core/src/amazonq/util/viewBadgeHandler.ts +++ /dev/null @@ -1,83 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -import { window, TreeItem, TreeView, ViewBadge } from 'vscode' -import { getLogger } from '../../shared/logger' -import globals from '../../shared/extensionGlobals' -import { AuthUtil } from '../../codewhisperer/util/authUtil' - -let badgeHelperView: TreeView | undefined - -/** - * invisible view meant exclusively to handle the view badge, note declaration has `"when": false`. - * webviews can provide a badge (you can hack it to show strings!), BUT: - * webview views can't show badges until they are loaded, - * so our best option is to use a do-nothing tree view and show a '1' - */ -export async function activateBadge() { - badgeHelperView = window.createTreeView('aws.AmazonQNeverShowBadge', { - treeDataProvider: { - getChildren: () => [], - getTreeItem: () => new TreeItem(''), - }, - }) - await showInitialViewBadge() -} - -/** - * Changes the view badge for the hidden view connected to the Amazon Q view - * @param badge ViewBadge to show, or undefined to blank the badge - */ -export function changeViewBadge(badge?: ViewBadge) { - if (badgeHelperView) { - badgeHelperView.badge = badge - } else { - getLogger().error('Attempted to call changeViewBadge before badgeHelperView set.') - } -} - -/** - * Removes the view badge from the badge helper view and prevents it from showing up ever again - */ -export function deactivateInitialViewBadge() { - globals.globalState.tryUpdate('hasAlreadyOpenedAmazonQ', true) - changeViewBadge() -} - -/** - * Show users a '1' badge on the Amazon Q icon if {@link shouldShowBadge} is true. - * - * This is intended to target users who are already using CWSPR and - * are autoupdating to a version of the extension with Q, - * since they may not know it exists otherwise. - */ -async function showInitialViewBadge() { - if (await shouldShowBadge()) { - changeViewBadge({ - value: 1, - tooltip: '', - }) - } -} - -/** - * Determines if a user should see an attract badge to entice them to use Amazon Q - * Shows a badge on the Amazon Q View Container IF: - * * the user has never, ever clicked into Amazon Q - * * The user has codewhispererCore auth and not codewhispererChat auth - * - * @returns True if the badge should be shown, false otherwise - */ -export async function shouldShowBadge(): Promise { - const hasAlreadyShown = globals.globalState.get('hasAlreadyOpenedAmazonQ') - if (!hasAlreadyShown) { - const state = await AuthUtil.instance.getChatAuthState() - if (state.codewhispererCore === 'connected' && state.codewhispererChat !== 'connected') { - return true - } - } - - return false -} diff --git a/packages/core/src/amazonq/webview/generators/webViewContent.ts b/packages/core/src/amazonq/webview/generators/webViewContent.ts index 6b935e83642..ea577574d4c 100644 --- a/packages/core/src/amazonq/webview/generators/webViewContent.ts +++ b/packages/core/src/amazonq/webview/generators/webViewContent.ts @@ -23,7 +23,7 @@ export class WebViewContentGenerator { return JSON.stringify(Array.from(featureConfigs.entries())) } - public async generate(extensionURI: Uri, webView: Webview, showWelcomePage: boolean): Promise { + public async generate(extensionURI: Uri, webView: Webview): Promise { const entrypoint = process.env.WEBPACK_DEVELOPER_SERVER ? 'http: localhost' : 'https: file+.vscode-resources.vscode-cdn.net' @@ -47,14 +47,14 @@ export class WebViewContentGenerator { Amazon Q (Preview) - ${await this.generateJS(extensionURI, webView, showWelcomePage)} + ${await this.generateJS(extensionURI, webView)} ` } - private async generateJS(extensionURI: Uri, webView: Webview, showWelcomePage: boolean): Promise { + private async generateJS(extensionURI: Uri, webView: Webview): Promise { const source = path.join('vue', 'src', 'amazonq', 'webview', 'ui', 'amazonq-ui.js') // Sent to dist/vue folder in webpack. const assetsPath = Uri.joinPath(extensionURI) const javascriptUri = Uri.joinPath(assetsPath, 'dist', source) @@ -80,6 +80,8 @@ export class WebViewContentGenerator { const disabledCommandsString = isSageMaker() ? `['/dev', '/transform']` : '[]' const disclaimerAcknowledged = globals.globalState.tryGet('aws.amazonq.disclaimerAcknowledged', Boolean, false) + const welcomeLoadCount = globals.globalState.tryGet('aws.amazonq.welcomeChatShowCount', Number, 0) + return ` ${cssLinks} @@ -87,7 +89,7 @@ export class WebViewContentGenerator { const init = () => { createMynahUI(acquireVsCodeApi(), ${ (await AuthUtil.instance.getChatAuthState()).amazonQ === 'connected' - },${featureConfigsString},${showWelcomePage},${disclaimerAcknowledged},${disabledCommandsString}); + },${featureConfigsString},${welcomeLoadCount},${disclaimerAcknowledged},${disabledCommandsString}); } ` diff --git a/packages/core/src/amazonq/webview/messages/messageDispatcher.ts b/packages/core/src/amazonq/webview/messages/messageDispatcher.ts index 95e301a0470..6acc250a25f 100644 --- a/packages/core/src/amazonq/webview/messages/messageDispatcher.ts +++ b/packages/core/src/amazonq/webview/messages/messageDispatcher.ts @@ -74,6 +74,11 @@ export function dispatchWebViewMessagesToApps( globals.globalState.tryUpdate('aws.amazonq.disclaimerAcknowledged', true) return } + case 'update-welcome-count': { + const currentLoadCount = globals.globalState.tryGet('aws.amazonq.welcomeChatShowCount', Number, 0) + void globals.globalState.tryUpdate('aws.amazonq.welcomeChatShowCount', currentLoadCount + 1) + return + } } if (msg.type === 'error') { diff --git a/packages/core/src/amazonq/webview/ui/apps/testChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/testChatConnector.ts index 3fa53cd97f8..ae179fd6c41 100644 --- a/packages/core/src/amazonq/webview/ui/apps/testChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/testChatConnector.ts @@ -13,6 +13,7 @@ import { TabsStorage, TabType } from '../storages/tabsStorage' import { TestMessageType } from '../../../../amazonqTest/chat/views/connector/connector' import { ChatPayload } from '../connector' import { BaseConnector, BaseConnectorProps } from './baseConnector' +import { FollowUpTypes } from '../../../commons/types' export interface ConnectorProps extends BaseConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void @@ -33,7 +34,7 @@ export interface MessageData { tabID: string type: TestMessageType } -//TODO: Refactor testChatConnector, scanChatConnector and other apps connector files post RIV +// TODO: Refactor testChatConnector, scanChatConnector and other apps connector files post RIV export class Connector extends BaseConnector { override getTabType(): TabType { return 'testgen' @@ -107,15 +108,43 @@ export class Connector extends BaseConnector { } onFileDiff = (tabID: string, filePath: string, deleted: boolean, messageId?: string): void => { - // TODO: add this back once we can advance flow from here - // this.sendMessageToExtension({ - // command: 'open-diff', - // tabID, - // filePath, - // deleted, - // messageId, - // tabType: 'testgen', - // }) + if (this.onChatAnswerReceived === undefined) { + return + } + // Open diff view + this.sendMessageToExtension({ + command: 'open-diff', + tabID, + filePath, + deleted, + messageId, + tabType: 'testgen', + }) + this.onChatAnswerReceived( + tabID, + { + type: ChatItemType.ANSWER, + messageId: messageId, + followUp: { + text: ' ', + options: [ + { + type: FollowUpTypes.AcceptCode, + pillText: 'Accept', + status: 'success', + icon: MynahIcons.OK, + }, + { + type: FollowUpTypes.RejectCode, + pillText: 'Reject', + status: 'error', + icon: MynahIcons.REVERT, + }, + ], + }, + }, + {} + ) } private processChatMessage = async (messageData: any): Promise => { diff --git a/packages/core/src/amazonq/webview/ui/commands.ts b/packages/core/src/amazonq/webview/ui/commands.ts index 643595e3e1f..d668cb5d3b7 100644 --- a/packages/core/src/amazonq/webview/ui/commands.ts +++ b/packages/core/src/amazonq/webview/ui/commands.ts @@ -41,5 +41,6 @@ type MessageCommand = | 'review' | 'open-user-guide' | 'send-telemetry' + | 'update-welcome-count' export type ExtensionMessage = Record & { command: MessageCommand } diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index d4243928a1b..c535409ca78 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -33,11 +33,17 @@ import { agentWalkthroughDataModel } from './walkthrough/agent' import { createClickTelemetry, createOpenAgentTelemetry } from './telemetry/actions' import { disclaimerAcknowledgeButtonId, disclaimerCard } from './texts/disclaimer' +/** + * The number of welcome chat tabs that can be opened before the NEXT one will become + * a regular chat tab. + */ +const welcomeCountThreshold = 3 + export const createMynahUI = ( ideApi: any, amazonQEnabled: boolean, featureConfigsSerialized: [string, FeatureContext][], - showWelcomePage: boolean, + welcomeCount: number, disclaimerAcknowledged: boolean, disabledCommands?: string[] ) => { @@ -46,7 +52,7 @@ export const createMynahUI = ( let mynahUI: MynahUI // eslint-disable-next-line prefer-const let connector: Connector - //Store the mapping between messageId and messageUserIntent for amazonq_interactWithMessage telemetry + // Store the mapping between messageId and messageUserIntent for amazonq_interactWithMessage telemetry const responseMetadata = new Map() window.addEventListener('error', (e) => { @@ -70,11 +76,23 @@ export const createMynahUI = ( }) }, }) + + const showWelcomePage = () => { + return welcomeCount < welcomeCountThreshold + } + + const updateWelcomeCount = () => { + ideApi.postMessage({ + command: 'update-welcome-count', + }) + welcomeCount += 1 + } + // Adding the first tab as CWC tab tabsStorage.addTab({ id: 'tab-1', status: 'free', - type: showWelcomePage ? 'welcome' : 'cwc', + type: showWelcomePage() ? 'welcome' : 'cwc', isSelected: true, }) @@ -88,6 +106,10 @@ export const createMynahUI = ( let isDocEnabled = amazonQEnabled + let featureConfigs: Map = tryNewMap(featureConfigsSerialized) + + const highlightCommand = featureConfigs.get('highlightCommand') + let tabDataGenerator = new TabDataGenerator({ isFeatureDevEnabled, isGumbyEnabled, @@ -95,6 +117,7 @@ export const createMynahUI = ( isTestEnabled, isDocEnabled, disabledCommands, + commandHighlight: highlightCommand, }) // eslint-disable-next-line prefer-const @@ -106,11 +129,8 @@ export const createMynahUI = ( // eslint-disable-next-line prefer-const let messageController: MessageController - // @ts-ignore - let featureConfigs: Map = tryNewMap(featureConfigsSerialized) - function getCodeBlockActions(messageData: any) { - //Show ViewDiff and AcceptDiff for allowedCommands in CWC + // Show ViewDiff and AcceptDiff for allowedCommands in CWC const isEnabled = featureConfigs.get('ViewDiffInChat')?.variation === 'TREATMENT' const tab = tabsStorage.getTab(messageData?.tabID || '') const allowedCommands = [ @@ -136,13 +156,13 @@ export const createMynahUI = ( }, } } - //Show only "Copy" option for codeblocks in Q Test Tab + // Show only "Copy" option for codeblocks in Q Test Tab if (tab?.type === 'testgen') { return { 'insert-to-cursor': undefined, } } - //Default will show "Copy" and "Insert at cursor" for codeblocks + // Default will show "Copy" and "Insert at cursor" for codeblocks return {} } @@ -181,6 +201,7 @@ export const createMynahUI = ( isTestEnabled, isDocEnabled, disabledCommands, + commandHighlight: highlightCommand, }) featureConfigs = tryNewMap(featureConfigsSerialized) @@ -541,6 +562,25 @@ export const createMynahUI = ( mynahUI = new MynahUI({ onReady: connector.uiReady, onTabAdd: (tabID: string) => { + /** + * If the next tab opening will cross the welcome count threshold then + * update the next tabs defaults + */ + if (welcomeCount + 1 >= welcomeCountThreshold) { + tabsStorage.updateTabTypeFromUnknown(tabID, 'cwc') + mynahUI?.updateTabDefaults({ + store: { + ...tabDataGenerator.getTabData('cwc', true), + tabHeaderDetails: void 0, + compactMode: false, + tabBackground: false, + }, + }) + } else { + // we haven't reached the welcome count limit yet + updateWelcomeCount() + } + // If featureDev has changed availability inbetween the default store settings and now // make sure to show/hide it accordingly mynahUI.updateStore(tabID, { @@ -626,10 +666,10 @@ export const createMynahUI = ( ideApi.postMessage(createClickTelemetry('amazonq-disclaimer-acknowledge-button')) // remove all disclaimer cards from all tabs - Object.keys(mynahUI.getAllTabs()).forEach((storeTabKey) => { + for (const storeTabKey of Object.keys(mynahUI.getAllTabs())) { // eslint-disable-next-line unicorn/no-null mynahUI.updateStore(storeTabKey, { promptInputStickyCard: null }) - }) + } return } case 'quick-start': { @@ -812,7 +852,7 @@ export const createMynahUI = ( 'tab-1': { isSelected: true, store: { - ...(showWelcomePage + ...(showWelcomePage() ? welcomeScreenTabData(tabDataGenerator).store : tabDataGenerator.getTabData('cwc', true)), ...(disclaimerCardActive ? { promptInputStickyCard: disclaimerCard } : {}), @@ -820,7 +860,9 @@ export const createMynahUI = ( }, }, defaults: { - store: tabDataGenerator.getTabData('cwc', true), + store: showWelcomePage() + ? welcomeScreenTabData(tabDataGenerator).store + : tabDataGenerator.getTabData('cwc', true), }, config: { maxTabs: 10, @@ -829,6 +871,14 @@ export const createMynahUI = ( }, }) + /** + * Update the welcome count if we've initially shown + * the welcome page + */ + if (showWelcomePage()) { + updateWelcomeCount() + } + followUpsInteractionHandler = new FollowUpInteractionHandler({ mynahUI, connector, diff --git a/packages/core/src/amazonq/webview/ui/quickActions/generator.ts b/packages/core/src/amazonq/webview/ui/quickActions/generator.ts index a2c7fa35960..81513a3e143 100644 --- a/packages/core/src/amazonq/webview/ui/quickActions/generator.ts +++ b/packages/core/src/amazonq/webview/ui/quickActions/generator.ts @@ -39,7 +39,7 @@ export class QuickActionGenerator { return [] } - //TODO: Update acc to UX + // TODO: Update acc to UX const quickActionCommands = [ { groupName: `Q Developer agentic capabilities`, @@ -86,7 +86,7 @@ export class QuickActionGenerator { ? [ { command: '/transform', - description: 'Transform your Java 8, 11, or 17 Maven projects', + description: 'Transform your Java project', icon: MynahIcons.TRANSFORM, }, ] diff --git a/packages/core/src/amazonq/webview/ui/quickActions/handler.ts b/packages/core/src/amazonq/webview/ui/quickActions/handler.ts index 78ef3d0e7ec..221fb4e53b6 100644 --- a/packages/core/src/amazonq/webview/ui/quickActions/handler.ts +++ b/packages/core/src/amazonq/webview/ui/quickActions/handler.ts @@ -109,11 +109,11 @@ export class QuickActionHandler { } let scanTabId: string | undefined = undefined - this.tabsStorage.getTabs().forEach((tab) => { + for (const tab of this.tabsStorage.getTabs()) { if (tab.type === 'review') { scanTabId = tab.id } - }) + } if (scanTabId !== undefined) { this.mynahUI.selectTab(scanTabId, eventId || '') @@ -292,11 +292,11 @@ export class QuickActionHandler { let gumbyTabId: string | undefined = undefined - this.tabsStorage.getTabs().forEach((tab) => { + for (const tab of this.tabsStorage.getTabs()) { if (tab.type === 'gumby') { gumbyTabId = tab.id } - }) + } if (gumbyTabId !== undefined) { this.mynahUI.selectTab(gumbyTabId, eventId || '') diff --git a/packages/core/src/amazonq/webview/ui/tabs/generator.ts b/packages/core/src/amazonq/webview/ui/tabs/generator.ts index b3263218c1d..a6d31e715df 100644 --- a/packages/core/src/amazonq/webview/ui/tabs/generator.ts +++ b/packages/core/src/amazonq/webview/ui/tabs/generator.ts @@ -3,12 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItemType, MynahUIDataModel } from '@aws/mynah-ui' +import { ChatItemType, MynahUIDataModel, QuickActionCommandGroup } from '@aws/mynah-ui' import { TabType } from '../storages/tabsStorage' import { FollowUpGenerator } from '../followUps/generator' import { QuickActionGenerator } from '../quickActions/generator' import { TabTypeDataMap } from './constants' import { agentWalkthroughDataModel } from '../walkthrough/agent' +import { FeatureContext } from '../../../../shared' export interface TabDataGeneratorProps { isFeatureDevEnabled: boolean @@ -17,11 +18,13 @@ export interface TabDataGeneratorProps { isTestEnabled: boolean isDocEnabled: boolean disabledCommands?: string[] + commandHighlight?: FeatureContext } export class TabDataGenerator { private followUpsGenerator: FollowUpGenerator public quickActionsGenerator: QuickActionGenerator + private highlightCommand?: FeatureContext constructor(props: TabDataGeneratorProps) { this.followUpsGenerator = new FollowUpGenerator() @@ -33,6 +36,7 @@ export class TabDataGenerator { isDocEnabled: props.isDocEnabled, disableCommands: props.disabledCommands, }) + this.highlightCommand = props.commandHighlight } public getTabData(tabType: TabType, needWelcomeMessages: boolean, taskName?: string): MynahUIDataModel { @@ -50,7 +54,7 @@ export class TabDataGenerator { 'Amazon Q Developer uses generative AI. You may need to verify responses. See the [AWS Responsible AI Policy](https://aws.amazon.com/machine-learning/responsible-ai/policy/).', quickActionCommands: this.quickActionsGenerator.generateForTab(tabType), promptInputPlaceholder: TabTypeDataMap[tabType].placeholder, - contextCommands: TabTypeDataMap[tabType].contextCommands, + contextCommands: this.getContextCommands(tabType), chatItems: needWelcomeMessages ? [ { @@ -66,4 +70,32 @@ export class TabDataGenerator { } return tabData } + + private getContextCommands(tabType: TabType): QuickActionCommandGroup[] | undefined { + if (tabType === 'agentWalkthrough' || tabType === 'welcome') { + return + } + + const commandName = this.highlightCommand?.value.stringValue + if (commandName === undefined || commandName === '') { + return TabTypeDataMap[tabType].contextCommands + } else { + const commandHighlight: QuickActionCommandGroup = { + groupName: 'Additional Commands', + commands: [ + { + command: commandName, + description: this.highlightCommand?.variation, + }, + ], + } + + const contextCommands = TabTypeDataMap[tabType].contextCommands + if (contextCommands === undefined) { + return [commandHighlight] + } else { + return [...contextCommands, commandHighlight] + } + } + } } diff --git a/packages/core/src/amazonq/webview/ui/walkthrough/agent.ts b/packages/core/src/amazonq/webview/ui/walkthrough/agent.ts index bb0b4b15896..f4a5add7aa1 100644 --- a/packages/core/src/amazonq/webview/ui/walkthrough/agent.ts +++ b/packages/core/src/amazonq/webview/ui/walkthrough/agent.ts @@ -58,6 +58,7 @@ Implement features or make changes across your workspace, all from a single prom status: 'main', disabled: false, flash: 'once', + fillState: 'hover', icon: MynahIcons.RIGHT_OPEN, id: 'quick-start-featuredev', text: `Quick start with **/dev**`, @@ -88,6 +89,7 @@ Automatically generate unit tests for your active file. status: 'main', disabled: false, flash: 'once', + fillState: 'hover', icon: MynahIcons.RIGHT_OPEN, id: 'quick-start-testgen', text: `Quick start with **/test**`, @@ -122,6 +124,7 @@ Create and update READMEs for better documented code. status: 'main', disabled: false, flash: 'once', + fillState: 'hover', icon: MynahIcons.RIGHT_OPEN, id: 'quick-start-doc', text: `Quick start with **/doc**`, @@ -156,6 +159,7 @@ Review code for issues, then get suggestions to fix your code instantaneously. status: 'main', disabled: false, flash: 'once', + fillState: 'hover', icon: MynahIcons.RIGHT_OPEN, id: 'quick-start-review', text: `Quick start with **/review**`, @@ -186,6 +190,7 @@ Upgrade library and language versions in your codebase. status: 'main', disabled: false, flash: 'once', + fillState: 'hover', icon: MynahIcons.RIGHT_OPEN, id: 'quick-start-gumby', text: `Quick start with **/transform**`, diff --git a/packages/core/src/amazonq/webview/webView.ts b/packages/core/src/amazonq/webview/webView.ts index d5488e75f16..74f60cbf67b 100644 --- a/packages/core/src/amazonq/webview/webView.ts +++ b/packages/core/src/amazonq/webview/webView.ts @@ -19,14 +19,7 @@ import { dispatchAppsMessagesToWebView, dispatchWebViewMessagesToApps } from './ import { MessageListener } from '../messages/messageListener' import { MessagePublisher } from '../messages/messagePublisher' import { TabType } from './ui/storages/tabsStorage' -import { deactivateInitialViewBadge, shouldShowBadge } from '../util/viewBadgeHandler' -import { telemetry } from '../../shared/telemetry/telemetry' import { amazonqMark } from '../../shared/performance/marks' -import { globals } from '../../shared' -import { AuthUtil } from '../../codewhisperer/util/authUtil' - -// The max number of times we should show the welcome to q chat panel before moving them to the regular one -const maxWelcomeWebviewLoads = 3 export class AmazonQChatViewProvider implements WebviewViewProvider { public static readonly viewType = 'aws.AmazonQChatView' @@ -65,48 +58,11 @@ export class AmazonQChatViewProvider implements WebviewViewProvider { dispatchAppsMessagesToWebView(webviewView.webview, this.appsMessagesListener) - /** - * Show the welcome to q chat ${maxWelcomeWebviewLoads} times before showing the normal panel - */ - const welcomeLoadCount = globals.globalState.tryGet('aws.amazonq.welcomeChatShowCount', Number, 0) - if (welcomeLoadCount < maxWelcomeWebviewLoads) { - webviewView.webview.html = await this.webViewContentGenerator.generate( - this.extensionContext.extensionUri, - webviewView.webview, - true - ) - - /** - * resolveWebviewView gets called even when the user isn't logged in and the auth page is showing. - * We don't want to incremenent the show count until the user has fully logged in and resolveWebviewView - * gets called again - */ - const authenticated = (await AuthUtil.instance.getChatAuthState()).amazonQ === 'connected' - if (authenticated) { - await globals.globalState.update('aws.amazonq.welcomeChatShowCount', welcomeLoadCount + 1) - } - } else { - webviewView.webview.html = await this.webViewContentGenerator.generate( - this.extensionContext.extensionUri, - webviewView.webview, - false - ) - } + webviewView.webview.html = await this.webViewContentGenerator.generate( + this.extensionContext.extensionUri, + webviewView.webview + ) performance.mark(amazonqMark.open) - - // badge is shown, emit telemetry for first time an existing, unscoped user tries Q - // note: this will fire on any not-properly-scoped Q entry. - // this means we can't tie it directly to the badge although it is hinted at - if (await shouldShowBadge()) { - telemetry.ui_click.emit({ - elementId: 'amazonq_tryAmazonQ', - passive: false, - }) - } - // if a user EVER enters Q, we should never show the badge again. - // the webview view only loads if the user clicks the view container, - // so we can essentially use this as a guarantee that a user has entered Q. - deactivateInitialViewBadge() } } diff --git a/packages/core/src/amazonqDoc/app.ts b/packages/core/src/amazonqDoc/app.ts index 4aba1b9e9bc..1847da2e168 100644 --- a/packages/core/src/amazonqDoc/app.ts +++ b/packages/core/src/amazonqDoc/app.ts @@ -89,7 +89,9 @@ export function init(appContext: AmazonQAppInitContext) { authenticatingSessionIDs = authenticatingSessions.map((session: any) => session.tabID) // We've already authenticated these sessions - authenticatingSessions.forEach((session: any) => (session.isAuthenticating = false)) + for (const session of authenticatingSessions) { + session.isAuthenticating = false + } } messenger.sendAuthenticationUpdate(authenticated, authenticatingSessionIDs) diff --git a/packages/core/src/amazonqDoc/constants.ts b/packages/core/src/amazonqDoc/constants.ts index ab872bd93e5..90284a90648 100644 --- a/packages/core/src/amazonqDoc/constants.ts +++ b/packages/core/src/amazonqDoc/constants.ts @@ -92,16 +92,53 @@ export const FolderSelectorFollowUps = [ }, ] +export const CodeChangeFollowUps = [ + { + pillText: i18n('AWS.amazonq.doc.pillText.accept'), + prompt: i18n('AWS.amazonq.doc.pillText.accept'), + type: FollowUpTypes.AcceptChanges, + icon: 'ok' as MynahIcons, + status: 'success' as Status, + }, + { + pillText: i18n('AWS.amazonq.doc.pillText.makeChanges'), + prompt: i18n('AWS.amazonq.doc.pillText.makeChanges'), + type: FollowUpTypes.MakeChanges, + icon: 'refresh' as MynahIcons, + status: 'info' as Status, + }, + { + pillText: i18n('AWS.amazonq.doc.pillText.reject'), + prompt: i18n('AWS.amazonq.doc.pillText.reject'), + type: FollowUpTypes.RejectChanges, + icon: 'cancel' as MynahIcons, + status: 'error' as Status, + }, +] + +export const NewSessionFollowUps = [ + { + pillText: i18n('AWS.amazonq.doc.pillText.newTask'), + type: FollowUpTypes.NewTask, + status: 'info' as Status, + }, + { + pillText: i18n('AWS.amazonq.doc.pillText.closeSession'), + type: FollowUpTypes.CloseSession, + status: 'info' as Status, + }, +] + export const SynchronizeDocumentation = { - pillText: 'Update README with recent code changes', - prompt: 'Update README with recent code changes', - type: 'SynchronizeDocumentation', + pillText: i18n('AWS.amazonq.doc.pillText.update'), + prompt: i18n('AWS.amazonq.doc.pillText.update'), + type: FollowUpTypes.SynchronizeDocumentation, } export const EditDocumentation = { - pillText: 'Make a specific change', - prompt: 'Make a specific change', - type: 'EditDocumentation', + pillText: i18n('AWS.amazonq.doc.pillText.makeChange'), + prompt: i18n('AWS.amazonq.doc.pillText.makeChange'), + type: FollowUpTypes.EditDocumentation, } export enum Mode { diff --git a/packages/core/src/amazonqDoc/controllers/chat/controller.ts b/packages/core/src/amazonqDoc/controllers/chat/controller.ts index e8ecceff0ca..c91a387484a 100644 --- a/packages/core/src/amazonqDoc/controllers/chat/controller.ts +++ b/packages/core/src/amazonqDoc/controllers/chat/controller.ts @@ -10,7 +10,9 @@ import { EditDocumentation, FolderSelectorFollowUps, Mode, + NewSessionFollowUps, SynchronizeDocumentation, + CodeChangeFollowUps, docScheme, featureName, findReadmePath, @@ -20,10 +22,8 @@ import { getLogger } from '../../../shared/logger' import { Session } from '../../session/session' import { i18n } from '../../../shared/i18n-helper' -import { telemetry } from '../../../shared/telemetry' import path from 'path' import { createSingleFileDialog } from '../../../shared/ui/common/openDialog' -import { MynahIcons } from '@aws/mynah-ui' import { MonthlyConversationLimitError, @@ -116,10 +116,17 @@ export class DocController { } /** Prompts user to choose a folder in current workspace for README creation/update. - * After user chooses a folder, displays confimraiton message to user with selected path. + * After user chooses a folder, displays confirmation message to user with selected path. * */ private async folderSelector(data: any) { + this.messenger.sendAnswer({ + type: 'answer', + tabID: data.tabID, + message: i18n('AWS.amazonq.doc.answer.chooseFolder'), + disableChatInput: true, + }) + const uri = await createSingleFileDialog({ canSelectFolders: true, canSelectFiles: false, @@ -133,7 +140,7 @@ export class DocController { this.messenger.sendAnswer({ type: 'answer', tabID: data.tabID, - message: 'No folder was selected, please try again.', + message: i18n('AWS.amazonq.doc.error.noFolderSelected'), followUps: retryFollowUps, disableChatInput: true, }) @@ -180,12 +187,6 @@ export class DocController { const codeGenerationId: string = message.messageId const zipFilePath: string = message.filePath const session = await this.sessionStorage.getSession(tabId) - telemetry.amazonq_isReviewedChanges.emit({ - amazonqConversationId: session.conversationId, - enabled: true, - result: 'Succeeded', - credentialStartUrl: AuthUtil.instance.startUrl, - }) const workspacePrefixMapping = getWorkspaceFoldersByPrefixes(session.config.workspaceFolders) const pathInfos = getPathsFromZipFilePath(zipFilePath, workspacePrefixMapping, session.config.workspaceFolders) @@ -298,18 +299,7 @@ export class DocController { tabID: data?.tabID, disableChatInput: true, message: 'Your changes have been discarded.', - followUps: [ - { - pillText: i18n('AWS.amazonq.featureDev.pillText.newTask'), - type: FollowUpTypes.NewTask, - status: 'info', - }, - { - pillText: i18n('AWS.amazonq.doc.pillText.closeSession'), - type: FollowUpTypes.CloseSession, - status: 'info', - }, - ], + followUps: NewSessionFollowUps, }) break case FollowUpTypes.ProceedFolderSelection: @@ -350,7 +340,6 @@ export class DocController { } private async fileClicked(message: any) { - // TODO: add Telemetry here const tabId: string = message.tabID const messageId = message.messageId const filePathToUpdate: string = message.filePath @@ -390,12 +379,6 @@ export class DocController { private async newTask(message: any) { // Old session for the tab is ending, delete it so we can create a new one for the message id this.docGenerationTask = new DocGenerationTask() - const session = await this.sessionStorage.getSession(message.tabID) - telemetry.amazonq_endChat.emit({ - amazonqConversationId: session.conversationId, - amazonqEndOfTheConversationLatency: performance.now() - session.telemetry.sessionStartTime, - result: 'Succeeded', - }) this.sessionStorage.deleteSession(message.tabID) // Re-run the opening flow, where we check auth + create a session @@ -412,27 +395,26 @@ export class DocController { this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.featureDev.placeholder.sessionClosed')) this.messenger.sendChatInputEnabled(message.tabID, false) - const session = await this.sessionStorage.getSession(message.tabID) this.docGenerationTask.reset() - - telemetry.amazonq_endChat.emit({ - amazonqConversationId: session.conversationId, - amazonqEndOfTheConversationLatency: performance.now() - session.telemetry.sessionStartTime, - result: 'Succeeded', - }) } private processErrorChatMessage = (err: any, message: any, session: Session | undefined) => { const errorMessage = createUserFacingErrorMessage(`${err.cause?.message ?? err.message}`) // eslint-disable-next-line unicorn/no-null this.messenger.sendUpdatePromptProgress(message.tabID, null) + if (err.constructor.name === MonthlyConversationLimitError.name) { + this.messenger.sendMonthlyLimitError(message.tabID) + } else { + const enableUserInput = this.mode === Mode.EDIT && err.remainingIterations > 0 - switch (err.constructor.name) { - case MonthlyConversationLimitError.name: - this.messenger.sendMonthlyLimitError(message.tabID) - break - default: - this.messenger.sendErrorMessage(errorMessage, message.tabID, 0, session?.conversationIdUnsafe, false) + this.messenger.sendErrorMessage( + errorMessage, + message.tabID, + 0, + session?.conversationIdUnsafe, + false, + enableUserInput + ) } } @@ -441,8 +423,6 @@ export class DocController { await this.onDocsGeneration(session, message.message, message.tabID) } catch (err: any) { this.processErrorChatMessage(err, message, session) - // Lock the chat input until they explicitly click one of the follow ups - this.messenger.sendChatInputEnabled(message.tabID, false) } } @@ -475,17 +455,12 @@ export class DocController { } await this.generateDocumentation({ message, session }) - this.messenger.sendChatInputEnabled(message?.tabID, false) - this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.doc.pillText.selectOption')) } catch (err: any) { this.processErrorChatMessage(err, message, session) - // Lock the chat input until they explicitly click one of the follow ups - this.messenger.sendChatInputEnabled(message.tabID, false) } } private async stopResponse(message: any) { - telemetry.ui_click.emit({ elementId: 'amazonq_stopCodeGeneration' }) this.messenger.sendAnswer({ message: i18n('AWS.amazonq.featureDev.pillText.stoppingCodeGeneration'), type: 'answer-part', @@ -605,40 +580,21 @@ export class DocController { this.messenger.sendAnswer({ type: 'answer', tabID: tabID, - message: `${this.mode === Mode.CREATE ? i18n('AWS.amazonq.doc.answer.readmeCreated') : i18n('AWS.amazonq.doc.answer.readmeUpdated')} ${i18n('AWS.amazonq.doc.answer.codeResult')}`, + message: `${this.mode === Mode.CREATE ? i18n('AWS.amazonq.doc.answer.readmeCreated') : i18n('AWS.amazonq.doc.answer.readmeUpdated')} ${remainingIterations > 0 ? i18n('AWS.amazonq.doc.answer.codeResult') : i18n('AWS.amazonq.doc.answer.acceptOrReject')}`, disableChatInput: true, }) - } - this.messenger.sendAnswer({ - message: undefined, - type: 'system-prompt', - disableChatInput: true, - followUps: [ - { - pillText: 'Accept', - prompt: 'Accept', - type: FollowUpTypes.AcceptChanges, - icon: 'ok' as MynahIcons, - status: 'success', - }, - { - pillText: 'Make changes', - prompt: 'Make changes', - type: FollowUpTypes.MakeChanges, - icon: 'refresh' as MynahIcons, - status: 'info', - }, - { - pillText: 'Reject', - prompt: 'Reject', - type: FollowUpTypes.RejectChanges, - icon: 'cancel' as MynahIcons, - status: 'error', - }, - ], - tabID: tabID, - }) + this.messenger.sendAnswer({ + message: undefined, + type: 'system-prompt', + disableChatInput: true, + followUps: + remainingIterations > 0 + ? CodeChangeFollowUps + : CodeChangeFollowUps.filter((followUp) => followUp.type !== FollowUpTypes.MakeChanges), + tabID: tabID, + }) + } } finally { if (session?.state?.tokenSource?.token.isCancellationRequested) { await this.newTask({ tabID }) @@ -657,10 +613,8 @@ export class DocController { type: 'answer', tabID: message.tabID, message: 'Follow instructions to re-authenticate ...', + disableChatInput: true, }) - - // Explicitly ensure the user goes through the re-authenticate flow - this.messenger.sendChatInputEnabled(message.tabID, false) } private tabClosed(message: any) { @@ -672,18 +626,6 @@ export class DocController { try { session = await this.sessionStorage.getSession(message.tabID) - const acceptedFiles = (paths?: { rejected: boolean }[]) => (paths || []).filter((i) => !i.rejected).length - - const amazonqNumberOfFilesAccepted = - acceptedFiles(session.state.filePaths) + acceptedFiles(session.state.deletedFiles) - - telemetry.amazonq_isAcceptedCodeChanges.emit({ - credentialStartUrl: AuthUtil.instance.startUrl, - amazonqConversationId: session.conversationId, - amazonqNumberOfFilesAccepted, - enabled: true, - result: 'Succeeded', - }) await session.insertChanges() const readmePath = findReadmePath(session.state.filePaths) @@ -697,20 +639,7 @@ export class DocController { type: 'answer', disableChatInput: true, tabID: message.tabID, - followUps: [ - { - pillText: 'Start a new documentation task', - prompt: 'Start a new documentation task', - type: FollowUpTypes.NewTask, - status: 'info', - }, - { - pillText: 'End session', - prompt: 'End session', - type: FollowUpTypes.CloseSession, - status: 'info', - }, - ], + followUps: NewSessionFollowUps, }) this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.doc.pillText.selectOption')) diff --git a/packages/core/src/amazonqDoc/controllers/docGenerationTask.ts b/packages/core/src/amazonqDoc/controllers/docGenerationTask.ts index 4fc57ba152c..848de727570 100644 --- a/packages/core/src/amazonqDoc/controllers/docGenerationTask.ts +++ b/packages/core/src/amazonqDoc/controllers/docGenerationTask.ts @@ -20,7 +20,7 @@ export class DocGenerationTask { public interactionType?: DocGenerationInteractionType public userIdentity?: string public numberOfNavigation = 0 - public folderLevel?: DocGenerationFolderLevel + public folderLevel: DocGenerationFolderLevel = 'ENTIRE_WORKSPACE' constructor(conversationId?: string) { this.conversationId = conversationId @@ -57,6 +57,6 @@ export class DocGenerationTask { this.interactionType = undefined this.userIdentity = undefined this.numberOfNavigation = 0 - this.folderLevel = undefined + this.folderLevel = 'ENTIRE_WORKSPACE' } } diff --git a/packages/core/src/amazonqDoc/errors.ts b/packages/core/src/amazonqDoc/errors.ts index fb918ec7c53..11a6514a616 100644 --- a/packages/core/src/amazonqDoc/errors.ts +++ b/packages/core/src/amazonqDoc/errors.ts @@ -7,61 +7,57 @@ import { ToolkitError } from '../shared/errors' import { i18n } from '../shared/i18n-helper' export class DocServiceError extends ToolkitError { - constructor(message: string, code: string) { + remainingIterations?: number + constructor(message: string, code: string, remainingIterations?: number) { super(message, { code }) + this.remainingIterations = remainingIterations } } -export class ReadmeTooLargeError extends ToolkitError { +export class ReadmeTooLargeError extends DocServiceError { constructor() { - super(i18n('AWS.amazonq.doc.error.readmeTooLarge'), { - code: ReadmeTooLargeError.name, - }) + super(i18n('AWS.amazonq.doc.error.readmeTooLarge'), ReadmeTooLargeError.name) } } -export class WorkspaceEmptyError extends ToolkitError { - constructor() { - super(i18n('AWS.amazonq.doc.error.workspaceEmpty'), { - code: WorkspaceEmptyError.name, - }) +export class ReadmeUpdateTooLargeError extends DocServiceError { + constructor(remainingIterations: number) { + super(i18n('AWS.amazonq.doc.error.readmeUpdateTooLarge'), ReadmeUpdateTooLargeError.name, remainingIterations) } } -export class NoChangeRequiredException extends ToolkitError { +export class WorkspaceEmptyError extends DocServiceError { constructor() { - super(i18n('AWS.amazonq.doc.error.noChangeRequiredException'), { - code: NoChangeRequiredException.name, - }) + super(i18n('AWS.amazonq.doc.error.workspaceEmpty'), WorkspaceEmptyError.name) } } -export class PromptRefusalException extends ToolkitError { +export class NoChangeRequiredException extends DocServiceError { constructor() { - super(i18n('AWS.amazonq.doc.error.promptRefusal'), { - code: PromptRefusalException.name, - }) + super(i18n('AWS.amazonq.doc.error.noChangeRequiredException'), NoChangeRequiredException.name) } } -export class ContentLengthError extends ToolkitError { - constructor() { - super(i18n('AWS.amazonq.doc.error.contentLengthError'), { code: ContentLengthError.name }) +export class PromptRefusalException extends DocServiceError { + constructor(remainingIterations: number) { + super(i18n('AWS.amazonq.doc.error.promptRefusal'), PromptRefusalException.name, remainingIterations) } } -export class PromptTooVagueError extends ToolkitError { +export class ContentLengthError extends DocServiceError { constructor() { - super(i18n('AWS.amazonq.doc.error.promptTooVague'), { - code: PromptTooVagueError.name, - }) + super(i18n('AWS.amazonq.doc.error.contentLengthError'), ContentLengthError.name) } } -export class PromptUnrelatedError extends ToolkitError { - constructor() { - super(i18n('AWS.amazonq.doc.error.promptUnrelated'), { - code: PromptUnrelatedError.name, - }) +export class PromptTooVagueError extends DocServiceError { + constructor(remainingIterations: number) { + super(i18n('AWS.amazonq.doc.error.promptTooVague'), PromptTooVagueError.name, remainingIterations) + } +} + +export class PromptUnrelatedError extends DocServiceError { + constructor(remainingIterations: number) { + super(i18n('AWS.amazonq.doc.error.promptUnrelated'), PromptUnrelatedError.name, remainingIterations) } } diff --git a/packages/core/src/amazonqDoc/messenger.ts b/packages/core/src/amazonqDoc/messenger.ts index 09be3dd11fb..f28e5e9060b 100644 --- a/packages/core/src/amazonqDoc/messenger.ts +++ b/packages/core/src/amazonqDoc/messenger.ts @@ -4,10 +4,9 @@ */ import { Messenger } from '../amazonq/commons/connector/baseMessenger' import { AppToWebViewMessageDispatcher } from '../amazonq/commons/connector/connectorMessages' -import { FollowUpTypes } from '../amazonq/commons/types' import { messageWithConversationId } from '../amazonqFeatureDev' import { i18n } from '../shared/i18n-helper' -import { docGenerationProgressMessage, DocGenerationStep, Mode } from './constants' +import { docGenerationProgressMessage, DocGenerationStep, Mode, NewSessionFollowUps } from './constants' import { inProgress } from './types' export class DocMessenger extends Messenger { @@ -48,25 +47,19 @@ export class DocMessenger extends Messenger { tabID: string, _retries: number, conversationId?: string, - _showDefaultMessage?: boolean + _showDefaultMessage?: boolean, + enableUserInput?: boolean ) { + if (enableUserInput) { + this.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.doc.placeholder.editReadme')) + this.sendChatInputEnabled(tabID, true) + } this.sendAnswer({ type: 'answer', tabID: tabID, message: errorMessage + messageWithConversationId(conversationId), - }) - - this.sendAnswer({ - message: undefined, - type: 'system-prompt', - followUps: [ - { - pillText: i18n('AWS.amazonq.featureDev.pillText.retry'), - type: FollowUpTypes.Retry, - status: 'warning', - }, - ], - tabID, + followUps: enableUserInput ? [] : NewSessionFollowUps, + disableChatInput: !enableUserInput, }) } } diff --git a/packages/core/src/amazonqDoc/session/sessionState.ts b/packages/core/src/amazonqDoc/session/sessionState.ts index 03e234a9429..7bf9c02e51b 100644 --- a/packages/core/src/amazonqDoc/session/sessionState.ts +++ b/packages/core/src/amazonqDoc/session/sessionState.ts @@ -45,6 +45,7 @@ import { PromptTooVagueError, PromptUnrelatedError, ReadmeTooLargeError, + ReadmeUpdateTooLargeError, WorkspaceEmptyError, } from '../errors' import { DocMessenger } from '../messenger' @@ -96,7 +97,7 @@ abstract class CodeGenBase { ++pollingIteration ) { const codegenResult = await this.config.proxyClient.getCodeGeneration(this.conversationId, codeGenerationId) - const codeGenerationRemainingIterationCount = codegenResult.codeGenerationRemainingIterationCount + const codeGenerationRemainingIterationCount = codegenResult.codeGenerationRemainingIterationCount || 0 const codeGenerationTotalIterationCount = codegenResult.codeGenerationTotalIterationCount getLogger().debug(`Codegen response: %O`, codegenResult) @@ -149,6 +150,9 @@ abstract class CodeGenBase { case codegenResult.codeGenerationStatusDetail?.includes('README_TOO_LARGE'): { throw new ReadmeTooLargeError() } + case codegenResult.codeGenerationStatusDetail?.includes('README_UPDATE_TOO_LARGE'): { + throw new ReadmeUpdateTooLargeError(codeGenerationRemainingIterationCount) + } case codegenResult.codeGenerationStatusDetail?.includes('WORKSPACE_TOO_LARGE'): { throw new ContentLengthError() } @@ -156,18 +160,19 @@ abstract class CodeGenBase { throw new WorkspaceEmptyError() } case codegenResult.codeGenerationStatusDetail?.includes('PROMPT_UNRELATED'): { - throw new PromptUnrelatedError() + throw new PromptUnrelatedError(codeGenerationRemainingIterationCount) } case codegenResult.codeGenerationStatusDetail?.includes('PROMPT_TOO_VAGUE'): { - throw new PromptTooVagueError() + throw new PromptTooVagueError(codeGenerationRemainingIterationCount) } case codegenResult.codeGenerationStatusDetail?.includes('PROMPT_REFUSAL'): { - throw new PromptRefusalException() + throw new PromptRefusalException(codeGenerationRemainingIterationCount) } case codegenResult.codeGenerationStatusDetail?.includes('Guardrails'): { throw new DocServiceError( i18n('AWS.amazonq.doc.error.docGen.default'), - 'GuardrailsException' + 'GuardrailsException', + codeGenerationRemainingIterationCount ) } case codegenResult.codeGenerationStatusDetail?.includes('EmptyPatch'): { @@ -182,7 +187,8 @@ abstract class CodeGenBase { case codegenResult.codeGenerationStatusDetail?.includes('Throttling'): { throw new DocServiceError( i18n('AWS.amazonq.featureDev.error.throttling'), - 'ThrottlingException' + 'ThrottlingException', + codeGenerationRemainingIterationCount ) } default: { diff --git a/packages/core/src/amazonqFeatureDev/app.ts b/packages/core/src/amazonqFeatureDev/app.ts index 99164f417be..a72851e8ac7 100644 --- a/packages/core/src/amazonqFeatureDev/app.ts +++ b/packages/core/src/amazonqFeatureDev/app.ts @@ -95,7 +95,9 @@ export function init(appContext: AmazonQAppInitContext) { authenticatingSessionIDs = authenticatingSessions.map((session) => session.tabID) // We've already authenticated these sessions - authenticatingSessions.forEach((session) => (session.isAuthenticating = false)) + for (const session of authenticatingSessions) { + session.isAuthenticating = false + } } messenger.sendAuthenticationUpdate(authenticated, authenticatingSessionIDs) diff --git a/packages/core/src/amazonqFeatureDev/client/featureDev.ts b/packages/core/src/amazonqFeatureDev/client/featureDev.ts index 947949d48a9..01be84828b3 100644 --- a/packages/core/src/amazonqFeatureDev/client/featureDev.ts +++ b/packages/core/src/amazonqFeatureDev/client/featureDev.ts @@ -25,7 +25,13 @@ import { createCodeWhispererChatStreamingClient } from '../../shared/clients/cod import { getClientId, getOptOutPreference, getOperatingSystem } from '../../shared/telemetry/util' import { extensionVersion } from '../../shared/vscode/env' import apiConfig = require('./codewhispererruntime-2022-11-11.json') -import { FeatureDevCodeAcceptanceEvent, FeatureDevCodeGenerationEvent, TelemetryEvent } from './featuredevproxyclient' +import { UserWrittenCodeTracker } from '../../codewhisperer' +import { + FeatureDevCodeAcceptanceEvent, + FeatureDevCodeGenerationEvent, + MetricData, + TelemetryEvent, +} from './featuredevproxyclient' // Re-enable once BE is able to handle retries. const writeAPIRetryOptions = { @@ -255,6 +261,7 @@ export class FeatureDevClient { references?: CodeReference[] } } + UserWrittenCodeTracker.instance.onQFeatureInvoked() const newFileContents: { zipFilePath: string; fileContent: string }[] = [] for (const [filePath, fileContent] of Object.entries(newFiles)) { @@ -299,6 +306,11 @@ export class FeatureDevClient { await this.sendFeatureDevEvent('featureDevCodeAcceptanceEvent', event) } + public async sendMetricData(event: MetricData) { + getLogger().debug(`featureDevCodeGenerationMetricData: dimensions: ${event.dimensions}`) + await this.sendFeatureDevEvent('metricData', event) + } + public async sendFeatureDevEvent( eventName: T, event: NonNullable diff --git a/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts b/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts index b9130811fed..8d3943d87ad 100644 --- a/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts +++ b/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts @@ -30,7 +30,7 @@ import { import { codeGenRetryLimit, defaultRetryLimit } from '../../limits' import { Session } from '../../session/session' import { featureDevScheme, featureName, generateDevFilePrompt } from '../../constants' -import { DeletedFileInfo, DevPhase, type NewFileInfo } from '../../types' +import { DeletedFileInfo, DevPhase, MetricDataOperationName, MetricDataResult, type NewFileInfo } from '../../types' import { AuthUtil } from '../../../codewhisperer/util/authUtil' import { AuthController } from '../../../amazonq/auth/controller' import { getLogger } from '../../../shared/logger' @@ -50,8 +50,6 @@ import { FollowUpTypes } from '../../../amazonq/commons/types' import { Messenger } from '../../../amazonq/commons/connector/baseMessenger' import { BaseChatSessionStorage } from '../../../amazonq/commons/baseChatStorage' -export const TotalSteps = 3 - export interface ChatControllerEventEmitters { readonly processHumanChatMessage: EventEmitter readonly followUpClicked: EventEmitter @@ -464,6 +462,7 @@ export class FeatureDevController { canBeVoted: true, }) this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.pillText.generatingCode')) + await session.sendMetricDataTelemetry(MetricDataOperationName.StartCodeGeneration, MetricDataResult.Success) await session.send(message) const filePaths = session.state.filePaths ?? [] const deletedFiles = session.state.deletedFiles ?? [] @@ -512,12 +511,17 @@ export class FeatureDevController { if (remainingIterations !== undefined && totalIterations !== undefined) { this.messenger.sendAnswer({ - type: 'answer', + type: 'answer' as const, tabID: tabID, - message: - remainingIterations === 0 - ? 'Would you like to add this code to your project?' - : `Would you like to add this code to your project, or provide feedback for new code? You have ${remainingIterations} out of ${totalIterations} code generations left.`, + message: (() => { + if (remainingIterations > 2) { + return 'Would you like me to add this code to your project, or provide feedback for new code?' + } else if (remainingIterations > 0) { + return `Would you like me to add this code to your project, or provide feedback for new code? You have ${remainingIterations} out of ${totalIterations} code generations left.` + } else { + return 'Would you like me to add this code to your project?' + } + })(), }) } @@ -537,15 +541,39 @@ export class FeatureDevController { await session.sendLinesOfCodeGeneratedTelemetry() } this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.pillText.selectOption')) + } catch (err: any) { + getLogger().error(`${featureName}: Error during code generation: ${err}`) + + let result: string + switch (err.constructor.name) { + case FeatureDevServiceError.name: + if (err.code === 'EmptyPatchException') { + result = MetricDataResult.LlmFailure + } else if (err.code === 'GuardrailsException' || err.code === 'ThrottlingException') { + result = MetricDataResult.Error + } else { + result = MetricDataResult.Fault + } + break + case PromptRefusalException.name: + case NoChangeRequiredException.name: + result = MetricDataResult.Error + break + default: + result = MetricDataResult.Fault + break + } + + await session.sendMetricDataTelemetry(MetricDataOperationName.EndCodeGeneration, result) + throw err } finally { // Finish processing the event if (session?.state?.tokenSource?.token.isCancellationRequested) { await this.workOnNewTask( session.tabID, - session.state.codeGenerationRemainingIterationCount || - TotalSteps - (session.state?.currentIteration || 0), - session.state.codeGenerationTotalIterationCount || TotalSteps, + session.state.codeGenerationRemainingIterationCount, + session.state.codeGenerationTotalIterationCount, session?.state?.tokenSource?.token.isCancellationRequested ) this.disposeToken(session) @@ -568,6 +596,7 @@ export class FeatureDevController { } } } + await session.sendMetricDataTelemetry(MetricDataOperationName.EndCodeGeneration, MetricDataResult.Success) } private sendUpdateCodeMessage(tabID: string) { @@ -589,10 +618,16 @@ export class FeatureDevController { if (isStoppedGeneration) { this.messenger.sendAnswer({ - message: - (remainingIterations ?? 0) <= 0 - ? "I stopped generating your code. You don't have more iterations left, however, you can start a new session." - : `I stopped generating your code. If you want to continue working on this task, provide another description. You have ${remainingIterations} out of ${totalIterations} code generations left.`, + message: ((remainingIterations) => { + if (totalIterations !== undefined) { + if (remainingIterations <= 0) { + return "I stopped generating your code. You don't have more iterations left, however, you can start a new session." + } else if (remainingIterations <= 2) { + return `I stopped generating your code. If you want to continue working on this task, provide another description. You have ${remainingIterations} out of ${totalIterations} code generations left.` + } + } + return 'I stopped generating your code. If you want to continue working on this task, provide another description.' + })(remainingIterations), type: 'answer-part', tabID, }) diff --git a/packages/core/src/amazonqFeatureDev/session/session.ts b/packages/core/src/amazonqFeatureDev/session/session.ts index 6d2713831a5..a1de23d1861 100644 --- a/packages/core/src/amazonqFeatureDev/session/session.ts +++ b/packages/core/src/amazonqFeatureDev/session/session.ts @@ -286,6 +286,25 @@ export class Session { return { leftPath, rightPath, ...diff } } + public async sendMetricDataTelemetry(operationName: string, result: string) { + await this.proxyClient.sendMetricData({ + metricName: 'Operation', + metricValue: 1, + timestamp: new Date(), + product: 'FeatureDev', + dimensions: [ + { + name: 'operationName', + value: operationName, + }, + { + name: 'result', + value: result, + }, + ], + }) + } + public async sendLinesOfCodeGeneratedTelemetry() { let charactersOfCodeGenerated = 0 let linesOfCodeGenerated = 0 diff --git a/packages/core/src/amazonqFeatureDev/session/sessionState.ts b/packages/core/src/amazonqFeatureDev/session/sessionState.ts index 705232b0536..f3cd49eb972 100644 --- a/packages/core/src/amazonqFeatureDev/session/sessionState.ts +++ b/packages/core/src/amazonqFeatureDev/session/sessionState.ts @@ -175,14 +175,16 @@ abstract class CodeGenBase { codeGenerationRemainingIterationCount?: number codeGenerationTotalIterationCount?: number }> { + let codeGenerationRemainingIterationCount = undefined + let codeGenerationTotalIterationCount = undefined for ( let pollingIteration = 0; pollingIteration < this.pollCount && !this.isCancellationRequested; ++pollingIteration ) { const codegenResult = await this.config.proxyClient.getCodeGeneration(this.conversationId, codeGenerationId) - const codeGenerationRemainingIterationCount = codegenResult.codeGenerationRemainingIterationCount - const codeGenerationTotalIterationCount = codegenResult.codeGenerationTotalIterationCount + codeGenerationRemainingIterationCount = codegenResult.codeGenerationRemainingIterationCount + codeGenerationTotalIterationCount = codegenResult.codeGenerationTotalIterationCount getLogger().debug(`Codegen response: %O`, codegenResult) telemetry.setCodeGenerationResult(codegenResult.codeGenerationStatus.status) @@ -272,6 +274,8 @@ abstract class CodeGenBase { newFiles: [], deletedFiles: [], references: [], + codeGenerationRemainingIterationCount: codeGenerationRemainingIterationCount, + codeGenerationTotalIterationCount: codeGenerationTotalIterationCount, } } } @@ -345,8 +349,13 @@ export class CodeGenState extends CodeGenBase implements SessionState { this.filePaths = codeGeneration.newFiles this.deletedFiles = codeGeneration.deletedFiles this.references = codeGeneration.references + this.codeGenerationRemainingIterationCount = codeGeneration.codeGenerationRemainingIterationCount this.codeGenerationTotalIterationCount = codeGeneration.codeGenerationTotalIterationCount + this.currentIteration = + this.codeGenerationRemainingIterationCount && this.codeGenerationTotalIterationCount + ? this.codeGenerationTotalIterationCount - this.codeGenerationRemainingIterationCount + : this.currentIteration + 1 if (action.uploadHistory && !action.uploadHistory[codeGenerationId] && codeGenerationId) { action.uploadHistory[codeGenerationId] = { @@ -366,7 +375,7 @@ export class CodeGenState extends CodeGenBase implements SessionState { this.deletedFiles, this.references, this.tabID, - this.currentIteration + 1, + this.currentIteration, this.codeGenerationRemainingIterationCount, this.codeGenerationTotalIterationCount, action.uploadHistory, diff --git a/packages/core/src/amazonqFeatureDev/types.ts b/packages/core/src/amazonqFeatureDev/types.ts index 9c1a86643a2..0bf0c8550de 100644 --- a/packages/core/src/amazonqFeatureDev/types.ts +++ b/packages/core/src/amazonqFeatureDev/types.ts @@ -115,3 +115,15 @@ export interface UpdateFilesPathsParams { messageId: string disableFileActions?: boolean } + +export enum MetricDataOperationName { + StartCodeGeneration = 'StartCodeGeneration', + EndCodeGeneration = 'EndCodeGeneration', +} + +export enum MetricDataResult { + Success = 'Success', + Fault = 'Fault', + Error = 'Error', + LlmFailure = 'LLMFailure', +} diff --git a/packages/core/src/amazonqFeatureDev/util/files.ts b/packages/core/src/amazonqFeatureDev/util/files.ts index a14cd0e859d..8c74f419493 100644 --- a/packages/core/src/amazonqFeatureDev/util/files.ts +++ b/packages/core/src/amazonqFeatureDev/util/files.ts @@ -13,7 +13,7 @@ import { getLogger } from '../../shared/logger/logger' import { maxFileSizeBytes } from '../limits' import { createHash } from 'crypto' import { CurrentWsFolders } from '../types' -import { ToolkitError } from '../../shared/errors' +import { hasCode, ToolkitError } from '../../shared/errors' import { AmazonqCreateUpload, Span, telemetry as amznTelemetry } from '../../shared/telemetry/telemetry' import { TelemetryHelper } from './telemetryHelper' import { maxRepoSizeBytes } from '../constants' @@ -48,7 +48,16 @@ export async function prepareRepoData( const ignoredExtensionMap = new Map() for (const file of files) { - const fileSize = (await fs.stat(file.fileUri)).size + let fileSize + try { + fileSize = (await fs.stat(file.fileUri)).size + } catch (error) { + if (hasCode(error) && error.code === 'ENOENT') { + // No-op: Skip if file does not exist + continue + } + throw error + } const isCodeFile_ = isCodeFile(file.relativeFilePath) // exclude user's devfile if `useAutoBuildFeature` is set to false const excludeDevFile = useAutoBuildFeature ? false : file.relativeFilePath === 'devfile.yaml' @@ -69,7 +78,17 @@ export async function prepareRepoData( totalBytes += fileSize const zipFolderPath = path.dirname(file.zipFilePath) - zip.addLocalFile(file.fileUri.fsPath, zipFolderPath) + + try { + zip.addLocalFile(file.fileUri.fsPath, zipFolderPath) + } catch (error) { + if (error instanceof Error && error.message.includes('File not found')) { + // No-op: Skip if file was deleted or does not exist + // Reference: https://github.com/cthackers/adm-zip/blob/1cd32f7e0ad3c540142a76609bb538a5cda2292f/adm-zip.js#L296-L321 + continue + } + throw error + } } const iterator = ignoredExtensionMap.entries() diff --git a/packages/core/src/amazonqGumby/chat/controller/controller.ts b/packages/core/src/amazonqGumby/chat/controller/controller.ts index cd00481377a..647c5682184 100644 --- a/packages/core/src/amazonqGumby/chat/controller/controller.ts +++ b/packages/core/src/amazonqGumby/chat/controller/controller.ts @@ -20,18 +20,14 @@ import { compileProject, finishHumanInTheLoop, getValidLanguageUpgradeCandidateProjects, - openBuildLogFile, - openHilPomFile, - parseBuildFile, postTransformationJob, processLanguageUpgradeTransformFormInput, processSQLConversionTransformFormInput, startTransformByQ, stopTransformByQ, validateCanCompileProject, - setMaven, getValidSQLConversionCandidateProjects, - validateSQLMetadataFile, + openHilPomFile, } from '../../../codewhisperer/commands/startTransformByQ' import { JDKVersion, TransformationCandidateProject, transformByQState } from '../../../codewhisperer/models/model' import { @@ -54,14 +50,17 @@ import { CodeTransformJavaTargetVersionsAllowed, CodeTransformJavaSourceVersionsAllowed, } from '../../../shared/telemetry/telemetry' -import { MetadataResult } from '../../../shared/telemetry/telemetryClient' import { CodeTransformTelemetryState } from '../../telemetry/codeTransformTelemetryState' -import { getAuthType } from '../../../codewhisperer/service/transformByQ/transformApiHandler' import DependencyVersions from '../../models/dependencies' import { getStringHash } from '../../../shared/utilities/textUtilities' -import { getVersionData } from '../../../codewhisperer/service/transformByQ/transformMavenHandler' import AdmZip from 'adm-zip' import { AuthError } from '../../../auth/sso/server' +import { + openBuildLogFile, + parseBuildFile, + validateSQLMetadataFile, +} from '../../../codewhisperer/service/transformByQ/transformFileHandler' +import { getAuthType } from '../../../auth/utils' // These events can be interactions within the chat, // or elsewhere in the IDE @@ -267,29 +266,41 @@ export class GumbyController { } private async handleLanguageUpgrade(message: any) { - try { - await this.beginTransformation(message) - const validProjects = await this.validateLanguageUpgradeProjects(message) - if (validProjects.length > 0) { - this.sessionStorage.getSession().updateCandidateProjects(validProjects) - await this.messenger.sendLanguageUpgradeProjectPrompt(validProjects, message.tabID) - } - } catch (err: any) { - getLogger().error(`Error handling language upgrade: ${err}`) - } + await telemetry.codeTransform_submitSelection + .run(async () => { + telemetry.record({ + codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), + userChoice: 'language upgrade', + }) + await this.beginTransformation(message) + const validProjects = await this.validateLanguageUpgradeProjects(message) + if (validProjects.length > 0) { + this.sessionStorage.getSession().updateCandidateProjects(validProjects) + await this.messenger.sendLanguageUpgradeProjectPrompt(validProjects, message.tabID) + } + }) + .catch((err) => { + getLogger().error(`Error handling language upgrade: ${err}`) + }) } private async handleSQLConversion(message: any) { - try { - await this.beginTransformation(message) - const validProjects = await this.validateSQLConversionProjects(message) - if (validProjects.length > 0) { - this.sessionStorage.getSession().updateCandidateProjects(validProjects) - await this.messenger.sendSelectSQLMetadataFileMessage(message.tabID) - } - } catch (err: any) { - getLogger().error(`Error handling SQL conversion: ${err}`) - } + await telemetry.codeTransform_submitSelection + .run(async () => { + telemetry.record({ + codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), + userChoice: 'sql conversion', + }) + await this.beginTransformation(message) + const validProjects = await this.validateSQLConversionProjects(message) + if (validProjects.length > 0) { + this.sessionStorage.getSession().updateCandidateProjects(validProjects) + await this.messenger.sendSelectSQLMetadataFileMessage(message.tabID) + } + }) + .catch((err) => { + getLogger().error(`Error handling SQL conversion: ${err}`) + }) } private async validateLanguageUpgradeProjects(message: any) { @@ -308,12 +319,6 @@ export class GumbyController { telemetryJavaVersion = JDKToTelemetryValue(javaVersion) as CodeTransformJavaSourceVersionsAllowed } telemetry.record({ codeTransformLocalJavaVersion: telemetryJavaVersion }) - - await setMaven() - const versionInfo = await getVersionData() - const mavenVersionInfoMessage = `${versionInfo[0]} (${transformByQState.getMavenName()})` - telemetry.record({ buildSystemVersion: mavenVersionInfoMessage }) - return validProjects }) return validProjects @@ -350,15 +355,16 @@ export class GumbyController { await this.handleUserLanguageUpgradeProjectChoice(message) break case ButtonActions.CANCEL_TRANSFORMATION_FORM: - telemetry.codeTransform_submitSelection.emit({ - codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), - userChoice: 'Cancel', - result: MetadataResult.Pass, - }) - this.transformationFinished({ - message: CodeWhispererConstants.jobCancelledChatMessage, - tabID: message.tabID, - includeStartNewTransformationButton: true, + telemetry.codeTransform_submitSelection.run(() => { + telemetry.record({ + codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), + userChoice: 'Cancel', + }) + this.transformationFinished({ + message: CodeWhispererConstants.jobCancelledChatMessage, + tabID: message.tabID, + includeStartNewTransformationButton: true, + }) }) break case ButtonActions.CONFIRM_SKIP_TESTS_FORM: @@ -381,7 +387,9 @@ export class GumbyController { break case ButtonActions.VIEW_TRANSFORMATION_HUB: await vscode.commands.executeCommand(GumbyCommands.FOCUS_TRANSFORMATION_HUB, CancelActionPositions.Chat) - this.messenger.sendJobSubmittedMessage(message.tabID) + break + case ButtonActions.VIEW_SUMMARY: + await vscode.commands.executeCommand('aws.amazonq.transformationHub.summary.reveal') break case ButtonActions.STOP_TRANSFORMATION_JOB: await stopTransformByQ(transformByQState.getJobId()) diff --git a/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts b/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts index b120aae986b..a586756cb61 100644 --- a/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts +++ b/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts @@ -197,13 +197,13 @@ export class Messenger { const projectFormOptions: { value: any; label: string }[] = [] const detectedJavaVersions = new Array() - projects.forEach((candidateProject) => { + for (const candidateProject of projects) { projectFormOptions.push({ value: candidateProject.path, label: candidateProject.name, }) detectedJavaVersions.push(candidateProject.JDKVersion) - }) + } const formItems: ChatItemFormItem[] = [] formItems.push({ @@ -243,8 +243,8 @@ export class Messenger { mandatory: true, options: [ { - value: JDKVersion.JDK17.toString(), - label: JDKVersion.JDK17.toString(), + value: JDKVersion.JDK17, + label: JDKVersion.JDK17, }, ], }) @@ -252,7 +252,7 @@ export class Messenger { this.dispatcher.sendAsyncEventProgress( new AsyncEventProgressMessage(tabID, { inProgress: true, - message: MessengerUtils.createLanguageUpgradeConfirmationPrompt(detectedJavaVersions), + message: CodeWhispererConstants.projectPromptChatMessage, }) ) @@ -279,12 +279,12 @@ export class Messenger { public async sendSQLConversionProjectPrompt(projects: TransformationCandidateProject[], tabID: string) { const projectFormOptions: { value: any; label: string }[] = [] - projects.forEach((candidateProject) => { + for (const candidateProject of projects) { projectFormOptions.push({ value: candidateProject.path, label: candidateProject.name, }) - }) + } const formItems: ChatItemFormItem[] = [] formItems.push({ @@ -376,19 +376,20 @@ export class Messenger { ) { const buttons: ChatItemButton[] = [] + // don't show these buttons when server build fails if (!disableJobActions) { - // Note: buttons can only be clicked once. - // To get around this, we remove the card after it's clicked and then resubmit the message. buttons.push({ keepCardAfterClick: true, text: CodeWhispererConstants.openTransformationHubButtonText, id: ButtonActions.VIEW_TRANSFORMATION_HUB, + disabled: false, // allow button to be re-clicked }) buttons.push({ keepCardAfterClick: true, text: CodeWhispererConstants.stopTransformationButtonText, id: ButtonActions.STOP_TRANSFORMATION_JOB, + disabled: false, }) } @@ -514,6 +515,16 @@ export class Messenger { keepCardAfterClick: false, text: CodeWhispererConstants.startTransformationButtonText, id: ButtonActions.CONFIRM_START_TRANSFORMATION_FLOW, + disabled: false, + }) + } + + if (transformByQState.getSummaryFilePath()) { + buttons.push({ + keepCardAfterClick: true, + text: CodeWhispererConstants.viewSummaryButtonText, + id: ButtonActions.VIEW_SUMMARY, + disabled: false, }) } @@ -661,12 +672,12 @@ ${codeSnippet} const valueFormOptions: { value: any; label: string }[] = [] - versions.allVersions.forEach((version) => { + for (const version of versions.allVersions) { valueFormOptions.push({ value: version, label: version, }) - }) + } const formItems: ChatItemFormItem[] = [] formItems.push({ diff --git a/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts b/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts index a7b15810312..3d3c18959d3 100644 --- a/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts +++ b/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts @@ -13,6 +13,7 @@ import DependencyVersions from '../../../models/dependencies' export enum ButtonActions { STOP_TRANSFORMATION_JOB = 'gumbyStopTransformationJob', VIEW_TRANSFORMATION_HUB = 'gumbyViewTransformationHub', + VIEW_SUMMARY = 'gumbyViewSummary', CONFIRM_LANGUAGE_UPGRADE_TRANSFORMATION_FORM = 'gumbyLanguageUpgradeTransformFormConfirm', CONFIRM_SQL_CONVERSION_TRANSFORMATION_FORM = 'gumbySQLConversionTransformFormConfirm', CANCEL_TRANSFORMATION_FORM = 'gumbyTransformFormCancel', // shared between Language Upgrade & SQL Conversion @@ -69,30 +70,8 @@ export default class MessengerUtils { } } - static createLanguageUpgradeConfirmationPrompt = (detectedJavaVersions: Array): string => { - let javaVersionString = 'Java project' - const uniqueJavaOptions = new Set(detectedJavaVersions) - - if (detectedJavaVersions.length > 1) { - // this means there is a Java version whose version we weren't able to determine - if (uniqueJavaOptions.has(undefined)) { - javaVersionString = 'Java projects' - } else { - javaVersionString = `Java ${Array.from(uniqueJavaOptions).join(' & ')} projects` - } - } else if (detectedJavaVersions.length === 1) { - if (!uniqueJavaOptions.has(undefined)) { - javaVersionString = `Java ${detectedJavaVersions[0]!.toString()} project` - } - } - - return CodeWhispererConstants.projectPromptChatMessage.replace('JAVA_VERSION_HERE', javaVersionString) - } - static createAvailableDependencyVersionString = (versions: DependencyVersions): string => { - let message = `I found ${versions.length} other dependency versions that are more recent than the dependency in your code that's causing an error: ${versions.currentVersion}. - -` + let message = `I found ${versions.length} other dependency versions that are more recent than the dependency in your code that's causing an error: ${versions.currentVersion}.` if (versions.majorVersions !== undefined && versions.majorVersions.length > 0) { message = message.concat( diff --git a/packages/core/src/amazonqGumby/index.ts b/packages/core/src/amazonqGumby/index.ts index 50005c984bb..8c1109f9997 100644 --- a/packages/core/src/amazonqGumby/index.ts +++ b/packages/core/src/amazonqGumby/index.ts @@ -6,4 +6,8 @@ export { activate } from './activation' export { default as DependencyVersions } from './models/dependencies' export { default as MessengerUtils } from './chat/controller/messenger/messengerUtils' +export { GumbyController } from './chat/controller/controller' +export { TabsStorage } from '../amazonq/webview/ui/storages/tabsStorage' +export * as startTransformByQ from '../../src/codewhisperer/commands/startTransformByQ' +export { setMaven } from '../../src/codewhisperer/service/transformByQ/transformFileHandler' export * from './errors' diff --git a/packages/core/src/amazonqTest/chat/controller/controller.ts b/packages/core/src/amazonqTest/chat/controller/controller.ts index a7938575a9b..ab1157dca38 100644 --- a/packages/core/src/amazonqTest/chat/controller/controller.ts +++ b/packages/core/src/amazonqTest/chat/controller/controller.ts @@ -20,7 +20,9 @@ import { TelemetryHelper, TestGenerationBuildStep, testGenState, + tooManyRequestErrorMessage, unitTestGenerationCancelMessage, + UserWrittenCodeTracker, } from '../../../codewhisperer' import { fs, @@ -241,72 +243,76 @@ export class TestController { // eslint-disable-next-line unicorn/no-null this.messenger.sendUpdatePromptProgress(data.tabID, null) const session = this.sessionStorage.getSession() - const isCancel = data.error.message === unitTestGenerationCancelMessage - telemetry.amazonq_utgGenerateTests.emit({ - cwsprChatProgrammingLanguage: session.fileLanguage ?? 'plaintext', - jobId: session.listOfTestGenerationJobId[0], // For RIV, UTG does only one StartTestGeneration API call - jobGroup: session.testGenerationJobGroupName, - hasUserPromptSupplied: session.hasUserPromptSupplied, - isCodeBlockSelected: session.isCodeBlockSelected, - buildPayloadBytes: session.srcPayloadSize, - buildZipFileBytes: session.srcZipFileSize, - artifactsUploadDuration: session.artifactsUploadDuration, - perfClientLatency: performance.now() - session.testGenerationStartTime, - result: isCancel ? 'Cancelled' : 'Failed', - reasonDesc: getTelemetryReasonDesc(data.error), - isSupportedLanguage: true, - credentialStartUrl: AuthUtil.instance.startUrl, - }) + const isCancel = data.error.uiMessage === unitTestGenerationCancelMessage + let telemetryErrorMessage = getTelemetryReasonDesc(data.error) + if (session.stopIteration) { + telemetryErrorMessage = getTelemetryReasonDesc(data.error.uiMessage.replaceAll('```', '')) + } + TelemetryHelper.instance.sendTestGenerationToolkitEvent( + session, + true, + true, + isCancel ? 'Cancelled' : 'Failed', + session.startTestGenerationRequestId, + performance.now() - session.testGenerationStartTime, + telemetryErrorMessage, + session.isCodeBlockSelected, + session.artifactsUploadDuration, + session.srcPayloadSize, + session.srcZipFileSize + ) if (session.stopIteration) { // Error from Science - this.messenger.sendMessage(data.error.message.replaceAll('```', ''), data.tabID, 'answer') + this.messenger.sendMessage(data.error.uiMessage.replaceAll('```', ''), data.tabID, 'answer') } else { isCancel - ? this.messenger.sendMessage(data.error.message, data.tabID, 'answer') + ? this.messenger.sendMessage(data.error.uiMessage, data.tabID, 'answer') : this.sendErrorMessage(data) } await this.sessionCleanUp() return } // Client side error messages - private sendErrorMessage(data: { tabID: string; error: { code: string; message: string } }) { + private sendErrorMessage(data: { + tabID: string + error: { uiMessage: string; message: string; code: string; statusCode: string } + }) { const { error, tabID } = data + // If user reached monthly limit for builderId + if (error.code === 'CreateTestJobError') { + if (error.message.includes(CodeWhispererConstants.utgLimitReached)) { + getLogger().error('Monthly quota reached for QSDA actions.') + return this.messenger.sendMessage( + i18n('AWS.amazonq.featureDev.error.monthlyLimitReached'), + tabID, + 'answer' + ) + } + if (error.message.includes('Too many requests')) { + getLogger().error(error.message) + return this.messenger.sendErrorMessage(tooManyRequestErrorMessage, tabID) + } + } if (isAwsError(error)) { if (error.code === 'ThrottlingException') { - // TODO: use the explicitly modeled exception reason for quota vs throttle - if (error.message.includes(CodeWhispererConstants.utgLimitReached)) { - getLogger().error('Monthly quota reached for QSDA actions.') - return this.messenger.sendMessage( - i18n('AWS.amazonq.featureDev.error.monthlyLimitReached'), - tabID, - 'answer' - ) - } else { - getLogger().error('Too many requests.') - // TODO: move to constants file - this.messenger.sendErrorMessage('Too many requests. Please wait before retrying.', tabID) - } - } else { - // other service errors: - // AccessDeniedException - should not happen because access is validated before this point in the client - // ValidationException - shouldn't happen because client should not send malformed requests - // ConflictException - should not happen because the client will maintain proper state - // InternalServerException - shouldn't happen but needs to be caught - getLogger().error('Other error message: %s', error.message) - this.messenger.sendErrorMessage( - 'Encountered an unexpected error when generating tests. Please try again', - tabID - ) + // TODO: use the explicitly modeled exception reason for quota vs throttle{ + getLogger().error(error.message) + this.messenger.sendErrorMessage(tooManyRequestErrorMessage, tabID) + return } - } else { - // other unexpected errors (TODO enumerate all other failure cases) + // other service errors: + // AccessDeniedException - should not happen because access is validated before this point in the client + // ValidationException - shouldn't happen because client should not send malformed requests + // ConflictException - should not happen because the client will maintain proper state + // InternalServerException - shouldn't happen but needs to be caught getLogger().error('Other error message: %s', error.message) - this.messenger.sendErrorMessage( - 'Encountered an unexpected error when generating tests. Please try again', - tabID - ) + this.messenger.sendErrorMessage('', tabID) + return } + // other unexpected errors (TODO enumerate all other failure cases) + getLogger().error('Other error message: %s', error.uiMessage) + this.messenger.sendErrorMessage('', tabID) } // This function handles actions if user clicked on any Button one of these cases will be executed @@ -316,7 +322,6 @@ export class TestController { case ButtonActions.STOP_TEST_GEN: testGenState.setToCancelling() telemetry.ui_click.emit({ elementId: 'unitTestGeneration_cancelTestGenerationProgress' }) - await this.sessionCleanUp() break case ButtonActions.STOP_BUILD: cancelBuild() @@ -433,7 +438,7 @@ export class TestController { session.hasUserPromptSupplied = message.prompt.length > 0 - //displaying user message prompt in Test tab + // displaying user message prompt in Test tab this.messenger.sendMessage(userMessage, tabID, 'prompt') this.messenger.sendChatInputEnabled(tabID, false) this.sessionStorage.getSession().conversationState = ConversationState.IN_PROGRESS @@ -441,19 +446,31 @@ export class TestController { const language = await this.getLanguageForFilePath(filePath) session.fileLanguage = language + const workspaceFolder = vscode.workspace.getWorkspaceFolder(fileEditorToTest.document.uri) /* For Re:Invent 2024 we are supporting only java and python for unit test generation, rest of the languages shows the similar experience as CWC */ - if (language !== 'java' && language !== 'python') { - const unsupportedLanguage = language.charAt(0).toUpperCase() + language.slice(1) - let unsupportedMessage = `I'm sorry, but /test only supports Python and Java
While ${unsupportedLanguage} is not supported, I will generate a suggestion below. ` - // handle the case when language is undefined - if (!unsupportedLanguage) { - unsupportedMessage = `I'm sorry, but /test only supports Python and Java
I will still generate a suggestion below. ` + if (!['java', 'python'].includes(language) || workspaceFolder === undefined) { + let unsupportedMessage: string + const unsupportedLanguage = language ? language.charAt(0).toUpperCase() + language.slice(1) : '' + if (!workspaceFolder) { + // File is outside of workspace + unsupportedMessage = `I can't generate tests for ${fileName} because the file is outside of workspace scope.
I can still provide examples, instructions and code suggestions.` + } else if (unsupportedLanguage) { + unsupportedMessage = `I'm sorry, but /test only supports Python and Java
While ${unsupportedLanguage} is not supported, I will generate a suggestion below.` + } else { + unsupportedMessage = `I'm sorry, but /test only supports Python and Java
I will still generate a suggestion below.` } this.messenger.sendMessage(unsupportedMessage, tabID, 'answer') - await this.onCodeGeneration(session, message.prompt, tabID, fileName, filePath) + await this.onCodeGeneration( + session, + message.prompt, + tabID, + fileName, + filePath, + workspaceFolder !== undefined + ) } else { this.messenger.sendCapabilityCard({ tabID }) this.messenger.sendMessage(testGenSummaryMessage(fileName), message.tabID, 'answer-part') @@ -653,12 +670,14 @@ export class TestController { acceptedLines = acceptedLines < 0 ? 0 : acceptedLines acceptedChars -= originalContent.length acceptedChars = acceptedChars < 0 ? 0 : acceptedChars + UserWrittenCodeTracker.instance.onQStartsMakingEdits() const document = await vscode.workspace.openTextDocument(absolutePath) await applyChanges( document, new vscode.Range(document.lineAt(0).range.start, document.lineAt(document.lineCount - 1).range.end), updatedContent ) + UserWrittenCodeTracker.instance.onQFinishesEdits() } else { await fs.writeFile(absolutePath, updatedContent) } @@ -669,7 +688,7 @@ export class TestController { const fileName = path.basename(session.generatedFilePath) const time = new Date().toLocaleString() // TODO: this is duplicated in basicCommands.ts for scan (codewhisperer). Fix this later. - session.references.forEach((reference) => { + for (const reference of session.references) { getLogger().debug('Processing reference: %O', reference) // Log values for debugging getLogger().debug('updatedContent: %s', updatedContent) @@ -704,7 +723,7 @@ export class TestController { '
' getLogger().debug('Adding reference log: %s', referenceLog) ReferenceLogViewProvider.instance.addReferenceLog(referenceLog) - }) + } // TODO: see if there's a better way to check if active file is a diff if (vscode.window.tabGroups.activeTabGroup.activeTab?.label.includes(amazonQTabSuffix)) { @@ -713,31 +732,33 @@ export class TestController { const document = await vscode.workspace.openTextDocument(absolutePath) await vscode.window.showTextDocument(document) // TODO: send the message once again once build is enabled - //this.messenger.sendMessage('Accepted', message.tabID, 'prompt') + // this.messenger.sendMessage('Accepted', message.tabID, 'prompt') telemetry.ui_click.emit({ elementId: 'unitTestGeneration_acceptDiff' }) - telemetry.amazonq_utgGenerateTests.emit({ - generatedCount: session.numberOfTestsGenerated, - acceptedCount: session.numberOfTestsGenerated, - generatedCharactersCount: session.charsOfCodeGenerated, - acceptedCharactersCount: session.charsOfCodeAccepted, - generatedLinesCount: session.linesOfCodeGenerated, - acceptedLinesCount: session.linesOfCodeAccepted, - cwsprChatProgrammingLanguage: session.fileLanguage ?? 'plaintext', - jobId: session.listOfTestGenerationJobId[0], // For RIV, UTG does only one StartTestGeneration API call so jobId = session.listOfTestGenerationJobId[0] - jobGroup: session.testGenerationJobGroupName, - buildPayloadBytes: session.srcPayloadSize, - buildZipFileBytes: session.srcZipFileSize, - artifactsUploadDuration: session.artifactsUploadDuration, - hasUserPromptSupplied: session.hasUserPromptSupplied, - isCodeBlockSelected: session.isCodeBlockSelected, - perfClientLatency: session.latencyOfTestGeneration, - isSupportedLanguage: true, - credentialStartUrl: AuthUtil.instance.startUrl, - result: 'Succeeded', - }) + + getLogger().info( + `Generated unit tests are accepted for ${session.fileLanguage ?? 'plaintext'} language with jobId: ${session.listOfTestGenerationJobId[0]}, jobGroupName: ${session.testGenerationJobGroupName}, result: Succeeded` + ) + TelemetryHelper.instance.sendTestGenerationToolkitEvent( + session, + true, + true, + 'Succeeded', + session.startTestGenerationRequestId, + session.latencyOfTestGeneration, + undefined, + session.isCodeBlockSelected, + session.artifactsUploadDuration, + session.srcPayloadSize, + session.srcZipFileSize, + session.charsOfCodeAccepted, + session.numberOfTestsGenerated, + session.linesOfCodeAccepted, + session.charsOfCodeGenerated, + session.numberOfTestsGenerated, + session.linesOfCodeGenerated + ) await this.endSession(message, FollowUpTypes.SkipBuildAndFinish) - await this.sessionCleanUp() return if (session.listOfTestGenerationJobId.length === 1) { @@ -797,20 +818,21 @@ export class TestController { message: string, tabID: string, fileName: string, - filePath: string + filePath: string, + fileInWorkspace: boolean ) { try { - //TODO: Write this entire gen response to basiccommands and call here. + // TODO: Write this entire gen response to basiccommands and call here. const editorText = await fs.readFileText(filePath) const triggerPayload = { - query: `Generate unit tests for the following part of my code: ${message}`, + query: `Generate unit tests for the following part of my code: ${message?.trim() || fileName}`, codeSelection: undefined, trigger: ChatTriggerType.ChatMessage, fileText: editorText, fileLanguage: session.fileLanguage, filePath: filePath, - message: `Generate unit tests for the following part of my code: ${message}`, + message: `Generate unit tests for the following part of my code: ${message?.trim() || fileName}`, matchPolicy: undefined, codeQuery: undefined, userIntent: UserIntent.GENERATE_UNIT_TESTS, @@ -819,13 +841,15 @@ export class TestController { const chatRequest = triggerPayloadToChatRequest(triggerPayload) const client = await createCodeWhispererChatStreamingClient() const response = await client.generateAssistantResponse(chatRequest) + UserWrittenCodeTracker.instance.onQFeatureInvoked() await this.messenger.sendAIResponse( response, session, tabID, randomUUID.toString(), triggerPayload, - fileName + fileName, + fileInWorkspace ) } finally { this.messenger.sendChatInputEnabled(tabID, true) @@ -834,39 +858,37 @@ export class TestController { } } - //TODO: Check if there are more cases to endSession if yes create a enum or type for step + // TODO: Check if there are more cases to endSession if yes create a enum or type for step private async endSession(data: any, step: FollowUpTypes) { + this.messenger.sendMessage('Unit test generation completed.', data.tabID, 'answer') + const session = this.sessionStorage.getSession() if (step === FollowUpTypes.RejectCode) { - telemetry.amazonq_utgGenerateTests.emit({ - generatedCount: session.numberOfTestsGenerated, - acceptedCount: 0, - generatedCharactersCount: session.charsOfCodeGenerated, - acceptedCharactersCount: 0, - generatedLinesCount: session.linesOfCodeGenerated, - acceptedLinesCount: 0, - cwsprChatProgrammingLanguage: session.fileLanguage ?? 'plaintext', - jobId: session.listOfTestGenerationJobId[0], // For RIV, UTG does only one StartTestGeneration API call so jobId = session.listOfTestGenerationJobId[0] - jobGroup: session.testGenerationJobGroupName, - buildPayloadBytes: session.srcPayloadSize, - buildZipFileBytes: session.srcZipFileSize, - artifactsUploadDuration: session.artifactsUploadDuration, - hasUserPromptSupplied: session.hasUserPromptSupplied, - isCodeBlockSelected: session.isCodeBlockSelected, - perfClientLatency: session.latencyOfTestGeneration, - isSupportedLanguage: true, - credentialStartUrl: AuthUtil.instance.startUrl, - result: 'Succeeded', - }) + TelemetryHelper.instance.sendTestGenerationToolkitEvent( + session, + true, + true, + 'Succeeded', + session.startTestGenerationRequestId, + session.latencyOfTestGeneration, + undefined, + session.isCodeBlockSelected, + session.artifactsUploadDuration, + session.srcPayloadSize, + session.srcZipFileSize, + 0, + 0, + 0, + session.charsOfCodeGenerated, + session.numberOfTestsGenerated, + session.linesOfCodeGenerated + ) telemetry.ui_click.emit({ elementId: 'unitTestGeneration_rejectDiff' }) } await this.sessionCleanUp() - // TODO: revert 'Accepted' to 'Skip build and finish' once supported - const message = step === FollowUpTypes.RejectCode ? 'Rejected' : 'Accepted' - this.messenger.sendMessage(message, data.tabID, 'prompt') - this.messenger.sendMessage(`Unit test generation workflow is completed.`, data.tabID, 'answer') + // this.messenger.sendMessage(`Unit test generation workflow is completed.`, data.tabID, 'answer') this.messenger.sendChatInputEnabled(data.tabID, true) return } @@ -876,7 +898,7 @@ export class TestController { */ private startInitialBuild(data: any) { - //TODO: Remove the fallback build command after stable version of backend build command. + // TODO: Remove the fallback build command after stable version of backend build command. const userMessage = `Would you like me to help build and execute the test? I will need you to let me know what build command to run if you do.` const followUps: FollowUps = { text: '', @@ -908,7 +930,7 @@ export class TestController { private async checkForInstallationDependencies(data: any) { // const session: Session = this.sessionStorage.getSession() // const listOfInstallationDependencies = session.testGenerationJob?.shortAnswer?.installationDependencies || [] - //MOCK: As there is no installation dependencies in shortAnswer + // MOCK: As there is no installation dependencies in shortAnswer const listOfInstallationDependencies = [''] const installationDependencies = listOfInstallationDependencies.join('\n') @@ -959,7 +981,7 @@ export class TestController { private async startLocalBuildExecution(data: any) { const session: Session = this.sessionStorage.getSession() // const installationDependencies = session.shortAnswer?.installationDependencies ?? [] - //MOCK: ignoring the installation case until backend send response + // MOCK: ignoring the installation case until backend send response const installationDependencies: string[] = [] const buildCommands = session.updatedBuildCommands if (!buildCommands) { @@ -989,7 +1011,7 @@ export class TestController { }) const status = await runBuildCommand(installationDependencies) - //TODO: Add separate status for installation dependencies + // TODO: Add separate status for installation dependencies session.buildStatus = status if (status === BuildStatus.FAILURE) { this.messenger.sendBuildProgressMessage({ @@ -1108,7 +1130,7 @@ export class TestController { false ) } - //TODO: Skip this if startTestGenerationProcess timeouts + // TODO: Skip this if startTestGenerationProcess timeouts if (session.generatedFilePath) { await this.showTestCaseSummary(data) } @@ -1245,7 +1267,7 @@ export class TestController { groupName ) if (session.listOfTestGenerationJobId.length && groupName) { - session.listOfTestGenerationJobId.forEach((id) => { + for (const id of session.listOfTestGenerationJobId) { if (id === session.acceptedJobId) { TelemetryHelper.instance.sendTestGenerationEvent( groupName, @@ -1271,7 +1293,7 @@ export class TestController { 0 ) } - }) + } } session.listOfTestGenerationJobId = [] session.testGenerationJobGroupName = undefined @@ -1295,14 +1317,24 @@ export class TestController { if (session.tabID) { getLogger().debug('Setting input state with tabID: %s', session.tabID) this.messenger.sendChatInputEnabled(session.tabID, true) - this.messenger.sendUpdatePlaceholder(session.tabID, '/test Generate unit tests') //TODO: Change according to the UX + this.messenger.sendUpdatePlaceholder(session.tabID, '/test Generate unit tests') // TODO: Change according to the UX } getLogger().debug( 'Deleting output.log and temp result directory. testGenerationLogsDir: %s', testGenerationLogsDir ) - await fs.delete(path.join(testGenerationLogsDir, 'output.log')) - await fs.delete(this.tempResultDirPath, { recursive: true }) + const outputLogPath = path.join(testGenerationLogsDir, 'output.log') + if (await fs.existsFile(outputLogPath)) { + await fs.delete(outputLogPath) + } + if ( + await fs + .stat(this.tempResultDirPath) + .then(() => true) + .catch(() => false) + ) { + await fs.delete(this.tempResultDirPath, { recursive: true }) + } } // TODO: return build command when product approves diff --git a/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts b/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts index 6051a9b51ad..f842a6c1808 100644 --- a/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts +++ b/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts @@ -36,9 +36,8 @@ import { CodeReference } from '../../../../amazonq/webview/ui/apps/amazonqCommon import { getHttpStatusCode, getRequestId, getTelemetryReasonDesc, ToolkitError } from '../../../../shared/errors' import { sleep, waitUntil } from '../../../../shared/utilities/timeoutUtils' import { keys } from '../../../../shared/utilities/tsUtils' -import { AuthUtil, testGenState } from '../../../../codewhisperer' +import { TelemetryHelper, testGenState } from '../../../../codewhisperer' import { cancellingProgressField, testGenCompletedField } from '../../../models/constants' -import { telemetry } from '../../../../shared/telemetry/telemetry' export type UnrecoverableErrorType = 'no-project-found' | 'no-open-file-found' | 'invalid-file-type' @@ -177,17 +176,18 @@ export class Messenger { ) } - //To show the response of unsupported languages to the user in the Q-Test tab + // To show the response of unsupported languages to the user in the Q-Test tab public async sendAIResponse( response: GenerateAssistantResponseCommandOutput, session: Session, tabID: string, triggerID: string, triggerPayload: TriggerPayload, - fileName: string + fileName: string, + fileInWorkspace: boolean ) { let message = '' - const messageId = response.$metadata.requestId ?? '' + let messageId = response.$metadata.requestId ?? '' let codeReference: CodeReference[] = [] if (response.generateAssistantResponseResponse === undefined) { @@ -267,6 +267,7 @@ export class Messenger { } if (requestID !== undefined) { + messageId = requestID message += `\n\nRequest ID: ${requestID}` } this.sendMessage(message.trim(), tabID, 'answer') @@ -274,29 +275,42 @@ export class Messenger { .finally(async () => { if (testGenState.isCancelling()) { this.sendMessage(CodeWhispererConstants.unitTestGenerationCancelMessage, tabID, 'answer') - telemetry.amazonq_utgGenerateTests.emit({ - cwsprChatProgrammingLanguage: session.fileLanguage ?? 'plaintext', - hasUserPromptSupplied: session.hasUserPromptSupplied, - perfClientLatency: performance.now() - session.testGenerationStartTime, - result: 'Cancelled', - reasonDesc: getTelemetryReasonDesc(CodeWhispererConstants.unitTestGenerationCancelMessage), - isSupportedLanguage: false, - credentialStartUrl: AuthUtil.instance.startUrl, - }) - + TelemetryHelper.instance.sendTestGenerationToolkitEvent( + session, + false, + fileInWorkspace, + 'Cancelled', + messageId, + performance.now() - session.testGenerationStartTime, + getTelemetryReasonDesc( + `TestGenCancelled: ${CodeWhispererConstants.unitTestGenerationCancelMessage}` + ), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'TestGenCancelled' + ) this.dispatcher.sendUpdatePromptProgress( new UpdatePromptProgressMessage(tabID, cancellingProgressField) ) await sleep(500) } else { - telemetry.amazonq_utgGenerateTests.emit({ - cwsprChatProgrammingLanguage: session.fileLanguage ?? 'plaintext', - hasUserPromptSupplied: session.hasUserPromptSupplied, - perfClientLatency: performance.now() - session.testGenerationStartTime, - result: 'Succeeded', - isSupportedLanguage: false, - credentialStartUrl: AuthUtil.instance.startUrl, - }) + TelemetryHelper.instance.sendTestGenerationToolkitEvent( + session, + false, + fileInWorkspace, + 'Succeeded', + messageId, + performance.now() - session.testGenerationStartTime, + undefined + ) this.dispatcher.sendUpdatePromptProgress( new UpdatePromptProgressMessage(tabID, testGenCompletedField) ) @@ -308,7 +322,7 @@ export class Messenger { }) } - //To show the Build progress in the chat + // To show the Build progress in the chat public sendBuildProgressMessage(params: SendBuildProgressMessageParams) { const { tabID, diff --git a/packages/core/src/amazonqTest/chat/controller/messenger/messengerUtils.ts b/packages/core/src/amazonqTest/chat/controller/messenger/messengerUtils.ts index 647951daef8..9e8fec4594e 100644 --- a/packages/core/src/amazonqTest/chat/controller/messenger/messengerUtils.ts +++ b/packages/core/src/amazonqTest/chat/controller/messenger/messengerUtils.ts @@ -14,7 +14,7 @@ export enum ButtonActions { STOP_BUILD = 'Stop-Build-Process', } -//TODO: Refactor the common functionality between Transform, FeatureDev, CWSPRChat, Scan and UTG to a new Folder. +// TODO: Refactor the common functionality between Transform, FeatureDev, CWSPRChat, Scan and UTG to a new Folder. export default class MessengerUtils { static stringToEnumValue = ( diff --git a/packages/core/src/amazonqTest/chat/session/session.ts b/packages/core/src/amazonqTest/chat/session/session.ts index cd188c10c0f..713b8fcf5f7 100644 --- a/packages/core/src/amazonqTest/chat/session/session.ts +++ b/packages/core/src/amazonqTest/chat/session/session.ts @@ -28,9 +28,10 @@ export class Session { // A tab may or may not be currently open public tabID: string | undefined - //This is unique per each test generation cycle + // This is unique per each test generation cycle public testGenerationJobGroupName: string | undefined = undefined public listOfTestGenerationJobId: string[] = [] + public startTestGenerationRequestId: string | undefined = undefined public testGenerationJob: TestGenerationJob | undefined // Start Test generation @@ -56,8 +57,8 @@ export class Session { public charsOfCodeAccepted: number = 0 public latencyOfTestGeneration: number = 0 - //TODO: Take values from ShortAnswer or TestGenerationJob - //Build loop + // TODO: Take values from ShortAnswer or TestGenerationJob + // Build loop public buildStatus: BuildStatus = BuildStatus.SUCCESS public updatedBuildCommands: string[] | undefined = undefined public testCoveragePercentage: number = 90 diff --git a/packages/core/src/amazonqTest/error.ts b/packages/core/src/amazonqTest/error.ts new file mode 100644 index 00000000000..a6694b35863 --- /dev/null +++ b/packages/core/src/amazonqTest/error.ts @@ -0,0 +1,67 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { ToolkitError } from '../shared/errors' + +export const technicalErrorCustomerFacingMessage = + 'I am experiencing technical difficulties at the moment. Please try again in a few minutes.' +const defaultTestGenErrorMessage = 'Amazon Q encountered an error while generating tests. Try again later.' +export class TestGenError extends ToolkitError { + constructor( + error: string, + code: string, + public uiMessage: string + ) { + super(error, { code }) + } +} +export class ProjectZipError extends TestGenError { + constructor(error: string) { + super(error, 'ProjectZipError', defaultTestGenErrorMessage) + } +} +export class InvalidSourceZipError extends TestGenError { + constructor() { + super('Failed to create valid source zip', 'InvalidSourceZipError', defaultTestGenErrorMessage) + } +} +export class CreateUploadUrlError extends TestGenError { + constructor(errorMessage: string) { + super(errorMessage, 'CreateUploadUrlError', technicalErrorCustomerFacingMessage) + } +} +export class UploadTestArtifactToS3Error extends TestGenError { + constructor(error: string) { + super(error, 'UploadTestArtifactToS3Error', technicalErrorCustomerFacingMessage) + } +} +export class CreateTestJobError extends TestGenError { + constructor(error: string) { + super(error, 'CreateTestJobError', technicalErrorCustomerFacingMessage) + } +} +export class TestGenTimedOutError extends TestGenError { + constructor() { + super( + 'Test generation failed. Amazon Q timed out.', + 'TestGenTimedOutError', + technicalErrorCustomerFacingMessage + ) + } +} +export class TestGenStoppedError extends TestGenError { + constructor() { + super('Unit test generation cancelled.', 'TestGenCancelled', 'Unit test generation cancelled.') + } +} +export class TestGenFailedError extends TestGenError { + constructor(error?: string) { + super(error ?? 'Test generation failed', 'TestGenFailedError', error ?? technicalErrorCustomerFacingMessage) + } +} +export class ExportResultsArchiveError extends TestGenError { + constructor(error?: string) { + super(error ?? 'Test generation failed', 'ExportResultsArchiveError', technicalErrorCustomerFacingMessage) + } +} diff --git a/packages/core/src/amazonqTest/models/constants.ts b/packages/core/src/amazonqTest/models/constants.ts index fa0c00d59cc..8370d4d3ca7 100644 --- a/packages/core/src/amazonqTest/models/constants.ts +++ b/packages/core/src/amazonqTest/models/constants.ts @@ -111,12 +111,12 @@ export const testGenBuildProgressMessage = (currentStep: TestGenerationBuildStep ${session.shortAnswer?.testCoverage ? `- Unit test coverage ${session.shortAnswer?.testCoverage}%` : ``} ${icon} Build ${statusText} ${icon} Assertion ${statusText}` - //TODO: Update Assertion % + // TODO: Update Assertion % } return message.trim() } -//TODO: Work on UX to show the build error in the progress message +// TODO: Work on UX to show the build error in the progress message const updateStepStatuses = (currentStep: TestGenerationBuildStep, status?: string) => { for (let step = TestGenerationBuildStep.INSTALL_DEPENDENCIES; step <= currentStep; step++) { const stepStatus: StepStatus = { diff --git a/packages/core/src/applicationcomposer/composerWebview.ts b/packages/core/src/applicationcomposer/composerWebview.ts index 94493091c3a..f43775df3c7 100644 --- a/packages/core/src/applicationcomposer/composerWebview.ts +++ b/packages/core/src/applicationcomposer/composerWebview.ts @@ -104,9 +104,9 @@ export class ApplicationComposer { } this.isPanelDisposed = true this.onVisualizationDisposeEmitter.fire() - this.disposables.forEach((disposable) => { + for (const disposable of this.disposables) { disposable.dispose() - }) + } this.onVisualizationDisposeEmitter.dispose() } diff --git a/packages/core/src/auth/auth.ts b/packages/core/src/auth/auth.ts index 3335f8ab333..5203735e05a 100644 --- a/packages/core/src/auth/auth.ts +++ b/packages/core/src/auth/auth.ts @@ -339,11 +339,6 @@ export class Auth implements AuthService, ConnectionManager { metadata: { connectionState: 'unauthenticated' }, }) - // Remove the split session logout prompt, if it exists. - if (!isAmazonQ()) { - await globals.globalState.update('aws.toolkit.separationPromptDismissed', true) - } - try { ;(await tokenProvider.getToken()) ?? (await tokenProvider.createToken()) const storedProfile = await this.store.addProfile(id, profile) @@ -1136,71 +1131,3 @@ export function hasVendedIamCredentials(isC9?: boolean, isSM?: boolean) { isSM ??= isSageMaker() return isSM || isC9 } - -type LoginCommand = 'aws.toolkit.auth.manageConnections' | 'aws.codecatalyst.manageConnections' -/** - * Temporary class that handles notifiting users who were logged out as part of - * splitting auth sessions between extensions. - * - * TODO: Remove after some time. - */ -export class SessionSeparationPrompt { - // Local variable handles per session displays, e.g. we forgot a CodeCatalyst connection AND - // an Explorer only connection. We only want to display once in this case. - // However, we don't want to set this at the global state level until a user interacts with the - // notification in case they miss it the first time. - #separationPromptDisplayed = false - - /** - * Open a prompt for that last used command name (or do nothing if no command name has ever been passed), - * which is useful to redisplay the prompt after reloads in case a user misses it. - */ - public async showAnyPreviousPrompt() { - const cmd = globals.globalState.tryGet('aws.toolkit.separationPromptCommand', String) - return cmd ? await this.showForCommand(cmd as LoginCommand) : undefined - } - - /** - * Displays a sign in prompt to the user if they have been logged out of the Toolkit as part of - * separating auth sessions between extensions. It will executed the passed command for sign in, - * (e.g. codecatalyst sign in vs explorer) - */ - public async showForCommand(cmd: LoginCommand) { - if ( - this.#separationPromptDisplayed || - globals.globalState.get('aws.toolkit.separationPromptDismissed') - ) { - return - } - - await globals.globalState.update('aws.toolkit.separationPromptCommand', cmd) - - await telemetry.toolkit_showNotification.run(async () => { - telemetry.record({ id: 'sessionSeparation' }) - this.#separationPromptDisplayed = true - void vscode.window - .showWarningMessage( - 'Amazon Q and AWS Toolkit no longer share connections. Please sign in again to use AWS Toolkit.', - 'Sign In' - ) - .then(async (resp) => { - await telemetry.toolkit_invokeAction.run(async () => { - telemetry.record({ source: 'sessionSeparationNotification' }) - if (resp === 'Sign In') { - telemetry.record({ action: 'signIn' }) - await vscode.commands.executeCommand(cmd) - } else { - telemetry.record({ action: 'dismiss' }) - } - - await globals.globalState.update('aws.toolkit.separationPromptDismissed', true) - }) - }) - }) - } - - static #instance: SessionSeparationPrompt - public static get instance() { - return (this.#instance ??= new SessionSeparationPrompt()) - } -} diff --git a/packages/core/src/auth/credentials/sharedCredentials.ts b/packages/core/src/auth/credentials/sharedCredentials.ts index 1b7ea038653..b78c0d53251 100644 --- a/packages/core/src/auth/credentials/sharedCredentials.ts +++ b/packages/core/src/auth/credentials/sharedCredentials.ts @@ -176,7 +176,7 @@ export function mergeAndValidateSections(data: BaseSection[]): ParseResult { export function parseIni(iniData: string, source: vscode.Uri): BaseSection[] { const sections = [] as BaseSection[] const lines = iniData.split(/\r?\n/).map((l) => l.split(/(^|\s)[;#]/)[0]) // remove comments - lines.forEach((line, lineNumber) => { + for (const [lineNumber, line] of lines.entries()) { const section = line.match(/^\s*\[([^\[\]]+)]\s*$/) const currentSection: BaseSection | undefined = sections[sections.length - 1] if (section) { @@ -195,7 +195,7 @@ export function parseIni(iniData: string, source: vscode.Uri): BaseSection[] { }) } } - }) + } return sections } diff --git a/packages/core/src/auth/credentials/utils.ts b/packages/core/src/auth/credentials/utils.ts index 891e17a6486..28fa5df1c4c 100644 --- a/packages/core/src/auth/credentials/utils.ts +++ b/packages/core/src/auth/credentials/utils.ts @@ -10,7 +10,6 @@ import * as vscode from 'vscode' import { Credentials } from '@aws-sdk/types' import { authHelpUrl } from '../../shared/constants' import globals from '../../shared/extensionGlobals' -import { isCloud9 } from '../../shared/extensionUtilities' import { messages, showMessageWithCancel, showViewLogsMessage } from '../../shared/utilities/messages' import { Timeout, waitTimeout } from '../../shared/utilities/timeoutUtils' import { fromExtensionManifest } from '../../shared/settings' @@ -37,8 +36,8 @@ export function asEnvironmentVariables(credentials: Credentials): NodeJS.Process export function showLoginFailedMessage(credentialsId: string, errMsg: string): void { const getHelp = localize('AWS.generic.message.getHelp', 'Get Help...') const editCreds = messages.editCredentials(false) - // TODO: getHelp page for Cloud9. - const buttons = isCloud9() ? [editCreds] : [editCreds, getHelp] + // TODO: Any work towards web/another cloud9 -esqe IDE may need different getHelp docs: + const buttons = [editCreds, getHelp] void showViewLogsMessage( localize('AWS.message.credentials.invalid', 'Credentials "{0}" failed to connect: {1}', credentialsId, errMsg), diff --git a/packages/core/src/auth/credentials/validation.ts b/packages/core/src/auth/credentials/validation.ts index e1bee0b8c08..a82eb26a36a 100644 --- a/packages/core/src/auth/credentials/validation.ts +++ b/packages/core/src/auth/credentials/validation.ts @@ -36,12 +36,12 @@ export function getCredentialsErrors( validateFunc: GetCredentialError = getCredentialError ): CredentialsErrors | undefined { const errors: CredentialsData = {} - Object.entries(data).forEach(([key, value]) => { + for (const [key, value] of Object.entries(data)) { if (!isCredentialsKey(key)) { - return + continue } errors[key] = validateFunc(key, value) - }) + } const hasErrors = Object.values(errors).some(Boolean) if (!hasErrors) { diff --git a/packages/core/src/auth/sso/cache.ts b/packages/core/src/auth/sso/cache.ts index 1c2be980630..7d43c07da35 100644 --- a/packages/core/src/auth/sso/cache.ts +++ b/packages/core/src/auth/sso/cache.ts @@ -15,6 +15,7 @@ import { SsoToken, ClientRegistration } from './model' import { DevSettings } from '../../shared/settings' import { onceChanged } from '../../shared/utilities/functionUtils' import globals from '../../shared/extensionGlobals' +import { ToolkitError } from '../../shared/errors' interface RegistrationKey { readonly startUrl: string @@ -78,6 +79,11 @@ export function getTokenCache(directory = getCacheDir()): KeyedCache } function read(data: StoredToken): SsoAccess { + // Validate data is not missing. Since the input data is passed directly from whatever is on disk. + if (!hasProps(data, 'accessToken')) { + throw new ToolkitError(`SSO cache data looks malformed`) + } + const registration = hasProps(data, 'clientId', 'clientSecret', 'registrationExpiresAt') ? { ...selectFrom(data, 'clientId', 'clientSecret', 'scopes', 'startUrl'), @@ -143,7 +149,9 @@ function getRegistrationCacheFile(ssoCacheDir: string, key: RegistrationKey): st const hash = (startUrl: string, scopes: string[]) => { const shasum = crypto.createHash('sha256') shasum.update(startUrl) - scopes.forEach((s) => shasum.update(s)) + for (const s of scopes) { + shasum.update(s) + } return shasum.digest('hex') } diff --git a/packages/core/src/auth/sso/constants.ts b/packages/core/src/auth/sso/constants.ts index 4b0e781ceaa..0e6bb082d7e 100644 --- a/packages/core/src/auth/sso/constants.ts +++ b/packages/core/src/auth/sso/constants.ts @@ -11,8 +11,15 @@ export const builderIdStartUrl = 'https://view.awsapps.com/start' export const internalStartUrl = 'https://amzn.awsapps.com/start' +/** + * Doc: https://docs.aws.amazon.com/singlesignon/latest/userguide/howtochangeURL.html + */ export const ssoUrlFormatRegex = /^(https?:\/\/(.+)\.awsapps\.com\/start|https?:\/\/identitycenter\.amazonaws\.com\/ssoins-[\da-zA-Z]{16})\/?$/ -export const ssoUrlFormatMessage = - 'URLs must start with http:// or https://. Example: https://d-xxxxxxxxxx.awsapps.com/start' +/** + * It is possible for a start url to be a completely custom url that redirects to something that matches the format + * below, so this message is only a warning. + */ +export const ssoUrlFormatMessage = 'URL possibly invalid. Expected format: https://xxxxxxxxxx.awsapps.com/start' +export const urlInvalidFormatMessage = 'URL format invalid. Expected format: https://xxxxxxxxxx.com/yyyy' diff --git a/packages/core/src/auth/sso/model.ts b/packages/core/src/auth/sso/model.ts index 2a05692148e..6cc462d8a57 100644 --- a/packages/core/src/auth/sso/model.ts +++ b/packages/core/src/auth/sso/model.ts @@ -15,7 +15,6 @@ import { CancellationError } from '../../shared/utilities/timeoutUtils' import { ssoAuthHelpUrl } from '../../shared/constants' import { openUrl } from '../../shared/utilities/vsCodeUtils' import { ToolkitError } from '../../shared/errors' -import { isCloud9 } from '../../shared/extensionUtilities' import { builderIdStartUrl } from './constants' export interface SsoToken { @@ -116,10 +115,7 @@ export async function openSsoPortalLink(startUrl: string, authorization: Authori async function showLoginNotification() { const name = startUrl === builderIdStartUrl ? localizedText.builderId() : localizedText.iamIdentityCenterFull() - // C9 doesn't support `detail` field with modals so we need to put it all in the `title` - const title = isCloud9() - ? `Confirm Code "${authorization.userCode}" for ${name} in the browser.` - : localize('AWS.auth.loginWithBrowser.messageTitle', 'Confirm Code for {0}', name) + const title = localize('AWS.auth.loginWithBrowser.messageTitle', 'Confirm Code for {0}', name) const detail = localize( 'AWS.auth.loginWithBrowser.messageDetail', 'Confirm this code in the browser: {0}', diff --git a/packages/core/src/auth/sso/server.ts b/packages/core/src/auth/sso/server.ts index 7c95b5ce016..cf25c1456d5 100644 --- a/packages/core/src/auth/sso/server.ts +++ b/packages/core/src/auth/sso/server.ts @@ -163,9 +163,9 @@ export class AuthSSOServer { getLogger().debug('AuthSSOServer: Attempting to close server.') - this.connections.forEach((connection) => { + for (const connection of this.connections) { connection.destroy() - }) + } this.server.close((err) => { if (err) { diff --git a/packages/core/src/auth/sso/ssoAccessTokenProvider.ts b/packages/core/src/auth/sso/ssoAccessTokenProvider.ts index bf9c620c2f3..d0c8af56c3f 100644 --- a/packages/core/src/auth/sso/ssoAccessTokenProvider.ts +++ b/packages/core/src/auth/sso/ssoAccessTokenProvider.ts @@ -33,7 +33,7 @@ import { randomUUID } from '../../shared/crypto' import { getExtRuntimeContext } from '../../shared/vscode/env' import { showInputBox } from '../../shared/ui/inputPrompter' import { AmazonQPromptSettings, DevSettings, PromptSettings, ToolkitPromptSettings } from '../../shared/settings' -import { onceChanged } from '../../shared/utilities/functionUtils' +import { debounce, onceChanged } from '../../shared/utilities/functionUtils' import { NestedMap } from '../../shared/utilities/map' import { asStringifiedStack } from '../../shared/telemetry/spans' import { showViewLogsMessage } from '../../shared/utilities/messages' @@ -97,7 +97,20 @@ export abstract class SsoAccessTokenProvider { this.reAuthState.set(this.profile, { reAuthReason: `invalidate():${reason}` }) } + /** + * Sometimes we get many calls at once and this + * can trigger redundant disk reads, or token refreshes. + * We debounce to avoid this. + * + * NOTE: The property {@link getTokenDebounced()} does not work with being stubbed for tests, so + * this redundant function was created to work around that. + */ public async getToken(): Promise { + return this.getTokenDebounced() + } + private getTokenDebounced = debounce(() => this._getToken(), 50) + /** Exposed for testing purposes only */ + public async _getToken(): Promise { const data = await this.cache.token.load(this.tokenCacheKey) SsoAccessTokenProvider.logIfChanged( indent( @@ -808,7 +821,7 @@ class DiskCacheErrorMessage { : ToolkitPromptSettings.instance // We know 'ssoCacheError' is in all extension prompt settings - if (await promptSettings.isPromptEnabled('ssoCacheError')) { + if (promptSettings.isPromptEnabled('ssoCacheError')) { const result = await showMessage() if (result === dontShow) { await promptSettings.disablePrompt('ssoCacheError') diff --git a/packages/core/src/auth/utils.ts b/packages/core/src/auth/utils.ts index ea26dc2a3c3..dd008a55fb4 100644 --- a/packages/core/src/auth/utils.ts +++ b/packages/core/src/auth/utils.ts @@ -18,9 +18,9 @@ import { formatError, ToolkitError } from '../shared/errors' import { asString } from './providers/credentials' import { TreeNode } from '../shared/treeview/resourceTreeDataProvider' import { createInputBox } from '../shared/ui/inputPrompter' -import { telemetry } from '../shared/telemetry/telemetry' +import { CredentialSourceId, telemetry } from '../shared/telemetry/telemetry' import { createCommonButtons, createExitButton, createHelpButton, createRefreshButton } from '../shared/ui/buttons' -import { getIdeProperties, isAmazonQ, isCloud9 } from '../shared/extensionUtilities' +import { getIdeProperties, isAmazonQ } from '../shared/extensionUtilities' import { addScopes, getDependentAuths } from './secondaryAuth' import { DevSettings } from '../shared/settings' import { createRegionPrompter } from '../shared/ui/common/region' @@ -45,7 +45,7 @@ import { Commands, placeholder } from '../shared/vscode/commands2' import { Auth } from './auth' import { validateIsNewSsoUrl, validateSsoUrlFormat } from './sso/validation' import { getLogger } from '../shared/logger' -import { isValidAmazonQConnection, isValidCodeWhispererCoreConnection } from '../codewhisperer/util/authUtil' +import { AuthUtil, isValidAmazonQConnection, isValidCodeWhispererCoreConnection } from '../codewhisperer/util/authUtil' import { AuthFormId } from '../login/webview/vue/types' import { extensionVersion } from '../shared/vscode/env' import { ExtStartUpSources } from '../shared/telemetry' @@ -562,9 +562,9 @@ export class AuthNode implements TreeNode { if (conn !== undefined && conn.state !== 'valid') { item.iconPath = getIcon('vscode-error') if (conn.state === 'authenticating') { - this.setDescription(item, 'authenticating...') + item.description = 'authenticating...' } else { - this.setDescription(item, 'expired or invalid, click to authenticate') + item.description = 'expired or invalid, click to authenticate' item.command = { title: 'Reauthenticate', command: '_aws.toolkit.auth.reauthenticate', @@ -578,14 +578,6 @@ export class AuthNode implements TreeNode { return item } - - private setDescription(item: vscode.TreeItem, text: string) { - if (isCloud9()) { - item.tooltip = item.tooltip ?? text - } else { - item.description = text - } - } } export async function hasIamCredentials( @@ -798,3 +790,13 @@ export function initializeCredentialsProviderManager() { manager.addProviderFactory(new SharedCredentialsProviderFactory()) manager.addProviders(new Ec2CredentialsProvider(), new EcsCredentialsProvider(), new EnvVarsCredentialsProvider()) } + +export async function getAuthType() { + let authType: CredentialSourceId | undefined = undefined + if (AuthUtil.instance.isEnterpriseSsoInUse() && AuthUtil.instance.isConnectionValid()) { + authType = 'iamIdentityCenter' + } else if (AuthUtil.instance.isBuilderIdInUse() && AuthUtil.instance.isConnectionValid()) { + authType = 'awsId' + } + return authType +} diff --git a/packages/core/src/awsService/accessanalyzer/vue/iamPolicyChecks.ts b/packages/core/src/awsService/accessanalyzer/vue/iamPolicyChecks.ts index 406b04a6d75..f6cc5e8cf18 100644 --- a/packages/core/src/awsService/accessanalyzer/vue/iamPolicyChecks.ts +++ b/packages/core/src/awsService/accessanalyzer/vue/iamPolicyChecks.ts @@ -12,7 +12,6 @@ import { VueWebview, VueWebviewPanel } from '../../../webviews/main' import { ExtContext } from '../../../shared/extensions' import { telemetry } from '../../../shared/telemetry/telemetry' import { AccessAnalyzer, SharedIniFileCredentials } from 'aws-sdk' -import { execFileSync } from 'child_process' import { ToolkitError } from '../../../shared/errors' import { makeTemporaryToolkitFolder, tryRemoveFolder } from '../../../shared/filesystemUtilities' import { globals } from '../../../shared' @@ -28,6 +27,7 @@ import { } from './constants' import { DefaultS3Client, parseS3Uri } from '../../../shared/clients/s3Client' import { ExpiredTokenException } from '@aws-sdk/client-sso-oidc' +import { ChildProcess } from '../../../shared/utilities/processUtils' const defaultTerraformConfigPath = 'resources/policychecks-tf-default.yaml' // Diagnostics for Custom checks are shared @@ -203,6 +203,7 @@ export class IamPolicyChecksWebview extends VueWebview { span.record({ findingsCount: data.findings.length, }) + // eslint-disable-next-line unicorn/no-array-for-each data.findings.forEach((finding: AccessAnalyzer.ValidatePolicyFinding) => { const message = `${finding.findingType}: ${finding.issueCode} - ${finding.findingDetails} Learn more: ${finding.learnMoreLink}` if ((finding.findingType as ValidatePolicyFindingType) === 'ERROR') { @@ -277,7 +278,7 @@ export class IamPolicyChecksWebview extends VueWebview { '--config', `${globals.context.asAbsolutePath(defaultTerraformConfigPath)}`, ] - this.executeValidatePolicyCommand({ + await this.executeValidatePolicyCommand({ command, args, cfnParameterPathExists: !!cfnParameterPath, @@ -300,7 +301,7 @@ export class IamPolicyChecksWebview extends VueWebview { if (cfnParameterPath !== '') { args.push('--template-configuration-file', `${cfnParameterPath}`) } - this.executeValidatePolicyCommand({ + await this.executeValidatePolicyCommand({ command, args, cfnParameterPathExists: !!cfnParameterPath, @@ -357,7 +358,7 @@ export class IamPolicyChecksWebview extends VueWebview { '--reference-policy-type', `${policyType}`, ] - this.executeCustomPolicyChecksCommand({ + await this.executeCustomPolicyChecksCommand({ command, args, cfnParameterPathExists: !!cfnParameterPath, @@ -391,7 +392,7 @@ export class IamPolicyChecksWebview extends VueWebview { if (cfnParameterPath !== '') { args.push('--template-configuration-file', `${cfnParameterPath}`) } - this.executeCustomPolicyChecksCommand({ + await this.executeCustomPolicyChecksCommand({ command, args, cfnParameterPathExists: !!cfnParameterPath, @@ -454,7 +455,7 @@ export class IamPolicyChecksWebview extends VueWebview { if (resources !== '') { args.push('--resources', `${resources}`) } - this.executeCustomPolicyChecksCommand({ + await this.executeCustomPolicyChecksCommand({ command, args, cfnParameterPathExists: !!cfnParameterPath, @@ -489,7 +490,7 @@ export class IamPolicyChecksWebview extends VueWebview { if (cfnParameterPath !== '') { args.push('--template-configuration-file', `${cfnParameterPath}`) } - this.executeCustomPolicyChecksCommand({ + await this.executeCustomPolicyChecksCommand({ command, args, cfnParameterPathExists: !!cfnParameterPath, @@ -525,7 +526,7 @@ export class IamPolicyChecksWebview extends VueWebview { '--config', `${globals.context.asAbsolutePath(defaultTerraformConfigPath)}`, ] - this.executeCustomPolicyChecksCommand({ + await this.executeCustomPolicyChecksCommand({ command, args, cfnParameterPathExists: !!cfnParameterPath, @@ -554,7 +555,7 @@ export class IamPolicyChecksWebview extends VueWebview { if (cfnParameterPath !== '') { args.push('--template-configuration-file', `${cfnParameterPath}`) } - this.executeCustomPolicyChecksCommand({ + await this.executeCustomPolicyChecksCommand({ command, args, cfnParameterPathExists: !!cfnParameterPath, @@ -573,22 +574,22 @@ export class IamPolicyChecksWebview extends VueWebview { } } - public executeValidatePolicyCommand(opts: PolicyCommandOpts & { policyType?: PolicyChecksPolicyType }) { - telemetry.accessanalyzer_iamPolicyChecksValidatePolicy.run((span) => { + public async executeValidatePolicyCommand(opts: PolicyCommandOpts & { policyType?: PolicyChecksPolicyType }) { + await telemetry.accessanalyzer_iamPolicyChecksValidatePolicy.run(async (span) => { try { span.record({ cfnParameterFileUsed: opts.cfnParameterPathExists, documentType: opts.documentType, inputPolicyType: opts.policyType ?? 'None', }) - const resp = execFileSync(opts.command, opts.args) - const findingsCount = this.handleValidatePolicyCliResponse(resp.toString()) + const result = await ChildProcess.run(opts.command, opts.args, { collect: true }) + const findingsCount = this.handleValidatePolicyCliResponse(result.stdout) span.record({ findingsCount: findingsCount, }) } catch (err: any) { if (err.status === 2) { - //CLI responds with a status code of 2 when findings are discovered + // CLI responds with a status code of 2 when findings are discovered const findingsCount = this.handleValidatePolicyCliResponse(err.stdout.toString()) span.record({ findingsCount: findingsCount, @@ -616,10 +617,12 @@ export class IamPolicyChecksWebview extends VueWebview { getResultCssColor('Success'), ]) } else { + // eslint-disable-next-line unicorn/no-array-for-each jsonOutput.BlockingFindings.forEach((finding: any) => { this.pushValidatePolicyDiagnostic(diagnostics, finding, true) findingsCount++ }) + // eslint-disable-next-line unicorn/no-array-for-each jsonOutput.NonBlockingFindings.forEach((finding: any) => { this.pushValidatePolicyDiagnostic(diagnostics, finding, false) findingsCount++ @@ -633,10 +636,10 @@ export class IamPolicyChecksWebview extends VueWebview { return findingsCount } - public executeCustomPolicyChecksCommand( + public async executeCustomPolicyChecksCommand( opts: PolicyCommandOpts & { checkType: PolicyChecksCheckType; referencePolicyType?: PolicyChecksPolicyType } ) { - telemetry.accessanalyzer_iamPolicyChecksCustomChecks.run((span) => { + await telemetry.accessanalyzer_iamPolicyChecksCustomChecks.run(async (span) => { try { span.record({ cfnParameterFileUsed: opts.cfnParameterPathExists, @@ -645,14 +648,14 @@ export class IamPolicyChecksWebview extends VueWebview { inputPolicyType: 'None', // Note: This will change once JSON policy language is enabled for Custom policy checks referencePolicyType: opts.referencePolicyType ?? 'None', }) - const resp = execFileSync(opts.command, opts.args) - const findingsCount = this.handleCustomPolicyChecksCliResponse(resp.toString()) + const resp = await ChildProcess.run(opts.command, opts.args) + const findingsCount = this.handleCustomPolicyChecksCliResponse(resp.stdout) span.record({ findingsCount: findingsCount, }) } catch (err: any) { if (err.status === 2) { - //CLI responds with a status code of 2 when findings are discovered + // CLI responds with a status code of 2 when findings are discovered const findingsCount = this.handleCustomPolicyChecksCliResponse(err.stdout.toString()) span.record({ findingsCount: findingsCount, @@ -682,11 +685,13 @@ export class IamPolicyChecksWebview extends VueWebview { getResultCssColor('Success'), ]) } else { + // eslint-disable-next-line unicorn/no-array-for-each jsonOutput.BlockingFindings.forEach((finding: any) => { this.pushCustomCheckDiagnostic(diagnostics, finding, true) errorMessage = getCheckNoNewAccessErrorMessage(finding) findingsCount++ }) + // eslint-disable-next-line unicorn/no-array-for-each jsonOutput.NonBlockingFindings.forEach((finding: any) => { this.pushCustomCheckDiagnostic(diagnostics, finding, false) findingsCount++ @@ -724,6 +729,7 @@ export class IamPolicyChecksWebview extends VueWebview { : finding.message const message = `${finding.findingType}: ${findingMessage} - Resource name: ${finding.resourceName}, Policy name: ${finding.policyName}` if (finding.details.reasons) { + // eslint-disable-next-line unicorn/no-array-for-each finding.details.reasons.forEach((reason: any) => { diagnostics.push( new vscode.Diagnostic( @@ -752,7 +758,7 @@ export async function renderIamPolicyChecks(context: ExtContext): Promise { } } -//Check if Cfn and Tf tools are installed -export function arePythonToolsInstalled(): boolean { +// Check if Cfn and Tf tools are installed +export async function arePythonToolsInstalled(): Promise { const logger: Logger = getLogger() let cfnToolInstalled = true let tfToolInstalled = true try { - execFileSync('tf-policy-validator') + await ChildProcess.run('tf-policy-validator') } catch (err: any) { if (isProcessNotFoundErr(err.message)) { tfToolInstalled = false @@ -841,7 +847,7 @@ export function arePythonToolsInstalled(): boolean { } } try { - execFileSync('cfn-policy-validator') + await ChildProcess.run('cfn-policy-validator') } catch (err: any) { if (isProcessNotFoundErr(err.message)) { cfnToolInstalled = false diff --git a/packages/core/src/awsService/appBuilder/explorer/detectSamProjects.ts b/packages/core/src/awsService/appBuilder/explorer/detectSamProjects.ts index cb179d94f0d..511217481b3 100644 --- a/packages/core/src/awsService/appBuilder/explorer/detectSamProjects.ts +++ b/packages/core/src/awsService/appBuilder/explorer/detectSamProjects.ts @@ -21,7 +21,9 @@ export async function detectSamProjects(): Promise { [] ) - projects.forEach((p) => results.set(p.samTemplateUri.toString(), p)) + for (const p of projects) { + results.set(p.samTemplateUri.toString(), p) + } return Array.from(results.values()) } @@ -55,7 +57,7 @@ export async function getFiles( return await vscode.workspace.findFiles(globPattern, excludePattern) } catch (error) { - getLogger().error(`Failed to get files with pattern ${pattern}:`, error) + getLogger().error(`Failed to find files with pattern ${pattern}:`, error) return [] } } diff --git a/packages/core/src/awsService/appBuilder/explorer/nodes/appNode.ts b/packages/core/src/awsService/appBuilder/explorer/nodes/appNode.ts index 5f8c6b4a81e..d7d5e51bb51 100644 --- a/packages/core/src/awsService/appBuilder/explorer/nodes/appNode.ts +++ b/packages/core/src/awsService/appBuilder/explorer/nodes/appNode.ts @@ -73,7 +73,7 @@ export class AppNode implements TreeNode { createPlaceholderItem( localize( 'AWS.appBuilder.explorerNode.app.noResourceTree', - '[Unable to load Resource tree for this App. Update SAM template]' + '[Unable to load resource tree for this app. Ensure SAM template is correct.]' ) ), ] diff --git a/packages/core/src/awsService/appBuilder/explorer/nodes/deployedNode.ts b/packages/core/src/awsService/appBuilder/explorer/nodes/deployedNode.ts index 913cdd067e0..cb0d1a669c8 100644 --- a/packages/core/src/awsService/appBuilder/explorer/nodes/deployedNode.ts +++ b/packages/core/src/awsService/appBuilder/explorer/nodes/deployedNode.ts @@ -96,7 +96,7 @@ export async function generateDeployedNode( .Configuration as Lambda.FunctionConfiguration newDeployedResource = new LambdaFunctionNode(lambdaNode, regionCode, configuration) } catch (error: any) { - getLogger().error('Error getting Lambda configuration %O', error) + getLogger().error('Error getting Lambda configuration: %O', error) throw ToolkitError.chain(error, 'Error getting Lambda configuration', { code: 'lambdaClientError', }) @@ -107,7 +107,7 @@ export async function generateDeployedNode( createPlaceholderItem( localize( 'AWS.appBuilder.explorerNode.unavailableDeployedResource', - '[Failed to retrive deployed resource.]' + '[Failed to retrive deployed resource. Ensure your AWS account is connected.]' ) ), ] @@ -119,8 +119,8 @@ export async function generateDeployedNode( try { v3configuration = (await v3Client.send(v3command)).Configuration as FunctionConfiguration logGroupName = v3configuration.LoggingConfig?.LogGroup - } catch { - getLogger().error('Error getting Lambda V3 configuration') + } catch (error: any) { + getLogger().error('Error getting Lambda V3 configuration: %O', error) } newDeployedResource.configuration = { ...newDeployedResource.configuration, @@ -156,7 +156,10 @@ export async function generateDeployedNode( getLogger().info('Details are missing or are incomplete for: %O', deployedResource) return [ createPlaceholderItem( - localize('AWS.appBuilder.explorerNode.noApps', '[This resource is not yet supported.]') + localize( + 'AWS.appBuilder.explorerNode.noApps', + '[This resource is not yet supported in AppBuilder.]' + ) ), ] } @@ -166,7 +169,7 @@ export async function generateDeployedNode( createPlaceholderItem( localize( 'AWS.appBuilder.explorerNode.unavailableDeployedResource', - '[Failed to retrive deployed resource.]' + '[Failed to retrieve deployed resource. Ensure correct stack name and region are in the samconfig.toml, and that your account is connected.]' ) ), ] diff --git a/packages/core/src/awsService/appBuilder/explorer/samProject.ts b/packages/core/src/awsService/appBuilder/explorer/samProject.ts index fd571cd6be8..ce8d0c4878a 100644 --- a/packages/core/src/awsService/appBuilder/explorer/samProject.ts +++ b/packages/core/src/awsService/appBuilder/explorer/samProject.ts @@ -42,14 +42,17 @@ export async function getStackName(projectRoot: vscode.Uri): Promise { } catch (error: any) { switch (error.code) { case SamConfigErrorCode.samNoConfigFound: - getLogger().info('No stack name or region information available in samconfig.toml: %O', error) + getLogger().info('Stack name and/or region information not found in samconfig.toml: %O', error) break case SamConfigErrorCode.samConfigParseError: - getLogger().error(`Error getting stack name or region information: ${error.message}`, error) + getLogger().error( + `Error parsing stack name and/or region information from samconfig.toml: ${error.message}. Ensure the information is correct.`, + error + ) void showViewLogsMessage('Encountered an issue reading samconfig.toml') break default: - getLogger().warn(`Error getting stack name or region information: ${error.message}`, error) + getLogger().warn(`Error parsing stack name and/or region information: ${error.message}`, error) } return {} } diff --git a/packages/core/src/awsService/appBuilder/utils.ts b/packages/core/src/awsService/appBuilder/utils.ts index de3dee8770d..63b116b20eb 100644 --- a/packages/core/src/awsService/appBuilder/utils.ts +++ b/packages/core/src/awsService/appBuilder/utils.ts @@ -24,14 +24,14 @@ const localize = nls.loadMessageBundle() export async function runOpenTemplate(arg?: TreeNode) { const templateUri = arg ? (arg.resource as SamAppLocation).samTemplateUri : await promptUserForTemplate() if (!templateUri || !(await fs.exists(templateUri))) { - throw new ToolkitError('No template provided', { code: 'NoTemplateProvided' }) + throw new ToolkitError('SAM Template not found, cannot open template', { code: 'NoTemplateProvided' }) } const document = await vscode.workspace.openTextDocument(templateUri) await vscode.window.showTextDocument(document) } /** - * Find and open the lambda handler with given ResoruceNode + * Find and open the lambda handler with given ResourceNode * If not found, a NoHandlerFound error will be raised * @param arg ResourceNode */ @@ -56,9 +56,12 @@ export async function runOpenHandler(arg: ResourceNode): Promise { arg.resource.resource.Runtime ) if (!handlerFile) { - throw new ToolkitError(`No handler file found with name "${arg.resource.resource.Handler}"`, { - code: 'NoHandlerFound', - }) + throw new ToolkitError( + `No handler file found with name "${arg.resource.resource.Handler}". Ensure the file exists in the expected location."`, + { + code: 'NoHandlerFound', + } + ) } await vscode.workspace.openTextDocument(handlerFile).then(async (doc) => await vscode.window.showTextDocument(doc)) } @@ -90,7 +93,7 @@ export async function getLambdaHandlerFile( ): Promise { const family = getFamily(runtime) if (!supportedRuntimeForHandler.has(family)) { - throw new ToolkitError(`Runtime ${runtime} is not supported for open handler button`, { + throw new ToolkitError(`Runtime ${runtime} is not supported for the 'Open handler' button`, { code: 'RuntimeNotSupported', }) } diff --git a/packages/core/src/awsService/appBuilder/walkthrough.ts b/packages/core/src/awsService/appBuilder/walkthrough.ts index 04f43d61878..26760d896aa 100644 --- a/packages/core/src/awsService/appBuilder/walkthrough.ts +++ b/packages/core/src/awsService/appBuilder/walkthrough.ts @@ -148,13 +148,13 @@ export async function getTutorial( const appSelected = appMap.get(project + runtime) telemetry.record({ action: project + runtime, source: source ?? 'AppBuilderWalkthrough' }) if (!appSelected) { - throw new ToolkitError(`Tried to get template '${project}+${runtime}', but it hasn't been registered.`) + throw new ToolkitError(`Template '${project}+${runtime}' does not exist, choose another template.`) } try { await getPattern(serverlessLandOwner, serverlessLandRepo, appSelected.asset, outputDir, true) } catch (error) { - throw new ToolkitError(`Error occurred while fetching the pattern from serverlessland: ${error}`) + throw new ToolkitError(`An error occurred while fetching this pattern from Serverless Land: ${error}`) } } @@ -190,7 +190,7 @@ export async function genWalkthroughProject( 'No' ) if (choice !== 'Yes') { - throw new ToolkitError(`${defaultTemplateName} already exist`) + throw new ToolkitError(`A file named ${defaultTemplateName} already exists in this path.`) } } @@ -256,9 +256,9 @@ export async function initWalkthroughProjectCommand() { let runtimeSelected: TutorialRuntimeOptions | undefined = undefined try { if (!walkthroughSelected || !(typeof walkthroughSelected === 'string')) { - getLogger().info('exit on no walkthrough selected') + getLogger().info('No walkthrough selected - exiting') void vscode.window.showErrorMessage( - localize('AWS.toolkit.lambda.walkthroughNotSelected', 'Please select a template first') + localize('AWS.toolkit.lambda.walkthroughNotSelected', 'Select a template in the walkthrough.') ) return } @@ -322,7 +322,7 @@ export async function getOrUpdateOrInstallSAMCli(source: string) { } } } catch (err) { - throw ToolkitError.chain(err, 'Failed to install or detect SAM') + throw ToolkitError.chain(err, 'Failed to install or detect SAM.') } finally { telemetry.record({ source: source, toolId: 'sam-cli' }) } diff --git a/packages/core/src/awsService/appBuilder/wizards/templateParametersWizard.ts b/packages/core/src/awsService/appBuilder/wizards/templateParametersWizard.ts new file mode 100644 index 00000000000..9641562d6f0 --- /dev/null +++ b/packages/core/src/awsService/appBuilder/wizards/templateParametersWizard.ts @@ -0,0 +1,62 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { Wizard } from '../../../shared/wizards/wizard' +import { createExitPrompter } from '../../../shared/ui/common/exitPrompter' +import * as CloudFormation from '../../../shared/cloudformation/cloudformation' +import { createInputBox } from '../../../shared/ui/inputPrompter' +import { createCommonButtons } from '../../../shared/ui/buttons' +import { getRecentResponse, updateRecentResponse } from '../../../shared/sam/utils' +import { getParameters } from '../../../lambda/config/parameterUtils' + +export interface TemplateParametersForm { + [key: string]: any +} + +export class TemplateParametersWizard extends Wizard { + template: vscode.Uri + preloadedTemplate: CloudFormation.Template | undefined + samTemplateParameters: Map | undefined + samCommandUrl: vscode.Uri + commandMementoRootKey: string + + public constructor(template: vscode.Uri, samCommandUrl: vscode.Uri, commandMementoRootKey: string) { + super({ exitPrompterProvider: createExitPrompter }) + this.template = template + this.samCommandUrl = samCommandUrl + this.commandMementoRootKey = commandMementoRootKey + } + + public override async init(): Promise { + this.samTemplateParameters = await getParameters(this.template) + this.preloadedTemplate = await CloudFormation.load(this.template.fsPath) + const samTemplateNames = new Set(this.samTemplateParameters?.keys() ?? []) + + for (const name of samTemplateNames) { + if (this.preloadedTemplate) { + const defaultValue = this.preloadedTemplate.Parameters + ? (this.preloadedTemplate.Parameters[name]?.Default as string) + : undefined + this.form[name].bindPrompter(() => + this.createParamPromptProvider(name, defaultValue).transform(async (item) => { + await updateRecentResponse(this.commandMementoRootKey, this.template.fsPath, name, item) + return item + }) + ) + } + } + + return this + } + + createParamPromptProvider(name: string, defaultValue: string | undefined) { + return createInputBox({ + title: `Specify SAM Template parameter value for ${name}`, + buttons: createCommonButtons(this.samCommandUrl), + value: getRecentResponse(this.commandMementoRootKey, this.template.fsPath, name) ?? defaultValue, + }) + } +} diff --git a/packages/core/src/awsService/apprunner/activation.ts b/packages/core/src/awsService/apprunner/activation.ts index 14cfb167f75..5b907d2b968 100644 --- a/packages/core/src/awsService/apprunner/activation.ts +++ b/packages/core/src/awsService/apprunner/activation.ts @@ -79,7 +79,7 @@ commandMap.set(['aws.apprunner.deleteService', deleteServiceFailed], deleteServi * Activates App Runner */ export async function activate(context: ExtContext): Promise { - commandMap.forEach((command, tuple) => { + for (const [tuple, command] of commandMap.entries()) { context.extensionContext.subscriptions.push( Commands.register(tuple[0], async (...args: any) => { try { @@ -90,5 +90,5 @@ export async function activate(context: ExtContext): Promise { } }) ) - }) + } } diff --git a/packages/core/src/awsService/apprunner/commands/pauseService.ts b/packages/core/src/awsService/apprunner/commands/pauseService.ts index a1f3bac54c9..b2cbdee1cae 100644 --- a/packages/core/src/awsService/apprunner/commands/pauseService.ts +++ b/packages/core/src/awsService/apprunner/commands/pauseService.ts @@ -18,7 +18,7 @@ export async function pauseService(node: AppRunnerServiceNode): Promise { try { const prompts = ToolkitPromptSettings.instance - const shouldNotify = await prompts.isPromptEnabled('apprunnerNotifyPause') + const shouldNotify = prompts.isPromptEnabled('apprunnerNotifyPause') const notifyPrompt = localize( 'aws.apprunner.pauseService.notify', 'Your service will be unavailable while paused. ' + diff --git a/packages/core/src/awsService/apprunner/explorer/apprunnerNode.ts b/packages/core/src/awsService/apprunner/explorer/apprunnerNode.ts index 889f94a51cc..4eb3c4bcf5b 100644 --- a/packages/core/src/awsService/apprunner/explorer/apprunnerNode.ts +++ b/packages/core/src/awsService/apprunner/explorer/apprunnerNode.ts @@ -58,6 +58,7 @@ export class AppRunnerNode extends AWSTreeNodeBase { while (true) { const next = await iterator.next() + // eslint-disable-next-line unicorn/no-array-for-each next.value.ServiceSummaryList.forEach((summary: AppRunner.Service) => services.push(summary)) if (next.done) { @@ -87,11 +88,12 @@ export class AppRunnerNode extends AWSTreeNodeBase { }) ) + // eslint-disable-next-line unicorn/no-array-for-each deletedNodeArns.forEach(this.deleteNode.bind(this)) } public startPollingNode(id: string): void { - this.pollingSet.start(id) + this.pollingSet.add(id) } public stopPollingNode(id: string): void { diff --git a/packages/core/src/awsService/apprunner/wizards/deploymentButton.ts b/packages/core/src/awsService/apprunner/wizards/deploymentButton.ts index 24a98895970..dee77c05e97 100644 --- a/packages/core/src/awsService/apprunner/wizards/deploymentButton.ts +++ b/packages/core/src/awsService/apprunner/wizards/deploymentButton.ts @@ -32,7 +32,7 @@ function makeDeployButtons() { async function showDeploymentCostNotification(): Promise { const settings = ToolkitPromptSettings.instance - if (await settings.isPromptEnabled('apprunnerNotifyPricing')) { + if (settings.isPromptEnabled('apprunnerNotifyPricing')) { const notice = localize( 'aws.apprunner.createService.priceNotice.message', 'App Runner automatic deployments incur an additional cost.' diff --git a/packages/core/src/awsService/cdk/explorer/detectCdkProjects.ts b/packages/core/src/awsService/cdk/explorer/detectCdkProjects.ts index 9a3fd363d1a..55f47f5938f 100644 --- a/packages/core/src/awsService/cdk/explorer/detectCdkProjects.ts +++ b/packages/core/src/awsService/cdk/explorer/detectCdkProjects.ts @@ -21,7 +21,9 @@ export async function detectCdkProjects( [] ) - projects.forEach((p) => results.set(p.cdkJsonUri.toString(), p)) + for (const p of projects) { + results.set(p.cdkJsonUri.toString(), p) + } return Array.from(results.values()) } diff --git a/packages/core/src/awsService/cloudWatchLogs/activation.ts b/packages/core/src/awsService/cloudWatchLogs/activation.ts index 5c3f4ac91c8..bfcaaf1d0e2 100644 --- a/packages/core/src/awsService/cloudWatchLogs/activation.ts +++ b/packages/core/src/awsService/cloudWatchLogs/activation.ts @@ -4,7 +4,7 @@ */ import * as vscode from 'vscode' -import { CLOUDWATCH_LOGS_SCHEME } from '../../shared/constants' +import { cloudwatchLogsLiveTailScheme, CLOUDWATCH_LOGS_SCHEME } from '../../shared/constants' import { Settings } from '../../shared/settings' import { addLogEvents } from './commands/addLogEvents' import { copyLogResource } from './commands/copyLogResource' @@ -19,16 +19,22 @@ import { searchLogGroup } from './commands/searchLogGroup' import { changeLogSearchParams } from './changeLogSearch' import { CloudWatchLogsNode } from './explorer/cloudWatchLogsNode' import { loadAndOpenInitialLogStreamFile, LogStreamCodeLensProvider } from './document/logStreamsCodeLensProvider' +import { clearDocument, closeSession, tailLogGroup } from './commands/tailLogGroup' +import { LiveTailDocumentProvider } from './document/liveTailDocumentProvider' +import { LiveTailSessionRegistry } from './registry/liveTailSessionRegistry' import { DeployedResourceNode } from '../appBuilder/explorer/nodes/deployedNode' import { isTreeNode } from '../../shared/treeview/resourceTreeDataProvider' import { getLogger } from '../../shared/logger/logger' import { ToolkitError } from '../../shared' +import { LiveTailCodeLensProvider } from './document/liveTailCodeLensProvider' export async function activate(context: vscode.ExtensionContext, configuration: Settings): Promise { const registry = LogDataRegistry.instance + const liveTailRegistry = LiveTailSessionRegistry.instance const documentProvider = new LogDataDocumentProvider(registry) - + const liveTailDocumentProvider = new LiveTailDocumentProvider() + const liveTailCodeLensProvider = new LiveTailCodeLensProvider(liveTailRegistry) context.subscriptions.push( vscode.languages.registerCodeLensProvider( { @@ -43,6 +49,20 @@ export async function activate(context: vscode.ExtensionContext, configuration: vscode.workspace.registerTextDocumentContentProvider(CLOUDWATCH_LOGS_SCHEME, documentProvider) ) + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { + language: 'log', + scheme: cloudwatchLogsLiveTailScheme, + }, + liveTailCodeLensProvider + ) + ) + + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider(cloudwatchLogsLiveTailScheme, liveTailDocumentProvider) + ) + context.subscriptions.push( vscode.workspace.onDidCloseTextDocument((doc) => { if (doc.isClosed && doc.uri.scheme === CLOUDWATCH_LOGS_SCHEME) { @@ -95,6 +115,23 @@ export async function activate(context: vscode.ExtensionContext, configuration: Commands.register('aws.cwl.changeTimeFilter', async () => changeLogSearchParams(registry, 'timeFilter')), + Commands.register('aws.cwl.tailLogGroup', async (node: LogGroupNode | CloudWatchLogsNode) => { + const logGroupInfo = + node instanceof LogGroupNode + ? { regionName: node.regionCode, groupName: node.logGroup.logGroupName! } + : undefined + const source = node ? (logGroupInfo ? 'ExplorerLogGroupNode' : 'ExplorerServiceNode') : 'Command' + await tailLogGroup(liveTailRegistry, source, liveTailCodeLensProvider, logGroupInfo) + }), + + Commands.register('aws.cwl.stopTailingLogGroup', async (document: vscode.TextDocument, source: string) => { + closeSession(document.uri, liveTailRegistry, source, liveTailCodeLensProvider) + }), + + Commands.register('aws.cwl.clearDocument', async (document: vscode.TextDocument) => { + await clearDocument(document) + }), + Commands.register('aws.appBuilder.searchLogs', async (node: DeployedResourceNode) => { try { const logGroupInfo = isTreeNode(node) @@ -112,6 +149,7 @@ export async function activate(context: vscode.ExtensionContext, configuration: }) ) } + function getFunctionLogGroupName(configuration: any) { const logGroupPrefix = '/aws/lambda/' return configuration.logGroupName || logGroupPrefix + configuration.FunctionName diff --git a/packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts b/packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts new file mode 100644 index 00000000000..c584a6147ce --- /dev/null +++ b/packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts @@ -0,0 +1,288 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { telemetry } from '../../../shared/telemetry/telemetry' +import { TailLogGroupWizard } from '../wizard/tailLogGroupWizard' +import { CancellationError } from '../../../shared/utilities/timeoutUtils' +import { LiveTailSession, LiveTailSessionConfiguration } from '../registry/liveTailSession' +import { LiveTailSessionRegistry } from '../registry/liveTailSessionRegistry' +import { + LiveTailSessionLogEvent, + LiveTailSessionUpdate, + StartLiveTailResponseStream, +} from '@aws-sdk/client-cloudwatch-logs' +import { getLogger, globals, ToolkitError } from '../../../shared' +import { uriToKey } from '../cloudWatchLogsUtils' +import { LiveTailCodeLensProvider } from '../document/liveTailCodeLensProvider' + +export async function tailLogGroup( + registry: LiveTailSessionRegistry, + source: string, + codeLensProvider: LiveTailCodeLensProvider, + logData?: { regionName: string; groupName: string } +): Promise { + await telemetry.cloudwatchlogs_startLiveTail.run(async (span) => { + const wizard = new TailLogGroupWizard(logData) + const wizardResponse = await wizard.run() + if (!wizardResponse) { + throw new CancellationError('user') + } + if (wizardResponse.logStreamFilter.type === 'menu' || wizardResponse.logStreamFilter.type === undefined) { + // logstream filter wizard uses type to determine which submenu to show. 'menu' is set when no type is selected + // and to show the 'menu' of selecting a type. This should not be reachable due to the picker logic, but validating in case. + throw new ToolkitError(`Invalid Log Stream filter type: ${wizardResponse.logStreamFilter.type}`) + } + const awsCredentials = await globals.awsContext.getCredentials() + if (awsCredentials === undefined) { + throw new ToolkitError('Failed to start LiveTail session: credentials are undefined.') + } + const liveTailSessionConfig: LiveTailSessionConfiguration = { + logGroupArn: wizardResponse.regionLogGroupSubmenuResponse.data, + logStreamFilter: wizardResponse.logStreamFilter, + logEventFilterPattern: wizardResponse.filterPattern, + region: wizardResponse.regionLogGroupSubmenuResponse.region, + awsCredentials: awsCredentials, + } + const session = new LiveTailSession(liveTailSessionConfig) + if (registry.has(uriToKey(session.uri))) { + await vscode.window.showTextDocument(session.uri, { preview: false }) + void vscode.window.showInformationMessage(`Switching editor to an existing session that matches request.`) + span.record({ + result: 'Succeeded', + sessionAlreadyStarted: true, + source: source, + }) + return + } + const document = await prepareDocument(session) + + const disposables: vscode.Disposable[] = [] + disposables.push(hideShowStatusBarItemsOnActiveEditor(session, document)) + disposables.push(closeSessionWhenAllEditorsClosed(session, registry, document, codeLensProvider)) + + try { + const stream = await session.startLiveTailSession() + registry.set(uriToKey(session.uri), session) + codeLensProvider.refresh() + getLogger().info(`LiveTail session started: ${uriToKey(session.uri)}`) + span.record({ + source: source, + result: 'Succeeded', + sessionAlreadyStarted: false, + hasTextFilter: Boolean(wizardResponse.filterPattern), + filterType: wizardResponse.logStreamFilter.type, + }) + await handleSessionStream(stream, document, session) + } finally { + for (const disposable of disposables) { + disposable.dispose() + } + } + }) +} + +export function closeSession( + sessionUri: vscode.Uri, + registry: LiveTailSessionRegistry, + source: string, + codeLensProvider: LiveTailCodeLensProvider +) { + telemetry.cloudwatchlogs_stopLiveTail.run((span) => { + const session = registry.get(uriToKey(sessionUri)) + if (session === undefined) { + throw new ToolkitError(`No LiveTail session found for URI: ${uriToKey(sessionUri)}`) + } + session.stopLiveTailSession() + registry.delete(uriToKey(sessionUri)) + void vscode.window.showInformationMessage(`Stopped LiveTail session: ${uriToKey(sessionUri)}`) + codeLensProvider.refresh() + span.record({ + result: 'Succeeded', + source: source, + duration: session.getLiveTailSessionDuration(), + }) + }) +} + +export async function clearDocument(textDocument: vscode.TextDocument) { + const edit = new vscode.WorkspaceEdit() + const startPosition = new vscode.Position(0, 0) + const endPosition = new vscode.Position(textDocument.lineCount, 0) + edit.delete(textDocument.uri, new vscode.Range(startPosition, endPosition)) + await vscode.workspace.applyEdit(edit) +} + +async function prepareDocument(session: LiveTailSession): Promise { + const textDocument = await vscode.workspace.openTextDocument(session.uri) + await clearDocument(textDocument) + await vscode.window.showTextDocument(textDocument, { preview: false }) + await vscode.languages.setTextDocumentLanguage(textDocument, 'log') + session.showStatusBarItem(true) + return textDocument +} + +async function handleSessionStream( + stream: AsyncIterable, + document: vscode.TextDocument, + session: LiveTailSession +) { + try { + for await (const event of stream) { + if (event.sessionUpdate !== undefined && event.sessionUpdate.sessionResults !== undefined) { + const formattedLogEvents = event.sessionUpdate.sessionResults.map((logEvent) => + formatLogEvent(logEvent) + ) + if (formattedLogEvents.length !== 0) { + // Determine should scroll before adding new lines to doc because adding large + // amount of new lines can push bottom of file out of view before scrolling. + const editorsToScroll = getTextEditorsToScroll(document) + await updateTextDocumentWithNewLogEvents(formattedLogEvents, document, session.maxLines) + // eslint-disable-next-line unicorn/no-array-for-each + editorsToScroll.forEach(scrollTextEditorToBottom) + } + session.eventRate = eventRate(event.sessionUpdate) + session.isSampled = isSampled(event.sessionUpdate) + } + } + } catch (e) { + if (session.isAborted) { + // Expected case. User action cancelled stream (CodeLens, Close Editor, etc.). + // AbortSignal interrupts the LiveTail stream, causing error to be thrown here. + // Can assume that stopLiveTailSession() has already been called - AbortSignal is only + // exposed through that method. + getLogger().info(`LiveTail session stopped: ${uriToKey(session.uri)}`) + } else { + // Unexpected exception. + session.stopLiveTailSession() + throw ToolkitError.chain( + e, + `Unexpected on-stream exception while tailing session: ${session.uri.toString()}` + ) + } + } +} + +function formatLogEvent(logEvent: LiveTailSessionLogEvent): string { + if (!logEvent.timestamp || !logEvent.message) { + return '' + } + const timestamp = new Date(logEvent.timestamp).toLocaleTimeString('en', { + timeStyle: 'medium', + hour12: false, + timeZone: 'UTC', + }) + let line = timestamp.concat('\t', logEvent.message) + if (!line.endsWith('\n')) { + line = line.concat('\n') + } + return line +} + +// Auto scroll visible LiveTail session editors if the end-of-file is in view. +// This allows for newly added log events to stay in view. +function getTextEditorsToScroll(document: vscode.TextDocument): vscode.TextEditor[] { + return vscode.window.visibleTextEditors.filter((editor) => { + if (editor.document !== document) { + return false + } + return editor.visibleRanges[0].contains(new vscode.Position(document.lineCount - 1, 0)) + }) +} + +function scrollTextEditorToBottom(editor: vscode.TextEditor) { + const position = new vscode.Position(Math.max(editor.document.lineCount - 2, 0), 0) + editor.revealRange(new vscode.Range(position, position), vscode.TextEditorRevealType.Default) +} + +async function updateTextDocumentWithNewLogEvents( + formattedLogEvents: string[], + document: vscode.TextDocument, + maxLines: number +) { + const edit = new vscode.WorkspaceEdit() + for (const formattedLogEvent of formattedLogEvents) { + edit.insert(document.uri, new vscode.Position(document.lineCount, 0), formattedLogEvent) + } + + if (document.lineCount + formattedLogEvents.length > maxLines) { + trimOldestLines(formattedLogEvents.length, maxLines, document, edit) + } + await vscode.workspace.applyEdit(edit) +} + +function trimOldestLines( + numNewLines: number, + maxLines: number, + document: vscode.TextDocument, + edit: vscode.WorkspaceEdit +) { + const numLinesToTrim = document.lineCount + numNewLines - maxLines + const startPosition = new vscode.Position(0, 0) + const endPosition = new vscode.Position(numLinesToTrim, 0) + const range = new vscode.Range(startPosition, endPosition) + edit.delete(document.uri, range) +} + +function isSampled(event: LiveTailSessionUpdate): boolean { + return event.sessionMetadata === undefined || event.sessionMetadata.sampled === undefined + ? false + : event.sessionMetadata.sampled +} + +function eventRate(event: LiveTailSessionUpdate): number { + return event.sessionResults === undefined ? 0 : event.sessionResults.length +} + +function hideShowStatusBarItemsOnActiveEditor( + session: LiveTailSession, + document: vscode.TextDocument +): vscode.Disposable { + return vscode.window.onDidChangeActiveTextEditor((editor) => { + session.showStatusBarItem(editor?.document === document) + }) +} + +/** + * The LiveTail session should be automatically closed if the user does not have the session's + * document in any Tab in their editor. + * + * `onDidCloseTextDocument` doesn't work for our case because the tailLogGroup command will keep the stream + * writing to the doc even when all its tabs/editors are closed, seemingly keeping the doc 'open'. + * Also there is no guarantee that this event fires when an editor tab is closed + * + * `onDidChangeVisibleTextEditors` returns editors that the user can see its contents. An editor that is open, but hidden + * from view, will not be returned. Meaning a Tab that is created (shown in top bar), but not open, will not be returned. Even if + * the tab isn't visible, we want to continue writing to the doc, and keep the session alive. + */ +function closeSessionWhenAllEditorsClosed( + session: LiveTailSession, + registry: LiveTailSessionRegistry, + document: vscode.TextDocument, + codeLensProvider: LiveTailCodeLensProvider +): vscode.Disposable { + return vscode.window.tabGroups.onDidChangeTabs((tabEvent) => { + const isOpen = isLiveTailSessionOpenInAnyTab(session) + if (!isOpen) { + closeSession(session.uri, registry, 'ClosedEditors', codeLensProvider) + void clearDocument(document) + } + }) +} + +function isLiveTailSessionOpenInAnyTab(liveTailSession: LiveTailSession) { + let isOpen = false + // eslint-disable-next-line unicorn/no-array-for-each + vscode.window.tabGroups.all.forEach(async (tabGroup) => { + for (const tab of tabGroup.tabs) { + if (tab.input instanceof vscode.TabInputText) { + if (liveTailSession.uri.toString() === tab.input.uri.toString()) { + isOpen = true + } + } + } + }) + return isOpen +} diff --git a/packages/core/src/awsService/cloudWatchLogs/document/liveTailCodeLensProvider.ts b/packages/core/src/awsService/cloudWatchLogs/document/liveTailCodeLensProvider.ts new file mode 100644 index 00000000000..dde0ddbbe28 --- /dev/null +++ b/packages/core/src/awsService/cloudWatchLogs/document/liveTailCodeLensProvider.ts @@ -0,0 +1,62 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { cloudwatchLogsLiveTailScheme } from '../../../shared/constants' +import { LiveTailSessionRegistry } from '../registry/liveTailSessionRegistry' +import { uriToKey } from '../cloudWatchLogsUtils' + +export class LiveTailCodeLensProvider implements vscode.CodeLensProvider { + private _onDidChangeCodeLenses: vscode.EventEmitter = new vscode.EventEmitter() + public readonly onDidChangeCodeLenses: vscode.Event = this._onDidChangeCodeLenses.event + + public constructor(private readonly registry: LiveTailSessionRegistry) {} + + public provideCodeLenses( + document: vscode.TextDocument, + token: vscode.CancellationToken + ): vscode.ProviderResult { + const uri = document.uri + // if registry does not contain session, it is assumed to have been stopped, thus, hide lenses. + if (uri.scheme !== cloudwatchLogsLiveTailScheme || !this.registry.has(uriToKey(uri))) { + return [] + } + const codeLenses: vscode.CodeLens[] = [] + codeLenses.push(this.buildClearDocumentCodeLens(document)) + codeLenses.push(this.buildStopTailingCodeLens(document)) + return codeLenses + } + + public refresh() { + this._onDidChangeCodeLenses.fire() + } + + private buildClearDocumentCodeLens(document: vscode.TextDocument): vscode.CodeLens { + const range = this.getBottomOfDocumentRange(document) + const command: vscode.Command = { + title: 'Clear document', + command: 'aws.cwl.clearDocument', + arguments: [document], + } + return new vscode.CodeLens(range, command) + } + + private buildStopTailingCodeLens(document: vscode.TextDocument): vscode.CodeLens { + const range = this.getBottomOfDocumentRange(document) + const command: vscode.Command = { + title: 'Stop tailing', + command: 'aws.cwl.stopTailingLogGroup', + arguments: [document, 'codeLens'], + } + return new vscode.CodeLens(range, command) + } + + private getBottomOfDocumentRange(document: vscode.TextDocument): vscode.Range { + return new vscode.Range( + new vscode.Position(document.lineCount - 1, 0), + new vscode.Position(document.lineCount - 1, 0) + ) + } +} diff --git a/packages/core/src/awsService/cloudWatchLogs/document/liveTailDocumentProvider.ts b/packages/core/src/awsService/cloudWatchLogs/document/liveTailDocumentProvider.ts new file mode 100644 index 00000000000..7d662890dcf --- /dev/null +++ b/packages/core/src/awsService/cloudWatchLogs/document/liveTailDocumentProvider.ts @@ -0,0 +1,13 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' + +export class LiveTailDocumentProvider implements vscode.TextDocumentContentProvider { + provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): vscode.ProviderResult { + // Content will be written to the document via handling a LiveTail response stream in the TailLogGroup command. + return '' + } +} diff --git a/packages/core/src/awsService/cloudWatchLogs/document/logStreamsCodeLensProvider.ts b/packages/core/src/awsService/cloudWatchLogs/document/logStreamsCodeLensProvider.ts index 9870ff009d2..9c6409d7318 100644 --- a/packages/core/src/awsService/cloudWatchLogs/document/logStreamsCodeLensProvider.ts +++ b/packages/core/src/awsService/cloudWatchLogs/document/logStreamsCodeLensProvider.ts @@ -50,9 +50,9 @@ export class LogStreamCodeLensProvider implements vscode.CodeLensProvider { const linesToGenerateCodeLens = await this.getStartingLineOfEachStreamId(document) // Create a code lens at the start of each Log Stream in the document - linesToGenerateCodeLens.forEach((idWithLine) => { + for (const idWithLine of linesToGenerateCodeLens) { codelenses.push(this.createLogStreamCodeLens(logGroupInfo, idWithLine)) - }) + } return codelenses } diff --git a/packages/core/src/awsService/cloudWatchLogs/registry/liveTailSession.ts b/packages/core/src/awsService/cloudWatchLogs/registry/liveTailSession.ts new file mode 100644 index 00000000000..87eba4d4079 --- /dev/null +++ b/packages/core/src/awsService/cloudWatchLogs/registry/liveTailSession.ts @@ -0,0 +1,159 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import * as AWS from '@aws-sdk/types' +import { + CloudWatchLogsClient, + StartLiveTailCommand, + StartLiveTailResponseStream, +} from '@aws-sdk/client-cloudwatch-logs' +import { LogStreamFilterResponse } from '../wizard/liveTailLogStreamSubmenu' +import { CloudWatchLogsSettings } from '../cloudWatchLogsUtils' +import { globals, Settings, ToolkitError } from '../../../shared' +import { createLiveTailURIFromArgs } from './liveTailSessionRegistry' +import { getUserAgent } from '../../../shared/telemetry/util' +import { convertToTimeString } from '../../../shared/datetime' + +export type LiveTailSessionConfiguration = { + logGroupArn: string + logStreamFilter?: LogStreamFilterResponse + logEventFilterPattern?: string + region: string + awsCredentials: AWS.Credentials +} + +export type LiveTailSessionClient = { + cwlClient: CloudWatchLogsClient + abortController: AbortController +} + +export class LiveTailSession { + private liveTailClient: LiveTailSessionClient + private _logGroupArn: string + private logStreamFilter?: LogStreamFilterResponse + private logEventFilterPattern?: string + private _maxLines: number + private _uri: vscode.Uri + private statusBarItem: vscode.StatusBarItem + private startTime: number | undefined + private endTime: number | undefined + private _eventRate: number + private _isSampled: boolean + + // While session is running, used to update the StatusBar each half second. + private statusBarUpdateTimer: NodeJS.Timer | undefined + + static settings = new CloudWatchLogsSettings(Settings.instance) + + public constructor(configuration: LiveTailSessionConfiguration) { + this._logGroupArn = configuration.logGroupArn + this.logStreamFilter = configuration.logStreamFilter + this.logEventFilterPattern = configuration.logEventFilterPattern + this.liveTailClient = { + cwlClient: new CloudWatchLogsClient({ + credentials: configuration.awsCredentials, + region: configuration.region, + customUserAgent: getUserAgent(), + }), + abortController: new AbortController(), + } + this._maxLines = LiveTailSession.settings.get('limit', 10000) + this._uri = createLiveTailURIFromArgs(configuration) + this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 0) + this._eventRate = 0 + this._isSampled = false + } + + public get maxLines() { + return this._maxLines + } + + public get uri() { + return this._uri + } + + public get logGroupArn() { + return this._logGroupArn + } + + public set eventRate(rate: number) { + this._eventRate = rate + } + + public set isSampled(isSampled: boolean) { + this._isSampled = isSampled + } + + public async startLiveTailSession(): Promise> { + const commandOutput = await this.liveTailClient.cwlClient.send(this.buildStartLiveTailCommand(), { + abortSignal: this.liveTailClient.abortController.signal, + }) + if (!commandOutput.responseStream) { + throw new ToolkitError('LiveTail session response stream is undefined.') + } + this.startTime = globals.clock.Date.now() + this.endTime = undefined + this.statusBarUpdateTimer = globals.clock.setInterval(() => { + this.updateStatusBarItemText() + }, 500) + return commandOutput.responseStream + } + + public stopLiveTailSession() { + this.endTime = globals.clock.Date.now() + this.statusBarItem.dispose() + globals.clock.clearInterval(this.statusBarUpdateTimer) + this.liveTailClient.abortController.abort() + this.liveTailClient.cwlClient.destroy() + } + + public getLiveTailSessionDuration(): number { + // Never started + if (this.startTime === undefined) { + return 0 + } + // Currently running + if (this.endTime === undefined) { + return globals.clock.Date.now() - this.startTime + } + return this.endTime - this.startTime + } + + public buildStartLiveTailCommand(): StartLiveTailCommand { + let logStreamNamePrefix = undefined + let logStreamName = undefined + if (this.logStreamFilter) { + if (this.logStreamFilter.type === 'prefix') { + logStreamNamePrefix = this.logStreamFilter.filter + logStreamName = undefined + } else if (this.logStreamFilter.type === 'specific') { + logStreamName = this.logStreamFilter.filter + logStreamNamePrefix = undefined + } + } + + return new StartLiveTailCommand({ + logGroupIdentifiers: [this.logGroupArn], + logStreamNamePrefixes: logStreamNamePrefix ? [logStreamNamePrefix] : undefined, + logStreamNames: logStreamName ? [logStreamName] : undefined, + logEventFilterPattern: this.logEventFilterPattern ? this.logEventFilterPattern : undefined, + }) + } + + public showStatusBarItem(shouldShow: boolean) { + shouldShow ? this.statusBarItem.show() : this.statusBarItem.hide() + } + + public updateStatusBarItemText() { + const elapsedTime = this.getLiveTailSessionDuration() + const timeString = convertToTimeString(elapsedTime) + const sampledString = this._isSampled ? 'Yes' : 'No' + this.statusBarItem.text = `Tailing: ${timeString}, ${this._eventRate} events/sec, Sampled: ${sampledString}` + } + + public get isAborted() { + return this.liveTailClient.abortController.signal.aborted + } +} diff --git a/packages/core/src/awsService/cloudWatchLogs/registry/liveTailSessionRegistry.ts b/packages/core/src/awsService/cloudWatchLogs/registry/liveTailSessionRegistry.ts new file mode 100644 index 00000000000..988725edc87 --- /dev/null +++ b/packages/core/src/awsService/cloudWatchLogs/registry/liveTailSessionRegistry.ts @@ -0,0 +1,34 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { cloudwatchLogsLiveTailScheme } from '../../../shared/constants' +import { LiveTailSession, LiveTailSessionConfiguration } from './liveTailSession' + +export class LiveTailSessionRegistry extends Map { + static #instance: LiveTailSessionRegistry + + public static get instance() { + return (this.#instance ??= new this()) + } + + public constructor() { + super() + } +} + +export function createLiveTailURIFromArgs(sessionData: LiveTailSessionConfiguration): vscode.Uri { + let uriStr = `${cloudwatchLogsLiveTailScheme}:${sessionData.region}:${sessionData.logGroupArn}` + + if (sessionData.logStreamFilter) { + if (sessionData.logStreamFilter.type !== 'all') { + uriStr += `:${sessionData.logStreamFilter.type}:${sessionData.logStreamFilter.filter}` + } else { + uriStr += `:${sessionData.logStreamFilter.type}` + } + } + uriStr += sessionData.logEventFilterPattern ? `:${sessionData.logEventFilterPattern}` : '' + + return vscode.Uri.parse(uriStr) +} diff --git a/packages/core/src/awsService/cloudWatchLogs/timeFilterSubmenu.ts b/packages/core/src/awsService/cloudWatchLogs/timeFilterSubmenu.ts index 1f4dbc1d28a..0bb3cd9670d 100644 --- a/packages/core/src/awsService/cloudWatchLogs/timeFilterSubmenu.ts +++ b/packages/core/src/awsService/cloudWatchLogs/timeFilterSubmenu.ts @@ -38,7 +38,7 @@ export class TimeFilterSubmenu extends Prompter { private get recentTimeItems(): DataQuickPickItem[] { const options: DataQuickPickItem[] = [] - //appromixate 31 days as month length (better to overshoot) + // appromixate 31 days as month length (better to overshoot) options.push({ label: 'All time', data: 0, diff --git a/packages/core/src/awsService/cloudWatchLogs/wizard/liveTailLogStreamSubmenu.ts b/packages/core/src/awsService/cloudWatchLogs/wizard/liveTailLogStreamSubmenu.ts new file mode 100644 index 00000000000..c94259684bb --- /dev/null +++ b/packages/core/src/awsService/cloudWatchLogs/wizard/liveTailLogStreamSubmenu.ts @@ -0,0 +1,163 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Prompter, PromptResult } from '../../../shared/ui/prompter' +import { DefaultCloudWatchLogsClient } from '../../../shared/clients/cloudWatchLogsClient' +import { createCommonButtons } from '../../../shared/ui/buttons' +import { createInputBox, InputBoxPrompter } from '../../../shared/ui/inputPrompter' +import { createQuickPick, DataQuickPickItem, QuickPickPrompter } from '../../../shared/ui/pickerPrompter' +import { pageableToCollection } from '../../../shared/utilities/collectionUtils' +import { CloudWatchLogs } from 'aws-sdk' +import { isValidResponse, StepEstimator } from '../../../shared/wizards/wizard' +import { isNonNullable } from '../../../shared/utilities/tsUtils' +import { + startLiveTailHelpUrl, + startLiveTailLogStreamNamesHelpUrl, + startLiveTailLogStreamPrefixHelpUrl, +} from '../../../shared/constants' + +export type LogStreamFilterType = 'menu' | 'prefix' | 'specific' | 'all' + +export interface LogStreamFilterResponse { + readonly filter?: string + readonly type: LogStreamFilterType +} + +export class LogStreamFilterSubmenu extends Prompter { + private logStreamPrefixRegEx = /^[^:*]*$/ + private currentState: LogStreamFilterType = 'menu' + private steps?: [current: number, total: number] + private region: string + private logGroupArn: string + public defaultPrompter: QuickPickPrompter = this.createMenuPrompter() + + public constructor(logGroupArn: string, region: string) { + super() + this.region = region + this.logGroupArn = logGroupArn + } + + public createMenuPrompter() { + const helpUri = startLiveTailHelpUrl + const prompter = createQuickPick(this.menuOptions, { + title: 'Include log events from...', + buttons: createCommonButtons(helpUri), + }) + return prompter + } + + private get menuOptions(): DataQuickPickItem[] { + const options: DataQuickPickItem[] = [] + options.push({ + label: 'All Log Streams', + data: 'all', + }) + options.push({ + label: 'Specific Log Stream', + data: 'specific', + }) + options.push({ + label: 'Log Streams matching prefix', + data: 'prefix', + }) + return options + } + + public createLogStreamPrefixBox(): InputBoxPrompter { + const helpUri = startLiveTailLogStreamPrefixHelpUrl + return createInputBox({ + title: 'Enter Log Stream prefix', + placeholder: 'log stream prefix (case sensitive; empty matches all)', + prompt: 'Only log events in Log Streams whose name starts with the supplied prefix will be included.', + validateInput: (input) => this.validateLogStreamPrefix(input), + buttons: createCommonButtons(helpUri), + }) + } + + public validateLogStreamPrefix(prefix: string) { + if (prefix.length > 512) { + return 'Log Stream prefix cannot be longer than 512 characters' + } + + if (!this.logStreamPrefixRegEx.test(prefix)) { + return 'Log Stream prefix must match pattern: [^:*]*' + } + } + + public createLogStreamSelector(): QuickPickPrompter { + const helpUri = startLiveTailLogStreamNamesHelpUrl + const client = new DefaultCloudWatchLogsClient(this.region) + const request: CloudWatchLogs.DescribeLogStreamsRequest = { + logGroupIdentifier: this.logGroupArn, + orderBy: 'LastEventTime', + descending: true, + } + const requester = (request: CloudWatchLogs.DescribeLogStreamsRequest) => client.describeLogStreams(request) + const collection = pageableToCollection(requester, request, 'nextToken', 'logStreams') + + const items = collection + .filter(isNonNullable) + .map((streams) => streams!.map((stream) => ({ data: stream.logStreamName!, label: stream.logStreamName! }))) + + return createQuickPick(items, { + title: 'Select Log Stream', + buttons: createCommonButtons(helpUri), + }) + } + + private switchState(newState: LogStreamFilterType) { + this.currentState = newState + } + + protected async promptUser(): Promise> { + while (true) { + switch (this.currentState) { + case 'menu': { + const prompter = this.createMenuPrompter() + this.steps && prompter.setSteps(this.steps[0], this.steps[1]) + + const resp = await prompter.prompt() + if (resp === 'prefix') { + this.switchState('prefix') + } else if (resp === 'specific') { + this.switchState('specific') + } else if (resp === 'all') { + return { filter: undefined, type: resp } + } else { + return undefined + } + + break + } + case 'prefix': { + const resp = await this.createLogStreamPrefixBox().prompt() + if (isValidResponse(resp)) { + return { filter: resp, type: 'prefix' } + } + this.switchState('menu') + break + } + case 'specific': { + const resp = await this.createLogStreamSelector().prompt() + if (isValidResponse(resp)) { + return { filter: resp, type: 'specific' } + } + this.switchState('menu') + break + } + } + } + } + + public setSteps(current: number, total: number): void { + this.steps = [current, total] + } + + // Unused + public get recentItem(): any { + return + } + public set recentItem(response: any) {} + public setStepEstimator(estimator: StepEstimator): void {} +} diff --git a/packages/core/src/awsService/cloudWatchLogs/wizard/tailLogGroupWizard.ts b/packages/core/src/awsService/cloudWatchLogs/wizard/tailLogGroupWizard.ts new file mode 100644 index 00000000000..025820df792 --- /dev/null +++ b/packages/core/src/awsService/cloudWatchLogs/wizard/tailLogGroupWizard.ts @@ -0,0 +1,109 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as nls from 'vscode-nls' +import { globals, ToolkitError } from '../../../shared' +import { DefaultCloudWatchLogsClient } from '../../../shared/clients/cloudWatchLogsClient' +import { cwlFilterPatternHelpUrl } from '../../../shared/constants' +import { createBackButton, createExitButton, createHelpButton } from '../../../shared/ui/buttons' +import { RegionSubmenu, RegionSubmenuResponse } from '../../../shared/ui/common/regionSubmenu' +import { createInputBox } from '../../../shared/ui/inputPrompter' +import { DataQuickPickItem } from '../../../shared/ui/pickerPrompter' +import { Wizard } from '../../../shared/wizards/wizard' +import { CloudWatchLogsGroupInfo } from '../registry/logDataRegistry' +import { LogStreamFilterResponse, LogStreamFilterSubmenu } from './liveTailLogStreamSubmenu' + +const localize = nls.loadMessageBundle() + +export interface TailLogGroupWizardResponse { + regionLogGroupSubmenuResponse: RegionSubmenuResponse + logStreamFilter: LogStreamFilterResponse + filterPattern: string +} + +export class TailLogGroupWizard extends Wizard { + public constructor(logGroupInfo?: CloudWatchLogsGroupInfo) { + super({ + initState: { + regionLogGroupSubmenuResponse: logGroupInfo + ? { + data: buildLogGroupArn(logGroupInfo.groupName, logGroupInfo.regionName), + region: logGroupInfo.regionName, + } + : undefined, + }, + }) + this.form.regionLogGroupSubmenuResponse.bindPrompter(createRegionLogGroupSubmenu) + this.form.logStreamFilter.bindPrompter((state) => { + if (!state.regionLogGroupSubmenuResponse?.data) { + throw new ToolkitError('Log Group name is null') + } + return new LogStreamFilterSubmenu( + state.regionLogGroupSubmenuResponse.data, + state.regionLogGroupSubmenuResponse.region + ) + }) + this.form.filterPattern.bindPrompter((state) => createFilterPatternPrompter()) + } +} + +export function createRegionLogGroupSubmenu(): RegionSubmenu { + return new RegionSubmenu( + getLogGroupQuickPickOptions, + { + title: localize('AWS.cwl.tailLogGroup.logGroupPromptTitle', 'Select Log Group to tail'), + buttons: [createExitButton()], + }, + { title: localize('AWS.cwl.tailLogGroup.regionPromptTitle', 'Select Region for Log Group') }, + 'Log Groups' + ) +} + +async function getLogGroupQuickPickOptions(regionCode: string): Promise[]> { + const client = new DefaultCloudWatchLogsClient(regionCode) + const logGroups = client.describeLogGroups() + + const logGroupsOptions: DataQuickPickItem[] = [] + + for await (const logGroupObject of logGroups) { + if (!logGroupObject.arn || !logGroupObject.logGroupName) { + throw new ToolkitError('Log Group name or arn is undefined') + } + + logGroupsOptions.push({ + label: logGroupObject.logGroupName, + data: formatLogGroupArn(logGroupObject.arn), + }) + } + + return logGroupsOptions +} + +export function buildLogGroupArn(logGroupName: string, region: string): string { + if (logGroupName.startsWith('arn:')) { + return logGroupName + } + const awsAccountId = globals.awsContext.getCredentialAccountId() + if (awsAccountId === undefined) { + throw new ToolkitError( + `Failed to construct Arn for Log Group because awsAccountId is undefined. Log Group: ${logGroupName}` + ) + } + return `arn:aws:logs:${region}:${awsAccountId}:log-group:${logGroupName}` +} + +function formatLogGroupArn(logGroupArn: string): string { + return logGroupArn.endsWith(':*') ? logGroupArn.substring(0, logGroupArn.length - 2) : logGroupArn +} + +export function createFilterPatternPrompter() { + const helpUri = cwlFilterPatternHelpUrl + return createInputBox({ + title: 'Provide log event filter pattern', + placeholder: 'filter pattern (case sensitive; empty matches all)', + prompt: 'Optional filter to include only log events that match the supplied pattern.', + buttons: [createHelpButton(helpUri), createBackButton(), createExitButton()], + }) +} diff --git a/packages/core/src/awsService/ec2/activation.ts b/packages/core/src/awsService/ec2/activation.ts index f8881af8347..18660732754 100644 --- a/packages/core/src/awsService/ec2/activation.ts +++ b/packages/core/src/awsService/ec2/activation.ts @@ -83,5 +83,6 @@ export async function activate(ctx: ExtContext): Promise { } export async function deactivate(): Promise { + // eslint-disable-next-line unicorn/no-array-for-each connectionManagers.forEach(async (manager) => await manager.dispose()) } diff --git a/packages/core/src/awsService/ec2/explorer/ec2ParentNode.ts b/packages/core/src/awsService/ec2/explorer/ec2ParentNode.ts index 854e6eacd1c..aa4115259c9 100644 --- a/packages/core/src/awsService/ec2/explorer/ec2ParentNode.ts +++ b/packages/core/src/awsService/ec2/explorer/ec2ParentNode.ts @@ -45,7 +45,7 @@ export class Ec2ParentNode extends AWSTreeNodeBase { if (!this.ec2InstanceNodes.has(instanceId)) { throw new Error(`Attempt to track ec2 node ${instanceId} that isn't a child`) } - this.pollingSet.start(instanceId) + this.pollingSet.add(instanceId) } public async updateChildren(): Promise { diff --git a/packages/core/src/awsService/ec2/model.ts b/packages/core/src/awsService/ec2/model.ts index fa7bbee71b7..9a5e13feaef 100644 --- a/packages/core/src/awsService/ec2/model.ts +++ b/packages/core/src/awsService/ec2/model.ts @@ -13,6 +13,7 @@ import { SsmClient } from '../../shared/clients/ssmClient' import { Ec2Client } from '../../shared/clients/ec2Client' import { VscodeRemoteConnection, + createBoundProcess, ensureDependencies, getDeniedSsmActions, openRemoteTerminal, @@ -20,8 +21,13 @@ import { } from '../../shared/remoteSession' import { DefaultIamClient } from '../../shared/clients/iamClient' import { ErrorInformation } from '../../shared/errors' -import { sshAgentSocketVariable, startSshAgent, startVscodeRemote } from '../../shared/extensions/ssh' -import { createBoundProcess } from '../../codecatalyst/model' +import { + sshAgentSocketVariable, + SshError, + startSshAgent, + startVscodeRemote, + testSshConnection, +} from '../../shared/extensions/ssh' import { getLogger } from '../../shared/logger/logger' import { CancellationError, Timeout } from '../../shared/utilities/timeoutUtils' import { showMessageWithCancel } from '../../shared/utilities/messages' @@ -38,6 +44,12 @@ export interface Ec2RemoteEnv extends VscodeRemoteConnection { ssmSession: SSM.StartSessionResponse } +export type Ec2OS = 'Amazon Linux' | 'Ubuntu' | 'macOS' +interface RemoteUser { + os: Ec2OS + name: string +} + export class Ec2Connecter implements vscode.Disposable { protected ssmClient: SsmClient protected ec2Client: Ec2Client @@ -149,13 +161,6 @@ export class Ec2Connecter implements vscode.Disposable { } } - public throwGeneralConnectionError(selection: Ec2Selection, error: Error) { - this.throwConnectionError('Unable to connect to target instance. ', selection, { - code: 'EC2SSMConnect', - cause: error, - }) - } - public async checkForStartSessionError(selection: Ec2Selection): Promise { await this.checkForInstanceStatusError(selection) @@ -184,7 +189,7 @@ export class Ec2Connecter implements vscode.Disposable { const response = await this.ssmClient.startSession(selection.instanceId) await this.openSessionInTerminal(response, selection) } catch (err: unknown) { - this.throwGeneralConnectionError(selection, err as Error) + this.throwConnectionError('', selection, err as Error) } } @@ -193,27 +198,53 @@ export class Ec2Connecter implements vscode.Disposable { const remoteUser = await this.getRemoteUser(selection.instanceId) const remoteEnv = await this.prepareEc2RemoteEnvWithProgress(selection, remoteUser) - + const testSession = await this.ssmClient.startSession(selection.instanceId, 'AWS-StartSSHSession') try { - await startVscodeRemote(remoteEnv.SessionProcess, remoteEnv.hostname, '/', remoteEnv.vscPath, remoteUser) + await testSshConnection( + remoteEnv.SessionProcess, + remoteEnv.hostname, + remoteEnv.sshPath, + remoteUser.name, + testSession + ) + await startVscodeRemote( + remoteEnv.SessionProcess, + remoteEnv.hostname, + '/', + remoteEnv.vscPath, + remoteUser.name + ) } catch (err) { - this.throwGeneralConnectionError(selection, err as Error) + const message = err instanceof SshError ? 'Testing SSH connection to instance failed' : '' + this.throwConnectionError(message, selection, err as Error) + } finally { + await this.ssmClient.terminateSession(testSession) } } - public async prepareEc2RemoteEnvWithProgress(selection: Ec2Selection, remoteUser: string): Promise { + public async prepareEc2RemoteEnvWithProgress( + selection: Ec2Selection, + remoteUser: RemoteUser + ): Promise { const timeout = new Timeout(60000) await showMessageWithCancel('AWS: Opening remote connection...', timeout) const remoteEnv = await this.prepareEc2RemoteEnv(selection, remoteUser).finally(() => timeout.cancel()) return remoteEnv } - public async prepareEc2RemoteEnv(selection: Ec2Selection, remoteUser: string): Promise { + private async startSSMSession(instanceId: string): Promise { + const ssmSession = await this.ssmClient.startSession(instanceId, 'AWS-StartSSHSession') + await this.addActiveSession(instanceId, ssmSession.SessionId!) + return ssmSession + } + + public async prepareEc2RemoteEnv(selection: Ec2Selection, remoteUser: RemoteUser): Promise { const logger = this.configureRemoteConnectionLogger(selection.instanceId) const { ssm, vsc, ssh } = (await ensureDependencies()).unwrap() const keyPair = await this.configureSshKeys(selection, remoteUser) - const hostNamePrefix = 'aws-ec2-' - const sshConfig = new SshConfig(ssh, hostNamePrefix, 'ec2_connect', keyPair.getPrivateKeyPath()) + const hostnamePrefix = 'aws-ec2-' + const hostname = `${hostnamePrefix}${selection.instanceId}` + const sshConfig = new SshConfig(ssh, hostnamePrefix, 'ec2_connect', keyPair.getPrivateKeyPath()) const config = await sshConfig.ensureValid() if (config.isErr()) { @@ -222,10 +253,12 @@ export class Ec2Connecter implements vscode.Disposable { throw err } - const ssmSession = await this.ssmClient.startSession(selection.instanceId, 'AWS-StartSSHSession') - await this.addActiveSession(selection.instanceId, ssmSession.SessionId!) + + const ssmSession = await this.startSSMSession(selection.instanceId) const vars = getEc2SsmEnv(selection, ssm, ssmSession) + getLogger().debug(`ec2: connect script logs at ${vars.LOG_FILE_LOCATION}`) + const envProvider = async () => { return { [sshAgentSocketVariable]: await startSshAgent(), ...vars } } @@ -236,7 +269,7 @@ export class Ec2Connecter implements vscode.Disposable { }) return { - hostname: `${hostNamePrefix}${selection.instanceId}`, + hostname, envProvider, sshPath: ssh, vscPath: vsc, @@ -253,38 +286,78 @@ export class Ec2Connecter implements vscode.Disposable { return logger } - public async configureSshKeys(selection: Ec2Selection, remoteUser: string): Promise { + public async configureSshKeys(selection: Ec2Selection, remoteUser: RemoteUser): Promise { const keyPair = await SshKeyPair.getSshKeyPair(`aws-ec2-key`, 30000) await this.sendSshKeyToInstance(selection, keyPair, remoteUser) return keyPair } + /** Removes old key(s) that we added to the remote ~/.ssh/authorized_keys file. */ + public async tryCleanKeys( + instanceId: string, + hintComment: string, + hostOS: Ec2OS, + remoteAuthorizedKeysPath: string + ) { + try { + const deleteExistingKeyCommand = getRemoveLinesCommand(hintComment, hostOS, remoteAuthorizedKeysPath) + await this.sendCommandAndWait(instanceId, deleteExistingKeyCommand) + } catch (e) { + getLogger().warn(`ec2: failed to clean keys: %O`, e) + } + } + + private async sendCommandAndWait(instanceId: string, command: string) { + return await this.ssmClient.sendCommandAndWait(instanceId, 'AWS-RunShellScript', { + commands: [command], + }) + } + public async sendSshKeyToInstance( selection: Ec2Selection, sshKeyPair: SshKeyPair, - remoteUser: string + remoteUser: RemoteUser ): Promise { const sshPubKey = await sshKeyPair.getPublicKey() + const hintComment = '#AWSToolkitForVSCode' - const remoteAuthorizedKeysPaths = `/home/${remoteUser}/.ssh/authorized_keys` - const command = `echo "${sshPubKey}" > ${remoteAuthorizedKeysPaths}` - const documentName = 'AWS-RunShellScript' + const remoteAuthorizedKeysPath = `/home/${remoteUser.name}/.ssh/authorized_keys` - await this.ssmClient.sendCommandAndWait(selection.instanceId, documentName, { - commands: [command], - }) + const appendStr = (s: string) => `echo "${s}" >> ${remoteAuthorizedKeysPath}` + const writeKeyCommand = appendStr([sshPubKey.replace('\n', ''), hintComment].join(' ')) + + await this.tryCleanKeys(selection.instanceId, hintComment, remoteUser.os, remoteAuthorizedKeysPath) + await this.sendCommandAndWait(selection.instanceId, writeKeyCommand) } - public async getRemoteUser(instanceId: string) { - const osName = await this.ssmClient.getTargetPlatformName(instanceId) - if (osName === 'Amazon Linux') { - return 'ec2-user' + public async getRemoteUser(instanceId: string): Promise { + const os = await this.ssmClient.getTargetPlatformName(instanceId) + if (os === 'Amazon Linux') { + return { name: 'ec2-user', os } } - if (osName === 'Ubuntu') { - return 'ubuntu' + if (os === 'Ubuntu') { + return { name: 'ubuntu', os } } - throw new ToolkitError(`Unrecognized OS name ${osName} on instance ${instanceId}`, { code: 'UnknownEc2OS' }) + throw new ToolkitError(`Unrecognized OS name ${os} on instance ${instanceId}`, { code: 'UnknownEc2OS' }) } } + +/** + * Generate bash command (as string) to remove lines containing `pattern`. + * @param pattern pattern for deleted lines. + * @param filepath filepath (as string) to target with the command. + * @returns bash command to remove lines from file. + */ +export function getRemoveLinesCommand(pattern: string, hostOS: Ec2OS, filepath: string): string { + if (pattern.includes('/')) { + throw new ToolkitError(`ec2: cannot match pattern containing '/', given: ${pattern}`) + } + // Linux allows not passing extension to -i, whereas macOS requires zero length extension. + return `sed -i${isLinux(hostOS) ? '' : " ''"} /${pattern}/d ${filepath}` +} + +function isLinux(os: Ec2OS): boolean { + return os === 'Amazon Linux' || os === 'Ubuntu' +} diff --git a/packages/core/src/awsService/ec2/remoteSessionManager.ts b/packages/core/src/awsService/ec2/remoteSessionManager.ts index 4c1843aabdb..4c5c8a665b8 100644 --- a/packages/core/src/awsService/ec2/remoteSessionManager.ts +++ b/packages/core/src/awsService/ec2/remoteSessionManager.ts @@ -31,6 +31,7 @@ export class Ec2SessionTracker extends Map implem } public async dispose(): Promise { + // eslint-disable-next-line unicorn/no-array-for-each this.forEach(async (_sessionId, instanceId) => await this.disconnectEnv(instanceId)) } diff --git a/packages/core/src/awsService/ec2/utils.ts b/packages/core/src/awsService/ec2/utils.ts index 306887f3270..105d2da06ff 100644 --- a/packages/core/src/awsService/ec2/utils.ts +++ b/packages/core/src/awsService/ec2/utils.ts @@ -8,6 +8,7 @@ import { copyToClipboard } from '../../shared/utilities/messages' import { Ec2Selection } from './prompter' import { sshLogFileLocation } from '../../shared/sshConfig' import { SSM } from 'aws-sdk' +import { getLogger } from '../../shared/logger' export function getIconCode(instance: SafeEc2Instance) { if (instance.LastSeenStatus === 'running') { @@ -42,6 +43,7 @@ export function getEc2SsmEnv( STREAM_URL: session.StreamUrl, SESSION_ID: session.SessionId, TOKEN: session.TokenValue, + DEBUG_LOG: getLogger().logLevelEnabled('debug') ? 1 : 0, }, process.env ) diff --git a/packages/core/src/awsService/ecs/commands.ts b/packages/core/src/awsService/ecs/commands.ts index 5845793e7b2..acf3daa8047 100644 --- a/packages/core/src/awsService/ecs/commands.ts +++ b/packages/core/src/awsService/ecs/commands.ts @@ -32,7 +32,7 @@ async function runCommandWizard( const wizard = new CommandWizard( container, - await ToolkitPromptSettings.instance.isPromptEnabled('ecsRunCommand'), + ToolkitPromptSettings.instance.isPromptEnabled('ecsRunCommand'), command ) const response = await wizard.run() @@ -75,7 +75,7 @@ export async function toggleExecuteCommandFlag( 'Disabling command execution will change the state of resources in your AWS account, including but not limited to stopping and restarting the service.\n Continue?' ) - if (await settings.isPromptEnabled(prompt)) { + if (settings.isPromptEnabled(prompt)) { const choice = await window.showWarningMessage(warningMessage, yes, yesDontAskAgain, no) if (choice === undefined || choice === no) { throw new CancellationError('user') diff --git a/packages/core/src/awsService/iot/commands/attachCertificate.ts b/packages/core/src/awsService/iot/commands/attachCertificate.ts index ef45917f7a0..c77f1d36d44 100644 --- a/packages/core/src/awsService/iot/commands/attachCertificate.ts +++ b/packages/core/src/awsService/iot/commands/attachCertificate.ts @@ -46,7 +46,7 @@ export async function attachCertificateCommand(node: IotThingNode, promptFun = p getLogger().debug('Attached certificate %O', cert.certificateId) - //Refresh the Thing node + // Refresh the Thing node await node.refreshNode() } diff --git a/packages/core/src/awsService/iot/commands/createCert.ts b/packages/core/src/awsService/iot/commands/createCert.ts index 22db3136831..3f6eae413db 100644 --- a/packages/core/src/awsService/iot/commands/createCert.ts +++ b/packages/core/src/awsService/iot/commands/createCert.ts @@ -14,7 +14,7 @@ import { Iot } from 'aws-sdk' import { fs } from '../../../shared' // eslint-disable-next-line @typescript-eslint/naming-convention -const MODE_RW_R_R = 0o644 //File permission 0644 rw-r--r-- for PEM files. +const MODE_RW_R_R = 0o644 // File permission 0644 rw-r--r-- for PEM files. // eslint-disable-next-line @typescript-eslint/naming-convention const PEM_FILE_ENCODING = 'ascii' @@ -60,10 +60,10 @@ export async function createCertificateCommand( getLogger().info(`Downloaded certificate ${certId}`) void vscode.window.showInformationMessage(localize('AWS.iot.createCert.success', 'Created certificate {0}', certId)) - //Save resources + // Save resources const saveSuccessful = await saveFunc(folderLocation, certId!, certPem, privateKey, publicKey) if (!saveSuccessful) { - //Delete the certificate if the key pair cannot be saved + // Delete the certificate if the key pair cannot be saved try { await node.iot.deleteCertificate({ certificateId: certId! }) } catch (e) { @@ -72,7 +72,7 @@ export async function createCertificateCommand( } } - //Refresh the Certificate Folder node + // Refresh the Certificate Folder node await node.refreshNode() } diff --git a/packages/core/src/awsService/iot/commands/createPolicy.ts b/packages/core/src/awsService/iot/commands/createPolicy.ts index 7a91de4fb5d..ba082811e34 100644 --- a/packages/core/src/awsService/iot/commands/createPolicy.ts +++ b/packages/core/src/awsService/iot/commands/createPolicy.ts @@ -33,7 +33,7 @@ export async function createPolicyCommand(node: IotPolicyFolderNode, getPolicyDo } try { - //Parse to ensure this is a valid JSON + // Parse to ensure this is a valid JSON const policyJSON = JSON.parse(data.toString()) await node.iot.createPolicy({ policyName, policyDocument: JSON.stringify(policyJSON) }) void vscode.window.showInformationMessage( @@ -45,7 +45,7 @@ export async function createPolicyCommand(node: IotPolicyFolderNode, getPolicyDo return } - //Refresh the Policy Folder node + // Refresh the Policy Folder node await node.refreshNode() } diff --git a/packages/core/src/awsService/iot/commands/createPolicyVersion.ts b/packages/core/src/awsService/iot/commands/createPolicyVersion.ts index 979c8d50beb..257626489f6 100644 --- a/packages/core/src/awsService/iot/commands/createPolicyVersion.ts +++ b/packages/core/src/awsService/iot/commands/createPolicyVersion.ts @@ -27,7 +27,7 @@ export async function createPolicyVersionCommand( } try { - //Parse to ensure this is a valid JSON + // Parse to ensure this is a valid JSON const policyJSON = JSON.parse(data.toString()) await node.iot.createPolicyVersion({ policyName, @@ -45,6 +45,6 @@ export async function createPolicyVersionCommand( return } - //Refresh the node + // Refresh the node node.refresh() } diff --git a/packages/core/src/awsService/iot/commands/createThing.ts b/packages/core/src/awsService/iot/commands/createThing.ts index 6981e48f192..2d9b1f11d4e 100644 --- a/packages/core/src/awsService/iot/commands/createThing.ts +++ b/packages/core/src/awsService/iot/commands/createThing.ts @@ -43,7 +43,7 @@ export async function createThingCommand(node: IotThingFolderNode): Promise { void showViewLogsMessage(localize('AWS.iot.deleteThing.error', 'Failed to delete Thing: {0}', thingName)) } - //Refresh the Things Folder node + // Refresh the Things Folder node await node.parent.refreshNode() } diff --git a/packages/core/src/awsService/iot/commands/detachCert.ts b/packages/core/src/awsService/iot/commands/detachCert.ts index 11489fa841d..a8384e34b79 100644 --- a/packages/core/src/awsService/iot/commands/detachCert.ts +++ b/packages/core/src/awsService/iot/commands/detachCert.ts @@ -49,6 +49,6 @@ export async function detachThingCertCommand(node: IotThingCertNode): Promise = Settings.instance ) { - //Show only 8 characters in the explorer instead of the full 64. The entire - //ID can be copied from the context menu or viewed when hovered over. + // Show only 8 characters in the explorer instead of the full 64. The entire + // ID can be copied from the context menu or viewed when hovered over. super(truncate(certificate.id, 8), collapsibleState) this.tooltip = localize( diff --git a/packages/core/src/awsService/iot/explorer/iotPolicyFolderNode.ts b/packages/core/src/awsService/iot/explorer/iotPolicyFolderNode.ts index 8d9e1ce809e..36af63cf495 100644 --- a/packages/core/src/awsService/iot/explorer/iotPolicyFolderNode.ts +++ b/packages/core/src/awsService/iot/explorer/iotPolicyFolderNode.ts @@ -19,10 +19,10 @@ import { IotNode } from './iotNodes' import { Settings } from '../../../shared/settings' import { ClassToInterfaceType } from '../../../shared/utilities/tsUtils' -//Length of certificate ID. The certificate ID is the last segment of the ARN. +// Length of certificate ID. The certificate ID is the last segment of the ARN. const certIdLength = 64 -//Number of digits of the certificate ID to show +// Number of digits of the certificate ID to show const certPreviewLength = 8 /** diff --git a/packages/core/src/awsService/redshift/activation.ts b/packages/core/src/awsService/redshift/activation.ts index 58dea2e0075..e7af970c26a 100644 --- a/packages/core/src/awsService/redshift/activation.ts +++ b/packages/core/src/awsService/redshift/activation.ts @@ -99,7 +99,7 @@ function getNotebookConnectClickedHandler(ctx: ExtContext, redshiftNotebookContr connectionParams = undefined } const edit = new vscode.WorkspaceEdit() - //NotebookEdit is only available for engine version > 1.68.0 + // NotebookEdit is only available for engine version > 1.68.0 const nbEdit = (vscode as any).NotebookEdit.updateNotebookMetadata({ connectionParams: connectionParams, }) diff --git a/packages/core/src/awsService/redshift/explorer/redshiftNode.ts b/packages/core/src/awsService/redshift/explorer/redshiftNode.ts index 52eb470283b..4c245b7f4de 100644 --- a/packages/core/src/awsService/redshift/explorer/redshiftNode.ts +++ b/packages/core/src/awsService/redshift/explorer/redshiftNode.ts @@ -143,7 +143,7 @@ export class RedshiftNode extends AWSTreeNodeBase implements LoadMoreNode { } public async createCluster(clusterName: string): Promise { - //Code for creating redshiftClient cluster + // Code for creating redshiftClient cluster } public [inspect.custom](): string { diff --git a/packages/core/src/awsService/redshift/notebook/redshiftNotebookController.ts b/packages/core/src/awsService/redshift/notebook/redshiftNotebookController.ts index 3a0c3ca2d9d..1db972314d3 100644 --- a/packages/core/src/awsService/redshift/notebook/redshiftNotebookController.ts +++ b/packages/core/src/awsService/redshift/notebook/redshiftNotebookController.ts @@ -122,13 +122,13 @@ export class RedshiftNotebookController { } let tableHtml = `

Results from ${connectionParams.warehouseIdentifier} - database: ${connectionParams.database}

` - //Adding column headers + // Adding column headers for (const column of columns) { tableHtml += `` } tableHtml += '' - //Adding data rows + // Adding data rows for (const row of records) { tableHtml += '' for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) { diff --git a/packages/core/src/awsService/s3/commands/uploadFile.ts b/packages/core/src/awsService/s3/commands/uploadFile.ts index ca77617af4e..41093a96f2f 100644 --- a/packages/core/src/awsService/s3/commands/uploadFile.ts +++ b/packages/core/src/awsService/s3/commands/uploadFile.ts @@ -111,8 +111,8 @@ export async function uploadFileCommand( const filesToUpload = await getFile(document) if (!filesToUpload || filesToUpload.length === 0) { - //if file is undefined, means the back button was pressed(there is no step before) or no file was selected - //thus break the loop of the 'wizard' + // if file is undefined, means the back button was pressed(there is no step before) or no file was selected + // thus break the loop of the 'wizard' showOutputMessage( localize( 'AWS.message.error.uploadFileCommand.noFileSelected', @@ -232,7 +232,7 @@ async function runBatchUploads(uploadRequests: UploadRequest[], outputChannel = outputChannel ) } - //at least one request failed + // at least one request failed const response = await vscode.window.showErrorMessage( localize( 'AWS.s3.uploadFile.retryPrompt', @@ -325,7 +325,7 @@ async function uploadBatchOfFiles( }) if (uploadResult) { - //this request failed to upload + // this request failed to upload failedRequests.push(uploadResult) } diff --git a/packages/core/src/awsService/s3/explorer/s3FileNode.ts b/packages/core/src/awsService/s3/explorer/s3FileNode.ts index 31b6429d265..1d4b3034c08 100644 --- a/packages/core/src/awsService/s3/explorer/s3FileNode.ts +++ b/packages/core/src/awsService/s3/explorer/s3FileNode.ts @@ -12,7 +12,6 @@ import { inspect } from 'util' import { S3BucketNode } from './s3BucketNode' import { S3FolderNode } from './s3FolderNode' import globals from '../../../shared/extensionGlobals' -import { isCloud9 } from '../../../shared/extensionUtilities' import { getIcon } from '../../../shared/icons' import { formatLocalized, getRelativeDate } from '../../../shared/datetime' @@ -42,13 +41,11 @@ export class S3FileNode extends AWSTreeNodeBase implements AWSResourceNode { } this.iconPath = getIcon('vscode-file') this.contextValue = 'awsS3FileNode' - this.command = !isCloud9() - ? { - command: 'aws.s3.openFile', - title: localize('AWS.command.s3.openFile', 'Open File'), - arguments: [this], - } - : undefined + this.command = { + command: 'aws.s3.openFile', + title: localize('AWS.command.s3.openFile', 'Open File'), + arguments: [this], + } } /** diff --git a/packages/core/src/awsService/s3/fileViewerManager.ts b/packages/core/src/awsService/s3/fileViewerManager.ts index 336737b027e..d800a3bfeee 100644 --- a/packages/core/src/awsService/s3/fileViewerManager.ts +++ b/packages/core/src/awsService/s3/fileViewerManager.ts @@ -346,7 +346,7 @@ export class S3FileViewerManager { } private async showEditNotification(): Promise { - if (!(await this.settings.isPromptEnabled(promptOnEditKey))) { + if (!this.settings.isPromptEnabled(promptOnEditKey)) { return } diff --git a/packages/core/src/awsexplorer/activation.ts b/packages/core/src/awsexplorer/activation.ts index 5ea7295bf98..224bbadb2fb 100644 --- a/packages/core/src/awsexplorer/activation.ts +++ b/packages/core/src/awsexplorer/activation.ts @@ -110,22 +110,20 @@ export async function activate(args: { ) const amazonQViewNode: ToolView[] = [] - if (!isCloud9()) { - if ( - isExtensionInstalled(VSCODE_EXTENSION_ID.amazonq) || - globals.globalState.get('aws.toolkit.amazonq.dismissed') - ) { - await setContext('aws.toolkit.amazonq.dismissed', true) - } - - // We should create the tree even if it's dismissed, in case the user installs Amazon Q later. - amazonQViewNode.push({ - nodes: [AmazonQNode.instance], - view: 'aws.amazonq.codewhisperer', - refreshCommands: [refreshAmazonQ, refreshAmazonQRootNode], - }) + if ( + isExtensionInstalled(VSCODE_EXTENSION_ID.amazonq) || + globals.globalState.get('aws.toolkit.amazonq.dismissed') + ) { + await setContext('aws.toolkit.amazonq.dismissed', true) } + // We should create the tree even if it's dismissed, in case the user installs Amazon Q later. + amazonQViewNode.push({ + nodes: [AmazonQNode.instance], + view: 'aws.amazonq.codewhisperer', + refreshCommands: [refreshAmazonQ, refreshAmazonQRootNode], + }) + const viewNodes: ToolView[] = [ ...amazonQViewNode, ...codecatalystViewNode, diff --git a/packages/core/src/awsexplorer/regionNode.ts b/packages/core/src/awsexplorer/regionNode.ts index 98fb14c369c..a5c5bafe104 100644 --- a/packages/core/src/awsexplorer/regionNode.ts +++ b/packages/core/src/awsexplorer/regionNode.ts @@ -29,9 +29,8 @@ import { DefaultSchemaClient } from '../shared/clients/schemaClient' import { getEcsRootNode } from '../awsService/ecs/model' import { compareTreeItems, TreeShim } from '../shared/treeview/utils' import { Ec2ParentNode } from '../awsService/ec2/explorer/ec2ParentNode' -import { DevSettings } from '../shared/settings' import { Ec2Client } from '../shared/clients/ec2Client' -import { isCloud9 } from '../shared/extensionUtilities' +import { Experiments } from '../shared/settings' interface ServiceNode { allRegions?: boolean @@ -65,7 +64,7 @@ const serviceCandidates: ServiceNode[] = [ }, { serviceId: 'ec2', - when: () => DevSettings.instance.isDevMode(), + when: () => Experiments.instance.isExperimentEnabled('ec2RemoteConnect'), createFn: (regionCode: string, partitionId: string) => new Ec2ParentNode(regionCode, partitionId, new Ec2Client(regionCode)), }, @@ -74,7 +73,6 @@ const serviceCandidates: ServiceNode[] = [ createFn: (regionCode: string) => new EcrNode(new DefaultEcrClient(regionCode)), }, { - when: () => !isCloud9(), serviceId: 'redshift', createFn: (regionCode: string) => new RedshiftNode(new DefaultRedshiftClient(regionCode)), }, diff --git a/packages/core/src/awsexplorer/toolView.ts b/packages/core/src/awsexplorer/toolView.ts index 6153e8a1ab3..99d20fbdb3a 100644 --- a/packages/core/src/awsexplorer/toolView.ts +++ b/packages/core/src/awsexplorer/toolView.ts @@ -5,8 +5,6 @@ import * as vscode from 'vscode' import { ResourceTreeDataProvider, TreeNode } from '../shared/treeview/resourceTreeDataProvider' -import { isCloud9 } from '../shared/extensionUtilities' -import { debounce } from '../shared/utilities/functionUtils' export interface ToolView { nodes: TreeNode[] @@ -26,20 +24,8 @@ export function createToolView(viewNode: ToolView): vscode.TreeView { for (const refreshCommand of viewNode.refreshCommands ?? []) { refreshCommand(treeDataProvider) } - const view = vscode.window.createTreeView(viewNode.view, { treeDataProvider }) - // Cloud9 will only refresh when refreshing the entire tree - if (isCloud9()) { - viewNode.nodes.forEach((node) => { - // Refreshes are delayed to guard against excessive calls to `getTreeItem` and `getChildren` - // The 10ms delay is arbitrary. A single event loop may be good enough in many scenarios. - const refresh = debounce(() => treeDataProvider.refresh(node), 10) - node.onDidChangeTreeItem?.(() => refresh()) - node.onDidChangeChildren?.(() => refresh()) - }) - } - - return view + return vscode.window.createTreeView(viewNode.view, { treeDataProvider }) } async function getChildren(roots: TreeNode[]) { diff --git a/packages/core/src/codecatalyst/activation.ts b/packages/core/src/codecatalyst/activation.ts index 812766f5687..db5ff9e12f0 100644 --- a/packages/core/src/codecatalyst/activation.ts +++ b/packages/core/src/codecatalyst/activation.ts @@ -16,7 +16,7 @@ import { DevEnvClient } from '../shared/clients/devenvClient' import { watchRestartingDevEnvs } from './reconnect' import { ToolkitPromptSettings } from '../shared/settings' import { dontShow } from '../shared/localizedText' -import { getIdeProperties, isCloud9 } from '../shared/extensionUtilities' +import { getIdeProperties } from '../shared/extensionUtilities' import { Commands } from '../shared/vscode/commands2' import { getCodeCatalystConfig } from '../shared/clients/codecatalystClient' import { isDevenvVscode } from './utils' @@ -26,7 +26,6 @@ import { DevEnvActivityStarter } from './devEnv' import { learnMoreCommand, onboardCommand, reauth } from './explorer' import { isInDevEnv } from '../shared/vscode/env' import { hasScopes, scopesCodeWhispererCore, getTelemetryMetadataForConn } from '../auth/connection' -import { SessionSeparationPrompt } from '../auth/auth' import { telemetry } from '../shared/telemetry/telemetry' import { asStringifiedStack } from '../shared/telemetry/spans' @@ -64,7 +63,6 @@ export async function activate(ctx: ExtContext): Promise { }) await authProvider.secondaryAuth.forgetConnection() - await SessionSeparationPrompt.instance.showForCommand('aws.codecatalyst.manageConnections') }) }, { emit: false, functionId: { name: 'activate', class: 'CodeCatalyst' } } @@ -80,23 +78,21 @@ export async function activate(ctx: ExtContext): Promise { }) ) - if (!isCloud9()) { - await GitExtension.instance.registerRemoteSourceProvider(remoteSourceProvider).then((disposable) => { - ctx.extensionContext.subscriptions.push(disposable) - }) + await GitExtension.instance.registerRemoteSourceProvider(remoteSourceProvider).then((disposable) => { + ctx.extensionContext.subscriptions.push(disposable) + }) - await GitExtension.instance - .registerCredentialsProvider({ - getCredentials(uri: vscode.Uri) { - if (uri.authority.endsWith(getCodeCatalystConfig().gitHostname)) { - return commands.withClient((client) => authProvider.getCredentialsForGit(client)) - } - }, - }) - .then((disposable) => ctx.extensionContext.subscriptions.push(disposable)) + await GitExtension.instance + .registerCredentialsProvider({ + getCredentials(uri: vscode.Uri) { + if (uri.authority.endsWith(getCodeCatalystConfig().gitHostname)) { + return commands.withClient((client) => authProvider.getCredentialsForGit(client)) + } + }, + }) + .then((disposable) => ctx.extensionContext.subscriptions.push(disposable)) - watchRestartingDevEnvs(ctx, authProvider) - } + watchRestartingDevEnvs(ctx, authProvider) const thisDevenv = (await getThisDevEnv(authProvider))?.unwrapOrElse((err) => { getLogger().error('codecatalyst: failed to get current Dev Enviroment: %s', err) @@ -118,9 +114,10 @@ export async function activate(ctx: ExtContext): Promise { const timeoutMin = thisDevenv.summary.inactivityTimeoutMinutes const timeout = timeoutMin === 0 ? 'never' : `${timeoutMin} min` getLogger().info('codecatalyst: Dev Environment timeout=%s, ides=%O', timeout, thisDevenv.summary.ides) - if (!isCloud9() && thisDevenv && !isDevenvVscode(thisDevenv.summary.ides)) { + if (thisDevenv && !isDevenvVscode(thisDevenv.summary.ides)) { // Prevent Toolkit from reconnecting to a "non-vscode" devenv by actively closing it. // Can happen if devenv is switched to ides="cloud9", etc. + // TODO: Is this needed without cloud9 check? void vscode.commands.executeCommand('workbench.action.remote.close') return } @@ -128,7 +125,7 @@ export async function activate(ctx: ExtContext): Promise { await showReadmeFileOnFirstLoad(ctx.extensionContext.workspaceState) const settings = ToolkitPromptSettings.instance - if (await settings.isPromptEnabled('remoteConnected')) { + if (settings.isPromptEnabled('remoteConnected')) { const message = localize( 'AWS.codecatalyst.connectedMessage', 'Welcome to your Amazon CodeCatalyst Dev Environment. For more options and information, view Dev Environment settings ({0} Extension > CodeCatalyst).', @@ -150,10 +147,6 @@ export async function activate(ctx: ExtContext): Promise { } async function showReadmeFileOnFirstLoad(workspaceState: vscode.ExtensionContext['workspaceState']): Promise { - if (isCloud9()) { - return - } - getLogger().debug('codecatalyst: showReadmeFileOnFirstLoad()') // Check dev env state to see if this is the first time the user has connected to a dev env const isFirstLoad = workspaceState.get('aws.codecatalyst.devEnv.isFirstLoad', true) diff --git a/packages/core/src/codecatalyst/explorer.ts b/packages/core/src/codecatalyst/explorer.ts index da41ceede27..d20fcc6d37d 100644 --- a/packages/core/src/codecatalyst/explorer.ts +++ b/packages/core/src/codecatalyst/explorer.ts @@ -5,7 +5,6 @@ import * as vscode from 'vscode' import { DevEnvironment } from '../shared/clients/codecatalystClient' -import { isCloud9 } from '../shared/extensionUtilities' import { addColor, getIcon } from '../shared/icons' import { TreeNode } from '../shared/treeview/resourceTreeDataProvider' import { Commands } from '../shared/vscode/commands2' @@ -21,7 +20,6 @@ export const learnMoreCommand = Commands.declare('aws.learnMore', () => async (d return openUrl(docsUrl) }) -// Only used in rare cases on C9 export const reauth = Commands.declare( '_aws.codecatalyst.reauthenticate', () => async (conn: SsoConnection, authProvider: CodeCatalystAuthenticationProvider) => { @@ -37,7 +35,7 @@ export const onboardCommand = Commands.declare( ) async function getLocalCommands(auth: CodeCatalystAuthenticationProvider) { - const docsUrl = isCloud9() ? codecatalyst.docs.cloud9.overview : codecatalyst.docs.vscode.overview + const docsUrl = codecatalyst.docs.overview const learnMoreNode = learnMoreCommand.build(docsUrl).asTreeNode({ label: 'Learn more about CodeCatalyst', iconPath: getIcon('vscode-question'), @@ -75,15 +73,6 @@ async function getLocalCommands(auth: CodeCatalystAuthenticationProvider) { ] } - if (isCloud9()) { - const item = reauth.build(auth.activeConnection, auth).asTreeNode({ - label: 'Failed to get the current Dev Environment. Click to try again.', - iconPath: addColor(getIcon(`vscode-error`), 'notificationsErrorIcon.foreground'), - }) - - return [item] - } - return [ CodeCatalystCommands.declared.cloneRepo.build().asTreeNode({ label: 'Clone Repository', @@ -141,7 +130,9 @@ export class CodeCatalystRootNode implements TreeNode { this.addRefreshEmitter(() => this.onDidChangeEmitter.fire()) this.authProvider.onDidChange(() => { - this.refreshEmitters.forEach((fire) => fire()) + for (const fire of this.refreshEmitters) { + fire() + } }) } diff --git a/packages/core/src/codecatalyst/model.ts b/packages/core/src/codecatalyst/model.ts index b2ba6912106..d49d8780cde 100644 --- a/packages/core/src/codecatalyst/model.ts +++ b/packages/core/src/codecatalyst/model.ts @@ -19,7 +19,6 @@ import { getLogger } from '../shared/logger' import { AsyncCollection, toCollection } from '../shared/utilities/asyncCollection' import { getCodeCatalystSpaceName, getCodeCatalystProjectName, getCodeCatalystDevEnvId } from '../shared/vscode/env' import { sshAgentSocketVariable, startSshAgent, startVscodeRemote } from '../shared/extensions/ssh' -import { ChildProcess } from '../shared/utilities/processUtils' import { isDevenvVscode } from './utils' import { Timeout } from '../shared/utilities/timeoutUtils' import { Commands } from '../shared/vscode/commands2' @@ -28,7 +27,7 @@ import { fileExists } from '../shared/filesystemUtilities' import { CodeCatalystAuthenticationProvider } from './auth' import { ToolkitError } from '../shared/errors' import { Result } from '../shared/utilities/result' -import { VscodeRemoteConnection, ensureDependencies } from '../shared/remoteSession' +import { EnvProvider, VscodeRemoteConnection, createBoundProcess, ensureDependencies } from '../shared/remoteSession' import { SshConfig, sshLogFileLocation } from '../shared/sshConfig' import { fs } from '../shared' @@ -36,49 +35,17 @@ export type DevEnvironmentId = Pick export const connectScriptPrefix = 'codecatalyst_connect' export const docs = { - vscode: { - main: vscode.Uri.parse('https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/codecatalyst-service'), - overview: vscode.Uri.parse( - 'https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/codecatalyst-overview.html' - ), - devenv: vscode.Uri.parse( - 'https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/codecatalyst-devenvironment.html' - ), - setup: vscode.Uri.parse( - 'https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/codecatalyst-setup.html' - ), - troubleshoot: vscode.Uri.parse( - 'https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/codecatalyst-troubleshoot.html' - ), - }, - cloud9: { - // Working with Amazon CodeCatalyst - main: vscode.Uri.parse('https://docs.aws.amazon.com/cloud9/latest/user-guide/ide-toolkits-cloud9'), - // Getting Started - overview: vscode.Uri.parse( - 'https://docs.aws.amazon.com/cloud9/latest/user-guide/ide-toolkits-cloud9-getstarted' - ), - // Opening Dev Environment settings in AWS Cloud9 - settings: vscode.Uri.parse('https://docs.aws.amazon.com/cloud9/latest/user-guide/ide-toolkits-settings-cloud9'), - // Resuming a Dev Environment in AWS Cloud9 - devenv: vscode.Uri.parse('https://docs.aws.amazon.com/cloud9/latest/user-guide/ide-toolkits-resume-cloud9'), - // Creating a Dev Environment in AWS Cloud9 - devenvCreate: vscode.Uri.parse( - 'https://docs.aws.amazon.com/cloud9/latest/user-guide/ide-toolkits-create-cloud9' - ), - // Stopping a Dev Environment in AWS Cloud9 - devenvStop: vscode.Uri.parse('https://docs.aws.amazon.com/cloud9/latest/user-guide/ide-toolkits-stop-cloud9'), - // Deleting a Dev Environment in AWS Cloud9 - devenvDelete: vscode.Uri.parse( - 'https://docs.aws.amazon.com/cloud9/latest/user-guide/ide-toolkits-delete-cloud9' - ), - // Editing the repo devfile for a Dev Environment in AWS Cloud9 - devfileEdit: vscode.Uri.parse( - 'https://docs.aws.amazon.com/cloud9/latest/user-guide/ide-toolkits-edit-devfile-cloud9' - ), - // Cloning a repository in AWS Cloud9 - cloneRepo: vscode.Uri.parse('https://docs.aws.amazon.com/cloud9/latest/user-guide/ide-toolkits-clone-cloud9'), - }, + main: vscode.Uri.parse('https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/codecatalyst-service'), + overview: vscode.Uri.parse( + 'https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/codecatalyst-overview.html' + ), + devenv: vscode.Uri.parse( + 'https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/codecatalyst-devenvironment.html' + ), + setup: vscode.Uri.parse('https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/codecatalyst-setup.html'), + troubleshoot: vscode.Uri.parse( + 'https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/codecatalyst-troubleshoot.html' + ), } as const export function getCodeCatalystSsmEnv(region: string, ssmPath: string, devenv: DevEnvironmentId): NodeJS.ProcessEnv { @@ -111,28 +78,6 @@ export function createCodeCatalystEnvProvider( } } -type EnvProvider = () => Promise - -/** - * Creates a new {@link ChildProcess} class bound to a specific dev environment. All instances of this - * derived class will have SSM session information injected as environment variables as-needed. - */ -export function createBoundProcess(envProvider: EnvProvider): typeof ChildProcess { - type Run = ChildProcess['run'] - return class SessionBoundProcess extends ChildProcess { - public override async run(...args: Parameters): ReturnType { - const options = args[0] - const envVars = await envProvider() - const spawnOptions = { - ...options?.spawnOptions, - env: { ...envVars, ...options?.spawnOptions?.env }, - } - - return super.run({ ...options, spawnOptions }) - } - } -} - export async function cacheBearerToken(bearerToken: string, devenvId: string): Promise { await fs.writeFile(bearerTokenCacheLocation(devenvId), `${bearerToken}`, 'utf8') } diff --git a/packages/core/src/codecatalyst/reconnect.ts b/packages/core/src/codecatalyst/reconnect.ts index 63a932c7d1a..ecedbd3eb04 100644 --- a/packages/core/src/codecatalyst/reconnect.ts +++ b/packages/core/src/codecatalyst/reconnect.ts @@ -180,10 +180,9 @@ async function pollDevEnvs( // Don't watch this devenv, it is already being re-opened in SSH. delete devenvs[id] } else if (!isDevenvVscode(metadata.ides)) { - // Technically vscode _can_ connect to a ideRuntime=jetbrains/cloud9 devenv, but - // we refuse to anyway so that the experience is consistent with other IDEs - // (jetbrains/cloud9) which are not capable of connecting to a devenv that lacks - // their runtime/bootstrap files. + // Technically vscode _can_ connect to a ideRuntime=jetbrains devenv, but + // we refuse to anyway so that the experience is consistent since that devenv + // is not capable of connecting to a devenv that lacks their runtime/bootstrap files. const ide = metadata.ides?.[0] const toIde = ide ? ` to "${ide.name}"` : '' progress.report({ message: `Dev Environment ${devenvName} was switched${toIde}` }) diff --git a/packages/core/src/codecatalyst/vue/configure/backend.ts b/packages/core/src/codecatalyst/vue/configure/backend.ts index e2ff928f2c6..e5dd360cac7 100644 --- a/packages/core/src/codecatalyst/vue/configure/backend.ts +++ b/packages/core/src/codecatalyst/vue/configure/backend.ts @@ -25,7 +25,6 @@ import { updateDevfileCommand } from '../../devfile' import { showViewLogsMessage } from '../../../shared/utilities/messages' import { isLongReconnect, removeReconnectionInformation, saveReconnectionInformation } from '../../reconnect' import { CodeCatalystClient, DevEnvironment } from '../../../shared/clients/codecatalystClient' -import { isCloud9 } from '../../../shared/extensionUtilities' const localize = nls.loadMessageBundle() @@ -165,7 +164,7 @@ export async function showConfigureDevEnv( activePanel ??= new Panel(ctx, client, devenv, commands) const webview = await activePanel.show({ title: localize('AWS.view.configureDevEnv.title', 'Dev Environment Settings'), - viewColumn: isCloud9() ? vscode.ViewColumn.One : vscode.ViewColumn.Active, + viewColumn: vscode.ViewColumn.Active, }) if (!subscriptions) { diff --git a/packages/core/src/codecatalyst/vue/create/backend.ts b/packages/core/src/codecatalyst/vue/create/backend.ts index d2531b12923..bdf49419243 100644 --- a/packages/core/src/codecatalyst/vue/create/backend.ts +++ b/packages/core/src/codecatalyst/vue/create/backend.ts @@ -29,7 +29,6 @@ import { isThirdPartyRepo, } from '../../../shared/clients/codecatalystClient' import { CancellationError } from '../../../shared/utilities/timeoutUtils' -import { isCloud9 } from '../../../shared/extensionUtilities' import { telemetry } from '../../../shared/telemetry/telemetry' import { isNonNullable } from '../../../shared/utilities/tsUtils' import { createOrgPrompter, createProjectPrompter } from '../../wizards/selectResource' @@ -267,7 +266,7 @@ export async function showCreateDevEnv( const webview = await activePanel!.show({ title: localize('AWS.view.createDevEnv.title', 'Create a CodeCatalyst Dev Environment'), - viewColumn: isCloud9() ? vscode.ViewColumn.One : vscode.ViewColumn.Active, + viewColumn: vscode.ViewColumn.Active, }) if (!subscriptions) { diff --git a/packages/core/src/codecatalyst/wizards/devenvSettings.ts b/packages/core/src/codecatalyst/wizards/devenvSettings.ts index 590b936bfb2..676b65fb74c 100644 --- a/packages/core/src/codecatalyst/wizards/devenvSettings.ts +++ b/packages/core/src/codecatalyst/wizards/devenvSettings.ts @@ -50,7 +50,9 @@ export function getInstanceDescription(type: InstanceType): InstanceDescription export function getAllInstanceDescriptions(): { [key: string]: InstanceDescription } { const desc: { [key: string]: InstanceDescription } = {} - entries(devenvOptions.instanceType).forEach(([k]) => (desc[k] = getInstanceDescription(k))) + for (const [k] of entries(devenvOptions.instanceType)) { + desc[k] = getInstanceDescription(k) + } return desc } diff --git a/packages/core/src/codecatalyst/wizards/selectResource.ts b/packages/core/src/codecatalyst/wizards/selectResource.ts index d1c3de3328b..54e3bb20951 100644 --- a/packages/core/src/codecatalyst/wizards/selectResource.ts +++ b/packages/core/src/codecatalyst/wizards/selectResource.ts @@ -4,7 +4,6 @@ */ import * as vscode from 'vscode' -import { isCloud9 } from '../../shared/extensionUtilities' import * as codecatalyst from '../../shared/clients/codecatalystClient' import { createCommonButtons, createRefreshButton } from '../../shared/ui/buttons' import { @@ -104,7 +103,7 @@ function createResourcePrompter( export function createOrgPrompter( client: codecatalyst.CodeCatalystClient ): QuickPickPrompter { - const helpUri = isCloud9() ? docs.cloud9.main : docs.vscode.main + const helpUri = docs.main return createResourcePrompter(client.listSpaces(), helpUri, { title: 'Select a CodeCatalyst Organization', placeholder: 'Search for an Organization', @@ -115,7 +114,7 @@ export function createProjectPrompter( client: codecatalyst.CodeCatalystClient, spaceName?: codecatalyst.CodeCatalystOrg['name'] ): QuickPickPrompter { - const helpUri = isCloud9() ? docs.cloud9.main : docs.vscode.main + const helpUri = docs.main const projects = spaceName ? client.listProjects({ spaceName }) : client.listResources('project') return createResourcePrompter(projects, helpUri, { @@ -129,7 +128,7 @@ export function createRepoPrompter( proj?: codecatalyst.CodeCatalystProject, thirdParty?: boolean ): QuickPickPrompter { - const helpUri = isCloud9() ? docs.cloud9.cloneRepo : docs.vscode.main + const helpUri = docs.main const repos = proj ? client.listSourceRepositories({ spaceName: proj.org.name, projectName: proj.name }, thirdParty) : client.listResources('repo', thirdParty) @@ -144,7 +143,7 @@ export function createDevEnvPrompter( client: codecatalyst.CodeCatalystClient, proj?: codecatalyst.CodeCatalystProject ): QuickPickPrompter { - const helpUri = isCloud9() ? docs.cloud9.devenv : docs.vscode.devenv + const helpUri = docs.devenv const envs = proj ? client.listDevEnvironments(proj) : client.listResources('devEnvironment') const filtered = envs.map((arr) => arr.filter((env) => isDevenvVscode(env.ides))) const isData = (obj: T | DataQuickPickItem['data']): obj is T => { diff --git a/packages/core/src/codewhisperer/activation.ts b/packages/core/src/codewhisperer/activation.ts index 0ed50296da4..17934f2fe38 100644 --- a/packages/core/src/codewhisperer/activation.ts +++ b/packages/core/src/codewhisperer/activation.ts @@ -9,7 +9,6 @@ import { getTabSizeSetting } from '../shared/utilities/editorUtilities' import { KeyStrokeHandler } from './service/keyStrokeHandler' import * as EditorContext from './util/editorContext' import * as CodeWhispererConstants from './models/constants' -import { getCompletionItems } from './service/completionProvider' import { vsCodeState, ConfigurationEntry, @@ -18,18 +17,16 @@ import { SecurityTreeViewFilterState, AggregatedCodeScanIssue, CodeScanIssue, + CodeIssueGroupingStrategyState, } from './models/model' import { invokeRecommendation } from './commands/invokeRecommendation' import { acceptSuggestion } from './commands/onInlineAcceptance' -import { resetIntelliSenseState } from './util/globalStateUtil' import { CodeWhispererSettings } from './util/codewhispererSettings' import { ExtContext } from '../shared/extensions' -import { TextEditorSelectionChangeKind } from 'vscode' import { CodeWhispererTracker } from './tracker/codewhispererTracker' import * as codewhispererClient from './client/codewhisperer' import { runtimeLanguageContext } from './util/runtimeLanguageContext' import { getLogger } from '../shared/logger' -import { isCloud9 } from '../shared/extensionUtilities' import { enableCodeSuggestions, toggleCodeSuggestions, @@ -60,6 +57,7 @@ import { ignoreAllIssues, focusIssue, showExploreAgentsView, + showCodeIssueGroupingQuickPick, } from './commands/basicCommands' import { sleep } from '../shared/utilities/timeoutUtils' import { ReferenceLogViewProvider } from './service/referenceLogViewProvider' @@ -99,6 +97,7 @@ import { SecurityIssueTreeViewProvider } from './service/securityIssueTreeViewPr import { setContext } from '../shared/vscode/setContext' import { syncSecurityIssueWebview } from './views/securityIssue/securityIssueWebview' import { detectCommentAboveLine } from '../shared/utilities/commentUtils' +import { UserWrittenCodeTracker } from './tracker/userWrittenCodeTracker' let localize: nls.LocalizeFunc @@ -106,21 +105,10 @@ export async function activate(context: ExtContext): Promise { localize = nls.loadMessageBundle() const codewhispererSettings = CodeWhispererSettings.instance - // Import old CodeWhisperer settings into Amazon Q - await CodeWhispererSettings.instance.importSettings() - // initialize AuthUtil earlier to make sure it can listen to connection change events. const auth = AuthUtil.instance auth.initCodeWhispererHooks() - /** - * Enable essential intellisense default settings for AWS C9 IDE - */ - - if (isCloud9()) { - await enableDefaultConfigCloud9() - } - // TODO: is this indirection useful? registerDeclaredCommands( context.extensionContext.subscriptions, @@ -132,7 +120,9 @@ export async function activate(context: ExtContext): Promise { * CodeWhisperer security panel */ const securityPanelViewProvider = new SecurityPanelViewProvider(context.extensionContext) - activateSecurityScan() + context.extensionContext.subscriptions.push( + vscode.window.registerWebviewViewProvider(SecurityPanelViewProvider.viewType, securityPanelViewProvider) + ) // TODO: this is already done in packages/core/src/extensionCommon.ts, why doesn't amazonq use that? registerCommandErrorHandler((info, error) => { @@ -291,6 +281,8 @@ export async function activate(context: ExtContext): Promise { listCodeWhispererCommands.register(), // quick pick with security issues tree filters showSecurityIssueFilters.register(), + // quick pick code issue grouping strategy + showCodeIssueGroupingQuickPick.register(), // reset security issue filters clearFilters.register(), // handle security issues tree item clicked @@ -299,6 +291,10 @@ export async function activate(context: ExtContext): Promise { SecurityTreeViewFilterState.instance.onDidChangeState((e) => { SecurityIssueTreeViewProvider.instance.refresh() }), + // refresh treeview when grouping strategy changes + CodeIssueGroupingStrategyState.instance.onDidChangeState((e) => { + SecurityIssueTreeViewProvider.instance.refresh() + }), // show a no match state SecurityIssueTreeViewProvider.instance.onDidChangeTreeData((e) => { const noMatches = @@ -484,22 +480,6 @@ export async function activate(context: ExtContext): Promise { }) } - function activateSecurityScan() { - context.extensionContext.subscriptions.push( - vscode.window.registerWebviewViewProvider(SecurityPanelViewProvider.viewType, securityPanelViewProvider) - ) - - context.extensionContext.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor((editor) => { - if (isCloud9()) { - if (editor) { - securityPanelViewProvider.setDecoration(editor, editor.document.uri) - } - } - }) - ) - } - function getAutoTriggerStatus(): boolean { return CodeSuggestionsState.instance.isSuggestionsEnabled() } @@ -520,9 +500,7 @@ export async function activate(context: ExtContext): Promise { } } - if (isCloud9()) { - setSubscriptionsforCloud9() - } else if (isInlineCompletionEnabled()) { + if (isInlineCompletionEnabled()) { await setSubscriptionsforInlineCompletion() await AuthUtil.instance.setVscodeContextProps() } @@ -555,7 +533,7 @@ export async function activate(context: ExtContext): Promise { } CodeWhispererCodeCoverageTracker.getTracker(e.document.languageId)?.countTotalTokens(e) - + UserWrittenCodeTracker.instance.onTextDocumentChange(e) /** * Handle this keystroke event only when * 1. It is not a backspace @@ -586,80 +564,6 @@ export async function activate(context: ExtContext): Promise { ) } - function setSubscriptionsforCloud9() { - /** - * Manual trigger - */ - context.extensionContext.subscriptions.push( - vscode.languages.registerCompletionItemProvider([...CodeWhispererConstants.platformLanguageIds], { - async provideCompletionItems( - document: vscode.TextDocument, - position: vscode.Position, - token: vscode.CancellationToken, - context: vscode.CompletionContext - ) { - const completionList = new vscode.CompletionList(getCompletionItems(document, position), false) - return completionList - }, - }), - /** - * Automated trigger - */ - vscode.workspace.onDidChangeTextDocument(async (e) => { - const editor = vscode.window.activeTextEditor - if (!editor) { - return - } - if (e.document !== editor.document) { - return - } - if (!runtimeLanguageContext.isLanguageSupported(e.document)) { - return - } - /** - * CodeWhisperer security panel dynamic handling - */ - securityPanelViewProvider.disposeSecurityPanelItem(e, editor) - CodeWhispererCodeCoverageTracker.getTracker(e.document.languageId)?.countTotalTokens(e) - - if (e.contentChanges.length === 0 || vsCodeState.isCodeWhispererEditing) { - return - } - /** - * Important: Doing this sleep(10) is to make sure - * 1. this event is processed by vs code first - * 2. editor.selection.active has been successfully updated by VS Code - * Then this event can be processed by our code. - */ - await sleep(CodeWhispererConstants.vsCodeCursorUpdateDelay) - await KeyStrokeHandler.instance.processKeyStroke(e, editor, client, await getConfigEntry()) - }), - - /** - * On intelliSense recommendation rejection, reset set intelli sense is active state - * Maintaining this variable because VS Code does not expose official intelliSense isActive API - */ - vscode.window.onDidChangeVisibleTextEditors(async (e) => { - resetIntelliSenseState(true, getAutoTriggerStatus(), RecommendationHandler.instance.isValidResponse()) - }), - vscode.window.onDidChangeActiveTextEditor(async (e) => { - resetIntelliSenseState(true, getAutoTriggerStatus(), RecommendationHandler.instance.isValidResponse()) - }), - vscode.window.onDidChangeTextEditorSelection(async (e) => { - if (e.kind === TextEditorSelectionChangeKind.Mouse) { - resetIntelliSenseState( - true, - getAutoTriggerStatus(), - RecommendationHandler.instance.isValidResponse() - ) - } - }), - vscode.workspace.onDidSaveTextDocument(async (e) => { - resetIntelliSenseState(true, getAutoTriggerStatus(), RecommendationHandler.instance.isValidResponse()) - }) - ) - } - void FeatureConfigProvider.instance.fetchFeatureConfigs().catch((error) => { getLogger().error('Failed to fetch feature configs - %s', error) }) @@ -670,27 +574,28 @@ export async function activate(context: ExtContext): Promise { function setSubscriptionsForCodeIssues() { context.extensionContext.subscriptions.push( vscode.workspace.onDidChangeTextDocument(async (e) => { - // verify the document is something with a finding - for (const issue of SecurityIssueProvider.instance.issues) { - if (issue.filePath === e.document.uri.fsPath) { - disposeSecurityDiagnostic(e) - - SecurityIssueProvider.instance.handleDocumentChange(e) - SecurityIssueTreeViewProvider.instance.refresh() - await syncSecurityIssueWebview(context) - - toggleIssuesVisibility((issue, filePath) => - filePath !== e.document.uri.fsPath - ? issue.visible - : !detectCommentAboveLine( - e.document, - issue.startLine, - CodeWhispererConstants.amazonqIgnoreNextLine - ) - ) - break - } + if (e.document.uri.scheme !== 'file') { + return } + const diagnostics = securityScanRender.securityDiagnosticCollection?.get(e.document.uri) + if (!diagnostics || diagnostics.length === 0) { + return + } + disposeSecurityDiagnostic(e) + + SecurityIssueProvider.instance.handleDocumentChange(e) + SecurityIssueTreeViewProvider.instance.refresh() + await syncSecurityIssueWebview(context) + + toggleIssuesVisibility((issue, filePath) => + filePath !== e.document.uri.fsPath + ? issue.visible + : !detectCommentAboveLine( + e.document, + issue.startLine, + CodeWhispererConstants.amazonqIgnoreNextLine + ) + ) }) ) } @@ -701,26 +606,15 @@ export async function shutdown() { await CodeWhispererTracker.getTracker().shutdown() } -export async function enableDefaultConfigCloud9() { - const editorSettings = vscode.workspace.getConfiguration('editor') - try { - await editorSettings.update('suggest.showMethods', true, vscode.ConfigurationTarget.Global) - // suggest.preview is available in vsc 1.57+ - await editorSettings.update('suggest.preview', true, vscode.ConfigurationTarget.Global) - await editorSettings.update('acceptSuggestionOnEnter', 'on', vscode.ConfigurationTarget.Global) - await editorSettings.update('snippetSuggestions', 'top', vscode.ConfigurationTarget.Global) - } catch (error) { - getLogger().error('amazonq: Failed to update user settings %O', error) - } -} - function toggleIssuesVisibility(visibleCondition: (issue: CodeScanIssue, filePath: string) => boolean) { const updatedIssues: AggregatedCodeScanIssue[] = SecurityIssueProvider.instance.issues.map((group) => ({ ...group, issues: group.issues.map((issue) => ({ ...issue, visible: visibleCondition(issue, group.filePath) })), })) securityScanRender.securityDiagnosticCollection?.clear() - updatedIssues.forEach((issue) => updateSecurityDiagnosticCollection(issue)) + for (const issue of updatedIssues) { + updateSecurityDiagnosticCollection(issue) + } SecurityIssueProvider.instance.issues = updatedIssues SecurityIssueTreeViewProvider.instance.refresh() } diff --git a/packages/core/src/codewhisperer/client/codewhisperer.ts b/packages/core/src/codewhisperer/client/codewhisperer.ts index c5a89b36e0c..7a869a68372 100644 --- a/packages/core/src/codewhisperer/client/codewhisperer.ts +++ b/packages/core/src/codewhisperer/client/codewhisperer.ts @@ -132,7 +132,7 @@ export class DefaultCodeWhispererClient { )) as CodeWhispererClient } - async createUserSdkClient(): Promise { + async createUserSdkClient(maxRetries?: number): Promise { const isOptedOut = CodeWhispererSettings.instance.isOptoutEnabled() session.setFetchCredentialStart() const bearerToken = await AuthUtil.instance.getBearerToken() @@ -144,6 +144,7 @@ export class DefaultCodeWhispererClient { apiConfig: userApiConfig, region: cwsprConfig.region, endpoint: cwsprConfig.endpoint, + maxRetries: maxRetries, credentials: new Credentials({ accessKeyId: 'xxx', secretAccessKey: 'xxx' }), onRequestSetup: [ (req) => { @@ -231,14 +232,14 @@ export class DefaultCodeWhispererClient { .promise() .then((resps) => { let logStr = 'amazonq: listAvailableCustomizations API request:' - resps.forEach((resp) => { + for (const resp of resps) { const requestId = resp.$response.requestId logStr += `\n${indent('RequestID: ', 4)}${requestId},\n${indent('Customizations:', 4)}` - resp.customizations.forEach((c, index) => { + for (const [index, c] of resp.customizations.entries()) { const entry = `${index.toString().padStart(2, '0')}: ${c.name?.trim()}` logStr += `\n${indent(entry, 8)}` - }) - }) + } + } getLogger().debug(logStr) return resps }) @@ -293,7 +294,8 @@ export class DefaultCodeWhispererClient { public async codeModernizerGetCodeTransformation( request: CodeWhispererUserClient.GetTransformationRequest ): Promise> { - return (await this.createUserSdkClient()).getTransformation(request).promise() + // instead of the default of 3 retries, use 8 retries for this API which is polled every 5 seconds + return (await this.createUserSdkClient(8)).getTransformation(request).promise() } /** @@ -316,7 +318,8 @@ export class DefaultCodeWhispererClient { public async codeModernizerGetCodeTransformationPlan( request: CodeWhispererUserClient.GetTransformationPlanRequest ): Promise> { - return (await this.createUserSdkClient()).getTransformationPlan(request).promise() + // instead of the default of 3 retries, use 8 retries for this API which is polled every 5 seconds + return (await this.createUserSdkClient(8)).getTransformationPlan(request).promise() } public async startCodeFixJob( diff --git a/packages/core/src/codewhisperer/client/user-service-2.json b/packages/core/src/codewhisperer/client/user-service-2.json index 123160fb0b3..8a847e603d7 100644 --- a/packages/core/src/codewhisperer/client/user-service-2.json +++ b/packages/core/src/codewhisperer/client/user-service-2.json @@ -626,7 +626,9 @@ "timestamp": { "shape": "Timestamp" }, "unmodifiedAcceptedCharacterCount": { "shape": "PrimitiveInteger" }, "totalNewCodeCharacterCount": { "shape": "PrimitiveInteger" }, - "totalNewCodeLineCount": { "shape": "PrimitiveInteger" } + "totalNewCodeLineCount": { "shape": "PrimitiveInteger" }, + "userWrittenCodeCharacterCount": { "shape": "PrimitiveInteger" }, + "userWrittenCodeLineCount": { "shape": "PrimitiveInteger" } } }, "CodeFixAcceptanceEvent": { diff --git a/packages/core/src/codewhisperer/commands/basicCommands.ts b/packages/core/src/codewhisperer/commands/basicCommands.ts index 8f428419853..3a614b003f0 100644 --- a/packages/core/src/codewhisperer/commands/basicCommands.ts +++ b/packages/core/src/codewhisperer/commands/basicCommands.ts @@ -27,7 +27,6 @@ import { connectToEnterpriseSso, getStartUrl } from '../util/getStartUrl' import { showCodeWhispererConnectionPrompt } from '../util/showSsoPrompt' import { ReferenceLogViewProvider } from '../service/referenceLogViewProvider' import { AuthUtil } from '../util/authUtil' -import { isCloud9 } from '../../shared/extensionUtilities' import { getLogger } from '../../shared/logger' import { isExtensionActive, isExtensionInstalled, localize, openUrl } from '../../shared/utilities/vsCodeUtils' import { @@ -50,7 +49,7 @@ import { once } from '../../shared/utilities/functionUtils' import { focusAmazonQPanel } from '../../codewhispererChat/commands/registerCommands' import { removeDiagnostic } from '../service/diagnosticsProvider' import { SsoAccessTokenProvider } from '../../auth/sso/ssoAccessTokenProvider' -import { ToolkitError, getTelemetryReason, getTelemetryReasonDesc } from '../../shared/errors' +import { ToolkitError, getErrorMsg, getTelemetryReason, getTelemetryReasonDesc } from '../../shared/errors' import { isRemoteWorkspace } from '../../shared/vscode/env' import { isBuilderIdConnection } from '../../auth/connection' import globals from '../../shared/extensionGlobals' @@ -66,6 +65,9 @@ import { cancel, confirm } from '../../shared' import { startCodeFixGeneration } from './startCodeFixGeneration' import { DefaultAmazonQAppInitContext } from '../../amazonq/apps/initContext' import path from 'path' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' +import { parsePatch } from 'diff' +import { createCodeIssueGroupingStrategyPrompter } from '../ui/prompters' const MessageTimeOut = 5_000 @@ -104,9 +106,7 @@ export const enableCodeSuggestions = Commands.declare( await setContext('aws.codewhisperer.connected', true) await setContext('aws.codewhisperer.connectionExpired', false) vsCodeState.isFreeTierLimitReached = false - if (!isCloud9()) { - await vscode.commands.executeCommand('aws.amazonq.refreshStatusBar') - } + await vscode.commands.executeCommand('aws.amazonq.refreshStatusBar') } ) @@ -222,7 +222,7 @@ export const showFileScan = Commands.declare( scanUuid ) } else if (onDemandFileScanState.isRunning()) { - //TODO: Pending with progress bar implementation in the Q chat Panel + // TODO: Pending with progress bar implementation in the Q chat Panel // User intends to stop the scan from Q chat panel. // Cancel only when the file scan state is "Running" await confirmStopSecurityScan( @@ -450,6 +450,7 @@ export const applySecurityFix = Commands.declare( } let languageId = undefined try { + UserWrittenCodeTracker.instance.onQStartsMakingEdits() const document = await vscode.workspace.openTextDocument(targetFilePath) languageId = document.languageId const updatedContent = await getPatchedCode(targetFilePath, suggestedFix.code) @@ -459,12 +460,21 @@ export const applySecurityFix = Commands.declare( } const edit = new vscode.WorkspaceEdit() - edit.replace( - document.uri, - new vscode.Range(document.lineAt(0).range.start, document.lineAt(document.lineCount - 1).range.end), - updatedContent - ) - SecurityIssueProvider.instance.disableEventHandler() + const diffs = parsePatch(suggestedFix.code) + for (const diff of diffs) { + for (const hunk of [...diff.hunks].reverse()) { + const startLine = document.lineAt(hunk.oldStart - 1) + const endLine = document.lineAt(hunk.oldStart - 1 + hunk.oldLines - 1) + const range = new vscode.Range(startLine.range.start, endLine.range.end) + + const newText = updatedContent + .split('\n') + .slice(hunk.newStart - 1, hunk.newStart - 1 + hunk.newLines) + .join('\n') + + edit.replace(document.uri, range, newText) + } + } const isApplied = await vscode.workspace.applyEdit(edit) if (isApplied) { void document.save().then((didSave) => { @@ -479,42 +489,44 @@ export const applySecurityFix = Commands.declare( const fileName = path.basename(targetFilePath) const time = new Date().toLocaleString() // TODO: this is duplicated in controller.ts for test. Fix this later. - suggestedFix.references?.forEach((reference) => { - getLogger().debug('Processing reference: %O', reference) - // Log values for debugging - getLogger().debug('suggested fix code: %s', suggestedFix.code) - getLogger().debug('updated content: %s', updatedContent) - getLogger().debug( - 'start: %d, end: %d', - reference.recommendationContentSpan?.start, - reference.recommendationContentSpan?.end - ) - // given a start and end index, figure out which line number they belong to when splitting a string on /n characters - const getLineNumber = (content: string, index: number): number => { - const lines = content.slice(0, index).split('\n') - return lines.length + if (suggestedFix.references) { + for (const reference of suggestedFix.references) { + getLogger().debug('Processing reference: %O', reference) + // Log values for debugging + getLogger().debug('suggested fix code: %s', suggestedFix.code) + getLogger().debug('updated content: %s', updatedContent) + getLogger().debug( + 'start: %d, end: %d', + reference.recommendationContentSpan?.start, + reference.recommendationContentSpan?.end + ) + // given a start and end index, figure out which line number they belong to when splitting a string on /n characters + const getLineNumber = (content: string, index: number): number => { + const lines = content.slice(0, index).split('\n') + return lines.length + } + const startLine = getLineNumber(updatedContent, reference.recommendationContentSpan!.start!) + const endLine = getLineNumber(updatedContent, reference.recommendationContentSpan!.end!) + getLogger().debug('startLine: %d, endLine: %d', startLine, endLine) + const code = updatedContent.slice( + reference.recommendationContentSpan?.start, + reference.recommendationContentSpan?.end + ) + getLogger().debug('Extracted code slice: %s', code) + const referenceLog = + `[${time}] Accepted recommendation ` + + CodeWhispererConstants.referenceLogText( + `
${code}
`, + reference.licenseName!, + reference.repository!, + fileName, + startLine === endLine ? `(line at ${startLine})` : `(lines from ${startLine} to ${endLine})` + ) + + '
' + getLogger().debug('Adding reference log: %s', referenceLog) + ReferenceLogViewProvider.instance.addReferenceLog(referenceLog) } - const startLine = getLineNumber(updatedContent, reference.recommendationContentSpan!.start!) - const endLine = getLineNumber(updatedContent, reference.recommendationContentSpan!.end!) - getLogger().debug('startLine: %d, endLine: %d', startLine, endLine) - const code = updatedContent.slice( - reference.recommendationContentSpan?.start, - reference.recommendationContentSpan?.end - ) - getLogger().debug('Extracted code slice: %s', code) - const referenceLog = - `[${time}] Accepted recommendation ` + - CodeWhispererConstants.referenceLogText( - `
${code}
`, - reference.licenseName!, - reference.repository!, - fileName, - startLine === endLine ? `(line at ${startLine})` : `(lines from ${startLine} to ${endLine})` - ) + - '
' - getLogger().debug('Adding reference log: %s', referenceLog) - ReferenceLogViewProvider.instance.addReferenceLog(referenceLog) - }) + } removeDiagnostic(document.uri, targetIssue) SecurityIssueProvider.instance.removeIssue(document.uri, targetIssue) @@ -553,6 +565,7 @@ export const applySecurityFix = Commands.declare( applyFixTelemetryEntry.result, !!targetIssue.suggestedFixes.length ) + UserWrittenCodeTracker.instance.onQFinishesEdits() } } ) @@ -665,7 +678,8 @@ export const generateFix = Commands.declare( }) await updateSecurityIssueWebview({ isGenerateFixLoading: true, - isGenerateFixError: false, + // eslint-disable-next-line unicorn/no-null + generateFixError: null, context: context.extensionContext, filePath: targetFilePath, shouldRefreshView: false, @@ -722,25 +736,27 @@ export const generateFix = Commands.declare( SecurityIssueProvider.instance.updateIssue(updatedIssue, targetFilePath) SecurityIssueTreeViewProvider.instance.refresh() } catch (err) { + const error = err instanceof Error ? err : new TypeError('Unexpected error') await updateSecurityIssueWebview({ issue: targetIssue, isGenerateFixLoading: false, - isGenerateFixError: true, + generateFixError: getErrorMsg(error, true), filePath: targetFilePath, context: context.extensionContext, - shouldRefreshView: true, + shouldRefreshView: false, }) SecurityIssueProvider.instance.updateIssue(targetIssue, targetFilePath) SecurityIssueTreeViewProvider.instance.refresh() throw err + } finally { + telemetry.record({ + component: targetSource, + detectorId: targetIssue.detectorId, + findingId: targetIssue.findingId, + ruleId: targetIssue.ruleId, + variant: refresh ? 'refresh' : undefined, + }) } - telemetry.record({ - component: targetSource, - detectorId: targetIssue.detectorId, - findingId: targetIssue.findingId, - ruleId: targetIssue.ruleId, - variant: refresh ? 'refresh' : undefined, - }) }) } ) @@ -872,6 +888,14 @@ export const showSecurityIssueFilters = Commands.declare({ id: 'aws.amazonq.secu } }) +export const showCodeIssueGroupingQuickPick = Commands.declare( + { id: 'aws.amazonq.codescan.showGroupingStrategy' }, + () => async () => { + const prompter = createCodeIssueGroupingStrategyPrompter() + await prompter.prompt() + } +) + export const focusIssue = Commands.declare( { id: 'aws.amazonq.security.focusIssue' }, () => async (issue: CodeScanIssue, filePath: string) => { diff --git a/packages/core/src/codewhisperer/commands/gettingStartedPageCommands.ts b/packages/core/src/codewhisperer/commands/gettingStartedPageCommands.ts index 110a2adf011..ab7c8e00afc 100644 --- a/packages/core/src/codewhisperer/commands/gettingStartedPageCommands.ts +++ b/packages/core/src/codewhisperer/commands/gettingStartedPageCommands.ts @@ -19,8 +19,8 @@ export class CodeWhispererCommandBackend { } const prompts = AmazonQPromptSettings.instance - //To check the condition If the user has already seen the welcome message - if (!(await prompts.isPromptEnabled('codeWhispererNewWelcomeMessage'))) { + // To check the condition If the user has already seen the welcome message + if (!prompts.isPromptEnabled('codeWhispererNewWelcomeMessage')) { telemetry.ui_click.emit({ elementId: 'codewhisperer_Learn_ButtonClick', passive: true }) } return showCodeWhispererWebview(this.extContext, source) diff --git a/packages/core/src/codewhisperer/commands/invokeRecommendation.ts b/packages/core/src/codewhisperer/commands/invokeRecommendation.ts index 508639ac45b..37fcb965774 100644 --- a/packages/core/src/codewhisperer/commands/invokeRecommendation.ts +++ b/packages/core/src/codewhisperer/commands/invokeRecommendation.ts @@ -7,7 +7,6 @@ import * as vscode from 'vscode' import { vsCodeState, ConfigurationEntry } from '../models/model' import { resetIntelliSenseState } from '../util/globalStateUtil' import { DefaultCodeWhispererClient } from '../client/codewhisperer' -import { isCloud9 } from '../../shared/extensionUtilities' import { RecommendationHandler } from '../service/recommendationHandler' import { session } from '../util/codeWhispererSession' import { RecommendationService } from '../service/recommendationService' @@ -21,17 +20,7 @@ export async function invokeRecommendation( client: DefaultCodeWhispererClient, config: ConfigurationEntry ) { - if (!config.isManualTriggerEnabled) { - return - } - /** - * IntelliSense in Cloud9 needs editor.suggest.showMethods - */ - if (!config.isShowMethodsEnabled && isCloud9()) { - void vscode.window.showWarningMessage('Turn on "editor.suggest.showMethods" to use Amazon Q inline suggestions') - return - } - if (!editor) { + if (!editor || !config.isManualTriggerEnabled) { return } diff --git a/packages/core/src/codewhisperer/commands/onAcceptance.ts b/packages/core/src/codewhisperer/commands/onAcceptance.ts index 7ed36cde581..e13c197cefd 100644 --- a/packages/core/src/codewhisperer/commands/onAcceptance.ts +++ b/packages/core/src/codewhisperer/commands/onAcceptance.ts @@ -9,7 +9,6 @@ import { runtimeLanguageContext } from '../util/runtimeLanguageContext' import { CodeWhispererTracker } from '../tracker/codewhispererTracker' import { CodeWhispererCodeCoverageTracker } from '../tracker/codewhispererCodeCoverageTracker' import { getLogger } from '../../shared/logger/logger' -import { isCloud9 } from '../../shared/extensionUtilities' import { handleExtraBrackets } from '../util/closingBracketUtil' import { RecommendationHandler } from '../service/recommendationHandler' import { ReferenceLogViewProvider } from '../service/referenceLogViewProvider' @@ -30,7 +29,7 @@ export async function onAcceptance(acceptanceEntry: OnRecommendationAcceptanceEn path.extname(acceptanceEntry.editor.document.fileName) ) const start = acceptanceEntry.range.start - const end = isCloud9() ? acceptanceEntry.editor.selection.active : acceptanceEntry.range.end + const end = acceptanceEntry.range.end // codewhisperer will be doing editing while formatting. // formatting should not trigger consoals auto trigger @@ -45,13 +44,8 @@ export async function onAcceptance(acceptanceEntry: OnRecommendationAcceptanceEn } // move cursor to end of suggestion before doing code format // after formatting, the end position will still be editor.selection.active - if (!isCloud9()) { - acceptanceEntry.editor.selection = new vscode.Selection(end, end) - } + acceptanceEntry.editor.selection = new vscode.Selection(end, end) - if (isCloud9()) { - vsCodeState.isIntelliSenseActive = false - } vsCodeState.isCodeWhispererEditing = false CodeWhispererTracker.getTracker().enqueue({ time: new Date(), diff --git a/packages/core/src/codewhisperer/commands/onInlineAcceptance.ts b/packages/core/src/codewhisperer/commands/onInlineAcceptance.ts index 3fd91d0f996..da581d1aacc 100644 --- a/packages/core/src/codewhisperer/commands/onInlineAcceptance.ts +++ b/packages/core/src/codewhisperer/commands/onInlineAcceptance.ts @@ -32,6 +32,7 @@ import { RecommendationService } from '../service/recommendationService' import { Container } from '../service/serviceContainer' import { telemetry } from '../../shared/telemetry' import { TelemetryHelper } from '../util/telemetryHelper' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' export const acceptSuggestion = Commands.declare( 'aws.amazonq.accept', @@ -126,6 +127,7 @@ export async function onInlineAcceptance(acceptanceEntry: OnRecommendationAccept acceptanceEntry.editor.document.getText(insertedCoderange), acceptanceEntry.editor.document.fileName ) + UserWrittenCodeTracker.instance.onQFinishesEdits() if (acceptanceEntry.references !== undefined) { const referenceLog = ReferenceLogViewProvider.getReferenceLog( acceptanceEntry.recommendation, diff --git a/packages/core/src/codewhisperer/commands/startSecurityScan.ts b/packages/core/src/codewhisperer/commands/startSecurityScan.ts index 698b9792187..ce91b0d6edd 100644 --- a/packages/core/src/codewhisperer/commands/startSecurityScan.ts +++ b/packages/core/src/codewhisperer/commands/startSecurityScan.ts @@ -6,7 +6,6 @@ import * as vscode from 'vscode' import * as nls from 'vscode-nls' import { ArtifactMap, DefaultCodeWhispererClient } from '../client/codewhisperer' -import { isCloud9 } from '../../shared/extensionUtilities' import { initSecurityScanRender } from '../service/diagnosticsProvider' import { SecurityPanelViewProvider } from '../views/securityPanelViewProvider' import { getLogger } from '../../shared/logger' @@ -291,16 +290,7 @@ export async function startSecurityScan( scanUuid ) } else { - showSecurityScanResults( - securityPanelViewProvider, - securityRecommendationCollection, - editor, - context, - scope, - zipMetadata, - total, - scanUuid - ) + showSecurityScanResults(securityRecommendationCollection, editor, context, scope, zipMetadata, total) } TelemetryHelper.instance.sendCodeScanSucceededEvent( codeScanTelemetryEntry.codewhispererLanguage, @@ -387,28 +377,22 @@ export async function startSecurityScan( } export function showSecurityScanResults( - securityPanelViewProvider: SecurityPanelViewProvider, securityRecommendationCollection: AggregatedCodeScanIssue[], editor: vscode.TextEditor | undefined, context: vscode.ExtensionContext, scope: CodeWhispererConstants.CodeAnalysisScope, zipMetadata: ZipMetadata, - totalIssues: number, - scanUuid: string | undefined + totalIssues: number ) { - if (isCloud9()) { - securityPanelViewProvider.addLines(securityRecommendationCollection, editor) - void vscode.commands.executeCommand('workbench.view.extension.aws-codewhisperer-security-panel') - } else { - initSecurityScanRender(securityRecommendationCollection, context, editor, scope) - if ( - totalIssues > 0 && - (scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT || - scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND) - ) { - SecurityIssuesTree.instance.focus() - } + initSecurityScanRender(securityRecommendationCollection, context, editor, scope) + if ( + totalIssues > 0 && + (scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT || + scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND) + ) { + SecurityIssuesTree.instance.focus() } + if (scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT) { populateCodeScanLogStream(zipMetadata.scannedFiles) } @@ -424,35 +408,32 @@ export function showScanResultsInChat( totalIssues: number, scanUuid: string | undefined ) { - if (isCloud9()) { - securityPanelViewProvider.addLines(securityRecommendationCollection, editor) - void vscode.commands.executeCommand('workbench.view.extension.aws-codewhisperer-security-panel') - } else { - const tabID = ChatSessionManager.Instance.getSession().tabID - const eventData = { - message: 'Show Findings in the Chat panel', - totalIssues, - securityRecommendationCollection, - fileName: scope === CodeAnalysisScope.FILE_ON_DEMAND ? [...zipMetadata.scannedFiles][0] : undefined, - tabID, - scope, - scanUuid, - } - switch (scope) { - case CodeAnalysisScope.PROJECT: - codeScanState.getChatControllers()?.showSecurityScan.fire(eventData) - break - case CodeAnalysisScope.FILE_ON_DEMAND: - onDemandFileScanState.getChatControllers()?.showSecurityScan.fire(eventData) - break - default: - break - } - initSecurityScanRender(securityRecommendationCollection, context, editor, scope) - if (totalIssues > 0) { - SecurityIssuesTree.instance.focus() - } + const tabID = ChatSessionManager.Instance.getSession().tabID + const eventData = { + message: 'Show Findings in the Chat panel', + totalIssues, + securityRecommendationCollection, + fileName: scope === CodeAnalysisScope.FILE_ON_DEMAND ? [...zipMetadata.scannedFiles][0] : undefined, + tabID, + scope, + scanUuid, + } + switch (scope) { + case CodeAnalysisScope.PROJECT: + codeScanState.getChatControllers()?.showSecurityScan.fire(eventData) + break + case CodeAnalysisScope.FILE_ON_DEMAND: + onDemandFileScanState.getChatControllers()?.showSecurityScan.fire(eventData) + break + default: + break } + + initSecurityScanRender(securityRecommendationCollection, context, editor, scope) + if (totalIssues > 0) { + SecurityIssuesTree.instance.focus() + } + populateCodeScanLogStream(zipMetadata.scannedFiles) if (scope === CodeAnalysisScope.PROJECT) { showScanCompletedNotification(totalIssues, zipMetadata.scannedFiles) diff --git a/packages/core/src/codewhisperer/commands/startTestGeneration.ts b/packages/core/src/codewhisperer/commands/startTestGeneration.ts index c4f6d06b939..429e3585d36 100644 --- a/packages/core/src/codewhisperer/commands/startTestGeneration.ts +++ b/packages/core/src/codewhisperer/commands/startTestGeneration.ts @@ -15,13 +15,13 @@ import { throwIfCancelled, } from '../service/testGenHandler' import path from 'path' -import { testGenState } from '..' +import { testGenState } from '../models/model' import { ChatSessionManager } from '../../amazonqTest/chat/storages/chatSession' -import { ChildProcess, spawn } from 'child_process' +import { ChildProcess, spawn } from 'child_process' // eslint-disable-line no-restricted-imports import { BuildStatus } from '../../amazonqTest/chat/session/session' import { fs } from '../../shared/fs/fs' import { TestGenerationJobStatus } from '../models/constants' -import { TestGenFailedError } from '../models/errors' +import { TestGenFailedError } from '../../amazonqTest/error' import { Range } from '../client/codewhispereruserclient' // eslint-disable-next-line unicorn/no-null @@ -37,7 +37,7 @@ export async function startTestGenerationProcess( ) { const logger = getLogger() const session = ChatSessionManager.Instance.getSession() - //TODO: Step 0: Initial Test Gen telemetry + // TODO: Step 0: Initial Test Gen telemetry try { logger.verbose(`Starting Test Generation `) logger.verbose(`Tab ID: ${tabID} !== ${session.tabID}`) @@ -75,8 +75,9 @@ export async function startTestGenerationProcess( try { artifactMap = await getPresignedUrlAndUploadTestGen(zipMetadata) } finally { - if (await fs.existsFile(path.join(testGenerationLogsDir, 'output.log'))) { - await fs.delete(path.join(testGenerationLogsDir, 'output.log')) + const outputLogPath = path.join(testGenerationLogsDir, 'output.log') + if (await fs.existsFile(outputLogPath)) { + await fs.delete(outputLogPath) } await zipUtil.removeTmpFiles(zipMetadata) session.artifactsUploadDuration = performance.now() - uploadStartTime @@ -118,8 +119,9 @@ export async function startTestGenerationProcess( fileName, initialExecution ) - //TODO: Send status to test summary + // TODO: Send status to test summary if (jobStatus === TestGenerationJobStatus.FAILED) { + session.numberOfTestsGenerated = 0 logger.verbose(`Test generation failed.`) throw new TestGenFailedError() } @@ -130,7 +132,7 @@ export async function startTestGenerationProcess( /** * Step 5: Process and show the view diff by getting the results from exportResultsArchive */ - //https://github.com/aws/aws-toolkit-vscode/blob/0164d4145e58ae036ddf3815455ea12a159d491d/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts#L314-L405 + // https://github.com/aws/aws-toolkit-vscode/blob/0164d4145e58ae036ddf3815455ea12a159d491d/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts#L314-L405 await exportResultsArchive( artifactMap.SourceCode, testJob.testGenerationJob.testGenerationJobGroupName, @@ -141,7 +143,7 @@ export async function startTestGenerationProcess( ) } catch (error) { logger.error(`startTestGenerationProcess failed: %O`, error) - //TODO: Send error message to Chat + // TODO: Send error message to Chat testGenState.getChatControllers()?.errorThrown.fire({ tabID: session.tabID, error: error, diff --git a/packages/core/src/codewhisperer/commands/startTransformByQ.ts b/packages/core/src/codewhisperer/commands/startTransformByQ.ts index d8cb2948f1f..154b8c07dcf 100644 --- a/packages/core/src/codewhisperer/commands/startTransformByQ.ts +++ b/packages/core/src/codewhisperer/commands/startTransformByQ.ts @@ -5,11 +5,10 @@ import * as vscode from 'vscode' import * as fs from 'fs' // eslint-disable-line no-restricted-imports -import * as os from 'os' -import * as xml2js from 'xml2js' import path from 'path' import { getLogger } from '../../shared/logger' import * as CodeWhispererConstants from '../models/constants' +import * as localizedText from '../../shared/localizedText' import { transformByQState, StepProgress, @@ -18,7 +17,6 @@ import { FolderInfo, ZipManifest, TransformByQStatus, - DB, TransformationType, TransformationCandidateProject, } from '../models/model' @@ -56,7 +54,6 @@ import { MetadataResult } from '../../shared/telemetry/telemetryClient' import { submitFeedback } from '../../feedback/vue/submitFeedback' import { placeholder } from '../../shared/vscode/commands2' import { - AbsolutePathDetectedError, AlternateDependencyVersionsNotFoundError, JavaHomeNotSetError, JobStartError, @@ -71,6 +68,7 @@ import { getJsonValuesFromManifestFile, highlightPomIssueInProject, parseVersionsListFromPomFile, + setMaven, writeLogs, } from '../service/transformByQ/transformFileHandler' import { sleep } from '../../shared/utilities/timeoutUtils' @@ -81,7 +79,6 @@ import { setContext } from '../../shared/vscode/setContext' import { makeTemporaryToolkitFolder } from '../../shared' import globals from '../../shared/extensionGlobals' import { convertDateToTimestamp } from '../../shared/datetime' -import { isWin } from '../../shared/vscode/env' import { findStringInDirectory } from '../../shared/utilities/workspaceUtils' function getFeedbackCommentData() { @@ -111,63 +108,6 @@ export async function processSQLConversionTransformFormInput(pathToProject: stri // targetJDKVersion defaults to JDK17, the only supported version, which is fine } -export async function validateSQLMetadataFile(fileContents: string, message: any) { - try { - const sctData = await xml2js.parseStringPromise(fileContents) - const dbEntities = sctData['tree']['instances'][0]['ProjectModel'][0]['entities'][0] - const sourceDB = dbEntities['sources'][0]['DbServer'][0]['$']['vendor'].trim().toUpperCase() - const targetDB = dbEntities['targets'][0]['DbServer'][0]['$']['vendor'].trim().toUpperCase() - const sourceServerName = dbEntities['sources'][0]['DbServer'][0]['$']['name'].trim() - transformByQState.setSourceServerName(sourceServerName) - if (sourceDB !== DB.ORACLE) { - transformByQState.getChatMessenger()?.sendUnrecoverableErrorResponse('unsupported-source-db', message.tabID) - return false - } else if (targetDB !== DB.AURORA_POSTGRESQL && targetDB !== DB.RDS_POSTGRESQL) { - transformByQState.getChatMessenger()?.sendUnrecoverableErrorResponse('unsupported-target-db', message.tabID) - return false - } - transformByQState.setSourceDB(sourceDB) - transformByQState.setTargetDB(targetDB) - - const serverNodeLocations = - sctData['tree']['instances'][0]['ProjectModel'][0]['relations'][0]['server-node-location'] - const schemaNames = new Set() - serverNodeLocations.forEach((serverNodeLocation: any) => { - const schemaNodes = serverNodeLocation['FullNameNodeInfoList'][0]['nameParts'][0][ - 'FullNameNodeInfo' - ].filter((node: any) => node['$']['typeNode'].toLowerCase() === 'schema') - schemaNodes.forEach((node: any) => { - schemaNames.add(node['$']['nameNode'].toUpperCase()) - }) - }) - transformByQState.setSchemaOptions(schemaNames) // user will choose one of these - getLogger().info( - `CodeTransformation: Parsed .sct file with source DB: ${sourceDB}, target DB: ${targetDB}, source host name: ${sourceServerName}, and schema names: ${Array.from(schemaNames)}` - ) - } catch (err: any) { - getLogger().error('CodeTransformation: Error parsing .sct file. %O', err) - transformByQState.getChatMessenger()?.sendUnrecoverableErrorResponse('error-parsing-sct-file', message.tabID) - return false - } - return true -} - -export async function setMaven() { - let mavenWrapperExecutableName = isWin() ? 'mvnw.cmd' : 'mvnw' - const mavenWrapperExecutablePath = path.join(transformByQState.getProjectPath(), mavenWrapperExecutableName) - if (fs.existsSync(mavenWrapperExecutablePath)) { - if (mavenWrapperExecutableName === 'mvnw') { - mavenWrapperExecutableName = './mvnw' // add the './' for non-Windows - } else if (mavenWrapperExecutableName === 'mvnw.cmd') { - mavenWrapperExecutableName = '.\\mvnw.cmd' // add the '.\' for Windows - } - transformByQState.setMavenName(mavenWrapperExecutableName) - } else { - transformByQState.setMavenName('mvn') - } - getLogger().info(`CodeTransformation: using Maven ${transformByQState.getMavenName()}`) -} - async function validateJavaHome(): Promise { const versionData = await getVersionData() let javaVersionUsedByMaven = versionData[1] @@ -290,45 +230,12 @@ export async function finalizeTransformByQ(status: string) { } } -export async function parseBuildFile() { - try { - const absolutePaths = ['users/', 'system/', 'volumes/', 'c:\\', 'd:\\'] - const alias = path.basename(os.homedir()) - absolutePaths.push(alias) - const buildFilePath = path.join(transformByQState.getProjectPath(), 'pom.xml') - if (fs.existsSync(buildFilePath)) { - const buildFileContents = fs.readFileSync(buildFilePath).toString().toLowerCase() - const detectedPaths = [] - for (const absolutePath of absolutePaths) { - if (buildFileContents.includes(absolutePath)) { - detectedPaths.push(absolutePath) - } - } - if (detectedPaths.length > 0) { - const warningMessage = CodeWhispererConstants.absolutePathDetectedMessage( - detectedPaths.length, - path.basename(buildFilePath), - detectedPaths.join(', ') - ) - transformByQState.getChatControllers()?.errorThrown.fire({ - error: new AbsolutePathDetectedError(warningMessage), - tabID: ChatSessionManager.Instance.getSession().tabID, - }) - getLogger().info('CodeTransformation: absolute path potentially in build file') - return warningMessage - } - } - } catch (err: any) { - // swallow error - getLogger().error(`CodeTransformation: error scanning for absolute paths, tranformation continuing: ${err}`) - } - return undefined -} - export async function preTransformationUploadCode() { await vscode.commands.executeCommand('aws.amazonq.transformationHub.focus') - void vscode.window.showInformationMessage(CodeWhispererConstants.jobStartedNotification) + void vscode.window.showInformationMessage(CodeWhispererConstants.jobStartedNotification, { + title: localizedText.ok, + }) let uploadId = '' throwIfCancelled() @@ -357,6 +264,7 @@ export async function preTransformationUploadCode() { transformByQState.setPayloadFilePath(payloadFilePath) uploadId = await uploadPayload(payloadFilePath) + telemetry.record({ codeTransformJobId: uploadId }) // uploadId is re-used as jobId }) } catch (err) { const errorMessage = (err as Error).message @@ -497,12 +405,6 @@ export async function openHilPomFile() { ) } -export async function openBuildLogFile() { - const logFilePath = transformByQState.getPreBuildLogFilePath() - const doc = await vscode.workspace.openTextDocument(logFilePath) - await vscode.window.showTextDocument(doc) -} - export async function terminateHILEarly(jobID: string) { // Call resume with "REJECTED" state which will put our service // back into the normal flow and will not trigger HIL again for this step @@ -752,14 +654,14 @@ export async function getValidSQLConversionCandidateProjects() { let resultLog = '' for (const project of javaProjects) { // as long as at least one of these strings is found, project contains embedded SQL statements - const searchStrings = ['oracle.jdbc.OracleDriver', 'jdbc:oracle:thin:@', 'jdbc:oracle:oci:@', 'jdbc:odbc:'] + const searchStrings = ['oracle.jdbc.', 'jdbc:oracle:', 'jdbc:odbc:'] for (const str of searchStrings) { const spawnResult = await findStringInDirectory(str, project.path) // just for telemetry purposes if (spawnResult.error || spawnResult.stderr) { - resultLog += `search failed: ${JSON.stringify(spawnResult)}` + resultLog += `search error: ${JSON.stringify(spawnResult)}--` } else { - resultLog += `search succeeded: ${spawnResult.exitCode}` + resultLog += `search complete (exit code: ${spawnResult.exitCode})--` } getLogger().info(`CodeTransformation: searching for ${str} in ${project.path}, result = ${resultLog}`) if (spawnResult.exitCode === 0) { @@ -835,20 +737,22 @@ export async function postTransformationJob() { const mavenVersionInfoMessage = `${versionInfo[0]} (${transformByQState.getMavenName()})` const javaVersionInfoMessage = `${versionInfo[1]} (${transformByQState.getMavenName()})` - // Note: IntelliJ implementation of ResultStatusMessage includes additional metadata such as jobId. telemetry.codeTransform_totalRunTime.emit({ buildSystemVersion: mavenVersionInfoMessage, codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId: transformByQState.getJobId(), codeTransformResultStatusMessage: resultStatusMessage, codeTransformRunTimeLatency: durationInMs, codeTransformLocalJavaVersion: javaVersionInfoMessage, result: resultStatusMessage === TransformByQStatus.Succeeded ? MetadataResult.Pass : MetadataResult.Fail, - reason: resultStatusMessage, + reason: `${resultStatusMessage}-${chatMessage}`, }) } if (transformByQState.isSucceeded()) { - void vscode.window.showInformationMessage(CodeWhispererConstants.jobCompletedNotification(diffMessage)) + void vscode.window.showInformationMessage(CodeWhispererConstants.jobCompletedNotification(diffMessage), { + title: localizedText.ok, + }) } else if (transformByQState.isPartiallySucceeded()) { void vscode.window .showInformationMessage( @@ -923,6 +827,7 @@ export async function stopTransformByQ(jobId: string) { await telemetry.codeTransform_jobIsCancelledByUser.run(async () => { telemetry.record({ codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId: jobId, }) if (transformByQState.isRunning()) { getLogger().info('CodeTransformation: User requested to stop transformation. Stopping transformation.') diff --git a/packages/core/src/codewhisperer/index.ts b/packages/core/src/codewhisperer/index.ts index 54a1c508322..3aea72fb4ca 100644 --- a/packages/core/src/codewhisperer/index.ts +++ b/packages/core/src/codewhisperer/index.ts @@ -26,6 +26,7 @@ export type { SendTelemetryEventResponse, TelemetryEvent, InlineChatEvent, + Customization, } from './client/codewhispereruserclient.d.ts' export type { default as CodeWhispererUserClient } from './client/codewhispereruserclient.d.ts' export { SecurityPanelViewProvider } from './views/securityPanelViewProvider' @@ -72,7 +73,7 @@ export { DocumentChangedSource, KeyStrokeHandler, DefaultDocumentChangedType } f export { ReferenceLogViewProvider } from './service/referenceLogViewProvider' export { LicenseUtil } from './util/licenseUtil' export { SecurityIssueProvider } from './service/securityIssueProvider' -export { listScanResults, mapToAggregatedList } from './service/securityScanHandler' +export { listScanResults, mapToAggregatedList, pollScanJobStatus } from './service/securityScanHandler' export { CodeWhispererCodeCoverageTracker } from './tracker/codewhispererCodeCoverageTracker' export { TelemetryHelper } from './util/telemetryHelper' export { LineSelection, LineTracker } from './tracker/lineTracker' @@ -96,8 +97,10 @@ export * as supplementalContextUtil from './util/supplementalContext/supplementa export * from './service/diagnosticsProvider' export * as diagnosticsProvider from './service/diagnosticsProvider' export * from './ui/codeWhispererNodes' -export { SecurityScanError } from '../codewhisperer/models/errors' +export { SecurityScanError, SecurityScanTimedOutError } from '../codewhisperer/models/errors' export * as CodeWhispererConstants from '../codewhisperer/models/constants' -export { getSelectedCustomization } from './util/customizationUtil' +export { getSelectedCustomization, setSelectedCustomization, baseCustomization } from './util/customizationUtil' export { Container } from './service/serviceContainer' export * from './util/gitUtil' +export * from './ui/prompters' +export { UserWrittenCodeTracker } from './tracker/userWrittenCodeTracker' diff --git a/packages/core/src/codewhisperer/models/constants.ts b/packages/core/src/codewhisperer/models/constants.ts index 97d9087595b..f0ff34dcb02 100644 --- a/packages/core/src/codewhisperer/models/constants.ts +++ b/packages/core/src/codewhisperer/models/constants.ts @@ -84,6 +84,7 @@ export const lineBreakWin = '\r\n' export const supplementalContextTimeoutInMs = 100 +export const supplementalContextMaxTotalLength = 20480 /** * Ux of recommendations */ @@ -97,6 +98,7 @@ export const completionDetail = 'Amazon Q' export const codewhisperer = 'Amazon Q' // use vscode languageId here / Supported languages +// TODO: Dropped Cloud9 support - do we need Cloud9-commented entries here? export const platformLanguageIds = [ 'java', 'python', @@ -257,9 +259,9 @@ export const codeScanZipExt = '.zip' export const contextTruncationTimeoutSeconds = 10 -export const codeScanJobTimeoutSeconds = 60 * 10 //10 minutes +export const standardScanTimeoutMs = 600_000 // 10 minutes -export const codeFileScanJobTimeoutSeconds = 60 * 10 //10 minutes +export const expressScanTimeoutMs = 60_000 export const codeFixJobTimeoutMs = 60_000 @@ -304,7 +306,9 @@ export const securityScanLanguageIds = [ 'csharp', 'go', 'ruby', - 'golang', // Cloud9 reports Go files with this language-id + // Cloud9 reports Go files with this language-id + // TODO: Dropped Cloud9 support - is this still needed? + 'golang', 'json', 'yaml', 'tf', @@ -396,7 +400,7 @@ export const failedToConnectIamIdentityCenter = `Failed to connect to IAM Identi export const stopScanMessage = 'Stop security review? This review will be counted as one complete review towards your monthly security review limits.' -//TODO: Change the Text according to the UX +// TODO: Change the Text according to the UX export const stopScanMessageInChat = 'Review is stopped. Retry reviews by selecting below options' export const showScannedFilesMessage = 'View Code Issues' @@ -501,7 +505,7 @@ export const codeTransformLocThreshold = 100000 export const jobStartedChatMessage = 'I am starting to transform your code. It can take 10 to 30 minutes to upgrade your code, depending on the size of your project. To monitor progress, go to the Transformation Hub. If I run into any issues, I might pause the transformation to get input from you on how to proceed.' -export const chooseTransformationObjective = `I can help you with the following tasks:\n- Upgrade your Java 8 and Java 11 codebases to Java 17, or upgrade Java 17 code with up to date libraries and other dependencies.\n- Convert embedded SQL code for Oracle to PostgreSQL database migrations in AWS DMS.\n\nWhat would you like to do? You can enter "language upgrade" or "sql conversion".` +export const chooseTransformationObjective = `I can help you with the following tasks:\n- Upgrade your Java 8 and Java 11 codebases to Java 17, or upgrade Java 17 code with up to date libraries and other dependencies.\n- Convert embedded SQL code for Oracle to PostgreSQL database migrations in AWS DMS. [Learn more](https://docs.aws.amazon.com/dms/latest/userguide/schema-conversion-embedded-sql.html).\n\nWhat would you like to do? You can enter "language upgrade" or "sql conversion".` export const chooseTransformationObjectivePlaceholder = 'Enter "language upgrade" or "sql conversion"' @@ -561,6 +565,8 @@ export const openTransformationHubButtonText = 'Open Transformation Hub' export const startTransformationButtonText = 'Start a new transformation' +export const viewSummaryButtonText = 'View summary' + export const stopTransformationButtonText = 'Stop transformation' export const checkingForProjectsChatMessage = 'Checking for eligible projects...' @@ -577,7 +583,7 @@ export const absolutePathDetectedMessage = (numPaths: number, buildFile: string, `I detected ${numPaths} potential absolute file path(s) in your ${buildFile} file: **${listOfPaths}**. Absolute file paths might cause issues when I build your code. Any errors will show up in the build log.` export const selectSQLMetadataFileHelpMessage = - 'Okay, I can convert the embedded SQL code for your Oracle to PostgreSQL transformation. To get started, upload the zipped metadata file from your schema conversion in AWS Data Migration Service (DMS). To retrieve the metadata file:\n1. Open your database migration project in the AWS DMS console.\n2. Open the schema conversion and choose **Convert the embedded SQL in your application**.\n3. Choose the link to Amazon S3 console.\n\nYou can download the metadata file from the {schema-conversion-project}/ directory. For more info, refer to the [documentation](https://docs.aws.amazon.com/dms/latest/userguide/schema-conversion-save-apply.html#schema-conversion-save).' + 'Okay, I can convert the embedded SQL code for your Oracle to PostgreSQL transformation. To get started, upload the zipped metadata file from your schema conversion in AWS Data Migration Service (DMS). To retrieve the metadata file:\n1. Open your database migration project in the AWS DMS console.\n2. Open the schema conversion and choose **Convert the embedded SQL in your application**.\n3. Once you complete the conversion, close the project and go to the S3 bucket where your project is stored.\n4. Open the folder and find the project folder ("sct-project").\n5. Download the object inside the project folder. This will be a zip file.\n\nFor more info, refer to the [documentation](https://docs.aws.amazon.com/dms/latest/userguide/schema-conversion-embedded-sql.html).' export const invalidMetadataFileUnsupportedSourceDB = 'I can only convert SQL for migrations from an Oracle source database. The provided .sct file indicates another source database for this migration.' @@ -723,17 +729,14 @@ export const noOpenProjectsFoundChatTestGenMessage = `Sorry, I couldn\'t find a export const unitTestGenerationCancelMessage = 'Unit test generation cancelled.' +export const tooManyRequestErrorMessage = 'Too many requests. Please wait before retrying.' + export const noJavaProjectsFoundChatMessage = `I couldn\'t find a project that I can upgrade. Currently, I support Java 8, Java 11, and Java 17 projects built on Maven. Make sure your project is open in the IDE. For more information, see the [Amazon Q documentation](${codeTransformPrereqDoc}).` export const linkToDocsHome = 'https://docs.aws.amazon.com/amazonq/latest/aws-builder-use-ug/code-transformation.html' export const linkToBillingInfo = 'https://aws.amazon.com/q/developer/pricing/' -export const linkToUploadZipTooLarge = - 'https://docs.aws.amazon.com/amazonq/latest/aws-builder-use-ug/troubleshooting-code-transformation.html#project-size-limit' - -export const linkToDownloadZipTooLarge = '' - export const dependencyFolderName = 'transformation_dependencies_temp_' export const cleanInstallErrorChatMessage = `Sorry, I couldn\'t run the Maven clean install command to build your project. For more information, see the [Amazon Q documentation](${codeTransformTroubleshootMvnFailure}).` @@ -743,7 +746,7 @@ export const cleanInstallErrorNotification = `Amazon Q could not run the Maven c export const enterJavaHomeChatMessage = 'Enter the path to JDK ' export const projectPromptChatMessage = - 'I can upgrade your JAVA_VERSION_HERE. To start the transformation, I need some information from you. Choose the project you want to upgrade and the target code version to upgrade to. Then, choose Confirm.' + 'I can upgrade your Java project. To start the transformation, I need some information from you. Choose the project you want to upgrade and the target code version to upgrade to. Then, choose Confirm.' export const windowsJavaHomeHelpChatMessage = 'To find the JDK path, run the following commands in a new terminal: `cd "C:/Program Files/Java"` and then `dir`. If you see your JDK version, run `cd ` and then `cd` to show the path.' @@ -863,7 +866,7 @@ export enum TestGenerationJobStatus { COMPLETED = 'COMPLETED', } -export enum ZipUseCase { +export enum FeatureUseCase { TEST_GENERATION = 'TEST_GENERATION', CODE_SCAN = 'CODE_SCAN', } diff --git a/packages/core/src/codewhisperer/models/errors.ts b/packages/core/src/codewhisperer/models/errors.ts index 3fe22f22af0..9466fede54d 100644 --- a/packages/core/src/codewhisperer/models/errors.ts +++ b/packages/core/src/codewhisperer/models/errors.ts @@ -172,3 +172,13 @@ export class CodeFixJobStoppedError extends CodeFixError { super('Code fix generation stopped by user.', 'CodeFixCancelled', defaultCodeFixErrorMessage) } } + +export class MonthlyCodeFixLimitError extends CodeFixError { + constructor() { + super( + i18n('AWS.amazonq.codefix.error.monthlyLimitReached'), + MonthlyCodeFixLimitError.name, + defaultCodeFixErrorMessage + ) + } +} diff --git a/packages/core/src/codewhisperer/models/model.ts b/packages/core/src/codewhisperer/models/model.ts index ade478ad875..d8eea5a018c 100644 --- a/packages/core/src/codewhisperer/models/model.ts +++ b/packages/core/src/codewhisperer/models/model.ts @@ -20,6 +20,7 @@ import { TransformationSteps } from '../client/codewhispereruserclient' import { Messenger } from '../../amazonqGumby/chat/controller/messenger/messenger' import { TestChatControllerEventEmitters } from '../../amazonqTest/chat/controller/controller' import { ScanChatControllerEventEmitters } from '../../amazonqScan/controller' +import { localize } from '../../shared/utilities/vsCodeUtils' // unavoidable global variables interface VsCodeState { @@ -47,11 +48,11 @@ export const vsCodeState: VsCodeState = { isFreeTierLimitReached: false, } -export type UtgStrategy = 'ByName' | 'ByContent' +export type UtgStrategy = 'byName' | 'byContent' export type CrossFileStrategy = 'opentabs' | 'codemap' | 'bm25' | 'default' -export type SupplementalContextStrategy = CrossFileStrategy | UtgStrategy | 'Empty' +export type SupplementalContextStrategy = CrossFileStrategy | UtgStrategy | 'empty' export type PatchInfo = { name: string @@ -370,7 +371,7 @@ enum TestGenStatus { Running, Cancelling, } -//TODO: Refactor model of /scan and /test +// TODO: Refactor model of /scan and /test export class TestGenState { // Define a constructor for this class private testGenState: TestGenStatus = TestGenStatus.NotStarted @@ -564,6 +565,52 @@ export class SecurityTreeViewFilterState { } } +export enum CodeIssueGroupingStrategy { + Severity = 'Severity', + FileLocation = 'FileLocation', +} +const defaultCodeIssueGroupingStrategy = CodeIssueGroupingStrategy.Severity + +export const codeIssueGroupingStrategies = Object.values(CodeIssueGroupingStrategy) +export const codeIssueGroupingStrategyLabel: Record = { + [CodeIssueGroupingStrategy.Severity]: localize('AWS.amazonq.scans.severity', 'Severity'), + [CodeIssueGroupingStrategy.FileLocation]: localize('AWS.amazonq.scans.fileLocation', 'File Location'), +} + +export class CodeIssueGroupingStrategyState { + #fallback: CodeIssueGroupingStrategy + #onDidChangeState = new vscode.EventEmitter() + onDidChangeState = this.#onDidChangeState.event + + static #instance: CodeIssueGroupingStrategyState + static get instance() { + return (this.#instance ??= new this()) + } + + protected constructor(fallback: CodeIssueGroupingStrategy = defaultCodeIssueGroupingStrategy) { + this.#fallback = fallback + } + + public getState(): CodeIssueGroupingStrategy { + const state = globals.globalState.tryGet('aws.amazonq.codescan.groupingStrategy', String) + return this.isValidGroupingStrategy(state) ? state : this.#fallback + } + + public async setState(_state: unknown) { + const state = this.isValidGroupingStrategy(_state) ? _state : this.#fallback + await globals.globalState.update('aws.amazonq.codescan.groupingStrategy', state) + this.#onDidChangeState.fire(state) + } + + private isValidGroupingStrategy(strategy: unknown): strategy is CodeIssueGroupingStrategy { + return Object.values(CodeIssueGroupingStrategy).includes(strategy as CodeIssueGroupingStrategy) + } + + public reset() { + return this.setState(this.#fallback) + } +} + /** * Q - Transform */ @@ -606,7 +653,7 @@ export enum JDKVersion { export enum DB { ORACLE = 'ORACLE', - RDS_POSTGRESQL = 'RDS_POSTGRESQL', + RDS_POSTGRESQL = 'POSTGRESQL', AURORA_POSTGRESQL = 'AURORA_POSTGRESQL', OTHER = 'OTHER', } @@ -1105,12 +1152,6 @@ export class TransformByQStoppedError extends ToolkitError { } } -export enum Cloud9AccessState { - NoAccess, - RequestedAccess, - HasAccess, -} - export interface TransformationCandidateProject { name: string path: string diff --git a/packages/core/src/codewhisperer/service/codeFixHandler.ts b/packages/core/src/codewhisperer/service/codeFixHandler.ts index 0358d8d3ed9..e260f3808ea 100644 --- a/packages/core/src/codewhisperer/service/codeFixHandler.ts +++ b/packages/core/src/codewhisperer/service/codeFixHandler.ts @@ -6,13 +6,14 @@ import { CodeWhispererUserClient } from '../indexNode' import * as CodeWhispererConstants from '../models/constants' import { codeFixState } from '../models/model' -import { getLogger, sleep } from '../../shared' +import { getLogger, isAwsError, sleep } from '../../shared' import { ArtifactMap, CreateUploadUrlRequest, DefaultCodeWhispererClient } from '../client/codewhisperer' import { CodeFixJobStoppedError, CodeFixJobTimedOutError, CreateCodeFixError, CreateUploadUrlError, + MonthlyCodeFixLimitError, } from '../models/errors' import { uploadArtifactToS3 } from './securityScanHandler' @@ -28,13 +29,13 @@ export async function getPresignedUrlAndUpload( } getLogger().verbose(`Prepare for uploading src context...`) const srcResp = await client.createUploadUrl(srcReq).catch((err) => { - getLogger().error(`Failed getting presigned url for uploading src context. Request id: ${err.requestId}`) - throw new CreateUploadUrlError(err) + getLogger().error('Failed getting presigned url for uploading src context. %O', err) + throw new CreateUploadUrlError(err.message) }) getLogger().verbose(`CreateUploadUrlRequest requestId: ${srcResp.$response.requestId}`) getLogger().verbose(`Complete Getting presigned Url for uploading src context.`) getLogger().verbose(`Uploading src context...`) - await uploadArtifactToS3(zipFilePath, srcResp) + await uploadArtifactToS3(zipFilePath, srcResp, CodeWhispererConstants.FeatureUseCase.CODE_SCAN) getLogger().verbose(`Complete uploading src context.`) const artifactMap: ArtifactMap = { SourceCode: srcResp.uploadId, @@ -60,7 +61,10 @@ export async function createCodeFixJob( } const resp = await client.startCodeFixJob(req).catch((err) => { - getLogger().error(`Failed creating code fix job. Request id: ${err.requestId}`) + getLogger().error('Failed creating code fix job. %O', err) + if (isAwsError(err) && err.code === 'ThrottlingException' && err.message.includes('reached for this month')) { + throw new MonthlyCodeFixLimitError() + } throw new CreateCodeFixError() }) getLogger().info(`AmazonQ generate fix Request id: ${resp.$response.requestId}`) diff --git a/packages/core/src/codewhisperer/service/completionProvider.ts b/packages/core/src/codewhisperer/service/completionProvider.ts index 10b90372cb4..226d04dec2b 100644 --- a/packages/core/src/codewhisperer/service/completionProvider.ts +++ b/packages/core/src/codewhisperer/service/completionProvider.ts @@ -16,10 +16,10 @@ import path from 'path' */ export function getCompletionItems(document: vscode.TextDocument, position: vscode.Position) { const completionItems: vscode.CompletionItem[] = [] - session.recommendations.forEach((recommendation, index) => { + for (const [index, recommendation] of session.recommendations.entries()) { completionItems.push(getCompletionItem(document, position, recommendation, index)) session.setSuggestionState(index, 'Showed') - }) + } return completionItems } diff --git a/packages/core/src/codewhisperer/service/diagnosticsProvider.ts b/packages/core/src/codewhisperer/service/diagnosticsProvider.ts index fb8e6a37d9a..65afc885073 100644 --- a/packages/core/src/codewhisperer/service/diagnosticsProvider.ts +++ b/packages/core/src/codewhisperer/service/diagnosticsProvider.ts @@ -35,10 +35,10 @@ export function initSecurityScanRender( } else if (scope === CodeAnalysisScope.PROJECT) { securityScanRender.securityDiagnosticCollection?.clear() } - securityRecommendationList.forEach((securityRecommendation) => { + for (const securityRecommendation of securityRecommendationList) { updateSecurityDiagnosticCollection(securityRecommendation) updateSecurityIssuesForProviders(securityRecommendation) - }) + } securityScanRender.initialized = true } @@ -58,11 +58,9 @@ export function updateSecurityDiagnosticCollection(securityRecommendation: Aggre const securityDiagnostics: vscode.Diagnostic[] = vscode.languages .getDiagnostics(uri) .filter((diagnostic) => diagnostic.source === codewhispererDiagnosticSourceLabel) - securityRecommendation.issues - .filter((securityIssue) => securityIssue.visible) - .forEach((securityIssue) => { - securityDiagnostics.push(createSecurityDiagnostic(securityIssue)) - }) + for (const securityIssue of securityRecommendation.issues.filter((securityIssue) => securityIssue.visible)) { + securityDiagnostics.push(createSecurityDiagnostic(securityIssue)) + } securityDiagnosticCollection.set(uri, securityDiagnostics) } @@ -74,14 +72,7 @@ export function createSecurityDiagnostic(securityIssue: CodeScanIssue) { vscode.DiagnosticSeverity.Warning ) securityDiagnostic.source = codewhispererDiagnosticSourceLabel - // const detectorUrl = securityIssue.recommendation.url - securityDiagnostic.code = securityIssue.findingId - // securityDiagnostic.code = detectorUrl - // ? { - // value: securityIssue.detectorId, - // target: vscode.Uri.parse(detectorUrl), - // } - // : securityIssue.detectorId + securityDiagnostic.code = securityIssue.ruleId securityDiagnostic.findingId = securityIssue.findingId return securityDiagnostic } @@ -115,27 +106,29 @@ export function disposeSecurityDiagnostic(event: vscode.TextDocumentChangeEvent) } ) - currentSecurityDiagnostics?.forEach((issue) => { - const intersection = changedRange.intersection(issue.range) - if ( - issue.severity === vscode.DiagnosticSeverity.Warning && - intersection && - (/\S/.test(changedText) || changedText === '') && - !CodeScansState.instance.isScansEnabled() - ) { - issue.severity = vscode.DiagnosticSeverity.Information - issue.message = 'Re-scan to validate the fix: ' + issue.message - issue.range = new vscode.Range(intersection.start, intersection.start) - } else if (issue.range.start.line >= changedRange.end.line) { - issue.range = new vscode.Range( - issue.range.start.line + lineOffset, - issue.range.start.character, - issue.range.end.line + lineOffset, - issue.range.end.character - ) + if (currentSecurityDiagnostics) { + for (const issue of currentSecurityDiagnostics) { + const intersection = changedRange.intersection(issue.range) + if ( + issue.severity === vscode.DiagnosticSeverity.Warning && + intersection && + (/\S/.test(changedText) || changedText === '') && + !CodeScansState.instance.isScansEnabled() + ) { + issue.severity = vscode.DiagnosticSeverity.Information + issue.message = 'Re-scan to validate the fix: ' + issue.message + issue.range = new vscode.Range(intersection.start, intersection.start) + } else if (issue.range.start.line >= changedRange.end.line) { + issue.range = new vscode.Range( + issue.range.start.line + lineOffset, + issue.range.start.character, + issue.range.end.line + lineOffset, + issue.range.end.character + ) + } + newSecurityDiagnostics.push(issue) } - newSecurityDiagnostics.push(issue) - }) + } securityScanRender.securityDiagnosticCollection?.set(uri, newSecurityDiagnostics) } diff --git a/packages/core/src/codewhisperer/service/importAdderProvider.ts b/packages/core/src/codewhisperer/service/importAdderProvider.ts index de16365713e..98b7c36adfd 100644 --- a/packages/core/src/codewhisperer/service/importAdderProvider.ts +++ b/packages/core/src/codewhisperer/service/importAdderProvider.ts @@ -4,7 +4,6 @@ */ import * as vscode from 'vscode' -import { isCloud9 } from '../../shared/extensionUtilities' import { Recommendation } from '../client/codewhisperer' import { CodeWhispererSettings } from '../util/codewhispererSettings' import { findLineToInsertImportStatement } from '../util/importAdderUtil' @@ -55,6 +54,7 @@ export class ImportAdderProvider implements vscode.CodeLensProvider { ) { const line = findLineToInsertImportStatement(editor, firstLineOfRecommendation) let mergedStatements = `` + // eslint-disable-next-line unicorn/no-array-for-each r.mostRelevantMissingImports?.forEach(async (i) => { // trust service response that this to-be-added import is necessary if (i.statement) { @@ -73,8 +73,7 @@ export class ImportAdderProvider implements vscode.CodeLensProvider { private isNotEnabled(languageId: string): boolean { return ( !this.supportedLanguages.includes(languageId) || - !CodeWhispererSettings.instance.isImportRecommendationEnabled() || - isCloud9() + !CodeWhispererSettings.instance.isImportRecommendationEnabled() ) } diff --git a/packages/core/src/codewhisperer/service/inlineCompletionItemProvider.ts b/packages/core/src/codewhisperer/service/inlineCompletionItemProvider.ts index e5ac2212e06..a6c424c321d 100644 --- a/packages/core/src/codewhisperer/service/inlineCompletionItemProvider.ts +++ b/packages/core/src/codewhisperer/service/inlineCompletionItemProvider.ts @@ -12,6 +12,7 @@ import { ReferenceInlineProvider } from './referenceInlineProvider' import { ImportAdderProvider } from './importAdderProvider' import { application } from '../util/codeWhispererApplication' import path from 'path' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' export class CWInlineCompletionItemProvider implements vscode.InlineCompletionItemProvider { private activeItemIndex: number | undefined @@ -170,6 +171,7 @@ export class CWInlineCompletionItemProvider implements vscode.InlineCompletionIt this.nextMove = 0 TelemetryHelper.instance.setFirstSuggestionShowTime() session.setPerceivedLatency() + UserWrittenCodeTracker.instance.onQStartsMakingEdits() this._onDidShow.fire() if (matchedCount >= 2 || this.nextToken !== '') { const result = [item] diff --git a/packages/core/src/codewhisperer/service/keyStrokeHandler.ts b/packages/core/src/codewhisperer/service/keyStrokeHandler.ts index 623f9aaa808..d32e875e8a4 100644 --- a/packages/core/src/codewhisperer/service/keyStrokeHandler.ts +++ b/packages/core/src/codewhisperer/service/keyStrokeHandler.ts @@ -8,7 +8,6 @@ import { DefaultCodeWhispererClient } from '../client/codewhisperer' import * as CodeWhispererConstants from '../models/constants' import { ConfigurationEntry } from '../models/model' import { getLogger } from '../../shared/logger' -import { isCloud9 } from '../../shared/extensionUtilities' import { RecommendationHandler } from './recommendationHandler' import { CodewhispererAutomatedTriggerType } from '../../shared/telemetry/telemetry' import { getTabSizeSetting } from '../../shared/utilities/editorUtilities' @@ -76,9 +75,6 @@ export class KeyStrokeHandler { } public shouldTriggerIdleTime(): boolean { - if (isCloud9() && RecommendationService.instance.isRunning) { - return false - } if (isInlineCompletionEnabled() && RecommendationService.instance.isRunning) { return false } @@ -101,14 +97,6 @@ export class KeyStrokeHandler { return } - // In Cloud9, do not auto trigger when - // 1. The input is from IntelliSense acceptance event - // 2. The input is from copy and paste some code - // event.contentChanges[0].text.length > 1 is a close estimate of 1 and 2 - if (isCloud9() && event.contentChanges.length > 0 && event.contentChanges[0].text.length > 1) { - return - } - const { rightFileContent } = extractContextForCodeWhisperer(editor) const rightContextLines = rightFileContent.split(/\r?\n/) const rightContextAtCurrentLine = rightContextLines[0] @@ -259,7 +247,7 @@ export class DefaultDocumentChangedType extends DocumentChangedType { // single line && single place reformat should consist of space chars only return DocumentChangedSource.Reformatting } else { - return isCloud9() ? DocumentChangedSource.RegularKey : DocumentChangedSource.Unknown + return DocumentChangedSource.Unknown } } diff --git a/packages/core/src/codewhisperer/service/recommendationHandler.ts b/packages/core/src/codewhisperer/service/recommendationHandler.ts index bbcd177a03b..1f5096ad1cc 100644 --- a/packages/core/src/codewhisperer/service/recommendationHandler.ts +++ b/packages/core/src/codewhisperer/service/recommendationHandler.ts @@ -14,7 +14,6 @@ import { AWSError } from 'aws-sdk' import { isAwsError } from '../../shared/errors' import { TelemetryHelper } from '../util/telemetryHelper' import { getLogger } from '../../shared/logger' -import { isCloud9 } from '../../shared/extensionUtilities' import { hasVendedIamCredentials } from '../../auth/auth' import { asyncCallWithTimeout, @@ -44,6 +43,7 @@ import { openUrl } from '../../shared/utilities/vsCodeUtils' import { indent } from '../../shared/utilities/textUtilities' import path from 'path' import { isIamConnection } from '../../auth/connection' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' /** * This class is for getRecommendation/listRecommendation API calls and its states @@ -63,9 +63,7 @@ const rejectCommand = Commands.declare('aws.amazonq.rejectCodeSuggestion', () => traceId: TelemetryHelper.instance.traceId, }) - if (!isCloud9('any')) { - await vscode.commands.executeCommand('editor.action.inlineSuggest.hide') - } + await vscode.commands.executeCommand('editor.action.inlineSuggest.hide') RecommendationHandler.instance.reportUserDecisions(-1) await Commands.tryExecute('aws.amazonq.refreshAnnotation') }) @@ -207,6 +205,8 @@ export class RecommendationHandler { session.requestContext = await EditorContext.buildGenerateRecommendationRequest(editor as vscode.TextEditor) } const request = session.requestContext.request + // record preprocessing end time + TelemetryHelper.instance.setPreprocessEndTime() // set start pos for non pagination call or first pagination call if (!pagination || (pagination && page === 0)) { @@ -309,13 +309,14 @@ export class RecommendationHandler { 4, true ).trimStart() - recommendations.forEach((item, index) => { + for (const [index, item] of recommendations.entries()) { msg += `\n ${index.toString().padStart(2, '0')}: ${indent(item.content, 8, true).trim()}` session.requestIdList.push(requestId) - }) + } getLogger().debug(msg) if (invocationResult === 'Succeeded') { CodeWhispererCodeCoverageTracker.getTracker(session.language)?.incrementServiceInvocationCount() + UserWrittenCodeTracker.instance.onQFeatureInvoked() } else { if ( (errorMessage?.includes(invalidCustomizationMessage) && errorCode === 'AccessDeniedException') || @@ -368,7 +369,7 @@ export class RecommendationHandler { TelemetryHelper.instance.setTypeAheadLength(typedPrefix.length) // mark suggestions that does not match typeahead when arrival as Discard // these suggestions can be marked as Showed if typeahead can be removed with new inline API - recommendations.forEach((r, i) => { + for (const [i, r] of recommendations.entries()) { const recommendationIndex = i + session.recommendations.length if ( !r.content.startsWith(typedPrefix) && @@ -377,7 +378,7 @@ export class RecommendationHandler { session.setSuggestionState(recommendationIndex, 'Discard') } session.setCompletionType(recommendationIndex, r) - }) + } session.recommendations = pagination ? session.recommendations.concat(recommendations) : recommendations if (isInlineCompletionEnabled() && this.hasAtLeastOneValidSuggestion(typedPrefix)) { this._onDidReceiveRecommendation.fire() @@ -472,9 +473,9 @@ export class RecommendationHandler { } reportDiscardedUserDecisions() { - session.recommendations.forEach((r, i) => { + for (const [i, _] of session.recommendations.entries()) { session.setSuggestionState(i, 'Discard') - }) + } this.reportUserDecisions(-1) } @@ -495,9 +496,7 @@ export class RecommendationHandler { session.suggestionStates, session.requestContext.supplementalMetadata ) - if (isCloud9('any')) { - this.clearRecommendations() - } else if (isInlineCompletionEnabled()) { + if (isInlineCompletionEnabled()) { this.clearInlineCompletionStates().catch((e) => { getLogger().error('clearInlineCompletionStates failed: %s', (e as Error).message) }) @@ -526,9 +525,9 @@ export class RecommendationHandler { // do not show recommendation if cursor is before invocation position // also mark as Discard if (editor.selection.active.isBefore(session.startPos)) { - session.recommendations.forEach((r, i) => { + for (const [i, _] of session.recommendations.entries()) { session.setSuggestionState(i, 'Discard') - }) + } reject() return false } @@ -544,9 +543,9 @@ export class RecommendationHandler { ) ) if (!session.recommendations[0].content.startsWith(typedPrefix.trimStart())) { - session.recommendations.forEach((r, i) => { + for (const [i, _] of session.recommendations.entries()) { session.setSuggestionState(i, 'Discard') - }) + } reject() return false } @@ -668,9 +667,9 @@ export class RecommendationHandler { editor.selection.active.isBefore(session.startPos) || editor.document.uri.fsPath !== this.documentUri?.fsPath ) { - session.recommendations.forEach((r, i) => { + for (const [i, _] of session.recommendations.entries()) { session.setSuggestionState(i, 'Discard') - }) + } this.reportUserDecisions(-1) } else if (session.recommendations.length > 0) { await this.showRecommendation(0, true) diff --git a/packages/core/src/codewhisperer/service/recommendationService.ts b/packages/core/src/codewhisperer/service/recommendationService.ts index 0a9a4e3c034..1da76995781 100644 --- a/packages/core/src/codewhisperer/service/recommendationService.ts +++ b/packages/core/src/codewhisperer/service/recommendationService.ts @@ -3,17 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ import * as vscode from 'vscode' -import { ConfigurationEntry, GetRecommendationsResponse, vsCodeState } from '../models/model' -import { isCloud9 } from '../../shared/extensionUtilities' +import { ConfigurationEntry, GetRecommendationsResponse } from '../models/model' import { isInlineCompletionEnabled } from '../util/commonUtil' import { CodewhispererAutomatedTriggerType, CodewhispererTriggerType, telemetry, } from '../../shared/telemetry/telemetry' -import { AuthUtil } from '../util/authUtil' -import { isIamConnection } from '../../auth/connection' -import { RecommendationHandler } from '../service/recommendationHandler' import { InlineCompletionService } from '../service/inlineCompletionService' import { ClassifierTrigger } from './classifierTrigger' import { DefaultCodeWhispererClient } from '../client/codewhisperer' @@ -81,67 +77,7 @@ export class RecommendationService { const traceId = telemetry.attributes?.traceId ?? randomUUID() TelemetryHelper.instance.setTraceId(traceId) await telemetry.withTraceId(async () => { - if (isCloud9('any')) { - // C9 manual trigger key alt/option + C is ALWAYS enabled because the VSC version C9 is on doesn't support setContextKey which is used for CODEWHISPERER_ENABLED - // therefore we need a connection check if there is ANY connection(regardless of the connection's state) connected to CodeWhisperer on C9 - if (triggerType === 'OnDemand' && !AuthUtil.instance.isConnected()) { - return - } - - RecommendationHandler.instance.checkAndResetCancellationTokens() - vsCodeState.isIntelliSenseActive = false - this._isRunning = true - let response: GetRecommendationsResponse = { - result: 'Failed', - errorMessage: undefined, - recommendationCount: 0, - } - - try { - this._onSuggestionActionEvent.fire({ - editor: editor, - isRunning: true, - triggerType: triggerType, - response: undefined, - }) - - if (isCloud9('classic') || isIamConnection(AuthUtil.instance.conn)) { - response = await RecommendationHandler.instance.getRecommendations( - client, - editor, - triggerType, - config, - autoTriggerType, - false - ) - } else { - if (AuthUtil.instance.isConnectionExpired()) { - await AuthUtil.instance.showReauthenticatePrompt() - } - response = await RecommendationHandler.instance.getRecommendations( - client, - editor, - triggerType, - config, - autoTriggerType, - true - ) - } - if (RecommendationHandler.instance.canShowRecommendationInIntelliSense(editor, true, response)) { - await vscode.commands.executeCommand('editor.action.triggerSuggest').then(() => { - vsCodeState.isIntelliSenseActive = true - }) - } - } finally { - this._isRunning = false - this._onSuggestionActionEvent.fire({ - editor: editor, - isRunning: false, - triggerType: triggerType, - response: response, - }) - } - } else if (isInlineCompletionEnabled()) { + if (isInlineCompletionEnabled()) { if (triggerType === 'OnDemand') { ClassifierTrigger.instance.recordClassifierResultForManualTrigger(editor) } diff --git a/packages/core/src/codewhisperer/service/referenceLogViewProvider.ts b/packages/core/src/codewhisperer/service/referenceLogViewProvider.ts index 2a2fe867321..9ec20b8cb44 100644 --- a/packages/core/src/codewhisperer/service/referenceLogViewProvider.ts +++ b/packages/core/src/codewhisperer/service/referenceLogViewProvider.ts @@ -9,7 +9,6 @@ import { LicenseUtil } from '../util/licenseUtil' import * as CodeWhispererConstants from '../models/constants' import { CodeWhispererSettings } from '../util/codewhispererSettings' import globals from '../../shared/extensionGlobals' -import { isCloud9 } from '../../shared/extensionUtilities' import { AuthUtil } from '../util/authUtil' import { session } from '../util/codeWhispererSession' @@ -127,22 +126,9 @@ export class ReferenceLogViewProvider implements vscode.WebviewViewProvider { } } - let csp = '' - if (isCloud9()) { - csp = `` - } return ` - ${csp} diff --git a/packages/core/src/codewhisperer/service/securityIssueProvider.ts b/packages/core/src/codewhisperer/service/securityIssueProvider.ts index fa60c7c215a..edd93de8433 100644 --- a/packages/core/src/codewhisperer/service/securityIssueProvider.ts +++ b/packages/core/src/codewhisperer/service/securityIssueProvider.ts @@ -4,7 +4,7 @@ */ import * as vscode from 'vscode' -import { AggregatedCodeScanIssue, CodeScanIssue, SuggestedFix } from '../models/model' +import { AggregatedCodeScanIssue, CodeScanIssue, CodeScansState, SuggestedFix } from '../models/model' export class SecurityIssueProvider { static #instance: SecurityIssueProvider public static get instance() { @@ -12,7 +12,6 @@ export class SecurityIssueProvider { } private _issues: AggregatedCodeScanIssue[] = [] - private _disableEventHandler: boolean = false public get issues() { return this._issues } @@ -21,26 +20,20 @@ export class SecurityIssueProvider { this._issues = issues } - public disableEventHandler() { - this._disableEventHandler = true - } - public handleDocumentChange(event: vscode.TextDocumentChangeEvent) { // handleDocumentChange function may be triggered while testing by our own code generation. if (!event.contentChanges || event.contentChanges.length === 0) { return } - if (this._disableEventHandler) { - this._disableEventHandler = false - return - } - const { changedRange, lineOffset } = event.contentChanges.reduce( + const { changedRange, changedText, lineOffset } = event.contentChanges.reduce( (acc, change) => ({ changedRange: acc.changedRange.union(change.range), + changedText: acc.changedText + change.text, lineOffset: acc.lineOffset + this._getLineOffset(change.range, change.text), }), { changedRange: event.contentChanges[0].range, + changedText: '', lineOffset: 0, } ) @@ -52,18 +45,20 @@ export class SecurityIssueProvider { return { ...group, issues: group.issues - .filter( - (issue) => - // Filter out any modified issues - !changedRange.intersection( - new vscode.Range( - issue.startLine, - event.document.lineAt(issue.startLine)?.range.start.character ?? 0, - issue.endLine, - event.document.lineAt(issue.endLine)?.range.end.character ?? 0 - ) - ) - ) + .filter((issue) => { + const range = new vscode.Range( + issue.startLine, + event.document.lineAt(issue.startLine)?.range.start.character ?? 0, + issue.endLine, + event.document.lineAt(issue.endLine - 1)?.range.end.character ?? 0 + ) + const intersection = changedRange.intersection(range) + return !( + intersection && + (/\S/.test(changedText) || changedText === '') && + !CodeScansState.instance.isScansEnabled() + ) + }) .map((issue) => { if (issue.startLine < changedRange.end.line) { return issue diff --git a/packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts b/packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts index e76a201be87..47490f2427f 100644 --- a/packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts +++ b/packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts @@ -4,7 +4,14 @@ */ import * as vscode from 'vscode' import path from 'path' -import { CodeScanIssue, SecurityTreeViewFilterState, severities, Severity } from '../models/model' +import { + CodeIssueGroupingStrategy, + CodeIssueGroupingStrategyState, + CodeScanIssue, + SecurityTreeViewFilterState, + severities, + Severity, +} from '../models/model' import globals from '../../shared/extensionGlobals' import { getLogger } from '../../shared/logger' import { SecurityIssueProvider } from './securityIssueProvider' @@ -34,6 +41,17 @@ export class SecurityIssueTreeViewProvider implements vscode.TreeDataProvider { + const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState() + switch (groupingStrategy) { + case CodeIssueGroupingStrategy.FileLocation: + return this.getChildrenGroupedByFileLocation(element) + case CodeIssueGroupingStrategy.Severity: + default: + return this.getChildrenGroupedBySeverity(element) + } + } + + private getChildrenGroupedBySeverity(element: SecurityViewTreeItem | undefined) { const filterHiddenSeverities = (severity: Severity) => !SecurityTreeViewFilterState.instance.getHiddenSeverities().includes(severity) @@ -64,6 +82,27 @@ export class SecurityIssueTreeViewProvider implements vscode.TreeDataProvider + !SecurityTreeViewFilterState.instance.getHiddenSeverities().includes(issue.severity) + + if (element instanceof FileItem) { + return element.issues + .filter(filterHiddenSeverities) + .filter((issue) => issue.visible) + .sort((a, b) => a.startLine - b.startLine) + .map((issue) => new IssueItem(element.filePath, issue)) + } + + const result = this.issueProvider.issues + .filter((group) => group.issues.some(filterHiddenSeverities)) + .filter((group) => group.issues.some((issue) => issue.visible)) + .sort((a, b) => a.filePath.localeCompare(b.filePath)) + .map((group) => new FileItem(group.filePath, group.issues.filter(filterHiddenSeverities))) + this._onDidChangeTreeData.fire(result) + return result + } + public refresh(): void { this._onDidChangeTreeData.fire() } @@ -118,7 +157,8 @@ export class IssueItem extends vscode.TreeItem { public readonly issue: CodeScanIssue ) { super(issue.title, vscode.TreeItemCollapsibleState.None) - this.description = `${path.basename(this.filePath)} [Ln ${this.issue.startLine + 1}, Col 1]` + this.description = this.getDescription() + this.iconPath = this.getSeverityIcon() this.tooltip = this.getTooltipMarkdown() this.command = { title: 'Focus Issue', @@ -132,6 +172,22 @@ export class IssueItem extends vscode.TreeItem { return globals.context.asAbsolutePath(`resources/images/severity-${this.issue.severity.toLowerCase()}.svg`) } + private getSeverityIcon() { + const iconPath = globals.context.asAbsolutePath( + `resources/icons/aws/amazonq/severity-${this.issue.severity.toLowerCase()}.svg` + ) + const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState() + return groupingStrategy !== CodeIssueGroupingStrategy.Severity ? iconPath : undefined + } + + private getDescription() { + const positionStr = `[Ln ${this.issue.startLine + 1}, Col 1]` + const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState() + return groupingStrategy !== CodeIssueGroupingStrategy.FileLocation + ? `${path.basename(this.filePath)} ${positionStr}` + : positionStr + } + private getContextValue() { return this.issue.suggestedFixes.length === 0 || !this.issue.suggestedFixes[0].code ? ContextValue.ISSUE_WITHOUT_FIX diff --git a/packages/core/src/codewhisperer/service/securityScanHandler.ts b/packages/core/src/codewhisperer/service/securityScanHandler.ts index 537638b52c9..22f5fa30e60 100644 --- a/packages/core/src/codewhisperer/service/securityScanHandler.ts +++ b/packages/core/src/codewhisperer/service/securityScanHandler.ts @@ -43,6 +43,8 @@ import { getTelemetryReasonDesc } from '../../shared/errors' import { CodeWhispererSettings } from '../util/codewhispererSettings' import { detectCommentAboveLine } from '../../shared/utilities/commentUtils' import { runtimeLanguageContext } from '../util/runtimeLanguageContext' +import { FeatureUseCase } from '../models/constants' +import { UploadTestArtifactToS3Error } from '../../amazonqTest/error' export async function listScanResults( client: DefaultCodeWhispererClient, @@ -68,13 +70,13 @@ export async function listScanResults( return resp.codeAnalysisFindings }) .promise() - issues.forEach((issue) => { + for (const issue of issues) { mapToAggregatedList(codeScanIssueMap, issue, editor, scope) - }) - codeScanIssueMap.forEach((issues, key) => { + } + for (const [key, issues] of codeScanIssueMap.entries()) { // Project path example: /Users/username/project // Key example: project/src/main/java/com/example/App.java - projectPaths.forEach((projectPath) => { + for (const projectPath of projectPaths) { // We need to remove the project path from the key to get the absolute path to the file // Do not use .. in between because there could be multiple project paths in the same parent dir. const filePath = path.join(projectPath, key.split('/').slice(1).join('/')) @@ -85,7 +87,7 @@ export async function listScanResults( } aggregatedCodeScanIssueList.push(aggregatedCodeScanIssue) } - }) + } const maybeAbsolutePath = `/${key}` if (existsSync(maybeAbsolutePath) && statSync(maybeAbsolutePath).isFile()) { const aggregatedCodeScanIssue: AggregatedCodeScanIssue = { @@ -94,7 +96,7 @@ export async function listScanResults( } aggregatedCodeScanIssueList.push(aggregatedCodeScanIssue) } - }) + } return aggregatedCodeScanIssueList } @@ -158,7 +160,7 @@ export function mapToAggregatedList( return true }) - filteredIssues.forEach((issue) => { + for (const issue of filteredIssues) { const filePath = issue.filePath if (codeScanIssueMap.has(filePath)) { if (!isExistingIssue(issue, codeScanIssueMap)) { @@ -169,7 +171,7 @@ export function mapToAggregatedList( } else { codeScanIssueMap.set(filePath, [issue]) } - }) + } } function isDuplicateIssue(issueA: RawCodeScanIssue, issueB: RawCodeScanIssue) { @@ -287,7 +289,7 @@ export async function getPresignedUrlAndUpload( logger.verbose(`CreateUploadUrlRequest request id: ${srcResp.$response.requestId}`) logger.verbose(`Complete Getting presigned Url for uploading src context.`) logger.verbose(`Uploading src context...`) - await uploadArtifactToS3(zipMetadata.zipFilePath, srcResp, scope) + await uploadArtifactToS3(zipMetadata.zipFilePath, srcResp, FeatureUseCase.CODE_SCAN, scope) logger.verbose(`Complete uploading src context.`) const artifactMap: ArtifactMap = { SourceCode: srcResp.uploadId, @@ -339,10 +341,11 @@ export function throwIfCancelled(scope: CodeWhispererConstants.CodeAnalysisScope break } } -//TODO: Refactor this +// TODO: Refactor this export async function uploadArtifactToS3( fileName: string, resp: CreateUploadUrlResponse, + featureUseCase: FeatureUseCase, scope?: CodeWhispererConstants.CodeAnalysisScope ) { const logger = getLoggerForScope(scope) @@ -365,18 +368,27 @@ export async function uploadArtifactToS3( }).response logger.debug(`StatusCode: ${response.status}, Text: ${response.statusText}`) } catch (error) { + let errorMessage = '' + const isCodeScan = featureUseCase === FeatureUseCase.CODE_SCAN + const featureType = isCodeScan ? 'security scans' : 'unit test generation' + const defaultMessage = isCodeScan ? 'Security scan failed.' : 'Test generation failed.' getLogger().error( - `Amazon Q is unable to upload workspace artifacts to Amazon S3 for security scans. For more information, see the Amazon Q documentation or contact your network or organization administrator.` + `Amazon Q is unable to upload workspace artifacts to Amazon S3 for ${featureType}. ` + + 'For more information, see the Amazon Q documentation or contact your network or organization administrator.' ) - const errorMessage = getTelemetryReasonDesc(error)?.includes(`"PUT" request failed with code "403"`) - ? `"PUT" request failed with code "403"` - : (getTelemetryReasonDesc(error) ?? 'Security scan failed.') - - throw new UploadArtifactToS3Error(errorMessage) + const errorDesc = getTelemetryReasonDesc(error) + if (errorDesc?.includes('"PUT" request failed with code "403"')) { + errorMessage = '"PUT" request failed with code "403"' + } else if (errorDesc?.includes('"PUT" request failed with code "503"')) { + errorMessage = '"PUT" request failed with code "503"' + } else { + errorMessage = errorDesc ?? defaultMessage + } + throw isCodeScan ? new UploadArtifactToS3Error(errorMessage) : new UploadTestArtifactToS3Error(errorMessage) } } -//TODO: Refactor this +// TODO: Refactor this export function getLoggerForScope(scope?: CodeWhispererConstants.CodeAnalysisScope) { return scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO ? getNullLogger() : getLogger() } @@ -391,10 +403,7 @@ function getPollingDelayMsForScope(scope: CodeWhispererConstants.CodeAnalysisSco } function getPollingTimeoutMsForScope(scope: CodeWhispererConstants.CodeAnalysisScope) { - return ( - (scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO || - scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND - ? CodeWhispererConstants.codeFileScanJobTimeoutSeconds - : CodeWhispererConstants.codeScanJobTimeoutSeconds) * 1000 - ) + return scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO + ? CodeWhispererConstants.expressScanTimeoutMs + : CodeWhispererConstants.standardScanTimeoutMs } diff --git a/packages/core/src/codewhisperer/service/testGenHandler.ts b/packages/core/src/codewhisperer/service/testGenHandler.ts index 218864ce256..48a66fb1f83 100644 --- a/packages/core/src/codewhisperer/service/testGenHandler.ts +++ b/packages/core/src/codewhisperer/service/testGenHandler.ts @@ -13,10 +13,18 @@ import CodeWhispererUserClient, { CreateUploadUrlRequest, TargetCode, } from '../client/codewhispereruserclient' -import { CreateUploadUrlError, InvalidSourceZipError, TestGenFailedError, TestGenTimedOutError } from '../models/errors' +import { + CreateTestJobError, + CreateUploadUrlError, + ExportResultsArchiveError, + InvalidSourceZipError, + TestGenFailedError, + TestGenStoppedError, + TestGenTimedOutError, +} from '../../amazonqTest/error' import { getMd5, uploadArtifactToS3 } from './securityScanHandler' import { fs, randomUUID, sleep, tempDirPath } from '../../shared' -import { ShortAnswer, TestGenerationJobStatus, testGenState } from '..' +import { ShortAnswer, testGenState } from '../models/model' import { ChatSessionManager } from '../../amazonqTest/chat/storages/chatSession' import { createCodeWhispererChatStreamingClient } from '../../shared/clients/codewhispererChatClient' import { downloadExportResultArchive } from '../../shared/utilities/download' @@ -24,12 +32,13 @@ import AdmZip from 'adm-zip' import path from 'path' import { ExportIntent } from '@amzn/codewhisperer-streaming' import { glob } from 'glob' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' -//TODO: Get TestFileName and Framework and to error message +// TODO: Get TestFileName and Framework and to error message export function throwIfCancelled() { - //TODO: fileName will be '' if user gives propt without opening + // TODO: fileName will be '' if user gives propt without opening if (testGenState.isCancelling()) { - throw Error(CodeWhispererConstants.unitTestGenerationCancelMessage) + throw new TestGenStoppedError() } } @@ -47,12 +56,12 @@ export async function getPresignedUrlAndUploadTestGen(zipMetadata: ZipMetadata) logger.verbose(`Prepare for uploading src context...`) const srcResp = await codeWhisperer.codeWhispererClient.createUploadUrl(srcReq).catch((err) => { getLogger().error(`Failed getting presigned url for uploading src context. Request id: ${err.requestId}`) - throw new CreateUploadUrlError(err) + throw new CreateUploadUrlError(err.message) }) logger.verbose(`CreateUploadUrlRequest requestId: ${srcResp.$response.requestId}`) logger.verbose(`Complete Getting presigned Url for uploading src context.`) logger.verbose(`Uploading src context...`) - await uploadArtifactToS3(zipMetadata.zipFilePath, srcResp) + await uploadArtifactToS3(zipMetadata.zipFilePath, srcResp, CodeWhispererConstants.FeatureUseCase.TEST_GENERATION) logger.verbose(`Complete uploading src context.`) const artifactMap: ArtifactMap = { SourceCode: srcResp.uploadId, @@ -94,11 +103,13 @@ export async function createTestJob( logger.debug('target line range end: %O', firstTargetLineRangeList?.end) const resp = await codewhispererClient.codeWhispererClient.startTestGeneration(req).catch((err) => { + ChatSessionManager.Instance.getSession().startTestGenerationRequestId = err.requestId logger.error(`Failed creating test job. Request id: ${err.requestId}`) - throw err + throw new CreateTestJobError(err.message) }) logger.info('Unit test generation request id: %s', resp.$response.requestId) logger.debug('Unit test generation data: %O', resp.$response.data) + ChatSessionManager.Instance.getSession().startTestGenerationRequestId = resp.$response.requestId if (resp.$response.error) { logger.error('Unit test generation error: %O', resp.$response.error) } @@ -146,7 +157,7 @@ export async function pollTestJobStatus( if (shortAnswerString) { const parsedShortAnswer = JSON.parse(shortAnswerString) const shortAnswer: ShortAnswer = JSON.parse(parsedShortAnswer) - //Stop the Unit test generation workflow if IDE receive stopIteration = true + // Stop the Unit test generation workflow if IDE receive stopIteration = true if (shortAnswer.stopIteration === 'true') { session.stopIteration = true throw new TestGenFailedError(shortAnswer.planSummary) @@ -180,9 +191,9 @@ export async function pollTestJobStatus( } ChatSessionManager.Instance.getSession().shortAnswer = shortAnswer } - if (resp.testGenerationJob?.status !== TestGenerationJobStatus.IN_PROGRESS) { - //This can be FAILED or COMPLETED - status = resp.testGenerationJob?.status as TestGenerationJobStatus + if (resp.testGenerationJob?.status !== CodeWhispererConstants.TestGenerationJobStatus.IN_PROGRESS) { + // This can be FAILED or COMPLETED + status = resp.testGenerationJob?.status as CodeWhispererConstants.TestGenerationJobStatus logger.verbose(`testgen job status: ${status}`) logger.verbose(`Complete polling test job status.`) break @@ -210,7 +221,7 @@ export async function exportResultsArchive( projectPath: string, initialExecution: boolean ) { - //TODO: Make a common Temp folder + // TODO: Make a common Temp folder const pathToArchiveDir = path.join(tempDirPath, 'q-testgen') const archivePathExists = await fs.existsDir(pathToArchiveDir) @@ -220,6 +231,8 @@ export async function exportResultsArchive( await fs.mkdir(pathToArchiveDir) let downloadErrorMessage = undefined + + const session = ChatSessionManager.Instance.getSession() try { const pathToArchive = path.join(pathToArchiveDir, 'QTestGeneration.zip') // Download and deserialize the zip @@ -227,7 +240,6 @@ export async function exportResultsArchive( const zip = new AdmZip(pathToArchive) zip.extractAllTo(pathToArchiveDir, true) - const session = ChatSessionManager.Instance.getSession() const testFilePathFromResponse = session?.shortAnswer?.testFilePath const testFilePath = testFilePathFromResponse ? testFilePathFromResponse.split('/').slice(1).join('/') // remove the project name @@ -239,16 +251,17 @@ export async function exportResultsArchive( projectName, }) - //If User accepts the diff + // If User accepts the diff testGenState.getChatControllers()?.sendUpdatePromptProgress.fire({ tabID: ChatSessionManager.Instance.getSession().tabID, status: 'Completed', }) } } catch (e) { + session.numberOfTestsGenerated = 0 downloadErrorMessage = (e as Error).message getLogger().error(`Unit Test Generation: ExportResultArchive error = ${downloadErrorMessage}`) - throw new Error('Error downloading test generation result artifacts: ' + downloadErrorMessage) + throw new ExportResultsArchiveError(downloadErrorMessage) } } @@ -287,8 +300,9 @@ export async function downloadResultArchive( } catch (e: any) { downloadErrorMessage = (e as Error).message getLogger().error(`Unit Test Generation: ExportResultArchive error = ${downloadErrorMessage}`) - throw e + throw new ExportResultsArchiveError(downloadErrorMessage) } finally { cwStreamingClient.destroy() + UserWrittenCodeTracker.instance.onQFeatureInvoked() } } diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts b/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts index b5f4b15991a..b5f4d2d1447 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts @@ -34,14 +34,13 @@ import { import { sleep } from '../../../shared/utilities/timeoutUtils' import AdmZip from 'adm-zip' import globals from '../../../shared/extensionGlobals' -import { CredentialSourceId, telemetry } from '../../../shared/telemetry/telemetry' +import { telemetry } from '../../../shared/telemetry/telemetry' import { CodeTransformTelemetryState } from '../../../amazonqGumby/telemetry/codeTransformTelemetryState' import { calculateTotalLatency } from '../../../amazonqGumby/telemetry/codeTransformTelemetry' import { MetadataResult } from '../../../shared/telemetry/telemetryClient' import request from '../../../shared/request' import { JobStoppedError, ZipExceedsSizeLimitError } from '../../../amazonqGumby/errors' import { writeLogs } from './transformFileHandler' -import { AuthUtil } from '../../util/authUtil' import { createCodeWhispererChatStreamingClient } from '../../../shared/clients/codewhispererChatClient' import { downloadExportResultArchive } from '../../../shared/utilities/download' import { ExportIntent, TransformationDownloadArtifactType } from '@amzn/codewhisperer-streaming' @@ -49,6 +48,8 @@ import fs from '../../../shared/fs/fs' import { ChatSessionManager } from '../../../amazonqGumby/chat/storages/chatSession' import { encodeHTML } from '../../../shared/utilities/textUtilities' import { convertToTimeString } from '../../../shared/datetime' +import { getAuthType } from '../../../auth/utils' +import { UserWrittenCodeTracker } from '../../tracker/userWrittenCodeTracker' export function getSha256(buffer: Buffer) { const hasher = crypto.createHash('sha256') @@ -56,16 +57,6 @@ export function getSha256(buffer: Buffer) { return hasher.digest('base64') } -export async function getAuthType() { - let authType: CredentialSourceId | undefined = undefined - if (AuthUtil.instance.isEnterpriseSsoInUse() && AuthUtil.instance.isConnectionValid()) { - authType = 'iamIdentityCenter' - } else if (AuthUtil.instance.isBuilderIdInUse() && AuthUtil.instance.isConnectionValid()) { - authType = 'awsId' - } - return authType -} - export function throwIfCancelled() { if (transformByQState.isCancelled()) { throw new TransformByQStoppedError() @@ -113,18 +104,45 @@ export async function uploadArtifactToS3( try { const uploadFileByteSize = (await nodefs.promises.stat(fileName)).size getLogger().info( - `Uploading project artifact at %s with checksum %s using uploadId: %s and size %s kB`, + `CodeTransformation: Uploading project artifact at %s with checksum %s using uploadId: %s and size %s kB`, fileName, sha256, resp.uploadId, Math.round(uploadFileByteSize / 1000) ) - const response = await request.fetch('PUT', resp.uploadUrl, { - body: buffer, - headers: getHeadersObj(sha256, resp.kmsKeyArn), - }).response - getLogger().info(`CodeTransformation: Status from S3 Upload = ${response.status}`) + let response = undefined + /* The existing S3 client has built-in retries but it requires the bucket name, so until + * CreateUploadUrl can be modified to return the S3 bucket name, manually implement retries. + * Alternatively, when waitUntil supports a fixed number of retries and retriableCodes, use that. + */ + const retriableCodes = [408, 429, 500, 502, 503, 504] + for (let i = 0; i < 4; i++) { + try { + response = await request.fetch('PUT', resp.uploadUrl, { + body: buffer, + headers: getHeadersObj(sha256, resp.kmsKeyArn), + }).response + getLogger().info(`CodeTransformation: upload to S3 status on attempt ${i + 1}/4 = ${response.status}`) + if (response.status === 200) { + break + } + throw new Error('Upload failed') + } catch (e: any) { + if (response && !retriableCodes.includes(response.status)) { + throw new Error(`Upload failed with status code = ${response.status}; did not automatically retry`) + } + if (i !== 3) { + await sleep(1000 * Math.pow(2, i)) + } + } + } + if (!response || response.status !== 200) { + const uploadFailedError = `Upload failed after up to 4 attempts with status code = ${response?.status ?? 'unavailable'}` + getLogger().error(`CodeTransformation: ${uploadFailedError}`) + throw new Error(uploadFailedError) + } + getLogger().info('CodeTransformation: Upload to S3 succeeded') } catch (e: any) { let errorMessage = `The upload failed due to: ${(e as Error).message}. For more information, see the [Amazon Q documentation](${CodeWhispererConstants.codeTransformTroubleshootUploadError})` if (errorMessage.includes('Request has expired')) { @@ -342,9 +360,9 @@ export async function zipCode( }, } // TO-DO: later consider making this add to path.join(zipManifest.dependenciesRoot, 'qct-sct-metadata', entry.entryName) so that it's more organized - metadataZip - .getEntries() - .forEach((entry) => zip.addFile(path.join(zipManifest.dependenciesRoot, entry.name), entry.getData())) + for (const entry of metadataZip.getEntries()) { + zip.addFile(path.join(zipManifest.dependenciesRoot, entry.name), entry.getData()) + } const sqlMetadataSize = (await nodefs.promises.stat(transformByQState.getMetadataPathSQL())).size getLogger().info(`CodeTransformation: SQL metadata file size = ${sqlMetadataSize}`) } @@ -430,6 +448,7 @@ export async function startJob(uploadId: string) { target: { language: targetLanguageVersion }, // always JDK17 }, }) + getLogger().info('CodeTransformation: called startJob API successfully') if (response.$response.requestId) { transformByQState.setJobFailureMetadata(` (request ID: ${response.$response.requestId})`) } @@ -497,15 +516,19 @@ export function addTableMarkdown(plan: string, stepId: string, tableMapping: { [ const table = JSON.parse(tableObj) plan += `\n\n\n${table.name}\n|` const columns = table.columnNames + // eslint-disable-next-line unicorn/no-array-for-each columns.forEach((columnName: string) => { plan += ` ${getFormattedString(columnName)} |` }) plan += '\n|' + // eslint-disable-next-line unicorn/no-array-for-each columns.forEach((_: any) => { plan += '-----|' }) + // eslint-disable-next-line unicorn/no-array-for-each table.rows.forEach((row: any) => { plan += '\n|' + // eslint-disable-next-line unicorn/no-array-for-each columns.forEach((columnName: string) => { if (columnName === 'relativePath') { plan += ` [${row[columnName]}](${row[columnName]}) |` // add MD link only for files @@ -520,11 +543,11 @@ export function addTableMarkdown(plan: string, stepId: string, tableMapping: { [ export function getTableMapping(stepZeroProgressUpdates: ProgressUpdates) { const map: { [key: string]: string } = {} - stepZeroProgressUpdates.forEach((update) => { + for (const update of stepZeroProgressUpdates) { // description should never be undefined since even if no data we show an empty table // but just in case, empty string allows us to skip this table without errors when rendering map[update.name] = update.description ?? '' - }) + } return map } @@ -534,6 +557,7 @@ export function getJobStatisticsHtml(jobStatistics: any) { return htmlString } htmlString += `
` + // eslint-disable-next-line unicorn/no-array-for-each jobStatistics.forEach((stat: { name: string; value: string }) => { htmlString += `

${CodeWhispererConstants.planHeaderMessage}

${CodeWhispererConstants.planDisclaimerMessage} Read more.

` - response.transformationPlan.transformationSteps.slice(1).forEach((step) => { + for (const step of response.transformationPlan.transformationSteps.slice(1)) { plan += `

${step.name}

Scroll to top

${step.description}

` plan = addTableMarkdown(plan, step.id, tableMapping) plan += `

` - }) + } plan += `

` plan += `

Appendix
Scroll to top


` plan = addTableMarkdown(plan, '-1', tableMapping) // ID of '-1' reserved for appendix table @@ -648,6 +672,7 @@ export async function pollTransformationJob(jobId: string, validStates: string[] }) } transformByQState.setPolledJobStatus(status) + getLogger().info(`CodeTransformation: polled job status = ${status}`) const errorMessage = response.transformationJob.reason if (errorMessage !== undefined) { @@ -745,6 +770,7 @@ export async function downloadResultArchive( throw e } finally { cwStreamingClient.destroy() + UserWrittenCodeTracker.instance.onQFeatureInvoked() } } diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformFileHandler.ts b/packages/core/src/codewhisperer/service/transformByQ/transformFileHandler.ts index f6c5e24bed1..767869d0310 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformFileHandler.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformFileHandler.ts @@ -8,11 +8,15 @@ import * as path from 'path' import * as os from 'os' import xml2js = require('xml2js') import * as CodeWhispererConstants from '../../models/constants' -import { existsSync, writeFileSync } from 'fs' // eslint-disable-line no-restricted-imports -import { BuildSystem, FolderInfo, transformByQState } from '../../models/model' +import { existsSync, readFileSync, writeFileSync } from 'fs' // eslint-disable-line no-restricted-imports +import { BuildSystem, DB, FolderInfo, transformByQState } from '../../models/model' import { IManifestFile } from '../../../amazonqFeatureDev/models' import fs from '../../../shared/fs/fs' import globals from '../../../shared/extensionGlobals' +import { ChatSessionManager } from '../../../amazonqGumby/chat/storages/chatSession' +import { AbsolutePathDetectedError } from '../../../amazonqGumby/errors' +import { getLogger } from '../../../shared/logger' +import { isWin } from '../../../shared/vscode/env' export function getDependenciesFolderInfo(): FolderInfo { const dependencyFolderName = `${CodeWhispererConstants.dependencyFolderName}${globals.clock.Date.now()}` @@ -37,6 +41,106 @@ export async function checkBuildSystem(projectPath: string) { return BuildSystem.Unknown } +export async function parseBuildFile() { + try { + const absolutePaths = ['users/', 'system/', 'volumes/', 'c:\\', 'd:\\'] + const alias = path.basename(os.homedir()) + absolutePaths.push(alias) + const buildFilePath = path.join(transformByQState.getProjectPath(), 'pom.xml') + if (existsSync(buildFilePath)) { + const buildFileContents = readFileSync(buildFilePath).toString().toLowerCase() + const detectedPaths = [] + for (const absolutePath of absolutePaths) { + if (buildFileContents.includes(absolutePath)) { + detectedPaths.push(absolutePath) + } + } + if (detectedPaths.length > 0) { + const warningMessage = CodeWhispererConstants.absolutePathDetectedMessage( + detectedPaths.length, + path.basename(buildFilePath), + detectedPaths.join(', ') + ) + transformByQState.getChatControllers()?.errorThrown.fire({ + error: new AbsolutePathDetectedError(warningMessage), + tabID: ChatSessionManager.Instance.getSession().tabID, + }) + getLogger().info('CodeTransformation: absolute path potentially in build file') + return warningMessage + } + } + } catch (err: any) { + // swallow error + getLogger().error(`CodeTransformation: error scanning for absolute paths, tranformation continuing: ${err}`) + } + return undefined +} + +export async function validateSQLMetadataFile(fileContents: string, message: any) { + try { + const sctData = await xml2js.parseStringPromise(fileContents) + const dbEntities = sctData['tree']['instances'][0]['ProjectModel'][0]['entities'][0] + const sourceDB = dbEntities['sources'][0]['DbServer'][0]['$']['vendor'].trim().toUpperCase() + const targetDB = dbEntities['targets'][0]['DbServer'][0]['$']['vendor'].trim().toUpperCase() + const sourceServerName = dbEntities['sources'][0]['DbServer'][0]['$']['name'].trim() + transformByQState.setSourceServerName(sourceServerName) + if (sourceDB !== DB.ORACLE) { + transformByQState.getChatMessenger()?.sendUnrecoverableErrorResponse('unsupported-source-db', message.tabID) + return false + } else if (targetDB !== DB.AURORA_POSTGRESQL && targetDB !== DB.RDS_POSTGRESQL) { + transformByQState.getChatMessenger()?.sendUnrecoverableErrorResponse('unsupported-target-db', message.tabID) + return false + } + transformByQState.setSourceDB(sourceDB) + transformByQState.setTargetDB(targetDB) + + const serverNodeLocations = + sctData['tree']['instances'][0]['ProjectModel'][0]['relations'][0]['server-node-location'] + const schemaNames = new Set() + // eslint-disable-next-line unicorn/no-array-for-each + serverNodeLocations.forEach((serverNodeLocation: any) => { + const schemaNodes = serverNodeLocation['FullNameNodeInfoList'][0]['nameParts'][0][ + 'FullNameNodeInfo' + ].filter((node: any) => node['$']['typeNode'].toLowerCase() === 'schema') + // eslint-disable-next-line unicorn/no-array-for-each + schemaNodes.forEach((node: any) => { + schemaNames.add(node['$']['nameNode'].toUpperCase()) + }) + }) + transformByQState.setSchemaOptions(schemaNames) // user will choose one of these + getLogger().info( + `CodeTransformation: Parsed .sct file with source DB: ${sourceDB}, target DB: ${targetDB}, source host name: ${sourceServerName}, and schema names: ${Array.from(schemaNames)}` + ) + } catch (err: any) { + getLogger().error('CodeTransformation: Error parsing .sct file. %O', err) + transformByQState.getChatMessenger()?.sendUnrecoverableErrorResponse('error-parsing-sct-file', message.tabID) + return false + } + return true +} + +export async function setMaven() { + let mavenWrapperExecutableName = isWin() ? 'mvnw.cmd' : 'mvnw' + const mavenWrapperExecutablePath = path.join(transformByQState.getProjectPath(), mavenWrapperExecutableName) + if (existsSync(mavenWrapperExecutablePath)) { + if (mavenWrapperExecutableName === 'mvnw') { + mavenWrapperExecutableName = './mvnw' // add the './' for non-Windows + } else if (mavenWrapperExecutableName === 'mvnw.cmd') { + mavenWrapperExecutableName = '.\\mvnw.cmd' // add the '.\' for Windows + } + transformByQState.setMavenName(mavenWrapperExecutableName) + } else { + transformByQState.setMavenName('mvn') + } + getLogger().info(`CodeTransformation: using Maven ${transformByQState.getMavenName()}`) +} + +export async function openBuildLogFile() { + const logFilePath = transformByQState.getPreBuildLogFilePath() + const doc = await vscode.workspace.openTextDocument(logFilePath) + await vscode.window.showTextDocument(doc) +} + export async function createPomCopy( dirname: string, pomFileVirtualFileReference: vscode.Uri, diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformMavenHandler.ts b/packages/core/src/codewhisperer/service/transformByQ/transformMavenHandler.ts index 6e0583459d2..e2bbbc6556b 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformMavenHandler.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformMavenHandler.ts @@ -6,11 +6,12 @@ import * as vscode from 'vscode' import { FolderInfo, transformByQState } from '../../models/model' import { getLogger } from '../../../shared/logger' import * as CodeWhispererConstants from '../../models/constants' -import { spawnSync } from 'child_process' // Consider using ChildProcess once we finalize all spawnSync calls +// Consider using ChildProcess once we finalize all spawnSync calls +import { spawnSync } from 'child_process' // eslint-disable-line no-restricted-imports import { CodeTransformBuildCommand, telemetry } from '../../../shared/telemetry/telemetry' import { CodeTransformTelemetryState } from '../../../amazonqGumby/telemetry/codeTransformTelemetryState' import { ToolkitError } from '../../../shared/errors' -import { writeLogs } from './transformFileHandler' +import { setMaven, writeLogs } from './transformFileHandler' import { throwIfCancelled } from './transformApiHandler' // run 'install' with either 'mvnw.cmd', './mvnw', or 'mvn' (if wrapper exists, we use that, otherwise we use regular 'mvn') @@ -107,6 +108,8 @@ function copyProjectDependencies(dependenciesFolder: FolderInfo, modulePath: str } export async function prepareProjectDependencies(dependenciesFolder: FolderInfo, rootPomPath: string) { + await setMaven() + getLogger().info('CodeTransformation: running Maven copy-dependencies') try { copyProjectDependencies(dependenciesFolder, rootPomPath) } catch (err) { @@ -116,6 +119,7 @@ export async function prepareProjectDependencies(dependenciesFolder: FolderInfo, ) } + getLogger().info('CodeTransformation: running Maven install') try { installProjectDependencies(dependenciesFolder, rootPomPath) } catch (err) { @@ -133,9 +137,9 @@ export async function prepareProjectDependencies(dependenciesFolder: FolderInfo, export async function getVersionData() { const baseCommand = transformByQState.getMavenName() // will be one of: 'mvnw.cmd', './mvnw', 'mvn' - const modulePath = transformByQState.getProjectPath() + const projectPath = transformByQState.getProjectPath() const args = ['-v'] - const spawnResult = spawnSync(baseCommand, args, { cwd: modulePath, shell: true, encoding: 'utf-8' }) + const spawnResult = spawnSync(baseCommand, args, { cwd: projectPath, shell: true, encoding: 'utf-8' }) let localMavenVersion: string | undefined = '' let localJavaVersion: string | undefined = '' diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformProjectValidationHandler.ts b/packages/core/src/codewhisperer/service/transformByQ/transformProjectValidationHandler.ts index 143f20af51a..ebc2caeda4c 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformProjectValidationHandler.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformProjectValidationHandler.ts @@ -2,11 +2,8 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ -import { BuildSystem, JDKVersion, TransformationCandidateProject } from '../../models/model' -import { getLogger } from '../../../shared/logger' -import * as CodeWhispererConstants from '../../models/constants' +import { BuildSystem, TransformationCandidateProject } from '../../models/model' import * as vscode from 'vscode' -import { spawnSync } from 'child_process' // Consider using ChildProcess once we finalize all spawnSync calls import { NoJavaProjectsFoundError, NoMavenJavaProjectsFoundError, @@ -69,72 +66,9 @@ async function getMavenJavaProjects(javaProjects: TransformationCandidateProject return mavenJavaProjects } -async function getProjectsValidToTransform(mavenJavaProjects: TransformationCandidateProject[]) { - const projectsValidToTransform: TransformationCandidateProject[] = [] - for (const project of mavenJavaProjects) { - let detectedJavaVersion = undefined - const projectPath = project.path - const compiledJavaFiles = await vscode.workspace.findFiles( - new vscode.RelativePattern(projectPath!, '**/*.class'), - '**/node_modules/**', - 1 - ) - if (compiledJavaFiles.length > 0) { - const classFilePath = `${compiledJavaFiles[0].fsPath}` - const baseCommand = 'javap' - const args = ['-v', classFilePath] - const spawnResult = spawnSync(baseCommand, args, { shell: false, encoding: 'utf-8' }) - if (spawnResult.status !== 0) { - let errorLog = '' - errorLog += spawnResult.error ? JSON.stringify(spawnResult.error) : '' - errorLog += `${spawnResult.stderr}\n${spawnResult.stdout}` - getLogger().error(`CodeTransformation: Error in running javap command = ${errorLog}`) - let errorReason = '' - if (spawnResult.stdout) { - errorReason = 'JavapExecutionError' - } else { - errorReason = 'JavapSpawnError' - } - if (spawnResult.error) { - const errorCode = (spawnResult.error as any).code ?? 'UNKNOWN' - errorReason += `-${errorCode}` - } - getLogger().error( - `CodeTransformation: Error in running javap command = ${errorReason}, log = ${errorLog}` - ) - } else { - const majorVersionIndex = spawnResult.stdout.indexOf('major version: ') - const javaVersion = spawnResult.stdout.slice(majorVersionIndex + 15, majorVersionIndex + 17).trim() - if (javaVersion === CodeWhispererConstants.JDK8VersionNumber) { - detectedJavaVersion = JDKVersion.JDK8 - } else if (javaVersion === CodeWhispererConstants.JDK11VersionNumber) { - detectedJavaVersion = JDKVersion.JDK11 - } else { - detectedJavaVersion = JDKVersion.UNSUPPORTED - } - } - } - - // detectedJavaVersion will be undefined if there are no .class files or if javap errors out, otherwise it will be JDK8, JDK11, or UNSUPPORTED - project.JDKVersion = detectedJavaVersion - projectsValidToTransform.push(project) - } - return projectsValidToTransform -} - -/* - * This function filters all open projects by first searching for a .java file and then searching for a pom.xml file in all projects. - * It also tries to detect the Java version of each project by running "javap" on a .class file of each project. - * As long as the project contains a .java file and a pom.xml file, the project is still considered valid for transformation, - * and we allow the user to specify the Java version. - */ +// This function filters all open projects by first searching for a .java file and then searching for a pom.xml file in all projects. export async function validateOpenProjects(projects: TransformationCandidateProject[]) { const javaProjects = await getJavaProjects(projects) - const mavenJavaProjects = await getMavenJavaProjects(javaProjects) - - // These projects we know must contain a pom.xml and a .java file - const projectsValidToTransform = await getProjectsValidToTransform(mavenJavaProjects) - - return projectsValidToTransform + return mavenJavaProjects } diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts b/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts index 98bdae3fcf4..ef2ce722598 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts @@ -98,7 +98,7 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider -

Job Status

+

Transformation Status

${ Object.keys(sessionJobHistory).length === 0 ? `

${CodeWhispererConstants.nothingToShowMessage}

` diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts b/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts index fbbe937c0a0..9339be10fc9 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts @@ -21,12 +21,12 @@ import { ExportResultArchiveStructure, downloadExportResultArchive } from '../.. import { getLogger } from '../../../shared/logger' import { telemetry } from '../../../shared/telemetry/telemetry' import { CodeTransformTelemetryState } from '../../../amazonqGumby/telemetry/codeTransformTelemetryState' -import { MetadataResult } from '../../../shared/telemetry/telemetryClient' import * as CodeWhispererConstants from '../../models/constants' import { createCodeWhispererChatStreamingClient } from '../../../shared/clients/codewhispererChatClient' import { ChatSessionManager } from '../../../amazonqGumby/chat/storages/chatSession' import { setContext } from '../../../shared/vscode/setContext' import * as codeWhisperer from '../../client/codewhisperer' +import { UserWrittenCodeTracker } from '../../tracker/userWrittenCodeTracker' export abstract class ProposedChangeNode { abstract readonly resourcePath: string @@ -39,7 +39,7 @@ export abstract class ProposedChangeNode { try { this.saveFile() } catch (err) { - //to do: file system-related error handling + // to do: file system-related error handling if (err instanceof Error) { getLogger().error(err.message) } @@ -145,7 +145,7 @@ export class DiffModel { public copyProject(pathToWorkspace: string, changedFiles: ParsedDiff[]) { const pathToTmpSrcDir = path.join(os.tmpdir(), `project-copy-${Date.now()}`) fs.mkdirSync(pathToTmpSrcDir) - changedFiles.forEach((file) => { + for (const file of changedFiles) { const pathToTmpFile = path.join(pathToTmpSrcDir, file.oldFileName!.substring(2)) // use mkdirsSync to create parent directories in pathToTmpFile too fs.mkdirSync(path.dirname(pathToTmpFile), { recursive: true }) @@ -154,7 +154,7 @@ export class DiffModel { if (fs.existsSync(pathToOldFile)) { fs.copyFileSync(pathToOldFile, pathToTmpFile) } - }) + } return pathToTmpSrcDir } @@ -178,6 +178,7 @@ export class DiffModel { } const changedFiles = parsePatch(diffContents) + getLogger().info('CodeTransformation: parsed patch file successfully') // path to the directory containing copy of the changed files in the transformed project const pathToTmpSrcDir = this.copyProject(pathToWorkspace, changedFiles) transformByQState.setProjectCopyFilePath(pathToTmpSrcDir) @@ -243,11 +244,11 @@ export class DiffModel { } public saveChanges() { - this.patchFileNodes.forEach((patchFileNode) => { - patchFileNode.children.forEach((changeNode) => { + for (const patchFileNode of this.patchFileNodes) { + for (const changeNode of patchFileNode.children) { changeNode.saveChange() - }) - }) + } + } } public rejectChanges() { @@ -325,7 +326,7 @@ export class ProposedTransformationExplorer { treeDataProvider: transformDataProvider, }) - const patchFiles: string[] = [] + let patchFiles: string[] = [] let singlePatchFile: string = '' let patchFilesDescriptions: DescriptionContent | undefined = undefined @@ -365,12 +366,11 @@ export class ProposedTransformationExplorer { }) vscode.commands.registerCommand('aws.amazonq.transformationHub.summary.reveal', async () => { - if (transformByQState.getSummaryFilePath() !== '') { + if (fs.existsSync(transformByQState.getSummaryFilePath())) { await vscode.commands.executeCommand( 'markdown.showPreview', vscode.Uri.file(transformByQState.getSummaryFilePath()) ) - telemetry.ui_click.emit({ elementId: 'transformationHub_viewSummary' }) } }) @@ -403,6 +403,7 @@ export class ProposedTransformationExplorer { pathToArchive ) + getLogger().info('CodeTransformation: downloaded results successfully') // Update downloaded artifact size exportResultsArchiveSize = (await fs.promises.stat(pathToArchive)).size @@ -426,10 +427,12 @@ export class ProposedTransformationExplorer { throw new Error('Error downloading diff') } finally { cwStreamingClient.destroy() + UserWrittenCodeTracker.instance.onQFeatureInvoked() } let deserializeErrorMessage = undefined let pathContainingArchive = '' + patchFiles = [] // reset patchFiles if there was a previous transformation try { // Download and deserialize the zip pathContainingArchive = path.dirname(pathToArchive) @@ -468,7 +471,7 @@ export class ProposedTransformationExplorer { } else { patchFiles.push(singlePatchFile) } - //Because multiple patches are returned once the ZIP is downloaded, we want to show the first one to start + // Because multiple patches are returned once the ZIP is downloaded, we want to show the first one to start diffModel.parseDiff( patchFiles[0], transformByQState.getProjectPath(), @@ -532,12 +535,15 @@ export class ProposedTransformationExplorer { }) vscode.commands.registerCommand('aws.amazonq.transformationHub.reviewChanges.acceptChanges', async () => { - diffModel.saveChanges() - telemetry.codeTransform_submitSelection.emit({ - codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), - userChoice: `acceptChanges-${patchFilesDescriptions?.content[diffModel.currentPatchIndex].name}`, + telemetry.codeTransform_submitSelection.run(() => { + getLogger().info('CodeTransformation: accepted changes') + diffModel.saveChanges() + telemetry.record({ + codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId: transformByQState.getJobId(), + userChoice: `acceptChanges-${patchFilesDescriptions?.content[diffModel.currentPatchIndex].name}`, + }) }) - telemetry.ui_click.emit({ elementId: 'transformationHub_acceptChanges' }) if (transformByQState.getMultipleDiffs()) { void vscode.window.showInformationMessage( CodeWhispererConstants.changesAppliedNotificationMultipleDiffs( @@ -549,7 +555,7 @@ export class ProposedTransformationExplorer { void vscode.window.showInformationMessage(CodeWhispererConstants.changesAppliedNotificationOneDiff) } - //We do this to ensure that the changesAppliedChatMessage is only sent to user when they accept the first diff.patch + // We do this to ensure that the changesAppliedChatMessage is only sent to user when they accept the first diff.patch transformByQState.getChatControllers()?.transformationFinished.fire({ message: CodeWhispererConstants.changesAppliedChatMessageMultipleDiffs( diffModel.currentPatchIndex, @@ -580,36 +586,22 @@ export class ProposedTransformationExplorer { // All patches have been applied, reset the state await reset() } - - telemetry.codeTransform_viewArtifact.emit({ - codeTransformArtifactType: 'ClientInstructions', - codeTransformVCSViewerSrcComponents: 'toastNotification', - codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), - codeTransformJobId: transformByQState.getJobId(), - codeTransformStatus: transformByQState.getStatus(), - userChoice: 'Submit', - result: MetadataResult.Pass, - }) }) vscode.commands.registerCommand('aws.amazonq.transformationHub.reviewChanges.rejectChanges', async () => { - diffModel.rejectChanges() - await reset() - telemetry.ui_click.emit({ elementId: 'transformationHub_rejectChanges' }) - + await telemetry.codeTransform_submitSelection.run(async () => { + getLogger().info('CodeTransformation: rejected changes') + diffModel.rejectChanges() + await reset() + telemetry.record({ + codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId: transformByQState.getJobId(), + userChoice: 'rejectChanges', + }) + }) transformByQState.getChatControllers()?.transformationFinished.fire({ tabID: ChatSessionManager.Instance.getSession().tabID, }) - - telemetry.codeTransform_viewArtifact.emit({ - codeTransformArtifactType: 'ClientInstructions', - codeTransformVCSViewerSrcComponents: 'toastNotification', - codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), - codeTransformJobId: transformByQState.getJobId(), - codeTransformStatus: transformByQState.getStatus(), - userChoice: 'Cancel', - result: MetadataResult.Pass, - }) }) } } diff --git a/packages/core/src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts b/packages/core/src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts index 925609ce185..39416eafe70 100644 --- a/packages/core/src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts +++ b/packages/core/src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts @@ -27,6 +27,8 @@ const autoClosingKeystrokeInputs = ['[]', '{}', '()', '""', "''"] /** * This singleton class is mainly used for calculating the code written by codeWhisperer + * TODO: Remove this tracker, uses user written code tracker instead. + * This is kept in codebase for server side backward compatibility until service fully switch to user written code */ export class CodeWhispererCodeCoverageTracker { private _acceptedTokens: { [key: string]: CodeWhispererToken[] } @@ -104,12 +106,12 @@ export class CodeWhispererCodeCoverageTracker { // the accepted characters after calculating user modification let unmodifiedAcceptedTokens = 0 for (const filename in this._acceptedTokens) { - this._acceptedTokens[filename].forEach((v) => { + for (const v of this._acceptedTokens[filename]) { if (filename in this._totalTokens && this._totalTokens[filename] >= v.accepted) { unmodifiedAcceptedTokens += v.accepted acceptedTokens += v.text.length } - }) + } } const percentCount = ((acceptedTokens / totalTokens) * 100).toFixed(2) const percentage = Math.round(parseInt(percentCount)) diff --git a/packages/core/src/codewhisperer/tracker/userWrittenCodeTracker.ts b/packages/core/src/codewhisperer/tracker/userWrittenCodeTracker.ts new file mode 100644 index 00000000000..2497006b0a4 --- /dev/null +++ b/packages/core/src/codewhisperer/tracker/userWrittenCodeTracker.ts @@ -0,0 +1,194 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { getLogger } from '../../shared/logger/logger' +import { runtimeLanguageContext } from '../util/runtimeLanguageContext' +import { AuthUtil } from '../util/authUtil' +import { getSelectedCustomization } from '../util/customizationUtil' +import { codeWhispererClient as client } from '../client/codewhisperer' +import { isAwsError } from '../../shared/errors' +import { CodewhispererLanguage, globals, undefinedIfEmpty } from '../../shared' + +/** + * This singleton class is mainly used for calculating the user written code + * for active Amazon Q users. + * It reports the user written code per 5 minutes when the user is coding and using Amazon Q features + */ +export class UserWrittenCodeTracker { + private _userWrittenNewCodeCharacterCount: Map + private _userWrittenNewCodeLineCount: Map + private _qIsMakingEdits: boolean + private _timer?: NodeJS.Timer + private _qUsageCount: number + private _lastQInvocationTime: number + + static #instance: UserWrittenCodeTracker + private static copySnippetThreshold = 50 + private static resetQIsEditingTimeoutMs = 2 * 60 * 1000 + private static defaultCheckPeriodMillis = 5 * 60 * 1000 + + private constructor() { + this._userWrittenNewCodeLineCount = new Map() + this._userWrittenNewCodeCharacterCount = new Map() + this._qUsageCount = 0 + this._qIsMakingEdits = false + this._timer = undefined + this._lastQInvocationTime = 0 + } + + public static get instance() { + return (this.#instance ??= new this()) + } + + public isActive(): boolean { + return globals.telemetry.telemetryEnabled && AuthUtil.instance.isConnected() + } + + // this should be invoked whenever there is a successful Q feature invocation + // for all Q features + public onQFeatureInvoked() { + this._qUsageCount += 1 + this._lastQInvocationTime = performance.now() + } + + public onQStartsMakingEdits() { + this._qIsMakingEdits = true + } + + public onQFinishesEdits() { + this._qIsMakingEdits = false + } + + public getUserWrittenCharacters(language: CodewhispererLanguage) { + return this._userWrittenNewCodeCharacterCount.get(language) || 0 + } + + public getUserWrittenLines(language: CodewhispererLanguage) { + return this._userWrittenNewCodeLineCount.get(language) || 0 + } + + public reset() { + this._userWrittenNewCodeLineCount = new Map() + this._userWrittenNewCodeCharacterCount = new Map() + this._qUsageCount = 0 + this._qIsMakingEdits = false + this._lastQInvocationTime = 0 + if (this._timer !== undefined) { + clearTimeout(this._timer) + this._timer = undefined + } + } + + public emitCodeContributions() { + const selectedCustomization = getSelectedCustomization() + + for (const [language, charCount] of this._userWrittenNewCodeCharacterCount) { + const lineCount = this.getUserWrittenLines(language) + if (charCount > 0) { + client + .sendTelemetryEvent({ + telemetryEvent: { + codeCoverageEvent: { + customizationArn: undefinedIfEmpty(selectedCustomization.arn), + programmingLanguage: { + languageName: runtimeLanguageContext.toRuntimeLanguage(language), + }, + acceptedCharacterCount: 0, + totalCharacterCount: 0, + timestamp: new Date(Date.now()), + userWrittenCodeCharacterCount: charCount, + userWrittenCodeLineCount: lineCount, + }, + }, + }) + .then() + .catch((error) => { + let requestId: string | undefined + if (isAwsError(error)) { + requestId = error.requestId + } + getLogger().debug( + `Failed to sendTelemetryEvent, requestId: ${requestId ?? ''}, message: ${error.message}` + ) + }) + } + } + } + + private tryStartTimer() { + if (this._timer !== undefined) { + return + } + if (!this.isActive()) { + getLogger().debug(`Skip emiting code contribution metric. Telemetry disabled or not logged in. `) + this.reset() + return + } + const startTime = performance.now() + this._timer = setTimeout(() => { + try { + const currentTime = performance.now() + const delay: number = UserWrittenCodeTracker.defaultCheckPeriodMillis + const diffTime: number = startTime + delay + if (diffTime <= currentTime) { + if (this._qUsageCount <= 0) { + getLogger().debug(`Skip emiting code contribution metric. There is no active Amazon Q usage. `) + return + } + if (this._userWrittenNewCodeCharacterCount.size === 0) { + getLogger().debug(`Skip emiting code contribution metric. There is no new code added. `) + return + } + this.emitCodeContributions() + } + } catch (e) { + getLogger().verbose(`Exception Thrown from QCodeGenTracker: ${e}`) + } finally { + this.reset() + } + }, UserWrittenCodeTracker.defaultCheckPeriodMillis) + } + + private countNewLines(str: string) { + return str.split('\n').length - 1 + } + + public onTextDocumentChange(e: vscode.TextDocumentChangeEvent) { + // do not count code written by Q as user written code + if ( + !runtimeLanguageContext.isLanguageSupported(e.document.languageId) || + e.contentChanges.length === 0 || + this._qIsMakingEdits + ) { + // if the boolean of qIsMakingEdits was incorrectly set to true + // due to unhandled edge cases or early terminated code paths + // reset it back to false after a reasonable period of time + if (this._qIsMakingEdits) { + if (performance.now() - this._lastQInvocationTime > UserWrittenCodeTracker.resetQIsEditingTimeoutMs) { + getLogger().warn(`Reset Q is editing state to false.`) + this._qIsMakingEdits = false + } + } + return + } + const contentChange = e.contentChanges[0] + // if user copies code into the editor for more than 50 characters + // do not count this as total new code, this will skew the data, + // reporting highly inflated user written code + if (contentChange.text.length > UserWrittenCodeTracker.copySnippetThreshold) { + return + } + const language = runtimeLanguageContext.normalizeLanguage(e.document.languageId) + if (language) { + const charCount = this.getUserWrittenCharacters(language) + this._userWrittenNewCodeCharacterCount.set(language, charCount + contentChange.text.length) + const lineCount = this.getUserWrittenLines(language) + this._userWrittenNewCodeLineCount.set(language, lineCount + this.countNewLines(contentChange.text)) + // start 5 min data reporting once valid user input is detected + this.tryStartTimer() + } + } +} diff --git a/packages/core/src/codewhisperer/ui/prompters.ts b/packages/core/src/codewhisperer/ui/prompters.ts new file mode 100644 index 00000000000..95541d84a82 --- /dev/null +++ b/packages/core/src/codewhisperer/ui/prompters.ts @@ -0,0 +1,34 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + codeIssueGroupingStrategies, + CodeIssueGroupingStrategy, + codeIssueGroupingStrategyLabel, + CodeIssueGroupingStrategyState, +} from '../models/model' +import { createQuickPick, QuickPickPrompter } from '../../shared/ui/pickerPrompter' +import { localize } from '../../shared/utilities/vsCodeUtils' + +export function createCodeIssueGroupingStrategyPrompter(): QuickPickPrompter { + const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState() + const prompter = createQuickPick( + codeIssueGroupingStrategies.map((strategy) => ({ + data: strategy, + label: codeIssueGroupingStrategyLabel[strategy], + })), + { + title: localize('AWS.amazonq.scans.groupIssues', 'Group Issues'), + placeholder: localize('AWS.amazonq.scans.groupIssues.placeholder', 'Select how to group code issues'), + } + ) + prompter.quickPick.activeItems = prompter.quickPick.items.filter((item) => item.data === groupingStrategy) + prompter.quickPick.onDidChangeSelection(async (items) => { + const [item] = items + await CodeIssueGroupingStrategyState.instance.setState(item.data) + prompter.quickPick.hide() + }) + return prompter +} diff --git a/packages/core/src/codewhisperer/util/authUtil.ts b/packages/core/src/codewhisperer/util/authUtil.ts index ed2dfd66e6c..a9045220ea2 100644 --- a/packages/core/src/codewhisperer/util/authUtil.ts +++ b/packages/core/src/codewhisperer/util/authUtil.ts @@ -8,7 +8,7 @@ import * as localizedText from '../../shared/localizedText' import { Auth } from '../../auth/auth' import { ToolkitError, isNetworkError, tryRun } from '../../shared/errors' import { getSecondaryAuth, setScopes } from '../../auth/secondaryAuth' -import { isCloud9, isSageMaker } from '../../shared/extensionUtilities' +import { isSageMaker } from '../../shared/extensionUtilities' import { AmazonQPromptSettings } from '../../shared/settings' import { scopesCodeWhispererCore, @@ -55,14 +55,8 @@ export const amazonQScopes = [...codeWhispererChatScopes, ...scopesGumby, ...sco * for Amazon Q. */ export const isValidCodeWhispererCoreConnection = (conn?: Connection): conn is Connection => { - if (isCloud9('classic')) { - return isIamConnection(conn) - } - return ( - (isSageMaker() && isIamConnection(conn)) || - (isCloud9('codecatalyst') && isIamConnection(conn)) || - (isSsoConnection(conn) && hasScopes(conn, codeWhispererCoreScopes)) + (isSageMaker() && isIamConnection(conn)) || (isSsoConnection(conn) && hasScopes(conn, codeWhispererCoreScopes)) ) } /** Superset that includes all of CodeWhisperer + Amazon Q */ @@ -144,10 +138,6 @@ export class AuthUtil { }) public async setVscodeContextProps() { - if (isCloud9()) { - return - } - await setContext('aws.codewhisperer.connected', this.isConnected()) const doShowAmazonQLoginView = !this.isConnected() || this.isConnectionExpired() await setContext('aws.amazonq.showLoginView', doShowAmazonQLoginView) @@ -366,7 +356,7 @@ export class AuthUtil { public async notifySessionConfiguration() { const suppressId = 'amazonQSessionConfigurationMessage' const settings = AmazonQPromptSettings.instance - const shouldShow = await settings.isPromptEnabled(suppressId) + const shouldShow = settings.isPromptEnabled(suppressId) if (!shouldShow) { return } @@ -457,7 +447,9 @@ export class AuthUtil { state[Features.codewhispererCore] = AuthStates.connected } if (isValidAmazonQConnection(conn)) { - Object.values(Features).forEach((v) => (state[v as Feature] = AuthStates.connected)) + for (const v of Object.values(Features)) { + state[v as Feature] = AuthStates.connected + } } } @@ -499,30 +491,6 @@ export class AuthUtil { } } -/** - * Returns true if an SSO connection with AmazonQ and CodeWhisperer scopes are found, - * even if the connection is expired. - * - * Note: This function will become irrelevant if/when the Amazon Q view tree is removed - * from the toolkit. - */ -export function isPreviousQUser() { - const auth = AuthUtil.instance - - if (!auth.isConnected() || !isSsoConnection(auth.conn)) { - return false - } - const missingScopes = - (auth.isEnterpriseSsoInUse() && !hasScopes(auth.conn, amazonQScopes)) || - !hasScopes(auth.conn, codeWhispererChatScopes) - - if (missingScopes) { - return false - } - - return true -} - export type FeatureAuthState = { [feature in Feature]: AuthState } export type Feature = (typeof Features)[keyof typeof Features] export type AuthState = (typeof AuthStates)[keyof typeof AuthStates] diff --git a/packages/core/src/codewhisperer/util/closingBracketUtil.ts b/packages/core/src/codewhisperer/util/closingBracketUtil.ts index 4273094b58a..466ca31a0b9 100644 --- a/packages/core/src/codewhisperer/util/closingBracketUtil.ts +++ b/packages/core/src/codewhisperer/util/closingBracketUtil.ts @@ -4,8 +4,6 @@ */ import * as vscode from 'vscode' -import { workspace, WorkspaceEdit } from 'vscode' -import { isCloud9 } from '../../shared/extensionUtilities' import * as CodeWhispererConstants from '../models/constants' interface bracketMapType { @@ -97,31 +95,18 @@ const removeBracketsFromRightContext = async ( ) => { const offset = editor.document.offsetAt(endPosition) - if (isCloud9()) { - const edits = idxToRemove.map((idx) => ({ - range: new vscode.Range( - editor.document.positionAt(offset + idx), - editor.document.positionAt(offset + idx + 1) - ), - newText: '', - })) - const wEdit = new WorkspaceEdit() - wEdit.set(editor.document.uri, [...edits]) - await workspace.applyEdit(wEdit) - } else { - await editor.edit( - (editBuilder) => { - idxToRemove.forEach((idx) => { - const range = new vscode.Range( - editor.document.positionAt(offset + idx), - editor.document.positionAt(offset + idx + 1) - ) - editBuilder.delete(range) - }) - }, - { undoStopAfter: false, undoStopBefore: false } - ) - } + await editor.edit( + (editBuilder) => { + for (const idx of idxToRemove) { + const range = new vscode.Range( + editor.document.positionAt(offset + idx), + editor.document.positionAt(offset + idx + 1) + ) + editBuilder.delete(range) + } + }, + { undoStopAfter: false, undoStopBefore: false } + ) } function getBracketsToRemove( diff --git a/packages/core/src/codewhisperer/util/codeWhispererSession.ts b/packages/core/src/codewhisperer/util/codeWhispererSession.ts index e5daae22d17..042cd947124 100644 --- a/packages/core/src/codewhisperer/util/codeWhispererSession.ts +++ b/packages/core/src/codewhisperer/util/codeWhispererSession.ts @@ -41,6 +41,7 @@ class CodeWhispererSession { fetchCredentialStartTime = 0 sdkApiCallStartTime = 0 invokeSuggestionStartTime = 0 + preprocessEndTime = 0 timeToFirstRecommendation = 0 firstSuggestionShowTime = 0 perceivedLatency = 0 diff --git a/packages/core/src/codewhisperer/util/codewhispererSettings.ts b/packages/core/src/codewhisperer/util/codewhispererSettings.ts index 374107cb28d..14fc7ef77d2 100644 --- a/packages/core/src/codewhisperer/util/codewhispererSettings.ts +++ b/packages/core/src/codewhisperer/util/codewhispererSettings.ts @@ -2,7 +2,7 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ -import { fromExtensionManifest, migrateSetting } from '../../shared/settings' +import { fromExtensionManifest } from '../../shared/settings' import { ArrayConstructor } from '../../shared/utilities/typeConstructors' const description = { @@ -18,22 +18,6 @@ const description = { } export class CodeWhispererSettings extends fromExtensionManifest('amazonQ', description) { - // TODO: Remove after a few releases - public async importSettings() { - await migrateSetting( - { key: 'aws.codeWhisperer.includeSuggestionsWithCodeReferences', type: Boolean }, - { key: 'amazonQ.showInlineCodeSuggestionsWithCodeReferences' } - ) - await migrateSetting( - { key: 'aws.codeWhisperer.importRecommendation', type: Boolean }, - { key: 'amazonQ.importRecommendationForInlineCodeSuggestions' } - ) - await migrateSetting( - { key: 'aws.codeWhisperer.shareCodeWhispererContentWithAWS', type: Boolean }, - { key: 'amazonQ.shareContentWithAWS' } - ) - } - public isSuggestionsWithCodeReferencesEnabled(): boolean { return this.get(`showInlineCodeSuggestionsWithCodeReferences`, false) } diff --git a/packages/core/src/codewhisperer/util/commonUtil.ts b/packages/core/src/codewhisperer/util/commonUtil.ts index 1d624e77b5e..d2df78f1369 100644 --- a/packages/core/src/codewhisperer/util/commonUtil.ts +++ b/packages/core/src/codewhisperer/util/commonUtil.ts @@ -6,7 +6,6 @@ import * as vscode from 'vscode' import * as semver from 'semver' import { distance } from 'fastest-levenshtein' -import { isCloud9 } from '../../shared/extensionUtilities' import { getInlineSuggestEnabled } from '../../shared/utilities/editorUtilities' import { AWSTemplateCaseInsensitiveKeyWords, @@ -31,12 +30,12 @@ export function asyncCallWithTimeout(asyncPromise: Promise, message: strin } export function isInlineCompletionEnabled() { - return getInlineSuggestEnabled() && !isCloud9() + return getInlineSuggestEnabled() } // This is the VS Code version that started to have regressions in inline completion API export function isVscHavingRegressionInlineCompletionApi() { - return semver.gte(vscode.version, '1.78.0') && getInlineSuggestEnabled() && !isCloud9() + return semver.gte(vscode.version, '1.78.0') && getInlineSuggestEnabled() } export function getFileExt(languageId: string) { diff --git a/packages/core/src/codewhisperer/util/customizationUtil.ts b/packages/core/src/codewhisperer/util/customizationUtil.ts index cfaf68b1afd..e87d17cfdb3 100644 --- a/packages/core/src/codewhisperer/util/customizationUtil.ts +++ b/packages/core/src/codewhisperer/util/customizationUtil.ts @@ -91,6 +91,12 @@ export const baseCustomization = { ), } +/** + * Gets the customization that should be used for user requests. If a user has manually selected + * a customization, always respect that choice. If not, check if the user is part of an AB + * group assigned a specific customization. If so, use that customization. If not, use the + * base customization. + */ export const getSelectedCustomization = (): Customization => { if ( !AuthUtil.instance.isCustomizationFeatureEnabled || @@ -105,21 +111,22 @@ export const getSelectedCustomization = (): Customization => { Object, {} ) - const result = selectedCustomizationArr[AuthUtil.instance.conn.label] || baseCustomization - - // A/B case - const customizationFeature = FeatureConfigProvider.getFeature(Features.customizationArnOverride) - const arnOverride = customizationFeature?.value.stringValue - const customizationOverrideName = customizationFeature?.variation - if (arnOverride === undefined || arnOverride === '') { - return result + const selectedCustomization = selectedCustomizationArr[AuthUtil.instance.conn.label] + + if (selectedCustomization && selectedCustomization.name !== baseCustomization.name) { + return selectedCustomization } else { - // A trick to prioritize arn from A/B over user's currently selected(for request and telemetry) - // but still shows customization info of user's currently selected. - return { - arn: arnOverride, - name: customizationOverrideName, - description: result.description, + const customizationFeature = FeatureConfigProvider.getFeature(Features.customizationArnOverride) + const arnOverride = customizationFeature?.value.stringValue + const customizationOverrideName = customizationFeature?.variation + if (arnOverride === undefined) { + return baseCustomization + } else { + return { + arn: arnOverride, + name: customizationOverrideName, + description: baseCustomization.description, + } } } } @@ -328,11 +335,11 @@ export const selectCustomization = async (customization: Customization) => { export const getAvailableCustomizationsList = async () => { const items: Customization[] = [] const response = await codeWhispererClient.listAvailableCustomizations() - response - .map((listAvailableCustomizationsResponse) => listAvailableCustomizationsResponse.customizations) - .forEach((customizations) => { - items.push(...customizations) - }) + for (const customizations of response.map( + (listAvailableCustomizationsResponse) => listAvailableCustomizationsResponse.customizations + )) { + items.push(...customizations) + } return items } diff --git a/packages/core/src/codewhisperer/util/editorContext.ts b/packages/core/src/codewhisperer/util/editorContext.ts index 4b58cb4f848..99a15fd1f02 100644 --- a/packages/core/src/codewhisperer/util/editorContext.ts +++ b/packages/core/src/codewhisperer/util/editorContext.ts @@ -216,7 +216,7 @@ function logSupplementalContext(supplementalContext: CodeWhispererSupplementalCo true ).trimStart() - supplementalContext.supplementalContextItems.forEach((context, index) => { + for (const [index, context] of supplementalContext.supplementalContextItems.entries()) { logString += indent(`\nChunk ${index}:\n`, 4, true) logString += indent( `Path: ${context.filePath} @@ -225,7 +225,7 @@ function logSupplementalContext(supplementalContext: CodeWhispererSupplementalCo 8, true ) - }) + } getLogger().debug(logString) } diff --git a/packages/core/src/codewhisperer/util/gitUtil.ts b/packages/core/src/codewhisperer/util/gitUtil.ts index f4a48dbd0cb..752c16ba6bf 100644 --- a/packages/core/src/codewhisperer/util/gitUtil.ts +++ b/packages/core/src/codewhisperer/util/gitUtil.ts @@ -3,8 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { showOutputMessage } from '../../shared/utilities/messages' -import { getLogger, globals, removeAnsi } from '../../shared' +import { getLogger, removeAnsi } from '../../shared' import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils' import { Uri } from 'vscode' @@ -17,10 +16,10 @@ export async function isGitRepo(folder: Uri): Promise { rejectOnErrorCode: true, onStdout: (text) => { output += text - showOutputMessage(removeAnsi(text), globals.outputChannel) + getLogger().verbose(removeAnsi(text)) }, onStderr: (text) => { - showOutputMessage(removeAnsi(text), globals.outputChannel) + getLogger().error(removeAnsi(text)) }, spawnOptions: { cwd: folder.fsPath, diff --git a/packages/core/src/codewhisperer/util/licenseUtil.ts b/packages/core/src/codewhisperer/util/licenseUtil.ts index 3a49adc6b4a..d21cd093e12 100644 --- a/packages/core/src/codewhisperer/util/licenseUtil.ts +++ b/packages/core/src/codewhisperer/util/licenseUtil.ts @@ -471,11 +471,13 @@ export class LicenseUtil { public static getUniqueLicenseNames(references: References | undefined): Set { const n = new Set() - references?.forEach((r) => { - if (r.licenseName) { - n.add(r.licenseName) + if (references) { + for (const r of references) { + if (r.licenseName) { + n.add(r.licenseName) + } } - }) + } return n } } diff --git a/packages/core/src/codewhisperer/util/showSsoPrompt.ts b/packages/core/src/codewhisperer/util/showSsoPrompt.ts index fbe1e6cb41c..229583a8cc2 100644 --- a/packages/core/src/codewhisperer/util/showSsoPrompt.ts +++ b/packages/core/src/codewhisperer/util/showSsoPrompt.ts @@ -14,15 +14,12 @@ import { CancellationError } from '../../shared/utilities/timeoutUtils' import { ToolkitError } from '../../shared/errors' import { createCommonButtons } from '../../shared/ui/buttons' import { telemetry } from '../../shared/telemetry/telemetry' -import { isCloud9 } from '../../shared/extensionUtilities' import { createBuilderIdItem, createSsoItem, createIamItem } from '../../auth/utils' import { Commands } from '../../shared/vscode/commands2' import { vsCodeState } from '../models/model' export const showCodeWhispererConnectionPrompt = async () => { - const items = isCloud9('classic') - ? [createSsoItem(), createCodeWhispererIamItem()] - : [createBuilderIdItem(), createSsoItem(), createCodeWhispererIamItem()] + const items = [createBuilderIdItem(), createSsoItem(), createCodeWhispererIamItem()] const resp = await showQuickPick(items, { title: 'Amazon Q: Add Connection to AWS', diff --git a/packages/core/src/codewhisperer/util/supplementalContext/crossFileContextUtil.ts b/packages/core/src/codewhisperer/util/supplementalContext/crossFileContextUtil.ts index 2a9423e6159..f4688e2b5a9 100644 --- a/packages/core/src/codewhisperer/util/supplementalContext/crossFileContextUtil.ts +++ b/packages/core/src/codewhisperer/util/supplementalContext/crossFileContextUtil.ts @@ -7,18 +7,20 @@ import * as vscode from 'vscode' import { FeatureConfigProvider, fs } from '../../../shared' import path = require('path') import { BM25Document, BM25Okapi } from './rankBm25' -import { ToolkitError } from '../../../shared/errors' import { crossFileContextConfig, supplementalContextTimeoutInMs, - supplemetalContextFetchingTimeoutMsg, + supplementalContextMaxTotalLength, } from '../../models/constants' -import { CancellationError } from '../../../shared/utilities/timeoutUtils' import { isTestFile } from './codeParsingUtil' import { getFileDistance } from '../../../shared/filesystemUtilities' import { getOpenFilesInWindow } from '../../../shared/utilities/editorUtilities' import { getLogger } from '../../../shared/logger/logger' -import { CodeWhispererSupplementalContext, CodeWhispererSupplementalContextItem } from '../../models/model' +import { + CodeWhispererSupplementalContext, + CodeWhispererSupplementalContextItem, + SupplementalContextStrategy, +} from '../../models/model' import { LspController } from '../../../amazonq/lsp/lspController' import { waitUntil } from '../../../shared/utilities/timeoutUtils' @@ -73,28 +75,51 @@ export async function fetchSupplementalContextForSrc( return undefined } + // fallback to opentabs if projectContext timeout + const opentabsContextPromise = waitUntil( + async function () { + return await fetchOpentabsContext(editor, cancellationToken) + }, + { timeout: supplementalContextTimeoutInMs, interval: 5, truthy: false } + ) + // opentabs context will use bm25 and users' open tabs to fetch supplemental context if (supplementalContextConfig === 'opentabs') { + const supContext = (await opentabsContextPromise) ?? [] return { - supplementalContextItems: (await fetchOpentabsContext(editor, cancellationToken)) ?? [], - strategy: 'opentabs', + supplementalContextItems: supContext, + strategy: supContext.length === 0 ? 'empty' : 'opentabs', } } // codemap will use opentabs context plus repomap if it's present if (supplementalContextConfig === 'codemap') { + let strategy: SupplementalContextStrategy = 'empty' + let hasCodemap: boolean = false + let hasOpentabs: boolean = false const opentabsContextAndCodemap = await waitUntil( async function () { const result: CodeWhispererSupplementalContextItem[] = [] const opentabsContext = await fetchOpentabsContext(editor, cancellationToken) const codemap = await fetchProjectContext(editor, 'codemap') + function addToResult(items: CodeWhispererSupplementalContextItem[]) { + for (const item of items) { + const curLen = result.reduce((acc, i) => acc + i.content.length, 0) + if (curLen + item.content.length < supplementalContextMaxTotalLength) { + result.push(item) + } + } + } + if (codemap && codemap.length > 0) { - result.push(...codemap) + addToResult(codemap) + hasCodemap = true } if (opentabsContext && opentabsContext.length > 0) { - result.push(...opentabsContext) + addToResult(opentabsContext) + hasOpentabs = true } return result @@ -102,20 +127,20 @@ export async function fetchSupplementalContextForSrc( { timeout: supplementalContextTimeoutInMs, interval: 5, truthy: false } ) + if (hasCodemap) { + strategy = 'codemap' + } else if (hasOpentabs) { + strategy = 'opentabs' + } else { + strategy = 'empty' + } + return { supplementalContextItems: opentabsContextAndCodemap ?? [], - strategy: 'codemap', + strategy: strategy, } } - // fallback to opentabs if projectContext timeout for 'default' | 'bm25' - const opentabsContextPromise = waitUntil( - async function () { - return await fetchOpentabsContext(editor, cancellationToken) - }, - { timeout: supplementalContextTimeoutInMs, interval: 5, truthy: false } - ) - // global bm25 without repomap if (supplementalContextConfig === 'bm25') { const projectBM25Promise = waitUntil( @@ -133,9 +158,10 @@ export async function fetchSupplementalContextForSrc( } } + const supContext = opentabsContext ?? [] return { - supplementalContextItems: opentabsContext ?? [], - strategy: 'opentabs', + supplementalContextItems: supContext, + strategy: supContext.length === 0 ? 'empty' : 'opentabs', } } @@ -188,14 +214,12 @@ export async function fetchOpentabsContext( // Step 1: Get relevant cross files to refer const relevantCrossFilePaths = await getCrossFileCandidates(editor) - throwIfCancelled(cancellationToken) // Step 2: Split files to chunks with upper bound on chunkCount // We restrict the total number of chunks to improve on latency. // Chunk linking is required as we want to pass the next chunk value for matched chunk. let chunkList: Chunk[] = [] for (const relevantFile of relevantCrossFilePaths) { - throwIfCancelled(cancellationToken) const chunks: Chunk[] = await splitFileToChunks(relevantFile, crossFileContextConfig.numberOfLinesEachChunk) const linkedChunks = linkChunks(chunks) chunkList.push(...linkedChunks) @@ -211,14 +235,11 @@ export async function fetchOpentabsContext( // and Find Best K chunks w.r.t input chunk using BM25 const inputChunk: Chunk = getInputChunk(editor) const bestChunks: Chunk[] = findBestKChunkMatches(inputChunk, chunkList, crossFileContextConfig.topK) - throwIfCancelled(cancellationToken) // Step 4: Transform best chunks to supplemental contexts const supplementalContexts: CodeWhispererSupplementalContextItem[] = [] let totalLength = 0 for (const chunk of bestChunks) { - throwIfCancelled(cancellationToken) - totalLength += chunk.nextContent.length if (totalLength > crossFileContextConfig.maximumTotalLength) { @@ -240,7 +261,7 @@ export async function fetchOpentabsContext( function findBestKChunkMatches(chunkInput: Chunk, chunkReferences: Chunk[], k: number): Chunk[] { const chunkContentList = chunkReferences.map((chunk) => chunk.content) - //performBM25Scoring returns the output in a sorted order (descending of scores) + // performBM25Scoring returns the output in a sorted order (descending of scores) const top3: BM25Document[] = new BM25Okapi(chunkContentList).topN(chunkInput.content, crossFileContextConfig.topK) return top3.map((doc) => { @@ -284,14 +305,8 @@ function getSupplementalContextConfig(languageId: vscode.TextDocument['languageI const group = FeatureConfigProvider.instance.getProjectContextGroup() switch (group) { - case 'control': - return 'opentabs' - - case 't1': + default: return 'codemap' - - case 't2': - return 'bm25' } } @@ -377,9 +392,3 @@ export async function getCrossFileCandidates(editor: vscode.TextEditor): Promise return fileToDistance.file }) } - -function throwIfCancelled(token: vscode.CancellationToken): void | never { - if (token.isCancellationRequested) { - throw new ToolkitError(supplemetalContextFetchingTimeoutMsg, { cause: new CancellationError('timeout') }) - } -} diff --git a/packages/core/src/codewhisperer/util/supplementalContext/rankBm25.ts b/packages/core/src/codewhisperer/util/supplementalContext/rankBm25.ts index 3e00013c9cf..a2c77e0b10f 100644 --- a/packages/core/src/codewhisperer/util/supplementalContext/rankBm25.ts +++ b/packages/core/src/codewhisperer/util/supplementalContext/rankBm25.ts @@ -31,24 +31,22 @@ export abstract class BM25 { this.corpusSize = corpus.length let numDoc = 0 - corpus - .map((document) => { - return tokenizer(document) - }) - .forEach((document) => { - this.docLen.push(document.length) - numDoc += document.length - - const frequencies = new Map() - document.forEach((word) => { - frequencies.set(word, (frequencies.get(word) || 0) + 1) - }) - this.docFreqs.push(frequencies) - - frequencies.forEach((freq, word) => { - this.nd.set(word, (this.nd.get(word) || 0) + 1) - }) - }) + for (const document of corpus.map((document) => { + return tokenizer(document) + })) { + this.docLen.push(document.length) + numDoc += document.length + + const frequencies = new Map() + for (const word of document) { + frequencies.set(word, (frequencies.get(word) || 0) + 1) + } + this.docFreqs.push(frequencies) + + for (const [word, _] of frequencies.entries()) { + this.nd.set(word, (this.nd.get(word) || 0) + 1) + } + } this.avgdl = numDoc / this.corpusSize @@ -96,14 +94,14 @@ export class BM25Okapi extends BM25 { const queryWords = defaultTokenizer(query) return this.docFreqs.map((docFreq, index) => { let score = 0 - queryWords.forEach((queryWord, _) => { + for (const [_, queryWord] of queryWords.entries()) { const queryWordFreqForDocument = docFreq.get(queryWord) || 0 const numerator = (this.idf.get(queryWord) || 0.0) * queryWordFreqForDocument * (this.k1 + 1) const denominator = queryWordFreqForDocument + this.k1 * (1 - this.b + (this.b * this.docLen[index]) / this.avgdl) score += numerator / denominator - }) + } return { content: this.corpus[index], diff --git a/packages/core/src/codewhisperer/util/supplementalContext/supplementalContextUtil.ts b/packages/core/src/codewhisperer/util/supplementalContext/supplementalContextUtil.ts index 5cb7c2bfb83..03f9d59b3f2 100644 --- a/packages/core/src/codewhisperer/util/supplementalContext/supplementalContextUtil.ts +++ b/packages/core/src/codewhisperer/util/supplementalContext/supplementalContextUtil.ts @@ -58,7 +58,7 @@ export async function fetchSupplementalContext( supplementalContextItems: [], contentsLength: 0, latency: performance.now() - timesBeforeFetching, - strategy: 'Empty', + strategy: 'empty', } } else { getLogger().error( diff --git a/packages/core/src/codewhisperer/util/supplementalContext/utgUtils.ts b/packages/core/src/codewhisperer/util/supplementalContext/utgUtils.ts index 63c29dc1c9a..a39a48183b0 100644 --- a/packages/core/src/codewhisperer/util/supplementalContext/utgUtils.ts +++ b/packages/core/src/codewhisperer/util/supplementalContext/utgUtils.ts @@ -54,7 +54,7 @@ export async function fetchSupplementalContextForTest( const shouldProceed = shouldFetchUtgContext(editor.document.languageId) if (!shouldProceed) { - return shouldProceed === undefined ? undefined : { supplementalContextItems: [], strategy: 'Empty' } + return shouldProceed === undefined ? undefined : { supplementalContextItems: [], strategy: 'empty' } } const languageConfig = utgLanguageConfigs[editor.document.languageId] @@ -69,10 +69,10 @@ export async function fetchSupplementalContextForTest( return { supplementalContextItems: await generateSupplementalContextFromFocalFile( crossSourceFile, - 'ByName', + 'byName', cancellationToken ), - strategy: 'ByName', + strategy: 'byName', } } throwIfCancelled(cancellationToken) @@ -84,10 +84,10 @@ export async function fetchSupplementalContextForTest( return { supplementalContextItems: await generateSupplementalContextFromFocalFile( crossSourceFile, - 'ByContent', + 'byContent', cancellationToken ), - strategy: 'ByContent', + strategy: 'byContent', } } @@ -95,7 +95,7 @@ export async function fetchSupplementalContextForTest( getLogger().debug(`CodeWhisperer failed to fetch utg context`) return { supplementalContextItems: [], - strategy: 'Empty', + strategy: 'empty', } } diff --git a/packages/core/src/codewhisperer/util/telemetryHelper.ts b/packages/core/src/codewhisperer/util/telemetryHelper.ts index 5176ffee2be..5276d869bb9 100644 --- a/packages/core/src/codewhisperer/util/telemetryHelper.ts +++ b/packages/core/src/codewhisperer/util/telemetryHelper.ts @@ -27,12 +27,22 @@ import { CodeWhispererSupplementalContext } from '../models/model' import { FeatureConfigProvider } from '../../shared/featureConfig' import { CodeScanRemediationsEventType } from '../client/codewhispereruserclient' import { CodeAnalysisScope as CodeAnalysisScopeClientSide } from '../models/constants' +import { Session } from '../../amazonqTest/chat/session/session' export class TelemetryHelper { // Some variables for client component latency - private sdkApiCallEndTime = 0 - private allPaginationEndTime = 0 - private firstResponseRequestId = '' + private _sdkApiCallEndTime = 0 + get sdkApiCallEndTime(): number { + return this._sdkApiCallEndTime + } + private _allPaginationEndTime = 0 + get allPaginationEndTime(): number { + return this._allPaginationEndTime + } + private _firstResponseRequestId = '' + get firstResponseRequestId(): string { + return this._firstResponseRequestId + } // variables for user trigger decision // these will be cleared after a invocation session private sessionDecisions: CodewhispererUserTriggerDecision[] = [] @@ -57,6 +67,52 @@ export class TelemetryHelper { return (this.#instance ??= new this()) } + public sendTestGenerationToolkitEvent( + session: Session, + isSupportedLanguage: boolean, + isFileInWorkspace: boolean, + result: 'Succeeded' | 'Failed' | 'Cancelled', + requestId?: string, + perfClientLatency?: number, + reasonDesc?: string, + isCodeBlockSelected?: boolean, + artifactsUploadDuration?: number, + buildPayloadBytes?: number, + buildZipFileBytes?: number, + acceptedCharactersCount?: number, + acceptedCount?: number, + acceptedLinesCount?: number, + generatedCharactersCount?: number, + generatedCount?: number, + generatedLinesCount?: number, + reason?: string + ) { + telemetry.amazonq_utgGenerateTests.emit({ + cwsprChatProgrammingLanguage: session.fileLanguage ?? 'plaintext', + hasUserPromptSupplied: session.hasUserPromptSupplied, + isSupportedLanguage: isSupportedLanguage, + isFileInWorkspace: isFileInWorkspace, + result: result, + artifactsUploadDuration: artifactsUploadDuration, + buildPayloadBytes: buildPayloadBytes, + buildZipFileBytes: buildZipFileBytes, + credentialStartUrl: AuthUtil.instance.startUrl, + acceptedCharactersCount: acceptedCharactersCount, + acceptedCount: acceptedCount, + acceptedLinesCount: acceptedLinesCount, + generatedCharactersCount: generatedCharactersCount, + generatedCount: generatedCount, + generatedLinesCount: generatedLinesCount, + isCodeBlockSelected: isCodeBlockSelected, + jobGroup: session.testGenerationJobGroupName, + jobId: session.listOfTestGenerationJobId[0], + perfClientLatency: perfClientLatency, + requestId: requestId, + reasonDesc: reasonDesc, + reason: reason, + }) + } + public recordServiceInvocationTelemetry( requestId: string, sessionId: string, @@ -212,7 +268,7 @@ export class TelemetryHelper { ) { const events: CodewhispererUserDecision[] = [] // emit user decision telemetry - recommendations.forEach((_elem, i) => { + for (const [i, _elem] of recommendations.entries()) { let uniqueSuggestionReferences: string | undefined = undefined const uniqueLicenseSet = LicenseUtil.getUniqueLicenseNames(_elem.references) if (uniqueLicenseSet.size > 0) { @@ -243,9 +299,9 @@ export class TelemetryHelper { } telemetry.codewhisperer_userDecision.emit(event) events.push(event) - }) + } - //aggregate suggestion references count + // aggregate suggestion references count const referenceCount = this.getAggregatedSuggestionReferenceCount(events) // aggregate user decision events at requestId level @@ -537,12 +593,20 @@ export class TelemetryHelper { public resetClientComponentLatencyTime() { session.invokeSuggestionStartTime = 0 + session.preprocessEndTime = 0 session.sdkApiCallStartTime = 0 - this.sdkApiCallEndTime = 0 + this._sdkApiCallEndTime = 0 session.fetchCredentialStartTime = 0 session.firstSuggestionShowTime = 0 - this.allPaginationEndTime = 0 - this.firstResponseRequestId = '' + this._allPaginationEndTime = 0 + this._firstResponseRequestId = '' + } + + public setPreprocessEndTime() { + if (session.preprocessEndTime !== 0) { + getLogger().warn(`inline completion preprocessEndTime has been set and not reset correctly`) + } + session.preprocessEndTime = performance.now() } /** This method is assumed to be invoked first at the start of execution **/ @@ -552,46 +616,46 @@ export class TelemetryHelper { } public setSdkApiCallEndTime() { - if (this.sdkApiCallEndTime === 0 && session.sdkApiCallStartTime !== 0) { - this.sdkApiCallEndTime = performance.now() + if (this._sdkApiCallEndTime === 0 && session.sdkApiCallStartTime !== 0) { + this._sdkApiCallEndTime = performance.now() } } public setAllPaginationEndTime() { - if (this.allPaginationEndTime === 0 && this.sdkApiCallEndTime !== 0) { - this.allPaginationEndTime = performance.now() + if (this._allPaginationEndTime === 0 && this._sdkApiCallEndTime !== 0) { + this._allPaginationEndTime = performance.now() } } public setFirstSuggestionShowTime() { - if (session.firstSuggestionShowTime === 0 && this.sdkApiCallEndTime !== 0) { + if (session.firstSuggestionShowTime === 0 && this._sdkApiCallEndTime !== 0) { session.firstSuggestionShowTime = performance.now() } } public setFirstResponseRequestId(requestId: string) { - if (this.firstResponseRequestId === '') { - this.firstResponseRequestId = requestId + if (this._firstResponseRequestId === '') { + this._firstResponseRequestId = requestId } } // report client component latency after all pagination call finish // and at least one suggestion is shown to the user public tryRecordClientComponentLatency() { - if (session.firstSuggestionShowTime === 0 || this.allPaginationEndTime === 0) { + if (session.firstSuggestionShowTime === 0 || this._allPaginationEndTime === 0) { return } telemetry.codewhisperer_clientComponentLatency.emit({ - codewhispererAllCompletionsLatency: this.allPaginationEndTime - session.sdkApiCallStartTime, + codewhispererAllCompletionsLatency: this._allPaginationEndTime - session.sdkApiCallStartTime, codewhispererCompletionType: 'Line', codewhispererCredentialFetchingLatency: session.sdkApiCallStartTime - session.fetchCredentialStartTime, codewhispererCustomizationArn: getSelectedCustomization().arn, codewhispererEndToEndLatency: session.firstSuggestionShowTime - session.invokeSuggestionStartTime, - codewhispererFirstCompletionLatency: this.sdkApiCallEndTime - session.sdkApiCallStartTime, + codewhispererFirstCompletionLatency: this._sdkApiCallEndTime - session.sdkApiCallStartTime, codewhispererLanguage: session.language, - codewhispererPostprocessingLatency: session.firstSuggestionShowTime - this.sdkApiCallEndTime, - codewhispererPreprocessingLatency: session.fetchCredentialStartTime - session.invokeSuggestionStartTime, - codewhispererRequestId: this.firstResponseRequestId, + codewhispererPostprocessingLatency: session.firstSuggestionShowTime - this._sdkApiCallEndTime, + codewhispererPreprocessingLatency: session.preprocessEndTime - session.invokeSuggestionStartTime, + codewhispererRequestId: this._firstResponseRequestId, codewhispererSessionId: session.sessionId, codewhispererTriggerType: session.triggerType, credentialStartUrl: AuthUtil.instance.startUrl, diff --git a/packages/core/src/codewhisperer/util/zipUtil.ts b/packages/core/src/codewhisperer/util/zipUtil.ts index 7678f9dcb12..64a9ccc3b8d 100644 --- a/packages/core/src/codewhisperer/util/zipUtil.ts +++ b/packages/core/src/codewhisperer/util/zipUtil.ts @@ -20,10 +20,10 @@ import { NoSourceFilesError, ProjectSizeExceededError, } from '../models/errors' -import { ZipUseCase } from '../models/constants' +import { FeatureUseCase } from '../models/constants' import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils' -import { showOutputMessage } from '../../shared/utilities/messages' -import { globals, removeAnsi } from '../../shared' +import { removeAnsi } from '../../shared' +import { ProjectZipError } from '../../amazonqTest/error' export interface ZipMetadata { rootDir: string @@ -114,18 +114,16 @@ export class ZipUtil { const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri) if (workspaceFolder) { - const projectName = workspaceFolder.name + // Note: workspaceFolder.name is not the same as the file system folder name, + // use the fsPath value instead + const projectName = path.basename(workspaceFolder.uri.fsPath) const relativePath = vscode.workspace.asRelativePath(uri) const zipEntryPath = this.getZipEntryPath(projectName, relativePath) zip.addFile(zipEntryPath, Buffer.from(content, 'utf-8')) if (scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND) { - await this.processCombinedGitDiff( - zip, - [workspaceFolder.uri.fsPath], - uri.fsPath, - CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND - ) + const gitDiffContent = `+++ b/${path.normalize(zipEntryPath)}` // Sending file path in payload for LLM code review + zip.addFile(ZipConstants.codeDiffFilePath, Buffer.from(gitDiffContent, 'utf-8')) } } else { zip.addFile(uri.fsPath, Buffer.from(content, 'utf-8')) @@ -138,19 +136,13 @@ export class ZipUtil { if (this.reachSizeLimit(this._totalSize, scope)) { throw new FileSizeExceededError() } - const zipFilePath = this.getZipDirPath(ZipUseCase.CODE_SCAN) + CodeWhispererConstants.codeScanZipExt + const zipFilePath = this.getZipDirPath(FeatureUseCase.CODE_SCAN) + CodeWhispererConstants.codeScanZipExt zip.writeZip(zipFilePath) return zipFilePath } - protected getZipEntryPath(projectName: string, relativePath: string, useCase?: ZipUseCase) { - // Workspaces with multiple folders have the folder names as the root folder, - // but workspaces with only a single folder don't. So prepend the workspace folder name - // if it is not present. - if (useCase === ZipUseCase.TEST_GENERATION) { - return path.join(projectName, relativePath) - } - return relativePath.split('/').shift() === projectName ? relativePath : path.join(projectName, relativePath) + protected getZipEntryPath(projectName: string, relativePath: string) { + return path.join(projectName, relativePath) } /** @@ -212,15 +204,15 @@ export class ZipUtil { await processDirectory(metadataDir) } - protected async zipProject(useCase: ZipUseCase, projectPath?: string, metadataDir?: string) { + protected async zipProject(useCase: FeatureUseCase, projectPath?: string, metadataDir?: string) { const zip = new admZip() let projectPaths = [] - if (useCase === ZipUseCase.TEST_GENERATION && projectPath) { + if (useCase === FeatureUseCase.TEST_GENERATION && projectPath) { projectPaths.push(projectPath) } else { projectPaths = this.getProjectPaths() } - if (useCase === ZipUseCase.CODE_SCAN) { + if (useCase === FeatureUseCase.CODE_SCAN) { await this.processCombinedGitDiff(zip, projectPaths, '', CodeWhispererConstants.CodeAnalysisScope.PROJECT) } const languageCount = new Map() @@ -229,7 +221,7 @@ export class ZipUtil { if (metadataDir) { await this.processMetadataDir(zip, metadataDir) } - if (useCase !== ZipUseCase.TEST_GENERATION) { + if (useCase !== FeatureUseCase.TEST_GENERATION) { this.processOtherFiles(zip, languageCount) } @@ -305,10 +297,10 @@ export class ZipUtil { rejectOnErrorCode: false, onStdout: (text) => { diffContent += text - showOutputMessage(removeAnsi(text), globals.outputChannel) + getLogger().verbose(removeAnsi(text)) }, onStderr: (text) => { - showOutputMessage(removeAnsi(text), globals.outputChannel) + getLogger().error(removeAnsi(text)) }, spawnOptions: { cwd: projectPath, @@ -342,10 +334,10 @@ export class ZipUtil { rejectOnErrorCode: true, onStdout: (text) => { diffContent += text - showOutputMessage(removeAnsi(text), globals.outputChannel) + getLogger().verbose(removeAnsi(text)) }, onStderr: (text) => { - showOutputMessage(removeAnsi(text), globals.outputChannel) + getLogger().error(removeAnsi(text)) }, spawnOptions: { cwd: projectPath, @@ -412,7 +404,7 @@ export class ZipUtil { zip: admZip, languageCount: Map, projectPaths: string[] | undefined, - useCase: ZipUseCase + useCase: FeatureUseCase ) { if (!projectPaths || projectPaths.length === 0) { return @@ -425,10 +417,11 @@ export class ZipUtil { this.getProjectScanPayloadSizeLimitInBytes() ) for (const file of sourceFiles) { - const zipEntryPath = this.getZipEntryPath(file.workspaceFolder.name, file.relativeFilePath, useCase) + const projectName = path.basename(file.workspaceFolder.uri.fsPath) + const zipEntryPath = this.getZipEntryPath(projectName, file.relativeFilePath) if (ZipConstants.knownBinaryFileExts.includes(path.extname(file.fileUri.fsPath))) { - if (useCase === ZipUseCase.TEST_GENERATION) { + if (useCase === FeatureUseCase.TEST_GENERATION) { continue } await this.processBinaryFile(zip, file.fileUri, zipEntryPath) @@ -441,16 +434,15 @@ export class ZipUtil { } protected processOtherFiles(zip: admZip, languageCount: Map) { - vscode.workspace.textDocuments + for (const document of vscode.workspace.textDocuments .filter((document) => document.uri.scheme === 'file') - .filter((document) => vscode.workspace.getWorkspaceFolder(document.uri) === undefined) - .forEach((document) => - this.processTextFile(zip, document.uri, document.getText(), languageCount, document.uri.fsPath) - ) + .filter((document) => vscode.workspace.getWorkspaceFolder(document.uri) === undefined)) { + this.processTextFile(zip, document.uri, document.getText(), languageCount, document.uri.fsPath) + } } protected async processTestCoverageFiles(targetPath: string) { - //TODO: will be removed post release + // TODO: will be removed post release const coverageFilePatterns = ['**/coverage.xml', '**/coverage.json', '**/coverage.txt'] let files: vscode.Uri[] = [] @@ -520,10 +512,10 @@ export class ZipUtil { return vscode.workspace.textDocuments.some((document) => document.uri.fsPath === uri.fsPath && document.isDirty) } - public getZipDirPath(useCase: ZipUseCase): string { + public getZipDirPath(useCase: FeatureUseCase): string { if (this._zipDir === '') { const prefix = - useCase === ZipUseCase.TEST_GENERATION + useCase === FeatureUseCase.TEST_GENERATION ? CodeWhispererConstants.TestGenerationTruncDirPrefix : CodeWhispererConstants.codeScanTruncDirPrefix @@ -537,7 +529,7 @@ export class ZipUtil { scope: CodeWhispererConstants.CodeAnalysisScope ): Promise { try { - const zipDirPath = this.getZipDirPath(ZipUseCase.CODE_SCAN) + const zipDirPath = this.getZipDirPath(FeatureUseCase.CODE_SCAN) let zipFilePath: string if ( scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO || @@ -545,7 +537,7 @@ export class ZipUtil { ) { zipFilePath = await this.zipFile(uri, scope) } else if (scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT) { - zipFilePath = await this.zipProject(ZipUseCase.CODE_SCAN) + zipFilePath = await this.zipProject(FeatureUseCase.CODE_SCAN) } else { throw new ToolkitError(`Unknown code analysis scope: ${scope}`) } @@ -571,7 +563,7 @@ export class ZipUtil { public async generateZipTestGen(projectPath: string, initialExecution: boolean): Promise { try { // const repoMapFile = await LspClient.instance.getRepoMapJSON() - const zipDirPath = this.getZipDirPath(ZipUseCase.TEST_GENERATION) + const zipDirPath = this.getZipDirPath(FeatureUseCase.TEST_GENERATION) const metadataDir = path.join(zipDirPath, 'utgRequiredArtifactsDir') @@ -599,7 +591,7 @@ export class ZipUtil { } } - const zipFilePath: string = await this.zipProject(ZipUseCase.TEST_GENERATION, projectPath, metadataDir) + const zipFilePath: string = await this.zipProject(FeatureUseCase.TEST_GENERATION, projectPath, metadataDir) const zipFileSize = (await fs.stat(zipFilePath)).size return { rootDir: zipDirPath, @@ -613,10 +605,12 @@ export class ZipUtil { } } catch (error) { getLogger().error('Zip error caused by: %s', error) - throw error + throw new ProjectZipError( + error instanceof Error ? error.message : 'Unknown error occurred during zip operation' + ) } } - //TODO: Refactor this + // TODO: Refactor this public async removeTmpFiles(zipMetadata: ZipMetadata, scope?: CodeWhispererConstants.CodeAnalysisScope) { const logger = getLoggerForScope(scope) logger.verbose(`Cleaning up temporary files...`) diff --git a/packages/core/src/codewhisperer/views/securityIssue/securityIssueWebview.ts b/packages/core/src/codewhisperer/views/securityIssue/securityIssueWebview.ts index 0a70d9e8319..d511bd9a5f6 100644 --- a/packages/core/src/codewhisperer/views/securityIssue/securityIssueWebview.ts +++ b/packages/core/src/codewhisperer/views/securityIssue/securityIssueWebview.ts @@ -24,11 +24,15 @@ import { ExtContext } from '../../../shared/extensions' export class SecurityIssueWebview extends VueWebview { public static readonly sourcePath: string = 'src/codewhisperer/views/securityIssue/vue/index.js' public readonly id = 'aws.codeWhisperer.securityIssue' + public readonly onChangeIssue = new vscode.EventEmitter() + public readonly onChangeFilePath = new vscode.EventEmitter() + public readonly onChangeGenerateFixLoading = new vscode.EventEmitter() + public readonly onChangeGenerateFixError = new vscode.EventEmitter() private issue: CodeScanIssue | undefined private filePath: string | undefined private isGenerateFixLoading: boolean = false - private isGenerateFixError: boolean = false + private generateFixError: string | null | undefined = undefined public constructor() { super(SecurityIssueWebview.sourcePath) @@ -40,10 +44,12 @@ export class SecurityIssueWebview extends VueWebview { public setIssue(issue: CodeScanIssue) { this.issue = issue + this.onChangeIssue.fire(issue) } public setFilePath(filePath: string) { this.filePath = filePath + this.onChangeFilePath.fire(filePath) } public applyFix() { @@ -90,14 +96,16 @@ export class SecurityIssueWebview extends VueWebview { public setIsGenerateFixLoading(isGenerateFixLoading: boolean) { this.isGenerateFixLoading = isGenerateFixLoading + this.onChangeGenerateFixLoading.fire(isGenerateFixLoading) } - public getIsGenerateFixError() { - return this.isGenerateFixError + public getGenerateFixError() { + return this.generateFixError } - public setIsGenerateFixError(isGenerateFixError: boolean) { - this.isGenerateFixError = isGenerateFixError + public setGenerateFixError(generateFixError: string | null | undefined) { + this.generateFixError = generateFixError + this.onChangeGenerateFixError.fire(generateFixError) } public generateFix() { @@ -189,16 +197,11 @@ const Panel = VueWebview.compilePanel(SecurityIssueWebview) let activePanel: InstanceType | undefined export async function showSecurityIssueWebview(ctx: vscode.ExtensionContext, issue: CodeScanIssue, filePath: string) { - const previousPanel = activePanel - const previousId = previousPanel?.server?.getIssue()?.findingId - if (previousPanel && previousId) { - previousPanel.server.closeWebview(previousId) - } - activePanel = new Panel(ctx) + activePanel ??= new Panel(ctx) activePanel.server.setIssue(issue) activePanel.server.setFilePath(filePath) activePanel.server.setIsGenerateFixLoading(false) - activePanel.server.setIsGenerateFixError(false) + activePanel.server.setGenerateFixError(undefined) const webviewPanel = await activePanel.show({ title: amazonqCodeIssueDetailsTabTitle, @@ -244,7 +247,7 @@ type WebviewParams = { issue?: CodeScanIssue filePath?: string isGenerateFixLoading?: boolean - isGenerateFixError?: boolean + generateFixError?: string | null shouldRefreshView: boolean context: vscode.ExtensionContext } @@ -252,7 +255,7 @@ export async function updateSecurityIssueWebview({ issue, filePath, isGenerateFixLoading, - isGenerateFixError, + generateFixError, shouldRefreshView, context, }: WebviewParams): Promise { @@ -268,8 +271,8 @@ export async function updateSecurityIssueWebview({ if (isGenerateFixLoading !== undefined) { activePanel.server.setIsGenerateFixLoading(isGenerateFixLoading) } - if (isGenerateFixError !== undefined) { - activePanel.server.setIsGenerateFixError(isGenerateFixError) + if (generateFixError !== undefined) { + activePanel.server.setGenerateFixError(generateFixError) } if (shouldRefreshView && filePath && issue) { await showSecurityIssueWebview(context, issue, filePath) diff --git a/packages/core/src/codewhisperer/views/securityIssue/vue/root.vue b/packages/core/src/codewhisperer/views/securityIssue/vue/root.vue index a6f01cdbc2a..c28d12a021d 100644 --- a/packages/core/src/codewhisperer/views/securityIssue/vue/root.vue +++ b/packages/core/src/codewhisperer/views/securityIssue/vue/root.vue @@ -47,17 +47,18 @@ -
+

Suggested code fix preview

-
-                Something went wrong. Retry
-            
+
{{ generateFixError }}
-
+
@@ -192,7 +193,7 @@ export default defineComponent({ endLine: 0, relativePath: '', isGenerateFixLoading: false, - isGenerateFixError: false, + generateFixError: undefined as string | null | undefined, languageId: 'plaintext', fixedCode: '', referenceText: '', @@ -201,6 +202,7 @@ export default defineComponent({ }, created() { this.getData() + this.setupEventListeners() }, beforeMount() { this.getData() @@ -214,8 +216,8 @@ export default defineComponent({ const relativePath = await client.getRelativePath() this.updateRelativePath(relativePath) const isGenerateFixLoading = await client.getIsGenerateFixLoading() - const isGenerateFixError = await client.getIsGenerateFixError() - this.updateGenerateFixState(isGenerateFixLoading, isGenerateFixError) + const generateFixError = await client.getGenerateFixError() + this.updateGenerateFixState(isGenerateFixLoading, generateFixError) const languageId = await client.getLanguageId() if (languageId) { this.updateLanguageId(languageId) @@ -223,12 +225,38 @@ export default defineComponent({ const fixedCode = await client.getFixedCode() this.updateFixedCode(fixedCode) }, + setupEventListeners() { + client.onChangeIssue(async (issue) => { + if (issue) { + this.updateFromIssue(issue) + } + const fixedCode = await client.getFixedCode() + this.updateFixedCode(fixedCode) + this.scrollTo('codeFixActions') + }) + client.onChangeFilePath(async (filePath) => { + const relativePath = await client.getRelativePath() + this.updateRelativePath(relativePath) + + const languageId = await client.getLanguageId() + if (languageId) { + this.updateLanguageId(languageId) + } + }) + client.onChangeGenerateFixLoading((isGenerateFixLoading) => { + this.isGenerateFixLoading = isGenerateFixLoading + this.scrollTo('codeFixSection') + }) + client.onChangeGenerateFixError((generateFixError) => { + this.generateFixError = generateFixError + }) + }, updateRelativePath(relativePath: string) { this.relativePath = relativePath }, - updateGenerateFixState(isGenerateFixLoading: boolean, isGenerateFixError: boolean) { + updateGenerateFixState(isGenerateFixLoading: boolean, generateFixError: string | null | undefined) { this.isGenerateFixLoading = isGenerateFixLoading - this.isGenerateFixError = isGenerateFixError + this.generateFixError = generateFixError }, updateLanguageId(languageId: string) { this.languageId = languageId @@ -339,6 +367,9 @@ ${this.fixedCode} } return doc.body.innerHTML }, + scrollTo(refName: string) { + this.$nextTick(() => this.$refs?.[refName]?.scrollIntoView({ behavior: 'smooth' })) + }, }, computed: { severityImage() { diff --git a/packages/core/src/codewhisperer/views/securityPanelViewProvider.ts b/packages/core/src/codewhisperer/views/securityPanelViewProvider.ts index d02eea155eb..5f3b6cece70 100644 --- a/packages/core/src/codewhisperer/views/securityPanelViewProvider.ts +++ b/packages/core/src/codewhisperer/views/securityPanelViewProvider.ts @@ -88,9 +88,9 @@ export class SecurityPanelViewProvider implements vscode.WebviewViewProvider { this.packageName } found ${total} issues

` ) - this.panelSets.forEach((panelSet, index) => { + for (const [index, panelSet] of this.panelSets.entries()) { this.addLine(panelSet, index) - }) + } this.update() if (editor) { this.setDecoration(editor, editor.document.uri) @@ -111,20 +111,20 @@ export class SecurityPanelViewProvider implements vscode.WebviewViewProvider { this.dynamicLog.push( `
` ) - panelSet.items.forEach((item) => { + for (const item of panelSet.items) { if (item.severity === vscode.DiagnosticSeverity.Warning) { this.dynamicLog.push(`${this.addClickableWarningItem(item)}`) } else { this.dynamicLog.push(`${this.addClickableInfoItem(item)}`) } - }) + } this.dynamicLog.push(`
`) } private persistLines() { - this.panelSets.forEach((panelSet, index) => { + for (const [index, panelSet] of this.panelSets.entries()) { this.persistLine(panelSet, index) - }) + } } private persistLine(panelSet: SecurityPanelSet, index: number) { @@ -134,13 +134,13 @@ export class SecurityPanelViewProvider implements vscode.WebviewViewProvider { this.persistLog.push( `
` ) - panelSet.items.forEach((item) => { + for (const item of panelSet.items) { if (item.severity === vscode.DiagnosticSeverity.Warning) { this.persistLog.push(`${this.addUnclickableWarningItem(item)}`) } else { this.persistLog.push(`${this.addUnclickableInfoItem(item)}`) } - }) + } this.persistLog.push(`
`) } @@ -171,13 +171,13 @@ export class SecurityPanelViewProvider implements vscode.WebviewViewProvider { } private createPanelSets(securityRecommendationCollection: AggregatedCodeScanIssue[]) { - securityRecommendationCollection.forEach((securityRecommendation) => { + for (const securityRecommendation of securityRecommendationCollection) { const panelSet: SecurityPanelSet = { path: securityRecommendation.filePath, uri: vscode.Uri.parse(securityRecommendation.filePath), items: [], } - securityRecommendation.issues.forEach((issue) => { + for (const issue of securityRecommendation.issues) { panelSet.items.push({ path: securityRecommendation.filePath, range: new vscode.Range(issue.startLine, 0, issue.endLine, 0), @@ -189,9 +189,9 @@ export class SecurityPanelViewProvider implements vscode.WebviewViewProvider { hoverMessage: issue.comment, }, }) - }) + } this.panelSets.push(panelSet) - }) + } } private getHtml(webview: vscode.Webview): string { @@ -232,15 +232,15 @@ export class SecurityPanelViewProvider implements vscode.WebviewViewProvider { public setDecoration(editor: vscode.TextEditor, uri: vscode.Uri) { editor.setDecorations(this.getDecorator(), []) const rangesToRend: vscode.DecorationOptions[] = [] - this.panelSets.forEach((panelSet) => { + for (const panelSet of this.panelSets) { if (panelSet.uri.fsPath === uri.fsPath) { - panelSet.items.forEach((item) => { + for (const item of panelSet.items) { if (item.severity === vscode.DiagnosticSeverity.Warning) { rangesToRend.push(item.decoration) } - }) + } } - }) + } if (rangesToRend.length > 0) { editor.setDecorations(this.getDecorator(), rangesToRend) } @@ -261,6 +261,7 @@ export class SecurityPanelViewProvider implements vscode.WebviewViewProvider { const changedText = event.contentChanges[0].text const lineOffset = this.getLineOffset(changedRange, changedText) + // eslint-disable-next-line unicorn/no-array-for-each currentPanelSet.items.forEach((item, index, items) => { const intersection = changedRange.intersection(item.range) if ( @@ -282,9 +283,9 @@ export class SecurityPanelViewProvider implements vscode.WebviewViewProvider { }) this.panelSets[index] = currentPanelSet this.dynamicLog = [] - this.panelSets.forEach((panelSet, index) => { + for (const [index, panelSet] of this.panelSets.entries()) { this.addLine(panelSet, index) - }) + } this.update() if (editor) { this.setDecoration(editor, editor.document.uri) diff --git a/packages/core/src/codewhisperer/vue/backend.ts b/packages/core/src/codewhisperer/vue/backend.ts index e2561cad87c..f92552e247a 100644 --- a/packages/core/src/codewhisperer/vue/backend.ts +++ b/packages/core/src/codewhisperer/vue/backend.ts @@ -8,7 +8,6 @@ import * as os from 'os' import * as vscode from 'vscode' import * as path from 'path' import { VueWebview } from '../../webviews/main' -import { isCloud9 } from '../../shared/extensionUtilities' import globals from '../../shared/extensionGlobals' import { telemetry, CodewhispererLanguage, CodewhispererGettingStartedTask } from '../../shared/telemetry/telemetry' import { fs } from '../../shared' @@ -29,7 +28,7 @@ export class CodeWhispererWebview extends VueWebview { private isFileSaved: boolean = false private getLocalFilePath(fileName: string): string { - //This will store the files in the global storage path of VSCode + // This will store the files in the global storage path of VSCode return path.join(globals.context.globalStorageUri.fsPath, fileName) } @@ -81,22 +80,22 @@ export class CodeWhispererWebview extends VueWebview { } } - //This function returns the OS type of the machine used in Shortcuts and Generate Suggestion Sections + // This function returns the OS type of the machine used in Shortcuts and Generate Suggestion Sections public getOSType(): OSType { return os.platform() === 'darwin' ? 'Mac' : 'RestOfOS' } - //This function opens the Keyboard shortcuts in VSCode + // This function opens the Keyboard shortcuts in VSCode async openShortCuts(): Promise { await vscode.commands.executeCommand('workbench.action.openGlobalKeybindings', 'codewhisperer') } - //This function opens the Feedback CodeWhisperer page in the webview + // This function opens the Feedback CodeWhisperer page in the webview async openFeedBack(): Promise { return submitFeedback(placeholder, 'Amazon Q') } - //------Telemetry------ + // ------Telemetry------ /** This represents the cause for the webview to open, whether a certain button was clicked or it opened automatically */ #codeWhispererSource?: CodeWhispererSource @@ -113,7 +112,7 @@ export class CodeWhispererWebview extends VueWebview { passive: true, }) } - //Telemetry for CodeWhisperer Try Example with two params Language and Task Type + // Telemetry for CodeWhisperer Try Example with two params Language and Task Type emitTryExampleClick(languageSelected: CodewhispererLanguage, taskType: CodewhispererGettingStartedTask) { telemetry.codewhisperer_onboardingClick.emit({ codewhispererLanguage: languageSelected, @@ -121,7 +120,7 @@ export class CodeWhispererWebview extends VueWebview { }) } } -//List of all events that are emitted from the webview of CodeWhisperer +// List of all events that are emitted from the webview of CodeWhisperer export type CodeWhispererUiClick = | 'codewhisperer_Resources_Documentation' | 'codewhisperer_Resources_Feedback' @@ -148,7 +147,7 @@ export async function showCodeWhispererWebview( } const webview = await activePanel!.show({ title: localize('AWS.view.gettingStartedPage.title', `Learn Amazon Q`), - viewColumn: isCloud9() ? vscode.ViewColumn.One : vscode.ViewColumn.Active, + viewColumn: vscode.ViewColumn.Active, }) if (!subscriptions) { @@ -160,8 +159,8 @@ export async function showCodeWhispererWebview( }), ] const prompts = AmazonQPromptSettings.instance - //To check the condition If the user has already seen the welcome message - if (await prompts.isPromptEnabled('codeWhispererNewWelcomeMessage')) { + // To check the condition If the user has already seen the welcome message + if (prompts.isPromptEnabled('codeWhispererNewWelcomeMessage')) { telemetry.ui_click.emit({ elementId: 'codewhisperer_Learn_PageOpen', passive: true }) } else { telemetry.ui_click.emit({ elementId: 'codewhisperer_Learn_PageOpen', passive: false }) diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index fc164ebb95c..b849b328bac 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -9,6 +9,7 @@ import * as vscode from 'vscode' import { ToolkitError } from '../../../../shared/errors' import { createCodeWhispererChatStreamingClient } from '../../../../shared/clients/codewhispererChatClient' import { createQDeveloperStreamingClient } from '../../../../shared/clients/qDeveloperChatClient' +import { UserWrittenCodeTracker } from '../../../../codewhisperer/tracker/userWrittenCodeTracker' export class ChatSession { private sessionId?: string @@ -48,6 +49,7 @@ export class ChatSession { } } + UserWrittenCodeTracker.instance.onQFeatureInvoked() return response } @@ -67,6 +69,8 @@ export class ChatSession { this.sessionId = response.conversationId + UserWrittenCodeTracker.instance.onQFeatureInvoked() + return response } } diff --git a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts index 9ff56523379..a3236f7d402 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts @@ -43,22 +43,24 @@ export function triggerPayloadToChatRequest(triggerPayload: TriggerPayload): { c if (triggerPayload.filePath !== undefined && triggerPayload.filePath !== '') { const documentSymbolFqns: DocumentSymbol[] = [] - triggerPayload.codeQuery?.fullyQualifiedNames?.used?.forEach((fqn) => { - const elem = { - name: fqn.symbol?.join('.') ?? '', - type: SymbolType.USAGE, - source: fqn.source?.join('.'), - } + if (triggerPayload.codeQuery?.fullyQualifiedNames?.used) { + for (const fqn of triggerPayload.codeQuery.fullyQualifiedNames.used) { + const elem = { + name: fqn.symbol?.join('.') ?? '', + type: SymbolType.USAGE, + source: fqn.source?.join('.'), + } - if ( - elem.name.length >= fqnNameSizeDownLimit && - elem.name.length < fqnNameSizeUpLimit && - (elem.source === undefined || - (elem.source.length >= fqnNameSizeDownLimit && elem.source.length < fqnNameSizeUpLimit)) - ) { - documentSymbolFqns.push(elem) + if ( + elem.name.length >= fqnNameSizeDownLimit && + elem.name.length < fqnNameSizeUpLimit && + (elem.source === undefined || + (elem.source.length >= fqnNameSizeDownLimit && elem.source.length < fqnNameSizeUpLimit)) + ) { + documentSymbolFqns.push(elem) + } } - }) + } let programmingLanguage if ( diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index 6a1d388c05d..a5205be78ca 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -231,17 +231,19 @@ export class ChatController { this.openLinkInExternalBrowser(click) } - private processQuickActionCommand(quickActionCommand: ChatPromptCommandType) { + private processQuickActionCommand(message: PromptMessage) { this.editorContextExtractor .extractContextForTrigger('QuickAction') .then((context) => { const triggerID = randomUUID() + const quickActionCommand = message.command as ChatPromptCommandType + this.messenger.sendQuickActionMessage(quickActionCommand, triggerID) this.triggerEventsStorage.addTriggerEvent({ id: triggerID, - tabID: undefined, + tabID: message.tabID, message: undefined, type: 'quick_action', quickAction: quickActionCommand, @@ -484,7 +486,7 @@ export class ChatController { recordTelemetryChatRunCommand('clear') return default: - this.processQuickActionCommand(message.command) + this.processQuickActionCommand(message) } } @@ -632,11 +634,11 @@ export class ChatController { if (CodeWhispererSettings.instance.isLocalIndexEnabled()) { const start = performance.now() triggerPayload.relevantTextDocuments = await LspController.instance.query(triggerPayload.message) - triggerPayload.relevantTextDocuments.forEach((doc) => { + for (const doc of triggerPayload.relevantTextDocuments) { getLogger().info( `amazonq: Using workspace files ${doc.relativeFilePath}, content(partial): ${doc.text?.substring(0, 200)}` ) - }) + } triggerPayload.projectContextQueryLatencyMs = performance.now() - start } else { this.messenger.sendOpenSettingsMessage(triggerID, tabID) diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index af6a3a2a3ce..6604fd7bb21 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -91,7 +91,7 @@ export class Messenger { * @returns count of multi-line code blocks in response. */ public async countTotalNumberOfCodeBlocks(message: string): Promise { - //TODO: remove this when moved to server-side. + // TODO: remove this when moved to server-side. if (message === undefined) { return 0 } diff --git a/packages/core/src/codewhispererChat/editor/codelens.ts b/packages/core/src/codewhispererChat/editor/codelens.ts index 651853a78cf..4df72d776d6 100644 --- a/packages/core/src/codewhispererChat/editor/codelens.ts +++ b/packages/core/src/codewhispererChat/editor/codelens.ts @@ -151,7 +151,9 @@ export class TryChatCodeLensProvider implements vscode.CodeLensProvider { dispose() { globals.globalState.tryUpdate('aws.amazonq.showTryChatCodeLens', false) TryChatCodeLensProvider.providerDisposable?.dispose() - this.disposables.forEach((d) => d.dispose()) + for (const d of this.disposables) { + d.dispose() + } } } diff --git a/packages/core/src/codewhispererChat/editor/context/file/fileExtractor.ts b/packages/core/src/codewhispererChat/editor/context/file/fileExtractor.ts index e9f0220dfd7..7606fe0708c 100644 --- a/packages/core/src/codewhispererChat/editor/context/file/fileExtractor.ts +++ b/packages/core/src/codewhispererChat/editor/context/file/fileExtractor.ts @@ -41,11 +41,11 @@ export class FileContextExtractor { if (languageId !== undefined) { const imports = await readImports(file.getText(), languageId) - imports - .filter(function (elem, index, self) { - return index === self.indexOf(elem) && elem !== languageId - }) - .forEach((importKey) => should.add(importKey)) + for (const importKey of imports.filter(function (elem, index, self) { + return index === self.indexOf(elem) && elem !== languageId + })) { + should.add(importKey) + } } return { diff --git a/packages/core/src/codewhispererChat/editor/context/file/javaImportReader.ts b/packages/core/src/codewhispererChat/editor/context/file/javaImportReader.ts index 332829868a4..b66a6c44936 100644 --- a/packages/core/src/codewhispererChat/editor/context/file/javaImportReader.ts +++ b/packages/core/src/codewhispererChat/editor/context/file/javaImportReader.ts @@ -24,13 +24,13 @@ export function extractContextFromJavaImports(names: any): string[] { if (commonJavaImportsPrefixesRegex.test(importStatement)) { return '' } else if (importStatement.startsWith(awsJavaSdkV1Prefix)) { - //@ts-ignore + // @ts-ignore return javaImport.packages?.at(1) ?? '' } else if (importStatement.startsWith(awsJavaSdkV2Prefix)) { - //@ts-ignore + // @ts-ignore return javaImport.packages?.at(2) ?? '' } else { - //@ts-ignore + // @ts-ignore return javaImport.packages?.at(0) ?? javaImport.organisation ?? javaImport.tld } }) diff --git a/packages/core/src/codewhispererChat/editor/context/focusArea/focusAreaExtractor.ts b/packages/core/src/codewhispererChat/editor/context/focusArea/focusAreaExtractor.ts index d2b80f2619f..d782f7147ff 100644 --- a/packages/core/src/codewhispererChat/editor/context/focusArea/focusAreaExtractor.ts +++ b/packages/core/src/codewhispererChat/editor/context/focusArea/focusAreaExtractor.ts @@ -7,7 +7,7 @@ import { TextEditor, Selection, TextDocument, Range } from 'vscode' import { FocusAreaContext, FullyQualifiedName } from './model' -const focusAreaCharLimit = 9_000 +const focusAreaCharLimit = 40_000 export class FocusAreaContextExtractor { public isCodeBlockSelected(editor: TextEditor): boolean { diff --git a/packages/core/src/codewhispererChat/storages/triggerEvents.ts b/packages/core/src/codewhispererChat/storages/triggerEvents.ts index d30ebf48939..1bbf08b6de9 100644 --- a/packages/core/src/codewhispererChat/storages/triggerEvents.ts +++ b/packages/core/src/codewhispererChat/storages/triggerEvents.ts @@ -30,6 +30,7 @@ export class TriggerEventsStorage { public removeTabEvents(tabID: string) { const events = this.triggerEventsByTabID.get(tabID) ?? [] + // eslint-disable-next-line unicorn/no-array-for-each events.forEach((event: TriggerEvent) => { this.triggerEvents.delete(event.id) }) diff --git a/packages/core/src/commands.ts b/packages/core/src/commands.ts index 6177c953242..f1e77f24cf9 100644 --- a/packages/core/src/commands.ts +++ b/packages/core/src/commands.ts @@ -36,7 +36,7 @@ import { CommonAuthWebview } from './login/webview' import { AuthSource, AuthSources } from './login/webview/util' import { ServiceItemId, isServiceItemId } from './login/webview/vue/types' import { authHelpUrl } from './shared/constants' -import { isCloud9, getIdeProperties } from './shared/extensionUtilities' +import { getIdeProperties } from './shared/extensionUtilities' import { telemetry } from './shared/telemetry/telemetry' import { createCommonButtons } from './shared/ui/buttons' import { showQuickPick } from './shared/ui/pickerPrompter' @@ -66,12 +66,7 @@ export function registerCommands(context: vscode.ExtensionContext) { const addConnection = Commands.register( { id: 'aws.toolkit.auth.addConnection', telemetryThrottleMs: false }, async () => { - const c9IamItem = createIamItem() - c9IamItem.detail = - 'Activates working with resources in the Explorer. Requires an access key ID and secret access key.' - const items = isCloud9() - ? [createSsoItem(), c9IamItem] - : [createBuilderIdItem(), createSsoItem(), createIamItem()] + const items = [createBuilderIdItem(), createSsoItem(), createIamItem()] const resp = await showQuickPick(items, { title: localize('aws.auth.addConnection.title', 'Add a Connection to {0}', getIdeProperties().company), @@ -113,9 +108,7 @@ export function registerCommands(context: vscode.ExtensionContext) { source = AuthSources.vscodeComponent } - // The auth webview page does not make sense to use in C9, - // so show the auth quick pick instead. - if (isCloud9('any') || isWeb()) { + if (isWeb()) { // TODO: CW no longer exists in toolkit. This should be moved to Amazon Q if (source.toLowerCase().includes('codewhisperer')) { // Show CW specific quick pick for CW connections diff --git a/packages/core/src/dev/activation.ts b/packages/core/src/dev/activation.ts index 7f37b0552eb..0033eaa8548 100644 --- a/packages/core/src/dev/activation.ts +++ b/packages/core/src/dev/activation.ts @@ -24,6 +24,7 @@ import { getSessionId } from '../shared/telemetry/util' import { NotificationsController } from '../notifications/controller' import { DevNotificationsState } from '../notifications/types' import { QuickPickItem } from 'vscode' +import { ChildProcess } from '../shared/utilities/processUtils' interface MenuOption { readonly label: string @@ -44,6 +45,7 @@ export type DevFunction = | 'editAuthConnections' | 'notificationsSend' | 'forceIdeCrash' + | 'startChildProcess' export type DevOptions = { context: vscode.ExtensionContext @@ -126,6 +128,11 @@ const menuOptions: () => Record = () => { detail: `Will SIGKILL ExtHost, { pid: ${process.pid}, sessionId: '${getSessionId().slice(0, 8)}-...' }, but the IDE itself will not crash.`, executor: forceQuitIde, }, + startChildProcess: { + label: 'ChildProcess: Start child process', + detail: 'Start ChildProcess from our utility wrapper for testing', + executor: startChildProcess, + }, } } @@ -334,7 +341,7 @@ class ObjectEditor { return this.openState(targetContext.secrets, key) case 'auth': // Auth memento is determined in a different way - return this.openState(getEnvironmentSpecificMemento(), key) + return this.openState(getEnvironmentSpecificMemento(globalState), key) } } @@ -411,7 +418,7 @@ async function openStorageFromInput() { title: 'Enter a key', }) } else if (target === 'globalsView') { - return new SkipPrompter('') + return new SkipPrompter() } else if (target === 'globals') { // List all globalState keys in the quickpick menu. const items = globalState @@ -483,7 +490,7 @@ async function resetState() { this.form.key.bindPrompter(({ target }) => { if (target && resettableFeatures.some((f) => f.name === target)) { - return new SkipPrompter('') + return new SkipPrompter() } throw new Error('invalid feature target') }) @@ -578,3 +585,15 @@ async function editNotifications() { await targetNotificationsController.pollForEmergencies() }) } + +async function startChildProcess() { + const result = await createInputBox({ + title: 'Enter a command', + }).prompt() + if (result) { + const [command, ...args] = result?.toString().split(' ') ?? [] + getLogger().info(`Starting child process: '${command}'`) + const processResult = await ChildProcess.run(command, args, { collect: true }) + getLogger().info(`Child process exited with code ${processResult.exitCode}`) + } +} diff --git a/packages/core/src/dev/beta.ts b/packages/core/src/dev/beta.ts index b09516dcc3c..89bd085e84e 100644 --- a/packages/core/src/dev/beta.ts +++ b/packages/core/src/dev/beta.ts @@ -18,7 +18,7 @@ import { isUserCancelledError, ToolkitError } from '../shared/errors' import { telemetry } from '../shared/telemetry/telemetry' import { cast } from '../shared/utilities/typeConstructors' import { CancellationError } from '../shared/utilities/timeoutUtils' -import { isAmazonQ, isCloud9, productName } from '../shared/extensionUtilities' +import { isAmazonQ, productName } from '../shared/extensionUtilities' import * as devConfig from './config' import { isReleaseVersion } from '../shared/vscode/env' import { getRelativeDate } from '../shared/datetime' @@ -49,7 +49,7 @@ async function updateBetaToolkitData(vsixUrl: string, data: BetaToolkit) { */ export async function activate(ctx: vscode.ExtensionContext) { const betaUrl = isAmazonQ() ? devConfig.betaUrl.amazonq : devConfig.betaUrl.toolkit - if (!isCloud9() && !isReleaseVersion() && betaUrl) { + if (!isReleaseVersion() && betaUrl) { ctx.subscriptions.push(watchBetaVSIX(betaUrl)) } } diff --git a/packages/core/src/dynamicResources/awsResourceManager.ts b/packages/core/src/dynamicResources/awsResourceManager.ts index a6045559675..de990e6c6b9 100644 --- a/packages/core/src/dynamicResources/awsResourceManager.ts +++ b/packages/core/src/dynamicResources/awsResourceManager.ts @@ -17,7 +17,6 @@ import { getLogger } from '../shared/logger/logger' import { getTabSizeSetting } from '../shared/utilities/editorUtilities' import { ResourceNode } from './explorer/nodes/resourceNode' import { ResourceTypeNode } from './explorer/nodes/resourceTypeNode' -import { isCloud9 } from '../shared/extensionUtilities' import globals from '../shared/extensionGlobals' import { fs } from '../shared' @@ -72,7 +71,7 @@ export class AwsResourceManager { } const doc = await vscode.workspace.openTextDocument(uri) - if (existing && !isCloud9()) { + if (existing) { await this.close(existing) } diff --git a/packages/core/src/dynamicResources/explorer/nodes/resourcesNode.ts b/packages/core/src/dynamicResources/explorer/nodes/resourcesNode.ts index 72374d17a5b..313ce4e7d2a 100644 --- a/packages/core/src/dynamicResources/explorer/nodes/resourcesNode.ts +++ b/packages/core/src/dynamicResources/explorer/nodes/resourcesNode.ts @@ -14,7 +14,6 @@ import { ResourceTypeNode } from './resourceTypeNode' import { CloudFormation } from 'aws-sdk' import { CloudControlClient, DefaultCloudControlClient } from '../../../shared/clients/cloudControlClient' import { memoizedGetResourceTypes, ResourceTypeMetadata } from '../../model/resources' -import { isCloud9 } from '../../../shared/extensionUtilities' import { ResourcesSettings } from '../../commands/configure' const localize = nls.loadMessageBundle() @@ -57,8 +56,7 @@ export class ResourcesNode extends AWSTreeNodeBase { public async updateChildren(): Promise { const resourceTypes = memoizedGetResourceTypes() - const defaultResources = isCloud9() ? Array.from(resourceTypes.keys()) : [] - const enabledResources = this.settings.get('enabledResources', defaultResources) + const enabledResources = this.settings.get('enabledResources', []) // Use the most recently update type definition per-type const types = await toArrayAsync(this.cloudFormation.listTypes()) diff --git a/packages/core/src/eventSchemas/commands/downloadSchemaItemCode.ts b/packages/core/src/eventSchemas/commands/downloadSchemaItemCode.ts index 5077b21c909..6c6ec818444 100644 --- a/packages/core/src/eventSchemas/commands/downloadSchemaItemCode.ts +++ b/packages/core/src/eventSchemas/commands/downloadSchemaItemCode.ts @@ -139,7 +139,7 @@ export class SchemaCodeDownloader { } catch (err) { const error = err as Error if (error.name === 'ResourceNotFound') { - //If the code generation wasn't previously kicked off, do so + // If the code generation wasn't previously kicked off, do so void vscode.window.showInformationMessage( localize( 'AWS.message.info.schemas.downloadCodeBindings.generate', @@ -149,10 +149,10 @@ export class SchemaCodeDownloader { ) await this.generator.generate(request) - //Then, poll for completion + // Then, poll for completion await this.poller.pollForCompletion(request) - //Download generated code bindings + // Download generated code bindings void vscode.window.showInformationMessage( localize( 'AWS.message.info.schemas.downloadCodeBindings.downloading', @@ -294,7 +294,7 @@ export class CodeExtractor { const codeZipFile = path.join(codeZipDir, fileName) const destinationDirectory = request.destinationDirectory.fsPath - //write binary data into a temp zip file in a temp directory + // write binary data into a temp zip file in a temp directory const zipContentsBinary = new Uint8Array(zipContents) const fd = fs.openSync(codeZipFile, 'w') fs.writeSync(fd, zipContentsBinary, 0, zipContentsBinary.byteLength, 0) @@ -328,7 +328,7 @@ export class CodeExtractor { const zipEntries = zip.getEntries() const detectedCollisions: string[] = [] - zipEntries.forEach(function (zipEntry) { + for (const zipEntry of zipEntries) { if (zipEntry.isDirectory) { // Ignore directories because those can/will merged } else { @@ -337,7 +337,7 @@ export class CodeExtractor { detectedCollisions.push(intendedDestinationPath) } } - }) + } if (detectedCollisions.length > 0) { this.writeToOutputChannel(detectedCollisions) diff --git a/packages/core/src/eventSchemas/commands/viewSchemaItem.ts b/packages/core/src/eventSchemas/commands/viewSchemaItem.ts index 5ad255e65c6..f3eca0428e5 100644 --- a/packages/core/src/eventSchemas/commands/viewSchemaItem.ts +++ b/packages/core/src/eventSchemas/commands/viewSchemaItem.ts @@ -51,5 +51,5 @@ export async function showSchemaContent( language: 'json', }) const editor = await vscode.window.showTextDocument(newDoc, vscode.ViewColumn.One, false) - await editor.edit((edit) => edit.insert(new vscode.Position(/*line*/ 0, /*character*/ 0), prettySchemaContent)) + await editor.edit((edit) => edit.insert(new vscode.Position(/* line*/ 0, /* character*/ 0), prettySchemaContent)) } diff --git a/packages/core/src/eventSchemas/providers/schemasDataProvider.ts b/packages/core/src/eventSchemas/providers/schemasDataProvider.ts index 23c43c1915f..ec280238183 100644 --- a/packages/core/src/eventSchemas/providers/schemasDataProvider.ts +++ b/packages/core/src/eventSchemas/providers/schemasDataProvider.ts @@ -76,7 +76,7 @@ export class SchemasDataProvider { if (!schemas || schemas.length === 0) { schemas = await toArrayAsync(client.listSchemas(registryName)) const singleItem: registrySchemasMap = { registryName: registryName, schemaList: schemas } - //wizard setup always calls getRegistries method prior to getSchemas, so this shouldn't be undefined + // wizard setup always calls getRegistries method prior to getSchemas, so this shouldn't be undefined if (!registrySchemasMapList) { this.pushRegionDataIntoCache(region, [], [singleItem], credentials) } diff --git a/packages/core/src/eventSchemas/vue/searchSchemas.ts b/packages/core/src/eventSchemas/vue/searchSchemas.ts index 32f04e954c8..a5dcb55527d 100644 --- a/packages/core/src/eventSchemas/vue/searchSchemas.ts +++ b/packages/core/src/eventSchemas/vue/searchSchemas.ts @@ -151,7 +151,9 @@ export async function getRegistryNames(node: RegistryItemNode | SchemasNode, cli if (node instanceof SchemasNode) { try { const registries = await toArrayAsync(listRegistryItems(client)) - registries.forEach((element) => registryNames.push(element.RegistryName!)) + for (const element of registries) { + registryNames.push(element.RegistryName!) + } } catch (err) { const error = err as Error getLogger().error(error) diff --git a/packages/core/src/extension.ts b/packages/core/src/extension.ts index 3f1039fbba7..4a57f96e8fe 100644 --- a/packages/core/src/extension.ts +++ b/packages/core/src/extension.ts @@ -17,7 +17,7 @@ import globals, { initialize, isWeb } from './shared/extensionGlobals' import { join } from 'path' import { Commands } from './shared/vscode/commands2' import { endpointsFileUrl, githubCreateIssueUrl, githubUrl } from './shared/constants' -import { getIdeProperties, aboutExtension, isCloud9, getDocUrl } from './shared/extensionUtilities' +import { getIdeProperties, aboutExtension, getDocUrl } from './shared/extensionUtilities' import { logAndShowError, logAndShowWebviewError } from './shared/utilities/logAndShowUtils' import { telemetry } from './shared/telemetry/telemetry' import { openUrl } from './shared/utilities/vsCodeUtils' @@ -38,7 +38,6 @@ import { RegionProvider, getEndpointsFromFetcher } from './shared/regions/region import { getMachineId, isAutomation } from './shared/vscode/env' import { registerCommandErrorHandler } from './shared/vscode/commands2' import { registerWebviewErrorHandler } from './webviews/server' -import { showQuickStartWebview } from './shared/extensionStartup' import { ExtContext, VSCODE_EXTENSION_ID } from './shared/extensions' import { getSamCliContext } from './shared/sam/cli/samCliContext' import { UriHandler } from './shared/vscode/uriHandler' @@ -75,7 +74,7 @@ export async function activateCommon( errors.init(fs.getUsername(), isAutomation()) await initializeComputeRegion() - globals.contextPrefix = '' //todo: disconnect supplied argument + globals.contextPrefix = '' // todo: disconnect supplied argument registerCommandErrorHandler((info, error) => { const defaultMessage = localize('AWS.generic.message.error', 'Failed to run command: {0}', info.id) @@ -89,7 +88,7 @@ export async function activateCommon( // Setup the logger const toolkitOutputChannel = vscode.window.createOutputChannel('AWS Toolkit', { log: true }) const toolkitLogChannel = vscode.window.createOutputChannel('AWS Toolkit Logs', { log: true }) - await activateLogger(context, contextPrefix, toolkitOutputChannel, toolkitLogChannel) + await activateLogger(context, contextPrefix, toolkitLogChannel, toolkitOutputChannel) globals.outputChannel = toolkitOutputChannel globals.logOutputChannel = toolkitLogChannel @@ -99,20 +98,7 @@ export async function activateCommon( void maybeShowMinVscodeWarning('1.83.0') - if (isCloud9()) { - vscode.window.withProgress = wrapWithProgressForCloud9(globals.outputChannel) - context.subscriptions.push( - Commands.register('aws.quickStart', async () => { - try { - await showQuickStartWebview(context) - } finally { - telemetry.aws_helpQuickstart.emit({ result: 'Succeeded' }) - } - }) - ) - } - - //setup globals + // setup globals globals.machineId = await getMachineId() globals.awsContext = new DefaultAwsContext() globals.sdkClientBuilder = new DefaultAWSClientBuilder(globals.awsContext) @@ -204,12 +190,12 @@ export function registerGenericCommands(extensionContext: vscode.ExtensionContex * https://docs.aws.amazon.com/general/latest/gr/rande.html */ export function makeEndpointsProvider() { - let localManifestFetcher: ResourceFetcher - let remoteManifestFetcher: ResourceFetcher + let localManifestFetcher: ResourceFetcher + let remoteManifestFetcher: ResourceFetcher if (isWeb()) { localManifestFetcher = { get: async () => JSON.stringify(endpoints) } // Cannot use HttpResourceFetcher due to web mode breaking on import - remoteManifestFetcher = { get: async () => (await fetch(endpointsFileUrl)).text() } + remoteManifestFetcher = { get: async () => await fetch(endpointsFileUrl) } } else { localManifestFetcher = new FileResourceFetcher(globals.manifestPaths.endpoints) // HACK: HttpResourceFetcher breaks web mode when imported, so we use webpack.IgnorePlugin() @@ -224,32 +210,3 @@ export function makeEndpointsProvider() { remote: () => getEndpointsFromFetcher(remoteManifestFetcher), } } - -/** - * Wraps the `vscode.window.withProgress` functionality with functionality that also writes to the output channel. - * - * Cloud9 does not show a progress notification. - */ -function wrapWithProgressForCloud9(channel: vscode.OutputChannel): (typeof vscode.window)['withProgress'] { - const withProgress = vscode.window.withProgress.bind(vscode.window) - - return (options, task) => { - if (options.title) { - channel.appendLine(options.title) - } - - return withProgress(options, (progress, token) => { - const newProgress: typeof progress = { - ...progress, - report: (value) => { - if (value.message) { - channel.appendLine(value.message) - } - progress.report(value) - }, - } - - return task(newProgress, token) - }) - } -} diff --git a/packages/core/src/extensionNode.ts b/packages/core/src/extensionNode.ts index b6d4a599ce1..54d908466f0 100644 --- a/packages/core/src/extensionNode.ts +++ b/packages/core/src/extensionNode.ts @@ -17,7 +17,6 @@ import { AwsContextCommands } from './shared/awsContextCommands' import { getIdeProperties, getExtEnvironmentDetails, - isCloud9, isSageMaker, showWelcomeMessage, } from './shared/extensionUtilities' @@ -46,12 +45,12 @@ import globals from './shared/extensionGlobals' import { Experiments, Settings, showSettingsFailedMsg } from './shared/settings' import { isReleaseVersion } from './shared/vscode/env' import { AuthStatus, AuthUserState, telemetry } from './shared/telemetry/telemetry' -import { Auth, SessionSeparationPrompt } from './auth/auth' +import { Auth } from './auth/auth' import { getTelemetryMetadataForConn } from './auth/connection' import { registerSubmitFeedback } from './feedback/vue/submitFeedback' import { activateCommon, deactivateCommon } from './extension' import { learnMoreAmazonQCommand, qExtensionPageCommand, dismissQTree } from './amazonq/explorer/amazonQChildrenNodes' -import { AuthUtil, codeWhispererCoreScopes, isPreviousQUser } from './codewhisperer/util/authUtil' +import { codeWhispererCoreScopes } from './codewhisperer/util/authUtil' import { installAmazonQExtension } from './codewhisperer/commands/basicCommands' import { isExtensionInstalled, VSCODE_EXTENSION_ID } from './shared/utilities' import { ExtensionUse, getAuthFormIdsFromConnection, initializeCredentialsProviderManager } from './auth/utils' @@ -85,10 +84,9 @@ export async function activate(context: vscode.ExtensionContext) { const toolkitEnvDetails = getExtEnvironmentDetails() // Splits environment details by new line, filter removes the empty string - toolkitEnvDetails - .split(/\r?\n/) - .filter(Boolean) - .forEach((line) => getLogger().info(line)) + for (const line of toolkitEnvDetails.split(/\r?\n/).filter(Boolean)) { + getLogger().info(line) + } globals.awsContextCommands = new AwsContextCommands(globals.regionProvider, Auth.instance) globals.schemaService = new SchemaService() @@ -140,16 +138,8 @@ export async function activate(context: vscode.ExtensionContext) { conn.scopes ) await Auth.instance.forgetConnection(conn) - await SessionSeparationPrompt.instance.showForCommand('aws.toolkit.auth.manageConnections') } } - - // Display last prompt if connections were forgotten in prior sessions - // but the user did not interact or sign in again. Useful in case the user misses it the first time. - await SessionSeparationPrompt.instance.showAnyPreviousPrompt() - - // MUST restore CW/Q auth so that we can see if this user is already a Q user. - await AuthUtil.instance.restore() }, { emit: false, functionId: { name: 'activate', class: 'ExtensionNodeCore' } } ) @@ -191,19 +181,17 @@ export async function activate(context: vscode.ExtensionContext) { await activateSchemas(extContext) - if (!isCloud9()) { - if (!isSageMaker()) { - // Amazon Q/CodeWhisperer Tree setup. - learnMoreAmazonQCommand.register() - qExtensionPageCommand.register() - dismissQTree.register() - installAmazonQExtension.register() + if (!isSageMaker()) { + // Amazon Q Tree setup. + learnMoreAmazonQCommand.register() + qExtensionPageCommand.register() + dismissQTree.register() + installAmazonQExtension.register() - await handleAmazonQInstall() - } - await activateApplicationComposer(context) - await activateThreatComposerEditor(context) + await handleAmazonQInstall() } + await activateApplicationComposer(context) + await activateThreatComposerEditor(context) await activateStepFunctions(context, globals.awsContext, globals.outputChannel) @@ -273,52 +261,47 @@ export async function deactivate() { async function handleAmazonQInstall() { const dismissedInstall = globals.globalState.get('aws.toolkit.amazonqInstall.dismissed') - if (isExtensionInstalled(VSCODE_EXTENSION_ID.amazonq) || dismissedInstall) { + if (dismissedInstall) { + return + } + + if (isExtensionInstalled(VSCODE_EXTENSION_ID.amazonq)) { + await globals.globalState.update('aws.toolkit.amazonqInstall.dismissed', true) return } await telemetry.toolkit_showNotification.run(async () => { - if (isPreviousQUser()) { - await installAmazonQExtension.execute() - telemetry.record({ id: 'amazonQStandaloneInstalled' }) - void vscode.window.showInformationMessage( - "Amazon Q is now its own extension.\n\nWe've auto-installed it for you with all the same features and settings from CodeWhisperer and Amazon Q chat." + telemetry.record({ id: 'amazonQStandaloneChange' }) + void vscode.window + .showInformationMessage( + 'Try Amazon Q, a generative AI assistant, with chat and code suggestions.', + 'Install', + 'Learn More' ) - await globals.globalState.update('aws.toolkit.amazonqInstall.dismissed', true) - } else { - telemetry.record({ id: 'amazonQStandaloneChange' }) - void vscode.window - .showInformationMessage( - 'Amazon Q has moved to its own extension.' + - '\nInstall it to use Amazon Q, a generative AI assistant, with chat and code suggestions.', - 'Install', - 'Learn More' - ) - .then(async (resp) => { - await telemetry.toolkit_invokeAction.run(async () => { - telemetry.record({ - source: ExtensionUse.instance.isFirstUse() - ? ExtStartUpSources.firstStartUp - : ExtStartUpSources.none, - }) - - if (resp === 'Learn More') { - // Clicking learn more will open the q extension page - telemetry.record({ action: 'learnMore' }) - await qExtensionPageCommand.execute() - return - } - - if (resp === 'Install') { - telemetry.record({ action: 'installAmazonQ' }) - await installAmazonQExtension.execute() - } else { - telemetry.record({ action: 'dismissQNotification' }) - } - await globals.globalState.update('aws.toolkit.amazonqInstall.dismissed', true) + .then(async (resp) => { + await telemetry.toolkit_invokeAction.run(async () => { + telemetry.record({ + source: ExtensionUse.instance.isFirstUse() + ? ExtStartUpSources.firstStartUp + : ExtStartUpSources.none, }) + + if (resp === 'Learn More') { + // Clicking learn more will open the q extension page + telemetry.record({ action: 'learnMore' }) + await qExtensionPageCommand.execute() + return + } + + if (resp === 'Install') { + telemetry.record({ action: 'installAmazonQ' }) + await installAmazonQExtension.execute() + } else { + telemetry.record({ action: 'dismissQNotification' }) + } + await globals.globalState.update('aws.toolkit.amazonqInstall.dismissed', true) }) - } + }) }) } @@ -343,17 +326,23 @@ async function getAuthState(): Promise> { const enabledScopes: Set = new Set() if (Auth.instance.hasConnections) { authStatus = 'expired' - ;(await Auth.instance.listConnections()).forEach((conn) => { + for (const conn of await Auth.instance.listConnections()) { const state = Auth.instance.getConnectionState(conn) if (state === 'valid') { authStatus = 'connected' } - getAuthFormIdsFromConnection(conn).forEach((id) => enabledConnections.add(id)) + for (const id of getAuthFormIdsFromConnection(conn)) { + enabledConnections.add(id) + } if (isSsoConnection(conn)) { - conn.scopes?.forEach((s) => enabledScopes.add(s)) + if (conn.scopes) { + for (const s of conn.scopes) { + enabledScopes.add(s) + } + } } - }) + } } // There may be other SSO connections in toolkit, but there is no use case for diff --git a/packages/core/src/lambda/activation.ts b/packages/core/src/lambda/activation.ts index 4a21b2e9611..3ea90d61e7c 100644 --- a/packages/core/src/lambda/activation.ts +++ b/packages/core/src/lambda/activation.ts @@ -72,10 +72,10 @@ export async function activate(context: ExtContext): Promise { await copyLambdaUrl(sourceNode, new DefaultLambdaClient(sourceNode.regionCode)) }), - registerSamInvokeVueCommand(context), + registerSamInvokeVueCommand(context.extensionContext), Commands.register('aws.launchDebugConfigForm', async (node: ResourceNode) => - registerSamDebugInvokeVueCommand(context, { resource: node }) + registerSamDebugInvokeVueCommand(context.extensionContext, { resource: node }) ) ) } diff --git a/packages/core/src/lambda/commands/createNewSamApp.ts b/packages/core/src/lambda/commands/createNewSamApp.ts index 38e10a2237e..8c6f5fbab19 100644 --- a/packages/core/src/lambda/commands/createNewSamApp.ts +++ b/packages/core/src/lambda/commands/createNewSamApp.ts @@ -39,13 +39,7 @@ import { isTemplateTargetProperties } from '../../shared/sam/debugger/awsSamDebu import { TemplateTargetProperties } from '../../shared/sam/debugger/awsSamDebugConfiguration' import { openLaunchJsonFile } from '../../shared/sam/debugger/commands/addSamDebugConfiguration' import { waitUntil } from '../../shared/utilities/timeoutUtils' -import { - getIdeProperties, - getDebugNewSamAppDocUrl, - isCloud9, - getLaunchConfigDocUrl, -} from '../../shared/extensionUtilities' -import { execFileSync } from 'child_process' +import { getIdeProperties, getDebugNewSamAppDocUrl, getLaunchConfigDocUrl } from '../../shared/extensionUtilities' import { checklogs } from '../../shared/localizedText' import globals from '../../shared/extensionGlobals' import { telemetry } from '../../shared/telemetry/telemetry' @@ -53,6 +47,7 @@ import { LambdaArchitecture, Result, Runtime } from '../../shared/telemetry/tele import { getTelemetryReason, getTelemetryResult } from '../../shared/errors' import { openUrl, replaceVscodeVars } from '../../shared/utilities/vsCodeUtils' import { fs } from '../../shared' +import { ChildProcess } from '../../shared/utilities/processUtils' export const samInitTemplateFiles: string[] = ['template.yaml', 'template.yml'] export const samInitReadmeFile: string = 'README.TOOLKIT.md' @@ -218,7 +213,9 @@ export async function createNewSamApplication( // Needs to be done or else gopls won't start if (goRuntimes.includes(createRuntime)) { try { - execFileSync('go', ['mod', 'tidy'], { cwd: path.join(path.dirname(templateUri.fsPath), 'hello-world') }) + await ChildProcess.run('go', ['mod', 'tidy'], { + spawnOptions: { cwd: path.join(path.dirname(templateUri.fsPath), 'hello-world') }, + }) } catch (err) { getLogger().warn( localize( @@ -415,12 +412,12 @@ export async function addInitialLaunchConfiguration( // optional for ZIP-lambdas but required for Image-lambdas if (runtime !== undefined) { - filtered.forEach((configuration) => { + for (const configuration of filtered) { if (!configuration.lambda) { configuration.lambda = {} } configuration.lambda.runtime = runtime - }) + } } await launchConfiguration.addDebugConfigurations(filtered) @@ -471,9 +468,7 @@ export async function writeToolkitReadme( .replace(/\$\{LISTOFCONFIGURATIONS\}/g, configString) .replace( /\$\{DOCURL\}/g, - isCloud9() - ? 'https://docs.aws.amazon.com/cloud9/latest/user-guide/serverless-apps-toolkit.html' - : 'https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/serverless-apps.html' + 'https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/serverless-apps.html' ) await fs.writeFile(readmeLocation, readme) diff --git a/packages/core/src/lambda/commands/downloadLambda.ts b/packages/core/src/lambda/commands/downloadLambda.ts index 16128ce5701..815ff2576e9 100644 --- a/packages/core/src/lambda/commands/downloadLambda.ts +++ b/packages/core/src/lambda/commands/downloadLambda.ts @@ -14,7 +14,7 @@ import { LaunchConfiguration, getReferencedHandlerPaths } from '../../shared/deb import { makeTemporaryToolkitFolder, fileExists, tryRemoveFolder } from '../../shared/filesystemUtilities' import * as localizedText from '../../shared/localizedText' import { getLogger } from '../../shared/logger' -import { HttpResourceFetcher } from '../../shared/resourcefetcher/httpResourceFetcher' +import { HttpResourceFetcher } from '../../shared/resourcefetcher/node/httpResourceFetcher' import { createCodeAwsSamDebugConfig } from '../../shared/sam/debugger/awsSamDebugConfiguration' import * as pathutils from '../../shared/utilities/pathUtils' import { localize } from '../../shared/utilities/vsCodeUtils' diff --git a/packages/core/src/lambda/commands/uploadLambda.ts b/packages/core/src/lambda/commands/uploadLambda.ts index 7627d53cfab..e5a4ce34755 100644 --- a/packages/core/src/lambda/commands/uploadLambda.ts +++ b/packages/core/src/lambda/commands/uploadLambda.ts @@ -20,7 +20,7 @@ import { getSamCliContext } from '../../shared/sam/cli/samCliContext' import { SamTemplateGenerator } from '../../shared/templates/sam/samTemplateGenerator' import { addCodiconToString } from '../../shared/utilities/textUtilities' import { getLambdaDetails, listLambdaFunctions } from '../utils' -import { getIdeProperties, isCloud9 } from '../../shared/extensionUtilities' +import { getIdeProperties } from '../../shared/extensionUtilities' import { createQuickPick, DataQuickPickItem } from '../../shared/ui/pickerPrompter' import { createCommonButtons } from '../../shared/ui/buttons' import { StepEstimator, Wizard, WIZARD_BACK } from '../../shared/wizards/wizard' @@ -481,10 +481,7 @@ async function uploadZipBuffer( ) } -export async function findApplicationJsonFile( - startPath: vscode.Uri, - cloud9 = isCloud9() -): Promise { +export async function findApplicationJsonFile(startPath: vscode.Uri): Promise { if (!(await fs.exists(startPath.fsPath))) { getLogger().error( 'findApplicationJsonFile() invalid path (not accessible or does not exist): "%s"', diff --git a/packages/core/src/lambda/local/debugConfiguration.ts b/packages/core/src/lambda/local/debugConfiguration.ts index 999b36ddce2..b21599b0563 100644 --- a/packages/core/src/lambda/local/debugConfiguration.ts +++ b/packages/core/src/lambda/local/debugConfiguration.ts @@ -54,20 +54,6 @@ export interface PythonDebugConfiguration extends SamLaunchRequestArgs { readonly pathMappings: PythonPathMapping[] } -/** Alternative (Cloud9) Python debugger: ikp3db */ -export interface PythonCloud9DebugConfiguration extends SamLaunchRequestArgs { - readonly runtimeFamily: RuntimeFamily.Python - /** Passed to "sam build --manifest …" */ - readonly manifestPath: string | undefined - - // Fields expected by the Cloud9 debug adapter. - // (Cloud9 sourcefile: debugger-vscode-mainthread-adapter.ts) - readonly port: number - readonly address: string - readonly localRoot: string - readonly remoteRoot: string -} - export interface DotNetDebugConfiguration extends SamLaunchRequestArgs { readonly runtimeFamily: RuntimeFamily.DotNet processName: string diff --git a/packages/core/src/lambda/models/samTemplates.ts b/packages/core/src/lambda/models/samTemplates.ts index 963cbdaafa5..5ec112a7dc4 100644 --- a/packages/core/src/lambda/models/samTemplates.ts +++ b/packages/core/src/lambda/models/samTemplates.ts @@ -12,10 +12,26 @@ import { supportsEventBridgeTemplates } from '../../../src/eventSchemas/models/s import { RuntimePackageType } from './samLambdaRuntime' import { getIdeProperties } from '../../shared/extensionUtilities' -export let helloWorldTemplate = 'helloWorldUninitialized' -export let eventBridgeHelloWorldTemplate = 'eventBridgeHelloWorldUninitialized' -export let eventBridgeStarterAppTemplate = 'eventBridgeStarterAppUnintialized' -export let stepFunctionsSampleApp = 'stepFunctionsSampleAppUnintialized' +export const helloWorldTemplate = localize( + 'AWS.samcli.initWizard.template.helloWorld.name', + '{0} SAM Hello World', + getIdeProperties().company +) +export const eventBridgeHelloWorldTemplate = localize( + 'AWS.samcli.initWizard.template.helloWorld.name', + '{0} SAM EventBridge Hello World', + getIdeProperties().company +) +export const eventBridgeStarterAppTemplate = localize( + 'AWS.samcli.initWizard.template.helloWorld.name', + '{0} SAM EventBridge App from Scratch', + getIdeProperties().company +) +export const stepFunctionsSampleApp = localize( + 'AWS.samcli.initWizard.template.helloWorld.name', + '{0} Step Functions Sample App', + getIdeProperties().company +) export const typeScriptBackendTemplate = 'App Backend using TypeScript' export const repromptUserForTemplate = 'REQUIRES_AWS_CREDENTIALS_REPROMPT_USER_FOR_TEMPLATE' @@ -23,33 +39,6 @@ export const cliVersionStepFunctionsTemplate = '0.52.0' export type SamTemplate = string -/** - * Lazy load strings for SAM template quick picks - * Need to be lazyloaded as `getIdeProperties` requires IDE activation for Cloud9 - */ -export function lazyLoadSamTemplateStrings(): void { - helloWorldTemplate = localize( - 'AWS.samcli.initWizard.template.helloWorld.name', - '{0} SAM Hello World', - getIdeProperties().company - ) - eventBridgeHelloWorldTemplate = localize( - 'AWS.samcli.initWizard.template.helloWorld.name', - '{0} SAM EventBridge Hello World', - getIdeProperties().company - ) - eventBridgeStarterAppTemplate = localize( - 'AWS.samcli.initWizard.template.helloWorld.name', - '{0} SAM EventBridge App from Scratch', - getIdeProperties().company - ) - stepFunctionsSampleApp = localize( - 'AWS.samcli.initWizard.template.helloWorld.name', - '{0} Step Functions Sample App', - getIdeProperties().company - ) -} - export function getSamTemplateWizardOption( runtime: Runtime, packageType: RuntimePackageType, diff --git a/packages/core/src/lambda/utils.ts b/packages/core/src/lambda/utils.ts index 63b4325da55..7fa56bc33e9 100644 --- a/packages/core/src/lambda/utils.ts +++ b/packages/core/src/lambda/utils.ts @@ -13,8 +13,6 @@ import { CloudFormationClient } from '../shared/clients/cloudFormationClient' import { LambdaClient } from '../shared/clients/lambdaClient' import { getFamily, getNodeMajorVersion, RuntimeFamily } from './models/samLambdaRuntime' import { getLogger } from '../shared/logger' -import { ResourceFetcher } from '../shared/resourcefetcher/resourcefetcher' -import { CompositeResourceFetcher } from '../shared/resourcefetcher/compositeResourceFetcher' import { HttpResourceFetcher } from '../shared/resourcefetcher/httpResourceFetcher' import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetcher' import { sampleRequestManifestPath } from './constants' @@ -99,7 +97,7 @@ interface SampleRequestManifest { export async function getSampleLambdaPayloads(): Promise { const logger = getLogger() - const sampleInput = await makeSampleRequestManifestResourceFetcher().get() + const sampleInput = await getSampleRequestManifest() if (!sampleInput) { throw new Error('Unable to retrieve Sample Request manifest') @@ -120,9 +118,11 @@ export async function getSampleLambdaPayloads(): Promise { return inputs } -function makeSampleRequestManifestResourceFetcher(): ResourceFetcher { - return new CompositeResourceFetcher( - new HttpResourceFetcher(sampleRequestManifestPath, { showUrl: true }), - new FileResourceFetcher(globals.manifestPaths.lambdaSampleRequests) - ) +async function getSampleRequestManifest(): Promise { + const httpResp = await new HttpResourceFetcher(sampleRequestManifestPath, { showUrl: true }).get() + if (!httpResp) { + const fileResp = new FileResourceFetcher(globals.manifestPaths.lambdaSampleRequests) + return fileResp.get() + } + return httpResp.text() } diff --git a/packages/core/src/lambda/vue/configEditor/samInvokeBackend.ts b/packages/core/src/lambda/vue/configEditor/samInvokeBackend.ts index 643ea4631e2..ba624536b0f 100644 --- a/packages/core/src/lambda/vue/configEditor/samInvokeBackend.ts +++ b/packages/core/src/lambda/vue/configEditor/samInvokeBackend.ts @@ -7,8 +7,6 @@ import * as path from 'path' import * as vscode from 'vscode' import * as nls from 'vscode-nls' import { LaunchConfiguration } from '../../../shared/debug/launchConfiguration' - -import { ExtContext } from '../../../shared/extensions' import { getLogger } from '../../../shared/logger' import { HttpResourceFetcher } from '../../../shared/resourcefetcher/httpResourceFetcher' import { @@ -28,8 +26,6 @@ import { tryGetAbsolutePath } from '../../../shared/utilities/workspaceUtils' import * as CloudFormation from '../../../shared/cloudformation/cloudformation' import { openLaunchJsonFile } from '../../../shared/sam/debugger/commands/addSamDebugConfiguration' import { getSampleLambdaPayloads } from '../../utils' -import { isCloud9 } from '../../../shared/extensionUtilities' -import { SamDebugConfigProvider } from '../../../shared/sam/debugger/awsSamDebugger' import { samLambdaCreatableRuntimes } from '../../models/samLambdaRuntime' import globals from '../../../shared/extensionGlobals' import { VueWebview } from '../../../webviews/main' @@ -78,7 +74,6 @@ export class SamInvokeWebview extends VueWebview { public readonly id = 'createLambda' public constructor( - private readonly extContext: ExtContext, // TODO(sijaden): get rid of `ExtContext` private readonly config?: AwsSamDebuggerConfiguration, private readonly data?: ResourceData ) { @@ -170,7 +165,8 @@ export class SamInvokeWebview extends VueWebview { return } const sampleUrl = `${sampleRequestPath}${pickerResponse.filename}` - const sample = (await new HttpResourceFetcher(sampleUrl, { showUrl: true }).get()) ?? '' + const resp = await new HttpResourceFetcher(sampleUrl, { showUrl: true }).get() + const sample = (await resp?.text()) ?? '' return sample } catch (err) { @@ -357,17 +353,8 @@ export class SamInvokeWebview extends VueWebview { const targetUri = await this.getUriFromLaunchConfig(finalConfig) const folder = targetUri ? vscode.workspace.getWorkspaceFolder(targetUri) : undefined - // Cloud9 currently can't resolve the `aws-sam` debug config provider. - // Directly invoke the config instead. - // NOTE: This bypasses the `${workspaceFolder}` resolution, but shouldn't naturally occur in Cloud9 - // (Cloud9 also doesn't currently have variable resolution support anyways) - if (isCloud9()) { - const provider = new SamDebugConfigProvider(this.extContext) - await provider.resolveDebugConfiguration(folder, finalConfig, undefined, source) - } else { - // startDebugging on VS Code goes through the whole resolution chain - await vscode.debug.startDebugging(folder, finalConfig) - } + // startDebugging on VS Code goes through the whole resolution chain + await vscode.debug.startDebugging(folder, finalConfig) } public async getLaunchConfigQuickPickItems( launchConfig: LaunchConfiguration, @@ -426,9 +413,9 @@ export class SamInvokeWebview extends VueWebview { const WebviewPanel = VueWebview.compilePanel(SamInvokeWebview) -export function registerSamInvokeVueCommand(context: ExtContext): vscode.Disposable { +export function registerSamInvokeVueCommand(context: vscode.ExtensionContext): vscode.Disposable { return Commands.register('aws.launchConfigForm', async (launchConfig?: AwsSamDebuggerConfiguration) => { - const webview = new WebviewPanel(context.extensionContext, context, launchConfig) + const webview = new WebviewPanel(context, launchConfig) await telemetry.sam_openConfigUi.run(async (span) => { await webview.show({ title: localize('AWS.command.launchConfigForm.title', 'Local Invoke and Debug Configuration'), @@ -439,11 +426,14 @@ export function registerSamInvokeVueCommand(context: ExtContext): vscode.Disposa }) } -export async function registerSamDebugInvokeVueCommand(context: ExtContext, params: { resource: ResourceNode }) { +export async function registerSamDebugInvokeVueCommand( + context: vscode.ExtensionContext, + params: { resource: ResourceNode } +) { const launchConfig: AwsSamDebuggerConfiguration | undefined = undefined const resource = params?.resource.resource const source = 'AppBuilderLocalInvoke' - const webview = new WebviewPanel(context.extensionContext, context, launchConfig, { + const webview = new WebviewPanel(context, launchConfig, { logicalId: resource.resource.Id ?? '', region: resource.region ?? '', location: resource.location.fsPath, diff --git a/packages/core/src/lambda/vue/configEditor/samInvokeFrontend.ts b/packages/core/src/lambda/vue/configEditor/samInvokeFrontend.ts index 95f0fa9a14f..c266abb8a4b 100644 --- a/packages/core/src/lambda/vue/configEditor/samInvokeFrontend.ts +++ b/packages/core/src/lambda/vue/configEditor/samInvokeFrontend.ts @@ -268,7 +268,7 @@ export default defineComponent({ if (!field) { return undefined } - //Reg ex for a comma with 0 or more whitespace before and/or after + // Reg ex for a comma with 0 or more whitespace before and/or after const re = /\s*,\s*/g return field.trim().split(re) }, @@ -448,9 +448,9 @@ export default defineComponent({ }, clearForm() { const init = initData() - Object.keys(init).forEach((k) => { + for (const k of Object.keys(init)) { ;(this as any)[k] = init[k as keyof typeof init] - }) + } }, }, }) diff --git a/packages/core/src/lambda/vue/remoteInvoke/invokeLambda.ts b/packages/core/src/lambda/vue/remoteInvoke/invokeLambda.ts index 501304c1a94..38b3700719c 100644 --- a/packages/core/src/lambda/vue/remoteInvoke/invokeLambda.ts +++ b/packages/core/src/lambda/vue/remoteInvoke/invokeLambda.ts @@ -219,7 +219,8 @@ export class RemoteInvokeWebview extends VueWebview { return } const sampleUrl = `${sampleRequestPath}${pickerResponse.filename}` - const sample = (await new HttpResourceFetcher(sampleUrl, { showUrl: true }).get()) ?? '' + const resp = await new HttpResourceFetcher(sampleUrl, { showUrl: true }).get() + const sample = (await resp?.text()) ?? '' return sample } catch (err) { diff --git a/packages/core/src/lambda/wizards/samDeployWizard.ts b/packages/core/src/lambda/wizards/samDeployWizard.ts index a45e07b0b74..98047b75db1 100644 --- a/packages/core/src/lambda/wizards/samDeployWizard.ts +++ b/packages/core/src/lambda/wizards/samDeployWizard.ts @@ -946,11 +946,11 @@ async function getTemplateChoices(...workspaceFolders: vscode.Uri[]): Promise = new Map() const labelCounts: Map = new Map() - templateUris.forEach((uri) => { + for (const uri of templateUris) { const label = SamTemplateQuickPickItem.getLabel(uri) uriToLabel.set(uri, label) labelCounts.set(label, 1 + (labelCounts.get(label) || 0)) - }) + } return Array.from(uriToLabel, ([uri, label]) => { const showWorkspaceFolderDetails: boolean = (labelCounts.get(label) || 0) > 1 diff --git a/packages/core/src/login/webview/commonAuthViewProvider.ts b/packages/core/src/login/webview/commonAuthViewProvider.ts index d5f94748e07..8f641acdb8d 100644 --- a/packages/core/src/login/webview/commonAuthViewProvider.ts +++ b/packages/core/src/login/webview/commonAuthViewProvider.ts @@ -11,7 +11,7 @@ "type": "webview", "id": "aws.AmazonCommonAuth", "name": "%AWS.amazonq.login%", -"when": "!isCloud9 && !aws.isSageMaker && !aws.amazonq.showView" +"when": "!aws.isSageMaker && !aws.amazonq.showView" }, * 2. Assign when clause context to this view. Manage the state of when clause context. diff --git a/packages/core/src/login/webview/vue/backend.ts b/packages/core/src/login/webview/vue/backend.ts index 0c1cbdaebc7..ed467175334 100644 --- a/packages/core/src/login/webview/vue/backend.ts +++ b/packages/core/src/login/webview/vue/backend.ts @@ -31,6 +31,7 @@ import { AuthEnabledFeatures, AuthError, AuthFlowState, AuthUiClick, userCancell import { DevSettings } from '../../../shared/settings' import { AuthSSOServer } from '../../../auth/sso/server' import { getLogger } from '../../../shared/logger/logger' +import { isValidUrl } from '../../../shared/utilities/uriUtils' export abstract class CommonAuthWebview extends VueWebview { private readonly className = 'CommonAuthWebview' @@ -276,4 +277,8 @@ export abstract class CommonAuthWebview extends VueWebview { cancelAuthFlow() { AuthSSOServer.lastInstance?.cancelCurrentFlow() } + + validateUrl(url: string) { + return isValidUrl(url) + } } diff --git a/packages/core/src/login/webview/vue/login.vue b/packages/core/src/login/webview/vue/login.vue index f15848a9069..4c9f65a2f6a 100644 --- a/packages/core/src/login/webview/vue/login.vue +++ b/packages/core/src/login/webview/vue/login.vue @@ -193,6 +193,7 @@ @keydown.enter="handleContinueClick()" />

{{ startUrlError }}

+

{{ startUrlWarning }}

Region
AWS Region that hosts identity directory
${column}