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;
}