Skip to content

Commit

Permalink
feat: triage command
Browse files Browse the repository at this point in the history
Assign triage status to findings. Each finding is identified by hash_v2.
Finding status can be:

* deferred
* as-designed
* active

Findings status is saved to file appmap-scanner-state.yml.
  • Loading branch information
kgilpin committed Jan 11, 2023
1 parent 71f98bd commit 022a81b
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 8 deletions.
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
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',
});
}
14 changes: 6 additions & 8 deletions packages/scanner/src/cli/scanArgs.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { Argv } from 'yargs';
import { directoryArg } from './directoryArg';

export default function (args: Argv): void {
args.option('directory', {
describe: 'program working directory',
type: 'string',
alias: 'd',
});
args.option('appmap-dir', {
describe: 'directory to recursively inspect for AppMaps',
});
directoryArg(args);

args.option('config', {
describe:
'path to assertions config file (TypeScript or YAML, check docs for configuration format)',
default: 'appmap-scanner.yml',
alias: 'c',
});
args.option('appmap-dir', {
describe: 'directory to recursively inspect for AppMaps',
});
args.option('report-file', {
describe: 'file name for findings report',
default: 'appmap-findings.json',
Expand Down
145 changes: 145 additions & 0 deletions packages/scanner/src/cli/triage/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { Arguments, Argv } from 'yargs';
import readline from 'readline';

import { verbose } from '../../rules/lib/util';

import CommandOptions from './options';
import { handleWorkingDirectory } from '../handleWorkingDirectory';
import { readFile, writeFile } from 'fs/promises';
import { dump, load } from 'js-yaml';
import { directoryArg } from '../directoryArg';
import { exists } from '../../../../cli/src/utils';

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',
}

type TriagedFinding = Record<FindingState, TriageItem[]>;

interface TriageItem {
hash_v2: string;
comment?: string;
updated_at: Date;
}

export default {
command: 'triage <finding...>',
describe: 'Triage findings by assigning them to a workflow state',
builder(args: Argv): Argv {
directoryArg(args);

args.option('state-file', {
describe: 'Name of the file containing the findings state',
type: 'string',
default: 'appmap-findings-state.yml',
});

args.option('comment', {
describe: 'Comment to associate with the triage action',
alias: ['c'],
});

args.option('state', {
describe: 'Workflow state to assign to the finding',
type: 'string',
demandOption: true,
options: ['active', 'deferred', 'as-designed'],
alias: ['s'],
});

args.positional('finding', {
describe: 'Identifying hash (hash_v2 digest) of the finding',
type: 'string',
array: true,
});

return args.strict();
},
async handler(options: Arguments): Promise<void> {
let { comment } = options as unknown as CommandOptions;
const {
stateFile: stateFileName,
directory,
verbose: isVerbose,
state: stateStr,
finding: findingHashArray,
} = options as unknown as CommandOptions;

if (isVerbose) {
verbose(true);
}

handleWorkingDirectory(directory);
const assignedState = stateStr as FindingState;

const findingHashes = new Set<string>(findingHashArray);

let triagedFindings: TriagedFinding;
if (await exists(stateFileName)) {
triagedFindings = load(await readFile(stateFileName, 'utf8')) as TriagedFinding;
} else {
triagedFindings = {
[FindingState.Active]: [],
[FindingState.Deferred]: [],
[FindingState.AsDesigned]: [],
} as TriagedFinding;
}

if (!comment) {
comment = await promptForComment();
}

const existingTriageItems = new Map<string, TriageItem>();
// Remove any findings that are previously triaged and will now be recategorized.
[FindingState.Active, FindingState.Deferred, FindingState.AsDesigned].forEach((state) => {
triagedFindings[state] = triagedFindings[state].filter((triagedFinding) => {
if (findingHashes.has(triagedFinding.hash_v2)) {
triagedFinding.comment = comment;
triagedFinding.updated_at = new Date(Date.now());
existingTriageItems.set(triagedFinding.hash_v2, triagedFinding);
return false;
}
return true;
});
});

findingHashes.forEach((hash) => {
let triagedFinding = existingTriageItems.get(hash);
if (!triagedFinding) {
triagedFinding = {
hash_v2: hash,
comment,
updated_at: new Date(Date.now()),
};
}

triagedFindings[assignedState].push(triagedFinding);
});

triagedFindings[assignedState] = triagedFindings[assignedState].sort((a, b) => {
let diff = b.updated_at.getTime() - a.updated_at.getTime();
if (diff === 0) diff = a.hash_v2.localeCompare(b.hash_v2);
return diff;
});

await writeFile(stateFileName, dump(triagedFindings));
},
};

async function promptForComment(): Promise<string | undefined> {
if (process.stdout.isTTY) {
const ui = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

return new Promise<string | undefined>((resolve) =>
ui.question('Comment (optional): ', resolve)
);
}
}
8 changes: 8 additions & 0 deletions packages/scanner/src/cli/triage/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default interface CommandOptions {
directory?: string;
stateFile: string;
verbose?: boolean;
finding: string[];
state: string;
comment?: string;
}

0 comments on commit 022a81b

Please sign in to comment.