From a02cb5b77915db1c7c0175aa3ce3a9534d2f4ed9 Mon Sep 17 00:00:00 2001 From: F-G Fernandez <26927750+frgfm@users.noreply.github.com> Date: Sat, 4 Nov 2023 00:49:02 +0100 Subject: [PATCH] feat: Adds command to analyze code using Quack API (#12) * feat: Adds command to analyze code * fix: Fixed guideline order * style: Fixes linting --- media/dark/debug-rerun.svg | 1 + media/light/debug-rerun.svg | 1 + package.json | 72 ++++++++-- src/extension.ts | 238 +++++++++++++++++++++++++++++++++- src/quack.ts | 120 ++++++++++++++--- src/util/session.ts | 32 +++++ src/webviews/guidelineView.ts | 9 ++ 7 files changed, 437 insertions(+), 36 deletions(-) create mode 100644 media/dark/debug-rerun.svg create mode 100644 media/light/debug-rerun.svg diff --git a/media/dark/debug-rerun.svg b/media/dark/debug-rerun.svg new file mode 100644 index 0000000..1ff6b32 --- /dev/null +++ b/media/dark/debug-rerun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/light/debug-rerun.svg b/media/light/debug-rerun.svg new file mode 100644 index 0000000..caafcbc --- /dev/null +++ b/media/light/debug-rerun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index 211ab10..ae1259b 100644 --- a/package.json +++ b/package.json @@ -76,15 +76,6 @@ ] }, "commands": [ - { - "command": "quack-companion.fetchGuidelines", - "category": "Quack AI", - "title": "Fetch contribution guidelines", - "icon": { - "light": "media/light/refresh.svg", - "dark": "media/dark/refresh.svg" - } - }, { "command": "quack-companion.findStarterIssues", "category": "Quack AI", @@ -108,7 +99,34 @@ { "command": "quack-companion.loginQuack", "category": "Quack AI", - "title": "Log in with GitHub" + "title": "Log in with GitHub & Quack" + }, + { + "command": "quack-companion.fetchGuidelines", + "category": "Quack AI", + "title": "Fetch contribution guidelines", + "icon": { + "light": "media/light/refresh.svg", + "dark": "media/dark/refresh.svg" + } + }, + { + "command": "quack-companion.analyzeCode", + "category": "Quack AI", + "title": "Analyze code with Quack AI", + "icon": { + "light": "media/light/debug-rerun.svg", + "dark": "media/dark/debug-rerun.svg" + } + }, + { + "command": "quack-companion.analyzeCodeMonoGuideline", + "category": "Quack AI", + "title": "Analyze code snippet in respect to a specific guideline", + "icon": { + "light": "media/light/debug-rerun.svg", + "dark": "media/dark/debug-rerun.svg" + } } ], "viewsWelcome": [ @@ -124,12 +142,44 @@ "mac": "cmd+shift+g", "key": "ctrl+shift+g" }, + { + "command": "quack-companion.analyzeCode", + "mac": "cmd+shift+q", + "key": "ctrl+shift+q" + }, { "command": "quack-companion.debugInfo", "mac": "cmd+shift+d", "key": "ctrl+shift+d" } - ] + ], + "menus": { + "editor/context": [ + { + "command": "quack-companion.analyzeCode", + "when": "editorHasSelection && resourceLangId == 'python'" + } + ], + "view/title": [ + { + "command": "quack-companion.fetchGuidelines", + "when": "view == quack-companion.guidelineTreeView", + "group": "navigation" + }, + { + "command": "quack-companion.analyzeCode", + "when": "view == quack-companion.guidelineTreeView", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "quack-companion.analyzeCodeMonoGuideline", + "when": "view == quack-companion.guidelineTreeView && viewItem == guidelineTreeItem", + "group": "inline" + } + ] + } }, "scripts": { "vscode:prepublish": "yarn run compile", diff --git a/src/extension.ts b/src/extension.ts index 6b94bdf..3bfda8b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,9 +7,17 @@ import * as vscode from "vscode"; import * as os from "os"; import { v4 as uuidv4 } from "uuid"; import clipboardy from "clipboardy"; -import { GuidelineTreeProvider } from "./webviews/guidelineView"; +import { + GuidelineTreeItem, + GuidelineTreeProvider, +} from "./webviews/guidelineView"; import telemetryClient from "./telemetry"; -import { getCurrentRepoName } from "./util/session"; +import { + getCurrentRepoName, + getSelectionText, + getSelectionRange, + getEditor, +} from "./util/session"; import { getRepoDetails, fetchStarterIssues, @@ -18,8 +26,12 @@ import { searchIssues, } from "./util/github"; import { + analyzeSnippet, + checkSnippet, fetchRepoGuidelines, QuackGuideline, + ComplianceResult, + GuidelineCompliance, verifyQuackEndpoint, authenticate, } from "./quack"; @@ -71,6 +83,13 @@ export function activate(context: vscode.ExtensionContext) { starterStatusBar.text = "$(search-fuzzy) Find starter issues"; starterStatusBar.command = "quack-companion.findStarterIssues"; starterStatusBar.show(); + // Check compliance + const complianceStatusBar = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + ); + complianceStatusBar.text = "$(pass) Check compliance"; + complianceStatusBar.command = "quack-companion.checkCompliance"; + complianceStatusBar.show(); interface TransformedGuideline { id: number; @@ -78,6 +97,10 @@ export function activate(context: vscode.ExtensionContext) { detail: string; } + // Diagnostic/warning collection + const diagnosticCollection = + vscode.languages.createDiagnosticCollection("quack-companion"); + context.subscriptions.push( vscode.commands.registerCommand( "quack-companion.fetchGuidelines", @@ -125,6 +148,7 @@ export function activate(context: vscode.ExtensionContext) { repository: repoName, }, }); + diagnosticCollection.clear(); }, ), ); @@ -247,16 +271,212 @@ export function activate(context: vscode.ExtensionContext) { }), ); + context.subscriptions.push( + vscode.commands.registerCommand("quack-companion.analyzeCode", async () => { + // Snippet + const codeSnippet = getSelectionText(); + const repoName: string = await getCurrentRepoName(); + const ghRepo: GitHubRepo = await getRepoDetails(repoName); + // Status bar + const statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + ); + statusBarItem.text = `$(sync~spin) Processing...`; + statusBarItem.show(); + + // Guidelines + const cachedGuidelines: QuackGuideline[] | undefined = + context.workspaceState.get("quack-companion.repoGuidelines"); + var guidelines: QuackGuideline[] = []; + if (cachedGuidelines) { + guidelines = cachedGuidelines; + } else { + vscode.window.showErrorMessage("Please refresh guidelines"); + return; + } + + if (guidelines.length > 0) { + const endpoint: string = + context.workspaceState.get("quack-companion.endpointURL") || + "https://api.quackai.com"; + const quackToken = context.workspaceState.get( + "quack-companion.quackToken", + ); + if (!quackToken) { + vscode.window.showErrorMessage("Please authenticate"); + return; + } + // Check compliance + const complianceStatus: ComplianceResult[] = await analyzeSnippet( + ghRepo.id, + codeSnippet, + endpoint, + quackToken, + ); + const statusIndexMap: { [key: number]: number } = {}; + complianceStatus.forEach((item: ComplianceResult, index: number) => { + statusIndexMap[item.guideline_id] = index; + }); + diagnosticCollection.clear(); + // Notify the webview to update its content + guidelineTreeView.refresh( + guidelines.map((guideline: QuackGuideline, index: number) => ({ + ...guideline, + completed: + complianceStatus[statusIndexMap[guideline.id]].is_compliant, + })), + ); + // Send messages + const selectionRange = getSelectionRange(); + var diagnostics: vscode.Diagnostic[] = []; + const guidelineIndexMap: { [key: number]: number } = {}; + guidelines.forEach((item: QuackGuideline, index: number) => { + guidelineIndexMap[item.id] = index; + }); + complianceStatus.forEach((item: ComplianceResult, index: number) => { + if (!item.is_compliant) { + vscode.window.showWarningMessage( + guidelines[guidelineIndexMap[item.guideline_id]].title + + ". " + + item.comment, + ); + const diagnostic = new vscode.Diagnostic( + selectionRange, + guidelines[guidelineIndexMap[item.guideline_id]].title + + "\n\n" + + item.comment, + vscode.DiagnosticSeverity.Warning, + ); + diagnostic.source = "Quack Companion"; + // diagnostic.code = guidelines[index].title; + // // Add the replacement + // const relatedInfo = new vscode.DiagnosticRelatedInformation( + // new vscode.Location( + // editor.document.uri, + // selectionRange, + // ), + // item.suggestion, + // ); + // diagnostic.relatedInformation = [relatedInfo]; + diagnostics.push(diagnostic); + } + }); + diagnosticCollection.set(getEditor().document.uri, diagnostics); + } + statusBarItem.dispose(); + // console.log(vscode.window.activeTextEditor?.document.languageId); + + // Telemetry + telemetryClient?.capture({ + distinctId: userId, + event: "vscode-analyze-code", + properties: { + repository: repoName, + }, + }); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "quack-companion.analyzeCodeMonoGuideline", + async (item: GuidelineTreeItem) => { + if (item) { + // Snippet + const codeSnippet = getSelectionText(); + // API prep + const endpoint: string = + context.workspaceState.get("quack-companion.endpointURL") || + "https://api.quackai.com"; + const quackToken = context.workspaceState.get( + "quack-companion.quackToken", + ); + if (!quackToken) { + vscode.window.showErrorMessage("Please authenticate"); + return; + } + // Status bar + const statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + ); + statusBarItem.text = `$(sync~spin) Processing...`; + statusBarItem.show(); + // Request + const complianceStatus: ComplianceResult = await checkSnippet( + item.guideline.id, + codeSnippet, + endpoint, + quackToken, + ); + // + // Guidelines + const cachedGuidelines: QuackGuideline[] | undefined = + context.workspaceState.get("quack-companion.repoGuidelines"); + var guidelines: QuackGuideline[] = []; + if (cachedGuidelines) { + guidelines = cachedGuidelines; + } else { + vscode.window.showErrorMessage("Please refresh guidelines"); + return; + } + // Notify the webview to update its content + guidelineTreeView.refreshItem( + item.guideline.id, + complianceStatus.is_compliant, + ); + // Send messages + const selectionRange = getSelectionRange(); + var diagnostics: vscode.Diagnostic[] = []; + if (!complianceStatus.is_compliant) { + vscode.window.showWarningMessage( + item.guideline.title + ". " + complianceStatus.comment, + ); + const diagnostic = new vscode.Diagnostic( + selectionRange, + item.guideline.title + "\n\n" + complianceStatus.comment, + vscode.DiagnosticSeverity.Warning, + ); + diagnostic.source = "Quack Companion"; + diagnostics.push(diagnostic); + } + diagnosticCollection.set(getEditor().document.uri, diagnostics); + statusBarItem.dispose(); + // Telemetry + telemetryClient?.capture({ + distinctId: userId, + event: "vscode-analyze-code-mono", + properties: { + repository: await getCurrentRepoName(), + }, + }); + } else { + vscode.window.showErrorMessage("No guideline selected."); + } + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "quack-companion.resetQuackWarning", + async () => { + diagnosticCollection.clear(); + }, + ), + ); + context.subscriptions.push( vscode.commands.registerCommand("quack-companion.setEndpoint", async () => { // Get user input + const defaultEndpoint: string = + (await context.workspaceState.get("quack-companion.endpointURL")) || + "https://api.quackai.com/"; const quackEndpoint = await vscode.window.showInputBox({ prompt: "Enter the endpoint URL for Quack API", - placeHolder: "https://api.quackai.com/", + placeHolder: defaultEndpoint, ignoreFocusOut: true, // This keeps the input box open when focus is lost, which can prevent some confusion }); if (quackEndpoint) { - console.log(quackEndpoint); const isValid: boolean = await verifyQuackEndpoint(quackEndpoint); if (isValid) { // Update the global context state @@ -264,6 +484,12 @@ export function activate(context: vscode.ExtensionContext) { "quack-companion.endpointURL", quackEndpoint, ); + // Reset the token + await context.workspaceState.update( + "quack-companion.quackToken", + undefined, + ); + updateContext(context); vscode.window.showInformationMessage( "Quack endpoint set successfully", ); @@ -306,10 +532,10 @@ export function activate(context: vscode.ExtensionContext) { } }), ); - // Commands to be run when activating - vscode.commands.executeCommand("quack-companion.fetchGuidelines"); // Update context updateContext(context); + // Commands to be run when activating + vscode.commands.executeCommand("quack-companion.fetchGuidelines"); } export function deactivate() { diff --git a/src/quack.ts b/src/quack.ts index 98be40d..c7817a5 100644 --- a/src/quack.ts +++ b/src/quack.ts @@ -13,6 +13,18 @@ export interface QuackGuideline { details: string; } +export interface ComplianceResult { + guideline_id: number; + is_compliant: boolean; + comment: string; +} + +export interface GuidelineCompliance { + is_compliant: boolean; + comment: string; + // suggestion: string; +} + export async function verifyQuackEndpoint( endpointURL: string, ): Promise { @@ -31,11 +43,45 @@ export async function verifyQuackEndpoint( } } +export async function authenticate( + githubToken: string, + endpointURL: string, +): Promise { + const quackURL = new URL("/api/v1/login/token", endpointURL).toString(); + try { + // Retrieve the guidelines + const response: AxiosResponse = await axios.post(quackURL, { + github_token: githubToken, + }); + + // Handle the response + if (response.status === 200) { + return response.data.access_token; + } else { + // The request returned a non-200 status code (e.g., 404) + // Show an error message or handle the error accordingly + vscode.window.showErrorMessage( + `Quack API returned status code ${response.status}`, + ); + throw new Error("Unable to authenticate"); + } + } catch (error) { + // Handle other errors that may occur during the request + console.error("Error fetching repository details:", error); + + // Show an error message or handle the error accordingly + vscode.window.showErrorMessage( + "Failed to fetch repository details. Make sure the repository exists and is public.", + ); + throw new Error("Unable to authenticate"); + } +} + export async function fetchRepoGuidelines( repoId: number, endpointURL: string, token: string, -): Promise { +): Promise { const quackURL = new URL( `/api/v1/repos/${repoId}/guidelines`, endpointURL, @@ -57,7 +103,7 @@ export async function fetchRepoGuidelines( vscode.window.showErrorMessage( `Quack API returned status code ${response.status}`, ); - return null; // or throw an error, return an empty object, etc. + throw new Error("Unable to fetch guidelines"); } } catch (error) { // Handle other errors that may occur during the request @@ -67,40 +113,76 @@ export async function fetchRepoGuidelines( vscode.window.showErrorMessage( "Failed to fetch repository details. Make sure the repository exists and is public.", ); - return null; // or throw an error, return an empty object, etc. + throw new Error("Unable to fetch guidelines"); } } -export async function authenticate( - githubToken: string, +export async function analyzeSnippet( + repoId: number, + code: string, endpointURL: string, -): Promise { - const quackURL = new URL("/api/v1/login/token", endpointURL).toString(); + token: string, +): Promise { + const quackURL = new URL( + `/api/v1/compute/analyze/${repoId}`, + endpointURL, + ).toString(); try { - // Retrieve the guidelines - const response: AxiosResponse = await axios.post(quackURL, { - github_token: githubToken, - }); + const response: AxiosResponse = await axios.post( + quackURL, + { code: code }, + { headers: { Authorization: `Bearer ${token}` } }, + ); // Handle the response if (response.status === 200) { - return response.data.access_token; + return response.data; } else { // The request returned a non-200 status code (e.g., 404) - // Show an error message or handle the error accordingly vscode.window.showErrorMessage( `Quack API returned status code ${response.status}`, ); - return null; // or throw an error, return an empty object, etc. + throw new Error("Unable to analyze code"); } } catch (error) { // Handle other errors that may occur during the request - console.error("Error fetching repository details:", error); + console.error("Error sending Quack API request:", error); + vscode.window.showErrorMessage("Invalid API request."); + throw new Error("Unable to analyze code"); + } +} - // Show an error message or handle the error accordingly - vscode.window.showErrorMessage( - "Failed to fetch repository details. Make sure the repository exists and is public.", +export async function checkSnippet( + guidelineId: number, + code: string, + endpointURL: string, + token: string, +): Promise { + const quackURL = new URL( + `/api/v1/compute/check/${guidelineId}`, + endpointURL, + ).toString(); + try { + const response: AxiosResponse = await axios.post( + quackURL, + { code: code }, + { headers: { Authorization: `Bearer ${token}` } }, ); - return null; // or throw an error, return an empty object, etc. + + // Handle the response + if (response.status === 200) { + return response.data; + } else { + // The request returned a non-200 status code (e.g., 404) + vscode.window.showErrorMessage( + `Quack API returned status code ${response.status}`, + ); + throw new Error("Unable to analyze code"); + } + } catch (error) { + // Handle other errors that may occur during the request + console.error("Error sending Quack API request:", error); + vscode.window.showErrorMessage("Invalid API request."); + throw new Error("Unable to analyze code"); } } diff --git a/src/util/session.ts b/src/util/session.ts index 6c536fc..972a662 100644 --- a/src/util/session.ts +++ b/src/util/session.ts @@ -56,3 +56,35 @@ export async function getCurrentRepoName(): Promise { }); }); } + +export function getEditor(): vscode.TextEditor { + // Snippet + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage("No active editor."); + throw new Error("No active editor."); + } + return editor; +} + +export function getSelectionText(): string { + // Snippet + const editor = getEditor(); + const selectionText = editor.document.getText(editor.selection); + if (selectionText.length === 0) { + vscode.window.showWarningMessage("No snippet selected."); + throw new Error("No snippet selected."); + } + return selectionText; +} + +export function getSelectionRange(): vscode.Range { + // Snippet + const editor = getEditor(); + return new vscode.Range( + editor.selection.start.line, + editor.selection.start.character, + editor.selection.end.line, + editor.selection.end.character, + ); +} diff --git a/src/webviews/guidelineView.ts b/src/webviews/guidelineView.ts index 8414aec..da53a06 100644 --- a/src/webviews/guidelineView.ts +++ b/src/webviews/guidelineView.ts @@ -76,6 +76,15 @@ export class GuidelineTreeProvider this._onDidChangeTreeData.fire(); } + refreshItem(guidelineId: number, completed: boolean): void { + let item = this._guidelineItems.find((g) => g.guideline.id === guidelineId); + if (item) { + item.guideline.completed = completed; + this._guidelineItems.forEach((item) => item.updateIconPath()); + this._onDidChangeTreeData.fire(); + } + } + getTreeItem(element: GuidelineTreeItem): vscode.TreeItem { return element; }