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: Triage command #692

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
4 changes: 4 additions & 0 deletions packages/client/src/loadConfiguration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import Configuration from './configuration';

<<<<<<< HEAD
export const DefaultURL = 'https://app.land';
=======
const DefaultURL = 'https://app.land';
>>>>>>> 701fa4d5 (feat: Recognize APPMAP_* env vars)

class Settings {
baseURL = DefaultURL;
Expand Down
2 changes: 2 additions & 0 deletions packages/scanner/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ScanCommand from './cli/scan/command';
import UploadCommand from './cli/upload/command';
import CICommand from './cli/ci/command';
import MergeCommand from './cli/merge/command';
import TriageCommand from './cli/triage/command';
import { verbose } from './rules/lib/util';
import { AbortError, ValidationError } from './errors';
import { ExitCode } from './cli/exitCode';
Expand Down Expand Up @@ -41,6 +42,7 @@ yargs(process.argv.slice(2))
.command(UploadCommand)
.command(CICommand)
.command(MergeCommand)
.command(TriageCommand)
.fail((msg, err, yargs) => {
if (msg) {
console.warn(yargs.help());
Expand Down
27 changes: 20 additions & 7 deletions packages/scanner/src/cli/ci/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@ import { writeFile } from 'fs/promises';
import { promisify } from 'util';
import { Arguments, Argv } from 'yargs';

import { FindingStatusListItem } from '@appland/client/dist/src';
import {
FindingStatusListItem,
loadConfiguration as loadClientConfiguration,
} from '@appland/client/dist/src';

import { parseConfigFile } from '../../configuration/configurationProvider';
import { ValidationError } from '../../errors';
import { ScanResults } from '../../report/scanResults';
import { verbose } from '../../rules/lib/util';
import { appmapDirFromConfig } from '../appmapDirFromConfig';
import { newFindings } from '../../findings';
import selectFindings from '../../selectFindings';
import findingsReport from '../../report/findingsReport';
import summaryReport from '../../report/summaryReport';

import resolveAppId from '../resolveAppId';
import validateFile from '../validateFile';
import upload from '../upload';
import { default as buildScanner } from '../scan/scanner';
import Scanner, { default as buildScanner } from '../scan/scanner';

import CommandOptions from './options';
import scanArgs from '../scanArgs';
Expand All @@ -26,13 +29,16 @@ import reportUploadURL from '../reportUploadURL';
import fail from '../fail';
import codeVersionArgs from '../codeVersionArgs';
import { handleWorkingDirectory } from '../handleWorkingDirectory';
import { stateFileNameArg } from '../triage/stateFileNameArg';
import assert from 'assert';

export default {
command: 'ci',
describe: 'Scan AppMaps, report findings to AppMap Server, and update SCM status',
builder(args: Argv): Argv {
scanArgs(args);
codeVersionArgs(args);
stateFileNameArg(args);

args.option('fail', {
describe: 'exit with non-zero status if there are any new findings',
Expand All @@ -59,6 +65,7 @@ export default {
let { appmapDir } = options as unknown as CommandOptions;
const {
config,
stateFile: stateFileName,
verbose: isVerbose,
fail: failOption,
app: appIdArg,
Expand All @@ -76,6 +83,8 @@ export default {
verbose(true);
}

loadClientConfiguration();

handleWorkingDirectory(directory);

if (!appmapDir) {
Expand All @@ -87,23 +96,27 @@ export default {
);

await validateFile('directory', appmapDir!);
const appId = await resolveAppId(appIdArg, appmapDir);
const appId = await resolveAppId(appIdArg, appmapDir, true);
assert(appId);

const glob = promisify(globCallback);
const files = await glob(`${appmapDir}/**/*.appmap.json`);

const configData = await parseConfigFile(config);

const scanner = await buildScanner(false, configData, files);
const scanner = new Scanner(configData, files);

const [rawScanResults, findingStatuses]: [ScanResults, FindingStatusListItem[]] =
await Promise.all([scanner.scan(), scanner.fetchFindingStatus(appIdArg, appmapDir)]);
await Promise.all([
scanner.scan(),
scanner.fetchFindingStatus(stateFileName, appIdArg, appmapDir),
]);

// Always report the raw data
await writeFile(reportFile, JSON.stringify(rawScanResults, null, 2));

const scanResults = rawScanResults.withFindings(
newFindings(rawScanResults.findings, findingStatuses)
selectFindings(rawScanResults.findings, findingStatuses, [])
);

findingsReport(scanResults.findings, scanResults.appMapMetadata);
Expand Down
9 changes: 9 additions & 0 deletions packages/scanner/src/cli/directoryArg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Argv } from 'yargs';

export function directoryArg(args: Argv<{}>): void {
args.option('directory', {
describe: 'program working directory',
type: 'string',
alias: 'd',
});
}
34 changes: 34 additions & 0 deletions packages/scanner/src/cli/findingsState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { readFile } from 'fs/promises';
import { load } from 'js-yaml';
import { exists } from '../../../cli/src/utils';

export enum FindingState {
// Finding is valid, accepted and should be fixed.
Active = 'active',
// Finding will not be acted upon at this time. The comment should clarify why this is.
Deferred = 'deferred',
// Finding is a false positive. The code is working as designed.
AsDesigned = 'as-designed',
}

export type FindingsState = Record<FindingState, FindingStateItem[]>;

export interface FindingStateItem {
hash_v2: string;
comment?: string;
updated_at: Date;
}

export async function loadFindingsState(stateFileName: string): Promise<FindingsState> {
let result: FindingsState;
if (await exists(stateFileName)) {
result = load(await readFile(stateFileName, 'utf8')) as FindingsState;
} else {
result = {
[FindingState.Active]: [],
[FindingState.Deferred]: [],
[FindingState.AsDesigned]: [],
} as FindingsState;
}
return result;
}
4 changes: 3 additions & 1 deletion packages/scanner/src/cli/merge/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { merge as mergeScannerJob } from '../../integration/appland/scannerJob/m
import resolveAppId from '../resolveAppId';
import updateCommitStatus from '../updateCommitStatus';
import fail from '../fail';
import assert from 'assert';

export default {
command: 'merge <merge-key>',
Expand Down Expand Up @@ -45,7 +46,8 @@ export default {
verbose(true);
}

const appId = await resolveAppId(appIdArg, '.');
const appId = await resolveAppId(appIdArg, '.', true);
assert(appId);

const mergeResults = await mergeScannerJob(appId, mergeKey);
console.warn(`Merged results to ${mergeResults.url}`);
Expand Down
15 changes: 11 additions & 4 deletions packages/scanner/src/cli/resolveAppId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,23 @@ async function resolveAppId(

export default async function (
appIdArg: string | undefined,
appMapDir: string | undefined
): Promise<string> {
appMapDir: string | undefined,
mustExist = false
): Promise<string | undefined> {
const appId = await resolveAppId(appIdArg, appMapDir);
if (!appId) throw new ValidationError('App was not provided and could not be resolved');

const appExists = await new App(appId).exists();
if (!appExists) {
throw new ValidationError(
`App "${appId}" is not valid or does not exist.\nPlease fix the app name in the appmap.yml file, or override it with the --app option.`
if (mustExist) {
throw new ValidationError(
`App "${appId}" is not valid or does not exist. Please fix the app name in the appmap.yml file, or override it with the --app option.`
);
}
console.warn(
`App "${appId}" does not exist on the AppMap Server. If this is unexpected, provide the correct app name in the appmap.yml file, or override it with the --app option.`
);
return;
}

return appId;
Expand Down
71 changes: 32 additions & 39 deletions packages/scanner/src/cli/scan/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@ import singleScan from './singleScan';
import watchScan from './watchScan';
import { parseConfigFile } from '../../configuration/configurationProvider';
import { handleWorkingDirectory } from '../handleWorkingDirectory';
import interactiveScan from './interactiveScan';
import { FindingState } from '../findingsState';
import { loadConfiguration as loadClientConfiguration } from '@appland/client';
import { stateFileNameArg } from '../triage/stateFileNameArg';
import assert from 'node:assert';

export default {
command: 'scan',
describe: 'Scan AppMaps for code behavior findings',
builder(args: Argv): Argv {
scanArgs(args);
stateFileNameArg(args);

args.option('interactive', {
describe: 'scan in interactive mode',
alias: 'i',
});
args.option('appmap-file', {
describe: 'single file to scan, or repeat this option to scan multiple specific files',
alias: 'f',
Expand All @@ -32,10 +32,9 @@ export default {
describe: 'choose your IDE protocol to open AppMaps directly in your IDE.',
options: ['vscode', 'x-mine', 'idea', 'pycharm'],
});
args.option('all', {
describe: 'report all findings, including duplicates of known findings',
default: false,
type: 'boolean',
args.option('finding-state', {
options: [FindingState.AsDesigned, FindingState.Deferred],
type: 'array',
});
args.option('watch', {
describe: 'scan code changes and report findings on changed files',
Expand All @@ -50,11 +49,11 @@ export default {
const {
appmapFile,
directory,
interactive,
stateFile: stateFileName,
config: configFile,
verbose: isVerbose,
all: reportAllFindings,
watch,
findingState: includeFindingStates,
app: appIdArg,
apiKey,
ide,
Expand All @@ -65,51 +64,45 @@ export default {
verbose(true);
}

handleWorkingDirectory(directory);

if (apiKey) {
process.env.APPLAND_API_KEY = apiKey;
}
loadClientConfiguration();

handleWorkingDirectory(directory);

if (appmapFile && watch) {
throw new ValidationError('Use --appmap-file or --watch, but not both');
}
if (reportAllFindings && watch) {
throw new ValidationError(
`Don't use --all with --watch, because in watch mode all findings are reported`
);
}

if (appmapDir) await validateFile('directory', appmapDir);
if (!appmapFile && !appmapDir) {
appmapDir = (await appmapDirFromConfig()) || '.';
}

let appId = appIdArg;
if (!watch && !reportAllFindings) appId = await resolveAppId(appIdArg, appmapDir);
const appId = await resolveAppId(appIdArg, appmapDir);

if (watch) {
const watchAppMapDir = appmapDir!;
return watchScan({ appId, appmapDir: watchAppMapDir, configFile });
assert(appmapDir);
return watchScan({
appId,
stateFileName,
appmapDir,
configFile,
includeFindingStates,
});
} else {
const configuration = await parseConfigFile(configFile);
if (interactive) {
return interactiveScan({
appmapFile,
appmapDir,
configuration,
});
} else {
return singleScan({
appmapFile,
appmapDir,
configuration,
reportAllFindings,
appId,
ide,
reportFile,
});
}
return singleScan({
appmapFile,
appmapDir,
stateFileName,
configuration,
includeFindingStates,
appId,
ide,
reportFile,
});
}
},
};
4 changes: 2 additions & 2 deletions packages/scanner/src/cli/scan/options.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { FindingState } from '../findingsState';
import ScanOptions from '../scanOptions';

export default interface CommandOptions extends ScanOptions {
all: boolean;
interactive: boolean;
watch: boolean;
appmapFile?: string | string[];
findingState: [FindingState.AsDesigned | FindingState.Deferred][];
ide?: string;
}
Loading