Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add advisor preview #127

Merged
merged 1 commit into from
Apr 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 22 additions & 22 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 10 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,13 @@
"reportFalsePositives": {
"type": "boolean",
"title": "Enable \"report false positives\"",
"description": "Allows reporting false positives for Snyk Code suggestions",
"description": "Allows reporting false positives for Snyk Code suggestions.",
"default": false
},
"advisor": {
"type": "boolean",
"title": "Enable \"Snyk Advisor\"",
"description": "Discover the health (maintenance, community, popularity & security) status of your open source packages.",
"default": false
}
}
Expand Down Expand Up @@ -362,8 +368,9 @@
"watch-resources": "sass media --no-source-map --watch",
"watch-all": "concurrently --kill-others 'npm run watch' 'npm run watch-resources'",
"pretest": "tsc -p ./",
"test:unit": "mocha --ui tdd -c 'out/test/unit/**/*.test.js'",
"test:unit:watch": "mocha --ui tdd -w -c 'out/test/unit/**/*.test.js'",
"test:unit:single": "mocha --ui tdd --require ts-node/register",
"test:unit": "npm run build && mocha --ui tdd -c 'out/test/unit/**/*.test.js'",
sangress marked this conversation as resolved.
Show resolved Hide resolved
"test:unit:watch": "npm run build && mocha --ui tdd -w -c 'out/test/unit/**/*.test.js'",
"test:integration": "node ./out/test/integration/runTest.js",
"lint": "npx eslint \"src/**/*.ts\"",
"lint:fix": "npx eslint --fix \"src/**/*.ts\"",
Expand Down
15 changes: 15 additions & 0 deletions src/snyk/advisor/advisorTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type AdvisorScoreLabel = {
popularity: string;
maintenance: string;
community: string;
security: string;
};

export type AdvisorScore = {
name: string;
score: number;
pending: boolean;
labels: AdvisorScoreLabel;
} | null;

export type AdvisorRegistry = 'npm-package' | 'python';
94 changes: 94 additions & 0 deletions src/snyk/advisor/editor/editorDecorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { getRenderOptions, LineDecorations, updateDecorations } from '../../common/editor/editorDecorator';
import { HoverAdapter } from '../../common/vscode/hover';
import { IVSCodeLanguages } from '../../common/vscode/languages';
import { IMarkdownStringAdapter } from '../../common/vscode/markdownString';
import { IThemeColorAdapter } from '../../common/vscode/theme';
import { Hover, TextEditorDecorationType } from '../../common/vscode/types';
import { IVSCodeWindow } from '../../common/vscode/window';
import { AdvisorScore } from '../advisorTypes';
import { messages } from '../messages/messages';
import { IAdvisorApiClient } from '../services/advisorApiClient';

const { SCORE_PREFIX } = messages;

export default class EditorDecorator {
private readonly decorationType: TextEditorDecorationType;
private readonly editorLastCharacterIndex = Number.MAX_SAFE_INTEGER;
private readonly fileDecorationLines: Map<string, LineDecorations> = new Map<string, LineDecorations>();

constructor(
private readonly window: IVSCodeWindow,
private readonly languages: IVSCodeLanguages,
private readonly themeColorAdapter: IThemeColorAdapter,
private readonly advisorApiClient: IAdvisorApiClient,
private readonly hoverAdapter: HoverAdapter,
private readonly markdownStringAdapter: IMarkdownStringAdapter,
) {
this.decorationType = this.window.createTextEditorDecorationType({
after: { margin: '0 0 0 1rem' },
});
}

addScoresDecorations(
filePath: string,
packageScore: AdvisorScore,
line: number,
decorations: LineDecorations = [],
): void {
if (!packageScore) {
return;
}
decorations[line] = {
range: this.languages.createRange(
line - 1,
this.editorLastCharacterIndex,
line - 1,
this.editorLastCharacterIndex,
),
renderOptions: getRenderOptions(
`${SCORE_PREFIX} ${Math.round(packageScore.score * 100)}/100`,
this.themeColorAdapter,
),
hoverMessage: this.getHoverMessage(packageScore)?.contents,
};

this.fileDecorationLines.set(filePath, decorations);
updateDecorations(this.window, filePath, decorations, this.decorationType);
}

getHoverMessage(score: AdvisorScore): Hover | null {
if (!score) {
return null;
}
const hoverMessageMarkdown = this.markdownStringAdapter.get(``);
hoverMessageMarkdown.isTrusted = true;
const hoverMessage = this.hoverAdapter.create(hoverMessageMarkdown);
hoverMessageMarkdown.appendMarkdown('| | | | |');
hoverMessageMarkdown.appendMarkdown('\n');
hoverMessageMarkdown.appendMarkdown('| ---- | ---- | ---- | :---- |');
hoverMessageMarkdown.appendMarkdown('\n');
Object.keys(score.labels).forEach(label => {
hoverMessageMarkdown.appendMarkdown(`| ${label}: | | | ${score?.labels[label]} |`);
hoverMessageMarkdown.appendMarkdown('\n');
});
hoverMessageMarkdown.appendMarkdown(
`[More Details](${this.advisorApiClient.getAdvisorUrl('npm-package')}/${score.name})`,
);

return hoverMessage;
}

resetDecorations(filePath: string): void {
const decorations: LineDecorations | undefined = this.fileDecorationLines.get(filePath);
if (!decorations) {
return;
}

const emptyDecorations = decorations.map(d => ({
...d,
renderOptions: getRenderOptions('', this.themeColorAdapter),
}));

updateDecorations(this.window, filePath, emptyDecorations, this.decorationType);
}
}
3 changes: 3 additions & 0 deletions src/snyk/advisor/messages/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const messages = {
SCORE_PREFIX: 'Advisor Score',
};
59 changes: 59 additions & 0 deletions src/snyk/advisor/services/advisorApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { DEFAULT_API_HEADERS } from '../../common/api/headers';
import { IConfiguration } from '../../common/configuration/configuration';
import { ILog } from '../../common/logger/interfaces';
import { AdvisorRegistry } from '../advisorTypes';

export interface IAdvisorApiClient {
post<T = unknown, R = AxiosResponse<T>>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<R>;
apiPath: string;
getAdvisorUrl(registry: AdvisorRegistry): string;
}

export class AdvisorApiClient implements IAdvisorApiClient {
private instance: AxiosInstance | null = null;
private readonly advisorBaseUrl = 'https://snyk.io/advisor';
apiPath = `/unstable/advisor/scores/npm-package`;

constructor(private readonly configuration: IConfiguration, private readonly logger: ILog) {}

getAdvisorUrl(registry: AdvisorRegistry): string {
return `${this.advisorBaseUrl}/${registry}`;
}

private get http(): AxiosInstance {
return this.instance != null ? this.instance : this.initHttp();
}

initHttp(): AxiosInstance {
const http = axios.create({
headers: DEFAULT_API_HEADERS,
responseType: 'json',
});

http.interceptors.response.use(
response => response,
(error: Error) => {
this.logger.error(`Call to Advisor API failed: ${error.message}`);
return Promise.reject(error);
},
);

this.instance = http;
return http;
}

async post<T = unknown, R = AxiosResponse<T>>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<R> {
const token = await this.configuration.getToken();
this.http.interceptors.request.use(req => {
req.baseURL = this.configuration.baseApiUrl;
req.headers = {
...req.headers,
Authorization: `token ${token}`,
} as { [header: string]: string };

return req;
});
return this.http.post<T, R>(url, data, config);
}
}
48 changes: 48 additions & 0 deletions src/snyk/advisor/services/advisorProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { AxiosResponse } from 'axios';
import { ILog } from '../../common/logger/interfaces';
import { ImportedModule } from '../../common/types';
import { AdvisorScore } from '../advisorTypes';
import { IAdvisorApiClient } from './advisorApiClient';

export default class AdvisorProvider {
protected scores: AdvisorScore[];
private cachePackages: string[] = [];

constructor(private readonly advisorApiClient: IAdvisorApiClient, private readonly logger: ILog) {}

public async getScores(modules: ImportedModule[]): Promise<AdvisorScore[]> {
const scores: AdvisorScore[] = [];
try {
const packages = modules.map(({ name }) => name);
if (!packages.filter(pkg => !this.cachePackages.includes(pkg)).length) {
return this.scores;
}
if (!packages.length) {
return scores;
}
const res: AxiosResponse = await this.advisorApiClient.post(
this.advisorApiClient.apiPath,
modules.map(({ name }) => name),
);

if (res.data) {
this.scores = res.data as AdvisorScore[];
this.cachePackages = this.scores.map(advisorScore => {
if (!advisorScore) {
return '';
}
if (!advisorScore.name) {
return '';
}
return advisorScore.name;
});
return res.data as AdvisorScore[];
}
} catch (err) {
if (err instanceof Error) {
this.logger.error(`Failed to get scores: ${err.message}`);
}
}
return scores;
}
}
Loading