diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4df3609e2c..be14af27878 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ on: required: false default: prerelease push: - branches: [master, feature/*] + branches: [master, feature/*, release/*] # tags: # - v[0-9]+.[0-9]+.[0-9]+ @@ -40,12 +40,16 @@ jobs: # run: echo 'TAG_NAME=prerelease' >> $GITHUB_ENV - if: github.event_name == 'workflow_dispatch' run: echo "TAG_NAME=${{ github.event.inputs.tag_name }}" >> $GITHUB_ENV - - if: github.ref_name != 'master' + - if: startsWith(github.ref_name, 'feature/') run: | - TAG_NAME=${{ github.ref_name }} - FEAT_NAME=$(echo $TAG_NAME | sed 's/feature\///') + FEAT_NAME=$(echo ${{ github.ref_name }} | sed 's/feature\///') echo "FEAT_NAME=$FEAT_NAME" >> $GITHUB_ENV echo "TAG_NAME=pre-$FEAT_NAME" >> $GITHUB_ENV + - if: startsWith(github.ref_name, 'release/') + run: | + RC_NAME=$(echo ${{ github.ref_name }} | sed 's/release\///') + echo "FEAT_NAME=" >> $GITHUB_ENV + echo "TAG_NAME=rc-$RC_NAME" >> $GITHUB_ENV - if: github.ref_name == 'master' run: | echo "FEAT_NAME=" >> $GITHUB_ENV @@ -105,10 +109,14 @@ jobs: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 - name: Delete existing prerelease - # "prerelease" (main branch) or "pre-" - if: "env.TAG_NAME == 'prerelease' || startsWith(env.TAG_NAME, 'pre-')" + # "prerelease" (main branch), "pre-", or "rc-" + if: env.TAG_NAME == 'prerelease' || startsWith(env.TAG_NAME, 'pre-') || startsWith(env.TAG_NAME, 'rc-') run: | - echo "SUBJECT=AWS IDE Extensions: ${FEAT_NAME:-${TAG_NAME}}" >> $GITHUB_ENV + if [[ "$TAG_NAME" == rc-* ]]; then + echo "SUBJECT=AWS IDE Extensions Release Candidate: ${TAG_NAME#rc-}" >> $GITHUB_ENV + else + echo "SUBJECT=AWS IDE Extensions: ${FEAT_NAME:-${TAG_NAME}}" >> $GITHUB_ENV + fi gh release delete "$TAG_NAME" --cleanup-tag --yes || true # git push origin :"$TAG_NAME" || true - name: Publish Prerelease diff --git a/.github/workflows/setup-release-candidate.yml b/.github/workflows/setup-release-candidate.yml new file mode 100644 index 00000000000..827a6ec81ab --- /dev/null +++ b/.github/workflows/setup-release-candidate.yml @@ -0,0 +1,108 @@ +name: Setup Release Candidate + +on: + workflow_dispatch: + inputs: + versionIncrement: + description: 'Release Version Increment' + default: 'Minor' + required: true + type: choice + options: + - Major + - Minor + - Patch + - Custom + customVersion: + description: "Custom Release Version (only used if release increment is 'Custom') - Format: 3.15.0" + default: '' + required: false + type: string + commitId: + description: 'Commit ID to create RC from' + required: true + type: string + +jobs: + setup-rc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.commitId }} + token: ${{ secrets.RELEASE_CANDIDATE_BRANCH_CREATION_PAT }} + persist-credentials: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Calculate Release Versions + id: release-version + run: | + customVersion="${{ inputs.customVersion }}" + versionIncrement="${{ inputs.versionIncrement }}" + + increment_version() { + local currentVersion=$1 + if [[ "$versionIncrement" == "Custom" && -n "$customVersion" ]]; then + echo "$customVersion" + else + IFS='.' read -r major minor patch <<< "$currentVersion" + case "$versionIncrement" in + "Major") + major=$((major + 1)) + minor=0 + patch=0 + ;; + "Minor") + minor=$((minor + 1)) + patch=0 + ;; + "Patch") + patch=$((patch + 1)) + ;; + esac + echo "$major.$minor.$patch" + fi + } + + # Read and increment toolkit version + toolkitCurrentVersion=$(node -e "console.log(require('./packages/toolkit/package.json').version)" | sed 's/-SNAPSHOT//') + toolkitNewVersion=$(increment_version "$toolkitCurrentVersion") + + # Read and increment amazonq version + amazonqCurrentVersion=$(node -e "console.log(require('./packages/amazonq/package.json').version)" | sed 's/-SNAPSHOT//') + amazonqNewVersion=$(increment_version "$amazonqCurrentVersion") + + echo "TOOLKIT_VERSION=$toolkitNewVersion" >> $GITHUB_OUTPUT + echo "AMAZONQ_VERSION=$amazonqNewVersion" >> $GITHUB_OUTPUT + # Use date-based branch naming instead of version-based because we release + # both extensions (toolkit and amazonq) from the same branch, and they may + # have different version numbers. We can change this in the future + echo "BRANCH_NAME=rc-$(date +%Y%m%d)" >> $GITHUB_OUTPUT + + - name: Create RC Branch and Update Versions + env: + TOOLKIT_VERSION: ${{ steps.release-version.outputs.TOOLKIT_VERSION }} + AMAZONQ_VERSION: ${{ steps.release-version.outputs.AMAZONQ_VERSION }} + BRANCH_NAME: ${{ steps.release-version.outputs.BRANCH_NAME }} + run: | + git config user.name "aws-toolkit-automation" + git config user.email "<>" + + # Create RC branch using date-based naming + git checkout -b $BRANCH_NAME + + # Update package versions individually + npm version --no-git-tag-version $TOOLKIT_VERSION -w packages/toolkit + npm version --no-git-tag-version $AMAZONQ_VERSION -w packages/amazonq + + # Commit version changes + git add packages/toolkit/package.json packages/amazonq/package.json package-lock.json + git commit -m "chore: bump versions - toolkit=$TOOLKIT_VERSION, amazonq=$AMAZONQ_VERSION" + + # Push RC branch + git push origin $BRANCH_NAME diff --git a/packages/amazonq/src/app/inline/EditRendering/displayImage.ts b/packages/amazonq/src/app/inline/EditRendering/displayImage.ts index 6509d2d744e..f25284c6b5a 100644 --- a/packages/amazonq/src/app/inline/EditRendering/displayImage.ts +++ b/packages/amazonq/src/app/inline/EditRendering/displayImage.ts @@ -16,6 +16,8 @@ import { EditSuggestionState } from '../editSuggestionState' import type { AmazonQInlineCompletionItemProvider } from '../completion' import { vsCodeState } from 'aws-core-vscode/codewhisperer' +const autoRejectEditCursorDistance = 25 + export class EditDecorationManager { private imageDecorationType: vscode.TextEditorDecorationType private removedCodeDecorationType: vscode.TextEditorDecorationType @@ -346,6 +348,19 @@ export async function displaySvgDecoration( void vscode.commands.executeCommand('aws.amazonq.inline.rejectEdit') } }) + const cursorChangeListener = vscode.window.onDidChangeTextEditorSelection((e) => { + if (!EditSuggestionState.isEditSuggestionActive()) { + return + } + if (e.textEditor !== editor) { + return + } + const currentPosition = e.selections[0].active + const distance = Math.abs(currentPosition.line - startLine) + if (distance > autoRejectEditCursorDistance) { + void vscode.commands.executeCommand('aws.amazonq.inline.rejectEdit') + } + }) await decorationManager.displayEditSuggestion( editor, svgImage, @@ -371,6 +386,7 @@ export async function displaySvgDecoration( await decorationManager.clearDecorations(editor) documentChangeListener.dispose() + cursorChangeListener.dispose() const params: LogInlineCompletionSessionResultsParams = { sessionId: session.sessionId, completionSessionResult: { @@ -405,6 +421,7 @@ export async function displaySvgDecoration( getLogger().info('Edit suggestion rejected') await decorationManager.clearDecorations(editor) documentChangeListener.dispose() + cursorChangeListener.dispose() const params: LogInlineCompletionSessionResultsParams = { sessionId: session.sessionId, completionSessionResult: { diff --git a/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts b/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts index 2a6496927d7..7f2bcbb40ea 100644 --- a/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts +++ b/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts @@ -129,6 +129,9 @@ describe('RecommendationService', () => { describe('getAllRecommendations', () => { it('should handle single request with no partial result token', async () => { + // Mock EditSuggestionState to return false (no edit suggestion active) + sandbox.stub(EditSuggestionState, 'isEditSuggestionActive').returns(false) + const mockFirstResult = { sessionId: 'test-session', items: [mockInlineCompletionItemOne], @@ -172,6 +175,9 @@ describe('RecommendationService', () => { }) it('should handle multiple request with partial result token', async () => { + // Mock EditSuggestionState to return false (no edit suggestion active) + sandbox.stub(EditSuggestionState, 'isEditSuggestionActive').returns(false) + const mockFirstResult = { sessionId: 'test-session', items: [mockInlineCompletionItemOne], diff --git a/packages/amazonq/test/unit/app/inline/EditRendering/displayImage.test.ts b/packages/amazonq/test/unit/app/inline/EditRendering/displayImage.test.ts index b88e30487a5..0a8cde5bacf 100644 --- a/packages/amazonq/test/unit/app/inline/EditRendering/displayImage.test.ts +++ b/packages/amazonq/test/unit/app/inline/EditRendering/displayImage.test.ts @@ -6,7 +6,30 @@ import * as vscode from 'vscode' import * as sinon from 'sinon' import assert from 'assert' -import { EditDecorationManager } from '../../../../../src/app/inline/EditRendering/displayImage' +import { EditDecorationManager, displaySvgDecoration } from '../../../../../src/app/inline/EditRendering/displayImage' +import { EditSuggestionState } from '../../../../../src/app/inline/editSuggestionState' + +// Shared helper function to create common stubs +function createCommonStubs(sandbox: sinon.SinonSandbox) { + const documentStub = { + getText: sandbox.stub().returns('Original code content'), + uri: vscode.Uri.file('/test/file.ts'), + lineAt: sandbox.stub().returns({ + text: 'Line text content', + range: new vscode.Range(0, 0, 0, 18), + rangeIncludingLineBreak: new vscode.Range(0, 0, 0, 19), + firstNonWhitespaceCharacterIndex: 0, + isEmptyOrWhitespace: false, + }), + } as unknown as sinon.SinonStubbedInstance + + const editorStub = { + document: documentStub, + setDecorations: sandbox.stub(), + } as unknown as sinon.SinonStubbedInstance + + return { documentStub, editorStub } +} describe('EditDecorationManager', function () { let sandbox: sinon.SinonSandbox @@ -25,23 +48,13 @@ describe('EditDecorationManager', function () { dispose: sandbox.stub(), } as unknown as sinon.SinonStubbedInstance - documentStub = { - getText: sandbox.stub().returns('Original code content'), - lineCount: 5, - lineAt: sandbox.stub().returns({ - text: 'Line text content', - range: new vscode.Range(0, 0, 0, 18), - rangeIncludingLineBreak: new vscode.Range(0, 0, 0, 19), - firstNonWhitespaceCharacterIndex: 0, - isEmptyOrWhitespace: false, - }), - } as unknown as sinon.SinonStubbedInstance - - editorStub = { - document: documentStub, - setDecorations: sandbox.stub(), - edit: sandbox.stub().resolves(true), - } as unknown as sinon.SinonStubbedInstance + const commonStubs = createCommonStubs(sandbox) + documentStub = commonStubs.documentStub + editorStub = commonStubs.editorStub + + // Add additional properties needed for this test suite - extend the stub objects + Object.assign(documentStub, { lineCount: 5 }) + Object.assign(editorStub, { edit: sandbox.stub().resolves(true) }) windowStub = sandbox.stub(vscode.window) windowStub.createTextEditorDecorationType.returns(decorationTypeStub as any) @@ -174,3 +187,124 @@ describe('EditDecorationManager', function () { sinon.assert.calledWith(editorStub.setDecorations.secondCall, manager['removedCodeDecorationType'], []) }) }) + +describe('displaySvgDecoration cursor distance auto-reject', function () { + let sandbox: sinon.SinonSandbox + let editorStub: sinon.SinonStubbedInstance + let windowStub: sinon.SinonStub + let commandsStub: sinon.SinonStub + let editSuggestionStateStub: sinon.SinonStub + let onDidChangeTextEditorSelectionStub: sinon.SinonStub + let selectionChangeListener: (e: vscode.TextEditorSelectionChangeEvent) => void + + // Helper function to setup displaySvgDecoration + async function setupDisplaySvgDecoration(startLine: number) { + return await displaySvgDecoration( + editorStub as unknown as vscode.TextEditor, + vscode.Uri.parse('data:image/svg+xml;base64,test'), + startLine, + 'new code', + [], + {} as any, + {} as any, + { itemId: 'test', insertText: 'patch' } as any + ) + } + + // Helper function to create selection change event + function createSelectionChangeEvent(line: number): vscode.TextEditorSelectionChangeEvent { + const position = new vscode.Position(line, 0) + const selection = new vscode.Selection(position, position) + return { + textEditor: editorStub, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + } as vscode.TextEditorSelectionChangeEvent + } + + beforeEach(function () { + sandbox = sinon.createSandbox() + + const commonStubs = createCommonStubs(sandbox) + editorStub = commonStubs.editorStub + + // Mock vscode.window.onDidChangeTextEditorSelection + onDidChangeTextEditorSelectionStub = sandbox.stub() + onDidChangeTextEditorSelectionStub.returns({ dispose: sandbox.stub() }) + windowStub = sandbox.stub(vscode.window, 'onDidChangeTextEditorSelection') + windowStub.callsFake((callback) => { + selectionChangeListener = callback + return { dispose: sandbox.stub() } + }) + + // Mock vscode.commands.executeCommand + commandsStub = sandbox.stub(vscode.commands, 'executeCommand') + + // Mock EditSuggestionState + editSuggestionStateStub = sandbox.stub(EditSuggestionState, 'isEditSuggestionActive') + editSuggestionStateStub.returns(true) + + // Mock other required dependencies + sandbox.stub(vscode.workspace, 'onDidChangeTextDocument').returns({ dispose: sandbox.stub() }) + }) + + afterEach(function () { + sandbox.restore() + }) + + it('should not reject when cursor moves less than 25 lines away', async function () { + const startLine = 50 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(startLine + 24)) + + sinon.assert.notCalled(commandsStub) + }) + + it('should not reject when cursor moves exactly 25 lines away', async function () { + const startLine = 50 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(startLine + 25)) + + sinon.assert.notCalled(commandsStub) + }) + + it('should reject when cursor moves more than 25 lines away', async function () { + const startLine = 50 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(startLine + 26)) + + sinon.assert.calledOnceWithExactly(commandsStub, 'aws.amazonq.inline.rejectEdit') + }) + + it('should reject when cursor moves more than 25 lines before the edit', async function () { + const startLine = 50 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(startLine - 26)) + + sinon.assert.calledOnceWithExactly(commandsStub, 'aws.amazonq.inline.rejectEdit') + }) + + it('should not reject when edit is near beginning of file and cursor cannot move far enough', async function () { + const startLine = 10 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(0)) + + sinon.assert.notCalled(commandsStub) + }) + + it('should not reject when edit suggestion is not active', async function () { + editSuggestionStateStub.returns(false) + + const startLine = 50 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(startLine + 100)) + + sinon.assert.notCalled(commandsStub) + }) +})