diff --git a/.config/Taskfile_shared.yml b/.config/Taskfile_shared.yml index 731b36b70..11a06b07c 100644 --- a/.config/Taskfile_shared.yml +++ b/.config/Taskfile_shared.yml @@ -7,6 +7,8 @@ env: &env vars: HOSTNAME: sh: echo ${HOSTNAME:-${HOST:-$(hostname)}} + PYTHON3: + sh: echo $VIRTUAL_ENV/bin/python3 tasks: setup: desc: Install dependencies @@ -22,6 +24,7 @@ tasks: - .config/requirements.in generates: - out/log/manifest-{{.HOSTNAME}}.yml + - "{{.PYTHON3}}" run: once interactive: true install: diff --git a/.config/dictionary.txt b/.config/dictionary.txt index c68e15775..07d43b53a 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -112,6 +112,7 @@ groovylint haaaad hbenl hostvars +hostsvars hotfixes iam icontent diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b12cc0508..ca2f4eab1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,26 +1,9 @@ version: 2 updates: - package-ecosystem: npm - directory: / - schedule: - interval: weekly - labels: - - dependabot-deps-updates - - skip-changelog - versioning-strategy: increase - open-pull-requests-limit: 2 - groups: - dependencies: - patterns: - - "*" - exclude-patterns: - - "@types/vscode" - ignore: - # requires ESM https://github.com/ansible/vscode-ansible/issues/1225 - - dependency-name: chai - versions: ["5.x"] - - package-ecosystem: npm - directory: /packages/ansible-language-server/ + directories: + - "/" + - "/packages/ansible-language-server/" schedule: interval: weekly labels: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 11b36dc40..e7ff4837d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,6 +36,9 @@ jobs: defaults: run: shell: ${{ matrix.shell || 'bash'}} + permissions: + id-token: write + checks: read # The type of runner that the job will run on runs-on: ${{ matrix.os || 'ubuntu-22.04' }} # see https://github.com/containers/podman/issues/13609 @@ -263,10 +266,10 @@ jobs: uses: codecov/codecov-action@v4 with: name: ${{ matrix.name }} - token: ${{ secrets.CODECOV_TOKEN }} files: out/coverage/lcov.info flags: unit fail_ci_if_error: true + use_oidc: true # cspell:ignore oidc - name: Upload vsix artifact if: ${{ matrix.name == 'test' }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a6a40e107..4aefcdd01 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ exclude: > minimum_pre_commit_version: 2.9.0 # types_or repos: - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v8.7.0 + rev: v8.8.0 hooks: - id: cspell # name: Spell check with cspell @@ -157,7 +157,7 @@ repos: docs/als/settings.md )$ - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.39.0 + rev: v0.40.0 hooks: - id: markdownlint exclude: > @@ -165,7 +165,7 @@ repos: docs/als/settings.md $ - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook - rev: v9.14.0 + rev: v9.16.0 hooks: - id: commitlint stages: [commit-msg] diff --git a/Taskfile.yml b/Taskfile.yml index 675232bca..10f3056c2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -110,6 +110,7 @@ tasks: desc: Lint the project cmds: - task: install + - packages/ansible-language-server/tools/can-release.sh - $VIRTUAL_ENV/bin/python3 ./tools/precheck.py - $VIRTUAL_ENV/bin/python3 -m pre_commit run -a - tools/dirty.sh diff --git a/examples/playbooks/lint-issue.yml b/examples/playbooks/lint-issue.yml new file mode 100644 index 000000000..d19a7862b --- /dev/null +++ b/examples/playbooks/lint-issue.yml @@ -0,0 +1,6 @@ +--- +- name: example + hosts: "{{ hostsvars['sss'] }}" + tasks: + - name: hello world + hello.world: diff --git a/media/playbookGeneration/style.css b/media/playbookGeneration/style.css index 00bc2edf0..815965608 100644 --- a/media/playbookGeneration/style.css +++ b/media/playbookGeneration/style.css @@ -1,3 +1,23 @@ +a:link { + text-decoration: none; +} + +a:visited { + text-decoration: none; +} + +a:hover { + text-decoration: none; +} + +a:active { + text-decoration: none; +} + +.backAnchor { + cursor: pointer; +} + .playbookGeneration { right: 0px; bottom: 0px; @@ -152,6 +172,12 @@ display: none; } +.promptContainer { + margin: 1em; + display: none; + color: var(--vscode-descriptionForeground); +} + .examplesContainer { margin-top: 1em; } @@ -167,6 +193,7 @@ border-radius: 8px; width: fit-content; max-width: 90%; + color: var(--vscode-descriptionForeground); } .continueButtonContainer { diff --git a/packages/ansible-language-server/Taskfile.yml b/packages/ansible-language-server/Taskfile.yml index db5d9c5a6..b1f9d6667 100644 --- a/packages/ansible-language-server/Taskfile.yml +++ b/packages/ansible-language-server/Taskfile.yml @@ -116,7 +116,6 @@ tasks: - task: build - rm -f {{ .TASKFILE_DIR }}/*.tgz - yarn pack --out '{{ .TASKFILE_DIR }}/%s-%v.tgz' - - ./tools/can-release.sh silent: false release: desc: Create a new release (used by CI) diff --git a/packages/ansible-language-server/src/ansibleLanguageService.ts b/packages/ansible-language-server/src/ansibleLanguageService.ts index 14cae940d..c940d9e2d 100644 --- a/packages/ansible-language-server/src/ansibleLanguageService.ts +++ b/packages/ansible-language-server/src/ansibleLanguageService.ts @@ -8,6 +8,7 @@ import { TextDocumentSyncKind, } from "vscode-languageserver"; import { TextDocument } from "vscode-languageserver-textdocument"; +import { v4 as uuidv4 } from "uuid"; import { doCompletion, doCompletionResolve, @@ -25,6 +26,11 @@ import { WorkspaceManager } from "./services/workspaceManager"; import { getAnsibleMetaData } from "./utils/getAnsibleMetaData"; import axios from "axios"; import { getBaseUri } from "./utils/webUtils"; +import { + ExplanationResponse, + GenerationResponse, + SummaryResponse, +} from "./interfaces/lightspeedApi"; /** * Initializes the connection and registers all lifecycle event handlers. @@ -354,77 +360,97 @@ export class AnsibleLanguageService { }, ); - this.connection.onRequest("playbook/explanation", async (params) => { - const accessToken: string = params["accessToken"]; - const URL: string = params["URL"]; - const content: string = params["content"]; - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }; - - const axiosInstance = axios.create({ - baseURL: `${getBaseUri(URL)}/api/v0`, - headers: headers, - }); + this.connection.onRequest( + "playbook/explanation", + async (params): Promise => { + const accessToken: string = params["accessToken"]; + const URL: string = params["URL"]; + const content: string = params["content"]; + const explanationId: string = params["explanationId"]; + + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }; - const explanation: string = await axiosInstance - .post("/ai/explanations/", { content: content }) - .then((response) => { - return response.data.content; + const axiosInstance = axios.create({ + baseURL: `${getBaseUri(URL)}/api/v0`, + headers: headers, }); - return explanation; - }); - - this.connection.onRequest("playbook/summary", async (params) => { - const accessToken: string = params["accessToken"]; - const URL: string = params["URL"]; - const content: string = params["content"]; + const result: ExplanationResponse = await axiosInstance + .post("/ai/explanations/", { + content: content, + explanationId: explanationId, + }) + .then((response) => { + return response.data; + }); + console.log(result); + + return result; + }, + ); - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }; + this.connection.onRequest( + "playbook/summary", + async (params): Promise => { + const accessToken: string = params["accessToken"]; + const URL: string = params["URL"]; + const content: string = params["content"]; - const axiosInstance = axios.create({ - baseURL: `${getBaseUri(URL)}/api/v0`, - headers: headers, - }); + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }; - const result: string = await axiosInstance - .post("/ai/summaries/", { content: content }) - .then((response) => { - return response.data.content; + const axiosInstance = axios.create({ + baseURL: `${getBaseUri(URL)}/api/v0`, + headers: headers, }); - return result; - }); + const result: SummaryResponse = await axiosInstance + .post("/ai/summaries/", { + content: content, + summaryId: uuidv4(), + }) + .then((response) => { + return response.data; + }); - this.connection.onRequest("playbook/generation", async (params) => { - const accessToken: string = params["accessToken"]; - const URL: string = params["URL"]; - const content: string = params["content"]; + return result; + }, + ); - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }; + this.connection.onRequest( + "playbook/generation", + async (params): Promise => { + const accessToken: string = params["accessToken"]; + const URL: string = params["URL"]; + const content: string = params["content"]; - const axiosInstance = axios.create({ - baseURL: `${getBaseUri(URL)}/api/v0`, - headers: headers, - }); + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }; - const result: string = await axiosInstance - .post("/ai/generations/", { content: content }) - .then((response) => { - return response.data.content; + const axiosInstance = axios.create({ + baseURL: `${getBaseUri(URL)}/api/v0`, + headers: headers, }); - return result; - }); + const result: GenerationResponse = await axiosInstance + .post("/ai/generations/", { + content: content, + generationId: uuidv4(), + }) + .then((response) => { + return response.data; + }); + + return result; + }, + ); } private handleError(error: unknown, contextName: string) { diff --git a/packages/ansible-language-server/src/interfaces/lightspeedApi.ts b/packages/ansible-language-server/src/interfaces/lightspeedApi.ts new file mode 100644 index 000000000..e675608d9 --- /dev/null +++ b/packages/ansible-language-server/src/interfaces/lightspeedApi.ts @@ -0,0 +1,20 @@ +/** + * Interface for Lightspeed playbook generation/explanation APIs + */ +export interface GenerationResponse { + content: string; + format: string; + generationId: string; +} + +export interface SummaryResponse { + content: string; + format: string; + summaryId: string; +} + +export interface ExplanationResponse { + content: string; + format: string; + explanationId: string; +} diff --git a/packages/ansible-language-server/src/services/ansibleLint.ts b/packages/ansible-language-server/src/services/ansibleLint.ts index 517bc797d..9df00f00b 100644 --- a/packages/ansible-language-server/src/services/ansibleLint.ts +++ b/packages/ansible-language-server/src/services/ansibleLint.ts @@ -163,13 +163,19 @@ export class AnsibleLint { typeof item.check_name === "string" && item.location && typeof item.location.path === "string" && - item.location.lines && - (item.location.lines.begin || - typeof item.location.lines.begin === "number") + ((item.location.positions && item.location.positions.begin) || + (item.location.lines && + (item.location.lines.begin || + typeof item.location.lines.begin === "number"))) ) { - const begin_line = - item.location.lines.begin.line || item.location.lines.begin || 1; - const begin_column = item.location.lines.begin.column || 1; + const begin_line = item.location.positions + ? item.location.positions.begin.line + : item.location.lines.begin.line || + item.location.lines.begin || + 1; + const begin_column = item.location.positions + ? item.location.positions.begin.column + : item.location.lines.begin.column || 1; const start: Position = { line: begin_line - 1, character: begin_column - 1, diff --git a/packages/ansible-language-server/test/fixtures/diagnostics/syntax_check_errors_in_ansible_lint.yml b/packages/ansible-language-server/test/fixtures/diagnostics/syntax_check_errors_in_ansible_lint.yml new file mode 100644 index 000000000..70dc39f43 --- /dev/null +++ b/packages/ansible-language-server/test/fixtures/diagnostics/syntax_check_errors_in_ansible_lint.yml @@ -0,0 +1,7 @@ +--- +- name: Example of syntax-check error reported by ansible-lint + hosts: "{{ hostsvars['sss'] }}" + tasks: + - name: Print hello world + ansible.builtin.ping: + data: "Hello, world" diff --git a/packages/ansible-language-server/test/providers/validationProvider.test.ts b/packages/ansible-language-server/test/providers/validationProvider.test.ts index 3f5a99e34..4108ecd9c 100644 --- a/packages/ansible-language-server/test/providers/validationProvider.test.ts +++ b/packages/ansible-language-server/test/providers/validationProvider.test.ts @@ -184,6 +184,43 @@ function testAnsibleLintErrors( ); } +function testAnsibleSyntaxCheckErrorsInAnsibleLint( + context: WorkspaceFolderContext | undefined, + validationManager: ValidationManager, + textDoc: TextDocument, + validationEnabled: boolean, +) { + const tests: testType[] = [ + { + name: "syntax-check errors in ansible-lint", + diagnosticReport: [ + { + severity: 1, + message: "--syntax-check", + range: { + start: { line: 1, character: 2 } as Position, + end: { + line: 1, + character: integer.MAX_VALUE, + } as Position, + }, + source: "Ansible", + }, + ], + }, + ]; + expect(context).to.not.be.undefined; + if (context) { + assertValidateTests( + tests, + context, + validationManager, + textDoc, + validationEnabled, + ); + } +} + function testAnsibleSyntaxCheckNoErrors( context: WorkspaceFolderContext | undefined, validationManager: ValidationManager, @@ -406,52 +443,119 @@ describe("doValidate()", () => { testAnsibleLintErrors(context, validationManager, textDoc, true); }); - }); - describe("Diagnostics using ansible-playbook --syntax-check", () => { - describe("no specific ansible lint errors", () => { - describe("With EE enabled @ee", () => { - before(async () => { - (await docSettings).validation.lint.enabled = false; - setFixtureAnsibleCollectionPathEnv( - "/home/runner/.ansible/collections:/usr/share/ansible", + describe("Syntax-check errors in ansible-lint", () => { + fixtureFilePath = + "diagnostics/syntax_check_errors_in_ansible_lint.yml"; + fixtureFileUri = resolveDocUri(fixtureFilePath); + context = workspaceManager.getContext(fixtureFileUri); + + textDoc = getDoc(fixtureFilePath); + expect(context).is.not.undefined; + if (context) { + docSettings = context.documentSettings.get(textDoc.uri); + + describe("With EE enabled @ee", () => { + before(async () => { + (await docSettings).validation.lint.enabled = false; + setFixtureAnsibleCollectionPathEnv( + "/home/runner/.ansible/collections:/usr/share/ansible", + ); + await enableExecutionEnvironmentSettings(docSettings); + }); + + testAnsibleSyntaxCheckErrorsInAnsibleLint( + context, + validationManager, + textDoc, + true, ); - await enableExecutionEnvironmentSettings(docSettings); + + after(async () => { + (await docSettings).validation.lint.enabled = true; + setFixtureAnsibleCollectionPathEnv(); + await disableExecutionEnvironmentSettings(docSettings); + }); }); - testAnsibleSyntaxCheckNoErrors( - context, - validationManager, - textDoc, - true, - ); + describe("With EE disabled", () => { + before(async () => { + (await docSettings).validation.lint.enabled = false; + setFixtureAnsibleCollectionPathEnv(); + await disableExecutionEnvironmentSettings(docSettings); + }); + testAnsibleSyntaxCheckErrorsInAnsibleLint( + context, + validationManager, + textDoc, + true, + ); + }); after(async () => { (await docSettings).validation.lint.enabled = true; setFixtureAnsibleCollectionPathEnv(); await disableExecutionEnvironmentSettings(docSettings); }); - }); + } + }); + }); - describe("With EE disabled", () => { - before(async () => { - (await docSettings).validation.lint.enabled = false; + describe("Diagnostics using ansible-playbook --syntax-check", () => { + describe("no specific ansible lint errors", () => { + fixtureFilePath = "diagnostics/lint_errors.yml"; + fixtureFileUri = resolveDocUri(fixtureFilePath); + context = workspaceManager.getContext(fixtureFileUri); + + textDoc = getDoc(fixtureFilePath); + expect(context).is.not.undefined; + + if (context) { + docSettings = context.documentSettings.get(textDoc.uri); + + describe("With EE enabled @ee", () => { + before(async () => { + (await docSettings).validation.lint.enabled = false; + setFixtureAnsibleCollectionPathEnv( + "/home/runner/.ansible/collections:/usr/share/ansible", + ); + await enableExecutionEnvironmentSettings(docSettings); + }); + + testAnsibleSyntaxCheckNoErrors( + context, + validationManager, + textDoc, + true, + ); + + after(async () => { + (await docSettings).validation.lint.enabled = true; + setFixtureAnsibleCollectionPathEnv(); + await disableExecutionEnvironmentSettings(docSettings); + }); + }); + + describe("With EE disabled", () => { + before(async () => { + (await docSettings).validation.lint.enabled = false; + setFixtureAnsibleCollectionPathEnv(); + await disableExecutionEnvironmentSettings(docSettings); + }); + + testAnsibleSyntaxCheckNoErrors( + context, + validationManager, + textDoc, + true, + ); + }); + after(async () => { + (await docSettings).validation.lint.enabled = true; setFixtureAnsibleCollectionPathEnv(); await disableExecutionEnvironmentSettings(docSettings); }); - - testAnsibleSyntaxCheckNoErrors( - context, - validationManager, - textDoc, - true, - ); - }); - after(async () => { - (await docSettings).validation.lint.enabled = true; - setFixtureAnsibleCollectionPathEnv(); - await disableExecutionEnvironmentSettings(docSettings); - }); + } }); describe("empty playbook", () => { diff --git a/src/definitions/lightspeed.ts b/src/definitions/lightspeed.ts index 2d858f447..b63962516 100644 --- a/src/definitions/lightspeed.ts +++ b/src/definitions/lightspeed.ts @@ -19,6 +19,11 @@ export enum UserAction { IGNORED = 2, // ignored the suggestion or didn't wait for suggestion to be displayed } +export enum ThumbsUpDownAction { + UP = 0, // Thumbs Up + DOWN = 1, //Thumbs Down +} + // eslint-disable-next-line @typescript-eslint/no-namespace export namespace LightSpeedCommands { export const LIGHTSPEED_AUTH_REQUEST = "ansible.lightspeed.oauth"; diff --git a/src/extension.ts b/src/extension.ts index 96fc47677..7f9553f7c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -68,6 +68,10 @@ import { LightspeedUser, AuthProviderType, } from "./features/lightspeed/lightspeedUser"; +import { + PlaybookOutlineEvent, + PlaybookExplanationEvent, +} from "./interfaces/lightspeed"; export let client: LanguageClient; export let lightSpeedManager: LightSpeedManager; @@ -555,9 +559,25 @@ export async function activate(context: ExtensionContext): Promise { ); context.subscriptions.push( - vscode.commands.registerCommand("ansible.lightspeed.thumbsUpDown", () => { - window.showInformationMessage("Thank you for your feedback!"); - }), + vscode.commands.registerCommand( + "ansible.lightspeed.thumbsUpDown", + async (param: PlaybookOutlineEvent | PlaybookExplanationEvent) => { + if ("outlineId" in param) { + lightSpeedManager.apiInstance.feedbackRequest( + { playbookOutlineFeedback: param }, + true, + true, + ); + } + if ("explanationId" in param) { + lightSpeedManager.apiInstance.feedbackRequest( + { playbookExplanationFeedback: param }, + true, + true, + ); + } + }, + ), ); context.subscriptions.push( diff --git a/src/features/lightspeed/playbookExplanation.ts b/src/features/lightspeed/playbookExplanation.ts index 24391f2ad..9cd16e6d5 100644 --- a/src/features/lightspeed/playbookExplanation.ts +++ b/src/features/lightspeed/playbookExplanation.ts @@ -6,6 +6,8 @@ import * as marked from "marked"; import { SettingsManager } from "../../settings"; import { lightSpeedManager } from "../../extension"; import { LightspeedUser } from "./lightspeedUser"; +import { ExplanationResponse } from "@ansible/ansible-language-server/src/interfaces/lightspeedApi"; +import { v4 as uuidv4 } from "uuid"; export const playbookExplanation = async ( extensionUri: vscode.Uri, @@ -20,7 +22,11 @@ export const playbookExplanation = async ( if (document?.languageId !== "ansible") { return; } - const currentPanel = PlaybookExplanationPanel.createOrShow(extensionUri); + const explanationId = uuidv4(); + const currentPanel = PlaybookExplanationPanel.createOrShow( + extensionUri, + explanationId, + ); currentPanel.setContent( `
@@ -37,11 +43,16 @@ export const playbookExplanation = async ( let markdown = ""; lightSpeedManager.statusBarProvider.statusBar.text = `$(loading~spin) ${lightSpeedStatusbarText}`; try { - markdown = await client.sendRequest("playbook/explanation", { - accessToken: accessToken, - URL: settingsManager.settings.lightSpeedService.URL, - content: content, - }); + const response: ExplanationResponse = await client.sendRequest( + "playbook/explanation", + { + accessToken: accessToken, + URL: settingsManager.settings.lightSpeedService.URL, + content: content, + explanationId: explanationId, + }, + ); + markdown = response.content; } catch (e) { console.log(e); currentPanel.setContent( @@ -66,7 +77,7 @@ export class PlaybookExplanationPanel { private readonly _extensionUri: vscode.Uri; private _disposables: vscode.Disposable[] = []; - public static createOrShow(extensionUri: vscode.Uri) { + public static createOrShow(extensionUri: vscode.Uri, explanationId: string) { const panel = vscode.window.createWebviewPanel( PlaybookExplanationPanel.viewType, "Explanation", @@ -87,7 +98,10 @@ export class PlaybookExplanationPanel { switch (command) { case "thumbsUp": case "thumbsDown": - vscode.commands.executeCommand("ansible.lightspeed.thumbsUpDown"); + vscode.commands.executeCommand("ansible.lightspeed.thumbsUpDown", { + action: message.action, + explanationId: explanationId, + }); break; } }); @@ -112,14 +126,11 @@ export class PlaybookExplanationPanel { ); } - public setContent(html_snippet: string, showFeedbackBox = false) { - this._panel.webview.html = this.buildFullHtml( - html_snippet, - showFeedbackBox, - ); + public setContent(htmlSnippet: string, showFeedbackBox = false) { + this._panel.webview.html = this.buildFullHtml(htmlSnippet, showFeedbackBox); } - private buildFullHtml(html_snippet: string, showFeedbackBox = false) { + private buildFullHtml(htmlSnippet: string, showFeedbackBox = false) { const webview = this._panel.webview; const webviewUri = getUri(webview, this._extensionUri, [ "out", @@ -167,7 +178,7 @@ export class PlaybookExplanationPanel {
- ${html_snippet} + ${htmlSnippet}
${showFeedbackBox ? feedbackBoxSnippet : ""} diff --git a/src/features/lightspeed/playbookGeneration.ts b/src/features/lightspeed/playbookGeneration.ts index 24fea796b..4d8b3276c 100644 --- a/src/features/lightspeed/playbookGeneration.ts +++ b/src/features/lightspeed/playbookGeneration.ts @@ -6,6 +6,10 @@ import { getUri } from "../utils/getUri"; import { SettingsManager } from "../../settings"; import { isLightspeedEnabled } from "../../extension"; import { LightspeedUser } from "./lightspeedUser"; +import { + GenerationResponse, + SummaryResponse, +} from "@ansible/ansible-language-server/src/interfaces/lightspeedApi"; async function openNewPlaybookEditor(playbook: string) { const options = { @@ -33,18 +37,21 @@ async function generatePlaybook( lightspeedAuthenticatedUser: LightspeedUser, settingsManager: SettingsManager, panel: vscode.WebviewPanel, -) { +): Promise { const accessToken = await lightspeedAuthenticatedUser.getLightspeedUserAccessToken(); if (!accessToken) { panel.webview.postMessage({ command: "exception" }); } - const playbook: string = await client.sendRequest("playbook/generation", { - accessToken, - URL: settingsManager.settings.lightSpeedService.URL, - content, - }); + const playbook: GenerationResponse = await client.sendRequest( + "playbook/generation", + { + accessToken, + URL: settingsManager.settings.lightSpeedService.URL, + content, + }, + ); return playbook; } @@ -55,18 +62,21 @@ async function summarizeInput( lightspeedAuthenticatedUser: LightspeedUser, settingsManager: SettingsManager, panel: vscode.WebviewPanel, -) { +): Promise { const accessToken = await lightspeedAuthenticatedUser.getLightspeedUserAccessToken(); if (!accessToken) { panel.webview.postMessage({ command: "exception" }); } - const summary: string = await client.sendRequest("playbook/summary", { - accessToken, - URL: settingsManager.settings.lightSpeedService.URL, - content, - }); + const summary: SummaryResponse = await client.sendRequest( + "playbook/summary", + { + accessToken, + URL: settingsManager.settings.lightSpeedService.URL, + content, + }, + ); return summary; } @@ -112,7 +122,7 @@ export async function showPlaybookGenerationPage( panel, ); panel?.dispose(); - await openNewPlaybookEditor(playbook); + await openNewPlaybookEditor(playbook.content); break; } case "summarizeInput": { @@ -129,7 +139,10 @@ export async function showPlaybookGenerationPage( } case "thumbsUp": case "thumbsDown": - vscode.commands.executeCommand("ansible.lightspeed.thumbsUpDown"); + vscode.commands.executeCommand("ansible.lightspeed.thumbsUpDown", { + action: message.action, + outlineId: message.outlineId, + }); break; } }); @@ -185,9 +198,15 @@ export function getWebviewContent(webview: Webview, extensionUri: Uri) {

Do the following steps look right to you?

+
+ + ""  + Edit + +
@@ -201,7 +220,7 @@ export function getWebviewContent(webview: Webview, extensionUri: Uri) {
- + Reset
diff --git a/src/interfaces/lightspeed.ts b/src/interfaces/lightspeed.ts index 03eab3518..874e4ad4d 100644 --- a/src/interfaces/lightspeed.ts +++ b/src/interfaces/lightspeed.ts @@ -1,5 +1,9 @@ import { AuthenticationSession } from "vscode"; -import { LIGHTSPEED_USER_TYPE, UserAction } from "../definitions/lightspeed"; +import { + LIGHTSPEED_USER_TYPE, + ThumbsUpDownAction, + UserAction, +} from "../definitions/lightspeed"; export interface LightspeedAuthSession extends AuthenticationSession { rhUserHasSeat: boolean; @@ -67,11 +71,23 @@ export interface IssueFeedbackEvent { description: string; } +export interface PlaybookOutlineEvent { + action: ThumbsUpDownAction; + outlineId: string; +} + +export interface PlaybookExplanationEvent { + action: ThumbsUpDownAction; + outlineId: string; +} + export interface FeedbackRequestParams { inlineSuggestion?: InlineSuggestionEvent; sentimentFeedback?: SentimentEvent; suggestionQualityFeedback?: SuggestionQualityEvent; issueFeedback?: IssueFeedbackEvent; + playbookExplanationFeedback?: PlaybookExplanationEvent; + playbookOutlineFeedback?: PlaybookOutlineEvent; model?: string; } diff --git a/src/webview/apps/lightspeed/playbookExplanation/main.ts b/src/webview/apps/lightspeed/playbookExplanation/main.ts index bba41e357..cd323b079 100644 --- a/src/webview/apps/lightspeed/playbookExplanation/main.ts +++ b/src/webview/apps/lightspeed/playbookExplanation/main.ts @@ -3,6 +3,7 @@ import { Button, vsCodeButton, } from "@vscode/webview-ui-toolkit"; +import { ThumbsUpDownAction } from "../../../../definitions/lightspeed"; provideVSCodeDesignSystem().register(vsCodeButton()); @@ -31,11 +32,28 @@ function changeDisplay(className: string, displayState: string) { element.style.display = displayState; } } - function sendThumbsup() { - vscode.postMessage({ command: "thumbsUp" }); + const thumbsUpButton = document.getElementById("thumbsup-button") as Button; + const thumbsDownButton = document.getElementById( + "thumbsdown-button", + ) as Button; + thumbsUpButton.setAttribute("class", "iconButtonSelected"); + thumbsDownButton.setAttribute("class", "iconButton"); + vscode.postMessage({ + command: "thumbsUp", + action: ThumbsUpDownAction.UP, + }); } function sendThumbsdown() { - vscode.postMessage({ command: "thumbsDown" }); + const thumbsUpButton = document.getElementById("thumbsup-button") as Button; + const thumbsDownButton = document.getElementById( + "thumbsdown-button", + ) as Button; + thumbsUpButton.setAttribute("class", "iconButton"); + thumbsDownButton.setAttribute("class", "iconButtonSelected"); + vscode.postMessage({ + command: "thumbsDown", + action: ThumbsUpDownAction.DOWN, + }); } diff --git a/src/webview/apps/lightspeed/playbookGeneration/main.ts b/src/webview/apps/lightspeed/playbookGeneration/main.ts index e287ac4e4..efd3dc4e7 100644 --- a/src/webview/apps/lightspeed/playbookGeneration/main.ts +++ b/src/webview/apps/lightspeed/playbookGeneration/main.ts @@ -7,6 +7,7 @@ import { vsCodeTextField, TextArea, } from "@vscode/webview-ui-toolkit"; +import { ThumbsUpDownAction } from "../../../../definitions/lightspeed"; provideVSCodeDesignSystem().register( vsCodeButton(), @@ -20,6 +21,7 @@ const TEXTAREA_MAX_HEIGHT = 500; let savedInput: string; let savedInputHeight: string | undefined; let savedSummary: string; +let outlineId: string | undefined; const vscode = acquireVsCodeApi(); @@ -30,6 +32,7 @@ window.addEventListener("load", () => { setListener("thumbsup-button", sendThumbsup); setListener("thumbsdown-button", sendThumbsdown); setListener("back-button", back); + setListener("back-anchor", back); setListenerOnTextArea(); @@ -47,9 +50,6 @@ window.addEventListener("message", (event) => { break; } case "summary": { - const button = document.getElementById("submit-icon") as Button; - button.setAttribute("class", "codicon codicon-run-all"); - changeDisplay("spinnerContainer", "none"); changeDisplay("bigIconButtonContainer", "none"); changeDisplay("examplesContainer", "none"); @@ -57,11 +57,19 @@ window.addEventListener("message", (event) => { changeDisplay("firstMessage", "none"); changeDisplay("secondMessage", "block"); changeDisplay("generatePlaybookContainer", "block"); + changeDisplay("promptContainer", "block"); + + updateThumbsUpDownButtons(false, false); const element = document.getElementById("playbook-text-area") as TextArea; - savedSummary = element.value = message.summary; + savedSummary = element.value = message.summary.content; + outlineId = message.summary.summaryId; resetTextAreaHeight(); - element.rows = 25; + + const prompt = document.getElementById("prompt") as HTMLSpanElement; + prompt.textContent = savedInput; + + element.rows = 20; break; } @@ -87,11 +95,16 @@ function setListener(id: string, func: any) { function setListenerOnTextArea() { const textArea = document.getElementById("playbook-text-area") as TextArea; const submitButton = document.getElementById("submit-button") as Button; + const resetButton = document.getElementById("reset-button") as Button; if (textArea) { textArea.addEventListener("input", async () => { const input = textArea.value; submitButton.disabled = input.length === 0; + if (savedSummary) { + resetButton.disabled = savedSummary === input; + } + adjustTextAreaHeight(); }); } @@ -129,6 +142,7 @@ function back() { changeDisplay("firstMessage", "block"); changeDisplay("secondMessage", "none"); changeDisplay("generatePlaybookContainer", "none"); + changeDisplay("promptContainer", "none"); const element = document.getElementById("playbook-text-area") as TextArea; if (savedInput) { @@ -149,24 +163,38 @@ async function generatePlaybook() { vscode.postMessage({ command: "generatePlaybook", content }); } -function sendThumbsup() { +function updateThumbsUpDownButtons(selectUp: boolean, selectDown: boolean) { const thumbsUpButton = document.getElementById("thumbsup-button") as Button; const thumbsDownButton = document.getElementById( "thumbsdown-button", ) as Button; - thumbsUpButton.setAttribute("class", "iconButtonSelected"); - thumbsDownButton.setAttribute("class", "iconButton"); - vscode.postMessage({ command: "thumbsUp" }); + thumbsUpButton.setAttribute( + "class", + selectUp ? "iconButtonSelected" : "iconButton", + ); + thumbsDownButton.setAttribute( + "class", + selectDown ? "iconButtonSelected" : "iconButton", + ); + thumbsUpButton.disabled = thumbsDownButton.disabled = selectUp || selectDown; +} + +function sendThumbsup() { + updateThumbsUpDownButtons(true, false); + vscode.postMessage({ + command: "thumbsUp", + action: ThumbsUpDownAction.UP, + outlineId, + }); } function sendThumbsdown() { - const thumbsUpButton = document.getElementById("thumbsup-button") as Button; - const thumbsDownButton = document.getElementById( - "thumbsdown-button", - ) as Button; - thumbsUpButton.setAttribute("class", "iconButton"); - thumbsDownButton.setAttribute("class", "iconButtonSelected"); - vscode.postMessage({ command: "thumbsDown" }); + updateThumbsUpDownButtons(false, true); + vscode.postMessage({ + command: "thumbsDown", + action: ThumbsUpDownAction.DOWN, + outlineId, + }); } function getTextAreaInShadowDOM() { diff --git a/test/helper.ts b/test/helper.ts index db006a5f9..3eea9e05d 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -21,7 +21,7 @@ export const ANSIBLE_COLLECTIONS_FIXTURES_BASE_PATH = path.resolve( ); const LIGHTSPEED_ACCESS_TOKEN = process.env.LIGHTSPEED_ACCESS_TOKEN || "dummy"; const LIGHTSPEED_INLINE_SUGGESTION_WAIT_TIME = - LIGHTSPEED_ACCESS_TOKEN === "dummy" ? 1000 : 10000; + LIGHTSPEED_ACCESS_TOKEN === "dummy" ? 2000 : 10000; const LIGHTSPEED_INLINE_SUGGESTION_AFTER_COMMIT_WAIT_TIME = LIGHTSPEED_ACCESS_TOKEN === "dummy" ? 200 : 2000; const LIGHTSPEED_INLINE_SUGGESTION_AFTER_TRIGGER_WAIT_TIME = 100; diff --git a/test/mockLightspeedServer/server.ts b/test/mockLightspeedServer/server.ts index f6cd8e265..57747b4bf 100644 --- a/test/mockLightspeedServer/server.ts +++ b/test/mockLightspeedServer/server.ts @@ -49,22 +49,22 @@ export default class Server { app.get("/", (req, res) => res.send("Lightspeed Mock")); app.post(`${API_ROOT}/ai/completions`, async (req, res) => { - await new Promise((r) => setTimeout(r, 500)); // fake 500ms latency + await new Promise((r) => setTimeout(r, 1000)); // fake 1s latency return completions(req, res); }); app.post(`${API_ROOT}/ai/contentmatches`, async (req, res) => { - await new Promise((r) => setTimeout(r, 500)); // fake 500ms latency + await new Promise((r) => setTimeout(r, 1000)); // fake 1s latency return res.send(contentmatches(req)); }); app.post(`${API_ROOT}/ai/summaries`, async (req, res) => { - await new Promise((r) => setTimeout(r, 500)); // fake 500ms latency + await new Promise((r) => setTimeout(r, 1000)); // fake 1s latency return summaries(req, res); }); app.post(`${API_ROOT}/ai/generations`, async (req, res) => { - await new Promise((r) => setTimeout(r, 500)); // fake 500ms latency + await new Promise((r) => setTimeout(r, 1000)); // fake 1s latency return generations(req, res); }); diff --git a/test/testScripts/lightspeed/e2eInlineSuggestion.test.ts b/test/testScripts/lightspeed/e2eInlineSuggestion.test.ts index da2811945..a5fa8751c 100644 --- a/test/testScripts/lightspeed/e2eInlineSuggestion.test.ts +++ b/test/testScripts/lightspeed/e2eInlineSuggestion.test.ts @@ -27,7 +27,7 @@ import { integer } from "vscode-languageclient"; const INSERT_TEXT = "**** I'm not a pilot ****"; -const LIGHTSPEED_INLINE_SUGGESTION_WAIT_TIME = 1000; +const LIGHTSPEED_INLINE_SUGGESTION_WAIT_TIME = 2000; const LIGHTSPEED_INLINE_SUGGESTION_AFTER_COMMIT_WAIT_TIME = 200; const LIGHTSPEED_INLINE_SUGGESTION_AFTER_IGNORE_WAIT_TIME = LIGHTSPEED_INLINE_SUGGESTION_AFTER_COMMIT_WAIT_TIME; diff --git a/test/ui-test/lightspeedUiTest.ts b/test/ui-test/lightspeedUiTest.ts index cf1b20aae..b4d316843 100644 --- a/test/ui-test/lightspeedUiTest.ts +++ b/test/ui-test/lightspeedUiTest.ts @@ -201,12 +201,21 @@ export function lightspeedUIAssetsTest(): void { setTimeout(res, 1000); }); - // Test Reset button + // Verify summary output and text edit let text = await textArea.getText(); expect(text.includes('Name: "Create an azure network..."')); await textArea.sendKeys("# COMMENT\n"); text = await textArea.getText(); expect(text.includes("# COMMENT\n")); + + // Verify the prompt is displayed as a static text + const prompt = await webView.findWebElement( + By.xpath("//span[@id='prompt']"), + ); + text = await prompt.getText(); + expect(text.includes("Create an azure network.")); + + // Test Reset button const resetButton = await webView.findWebElement( By.xpath("//vscode-button[@id='reset-button']"), ); @@ -219,7 +228,34 @@ export function lightspeedUIAssetsTest(): void { text = await textArea.getText(); expect(!text.includes("# COMMENT\n")); + // Test ThumbsUp button + const thumbsUpButton = await webView.findWebElement( + By.xpath("//vscode-button[@id='thumbsup-button']"), + ); + expect(thumbsUpButton, "thumbsUpButton should not be undefined").not.to + .be.undefined; + expect( + await thumbsUpButton.isEnabled(), + "thumbsUpButton should be enabled", + ); + thumbsUpButton.click(); + await new Promise((res) => { + setTimeout(res, 500); + }); + expect( + !thumbsUpButton.isEnabled, + "thumbsUpButton should not be enabled", + ); + + await webView.switchBack(); + let notifications = await workbench.getNotifications(); + let notification = notifications[0]; + let message = await notification.getMessage(); + expect(message).equals("Thanks for your feedback!"); + await notification.dismiss(); + // Test Back button + await webView.switchToFrame(5000); const backButton = await webView.findWebElement( By.xpath("//vscode-button[@id='back-button']"), ); @@ -239,6 +275,50 @@ export function lightspeedUIAssetsTest(): void { text = await textArea.getText(); expect(text.includes('Name: "Create an azure network..."')); + // Test ThumbsDown button + const thumbsDownButton = await webView.findWebElement( + By.xpath("//vscode-button[@id='thumbsdown-button']"), + ); + expect(thumbsDownButton, "thumbsDownButton should not be undefined").not + .to.be.undefined; + expect( + await thumbsUpButton.isEnabled(), + "thumbsDownButton should be enabled", + ); + thumbsUpButton.click(); + await new Promise((res) => { + setTimeout(res, 500); + }); + expect(!thumbsUpButton.isEnabled, "ThumbsDown should not be enabled"); + + await webView.switchBack(); + notifications = await workbench.getNotifications(); + notification = notifications[0]; + message = await notification.getMessage(); + expect(message).equals("Thanks for your feedback!"); + await notification.dismiss(); + + // Test Edit link next to the prompt text + await webView.switchToFrame(5000); + const backAnchor = await webView.findWebElement( + By.xpath("//a[@id='back-anchor']"), + ); + expect(backButton, "backButton should not be undefined").not.to.be + .undefined; + backAnchor.click(); + await new Promise((res) => { + setTimeout(res, 500); + }); + + text = await textArea.getText(); + expect(text.startsWith("Create an azure network.")); + submitButton.click(); + await new Promise((res) => { + setTimeout(res, 1000); + }); + text = await textArea.getText(); + expect(text.includes('Name: "Create an azure network..."')); + // Click Generate playbook button to invoke the generations API const generatePlaybookButton = await webView.findWebElement( By.xpath("//vscode-button[@id='generate-button']"), @@ -279,7 +359,7 @@ export function lightspeedUIAssetsTest(): void { // Open playbook explanation webview. await workbench.executeCommand( - "Ansible Lightspeed: Playbook explanation", + "Explain the playbook with Ansible Lightspeed", ); await new Promise((res) => { setTimeout(res, 2000);