diff --git a/codechecker_common/util.py b/codechecker_common/util.py index 47adb4a037..b7ef94bdaf 100644 --- a/codechecker_common/util.py +++ b/codechecker_common/util.py @@ -223,6 +223,23 @@ def generate_random_token(num_bytes: int = 32) -> str: return hash_value[idx:(idx + num_bytes)] +def thrift_to_json(obj): + """ + Recursively convert a Thrift object (or any object with __dict__) + into a JSON-serializable dict, skipping None-valued fields. + """ + if obj is None or isinstance(obj, (str, int, float, bool)): + return obj + if isinstance(obj, list): + return [thrift_to_json(x) for x in obj] + if isinstance(obj, dict): + return {k: thrift_to_json(v) for k, v in obj.items()} + if hasattr(obj, '__dict__'): + return {k: thrift_to_json(v) for k, v in obj.__dict__.items() + if v is not None} + return str(obj) + + def format_size(num: float, suffix: str = 'B') -> str: """ Pretty print storage units. diff --git a/docs/web/user_guide.md b/docs/web/user_guide.md index 4489e9f585..bd12525fcd 100644 --- a/docs/web/user_guide.md +++ b/docs/web/user_guide.md @@ -28,6 +28,11 @@ - [List runs (`runs`)](#list-runs-runs) - [List of run histories (`history`)](#list-of-run-histories-history) - [List analysis results' summary (`results`)](#list-analysis-results-summary-results) + - [Manage filter presets (`filter-preset`)](#manage-filter-presets-filter-preset) + - [Creating a filter preset](#creating-a-filter-preset) + - [Listing filter presets](#listing-filter-presets) + - [Deleting a filter preset](#deleting-a-filter-preset) + - [Applying a filter preset to results](#applying-a-filter-preset-to-results) - [Example](#example-1) - [Show differences between two runs (`diff`)](#show-differences-between-two-runs-diff) - [Show summarised count of results (`sum`)](#show-summarised-count-of-results-sum) @@ -1122,7 +1127,100 @@ CodeChecker cmd results my_run --severity critical high medium \ # Get detailed analysis results for a run in JSON format. CodeChecker cmd results -o json --details my_run + +# Get analysis results using a saved filter preset: +CodeChecker cmd results my_run --filter-preset my_preset \ + --url +``` + +#### Manage filter presets (`filter-preset`) + +Filter presets allow you to save a named set of filter parameters on the +server so they can be reused across multiple queries without having to +specify all the filter flags every time. + +A preset stores any combination of the filter arguments available to +`cmd results` (severity, checker name, file path, detection status, etc.). + +##### Creating a filter preset + ``` +# Create a minimal preset that filters by severity: +CodeChecker cmd filter-preset new \ + --name "high_and_critical" \ + --url \ + --severity critical high + +# Create a preset with many filter parameters: +CodeChecker cmd filter-preset new \ + --name "FullPreset" \ + --url \ + --severity critical high medium low style unspecified \ + --review-status unreviewed confirmed false_positive intentional \ + --detection-status new reopened unresolved resolved off unavailable \ + --report-status outstanding closed \ + --file "*/src/*.cpp" "*/include/*.h" \ + --checker-name "deadcode.DeadStores" "core.NullDereference" \ + --checker-msg "*null pointer*" "*memory leak*" \ + --analyzer-name clangsa clang-tidy \ + --component myComponent \ + --tag v1.0 v2.0 \ + --report-hash "abcdef1234567890" \ + --bug-path-length "1:50" \ + --detected-before "2026:03:09:00:00:00" \ + --detected-after "2025:01:01:00:00:00" \ + --fixed-before "2026:03:09:00:00:00" \ + --fixed-after "2025:06:01:00:00:00" \ + --outstanding-reports-date "2026:03:01:00:00:00" \ + --anywhere-on-report-path \ + --single-origin-report \ + --uniqueing off +``` + +Preset names must be unique. Attempting to create a preset with an +already-existing name will fail. + +##### Listing filter presets + +``` +# List all presets (default table output): +CodeChecker cmd filter-preset list \ + --url + +# List presets in CSV format: +CodeChecker cmd filter-preset list \ + --url \ + --output csv + +# List presets in JSON format: +CodeChecker cmd filter-preset list \ + --url \ + --output json +``` + +##### Deleting a filter preset + +You can find the preset ID by [listing filter presets](#listing-filter-presets). + +``` +# Delete a preset by its ID (shown in the list output): +CodeChecker cmd filter-preset delete \ + --id 1 \ + --url +``` + +##### Applying a filter preset to results + +Use the `--filter-preset` flag with `cmd results` to apply a saved preset: + +``` +CodeChecker cmd results my_run \ + --filter-preset "high_and_critical" \ + --url +``` + +**Note:** `--filter-preset` cannot be combined with other filter arguments. +Either use a preset or specify filters on the command line, not both. #### Show differences between two runs (`diff`) diff --git a/web/api/js/codechecker-api-node/dist/codechecker-api-6.67.0.tgz b/web/api/js/codechecker-api-node/dist/codechecker-api-6.67.0.tgz deleted file mode 100644 index 6e556b6c0d..0000000000 Binary files a/web/api/js/codechecker-api-node/dist/codechecker-api-6.67.0.tgz and /dev/null differ diff --git a/web/api/js/codechecker-api-node/dist/codechecker-api-6.68.0.tgz b/web/api/js/codechecker-api-node/dist/codechecker-api-6.68.0.tgz new file mode 100644 index 0000000000..6647b395d9 Binary files /dev/null and b/web/api/js/codechecker-api-node/dist/codechecker-api-6.68.0.tgz differ diff --git a/web/api/js/codechecker-api-node/package.json b/web/api/js/codechecker-api-node/package.json index eb304081d8..4baeb61da2 100644 --- a/web/api/js/codechecker-api-node/package.json +++ b/web/api/js/codechecker-api-node/package.json @@ -1,6 +1,6 @@ { "name": "codechecker-api", - "version": "6.67.0", + "version": "6.68.0", "description": "Generated node.js compatible API stubs for CodeChecker server.", "main": "lib", "homepage": "https://github.com/Ericsson/codechecker", diff --git a/web/api/py/codechecker_api/dist/codechecker_api.tar.gz b/web/api/py/codechecker_api/dist/codechecker_api.tar.gz index eb0ebd793c..2f160326ed 100644 Binary files a/web/api/py/codechecker_api/dist/codechecker_api.tar.gz and b/web/api/py/codechecker_api/dist/codechecker_api.tar.gz differ diff --git a/web/api/py/codechecker_api/setup.py b/web/api/py/codechecker_api/setup.py index 4face1d377..2fab47a581 100644 --- a/web/api/py/codechecker_api/setup.py +++ b/web/api/py/codechecker_api/setup.py @@ -8,7 +8,7 @@ with open('README.md', encoding='utf-8', errors="ignore") as f: long_description = f.read() -api_version = '6.67.0' +api_version = '6.68.0' setup( name='codechecker_api', diff --git a/web/api/py/codechecker_api_shared/dist/codechecker_api_shared.tar.gz b/web/api/py/codechecker_api_shared/dist/codechecker_api_shared.tar.gz index 2490b13225..b745496c3a 100644 Binary files a/web/api/py/codechecker_api_shared/dist/codechecker_api_shared.tar.gz and b/web/api/py/codechecker_api_shared/dist/codechecker_api_shared.tar.gz differ diff --git a/web/api/py/codechecker_api_shared/setup.py b/web/api/py/codechecker_api_shared/setup.py index 2986c3eeb2..a7a6dd74d2 100644 --- a/web/api/py/codechecker_api_shared/setup.py +++ b/web/api/py/codechecker_api_shared/setup.py @@ -8,7 +8,7 @@ with open('README.md', encoding='utf-8', errors="ignore") as f: long_description = f.read() -api_version = '6.67.0' +api_version = '6.68.0' setup( name='codechecker_api_shared', diff --git a/web/api/report_server.thrift b/web/api/report_server.thrift index 94d2597838..3de985c5e8 100644 --- a/web/api/report_server.thrift +++ b/web/api/report_server.thrift @@ -406,6 +406,12 @@ struct ReportFilter { 24: optional bool fullReportPathInComponent, } +struct FilterPreset { + 1: i64 id, // Unique ID of "FilterPreset". + 2: string name, // Human readable name of preset. + 3: ReportFilter reportFilter // Uniquely configured ReportFilter. +} + struct RunReportCount { 1: i64 runId, // Unique ID of the run. 2: string name, // Human readable name of the run. @@ -583,6 +589,40 @@ service codeCheckerDBAccess { 4: optional RunSortMode sortMode) throws (1: codechecker_api_shared.RequestFailed requestError), + //============================================ + // Filter grouping api calls. + //============================================ + + // Stores the given FilterPreset with the given id + // If the preset exists with the given id, it overwrites the name, and all preset values + // If the preset does not exist yet, throws and error + // If the id is -1 a new preset filter is created and the id of the new preset is returned. + // If a preset with that name already existed, it throws an error. Thus the "FilterPreset" name must be unique. + // The encoding of the name must be unicode. (whitespaces allowed) + // Maximum "FilterPreset" name 50 + // Returns: the id of the modified or created preset + // PERMISSION: PRODUCT_ADMIN + i64 storeFilterPreset(1: FilterPreset preset) + throws (1: codechecker_api_shared.RequestFailed requestError); + + // Returns the "FilterPreset" identified by id + // Throws and error in case there is no preset with the given id + // PERMISSION: PRODUCT_VIEW + FilterPreset getFilterPreset(1: i64 id) + throws (1: codechecker_api_shared.RequestFailed requestError); + + // Removes the FilterPreset with the given id + // Returns the id of the "FilterPreset" removed + // Throws an error if the preset with the given id does not exist. + // PERMISSION: PRODUCT_ADMIN + i64 deleteFilterPreset(1: i64 id) + throws (1: codechecker_api_shared.RequestFailed requestError); + + // Returns all "FilterPreset"s stored for the product repository + // PERMISSION: PRODUCT_VIEW + list listFilterPreset() + throws (1: codechecker_api_shared.RequestFailed requestError); + // Returns the number of available runs based on the run filter parameter. // PERMISSION: PRODUCT_VIEW i64 getRunCount(1: RunFilter runFilter) diff --git a/web/client/codechecker_client/cli/cmd.py b/web/client/codechecker_client/cli/cmd.py index e769717744..cdd62a2878 100644 --- a/web/client/codechecker_client/cli/cmd.py +++ b/web/client/codechecker_client/cli/cmd.py @@ -25,18 +25,13 @@ product_client, \ source_component_client, \ task_client, \ - token_client + token_client, \ + filter_preset_client +from codechecker_client.filter_defaults import DEFAULT_FILTER_VALUES from codechecker_common import arg, logger, util from codechecker_common.output import USER_FORMATS -DEFAULT_FILTER_VALUES = { - 'review_status': ['unreviewed', 'confirmed'], - 'detection_status': ['new', 'reopened', 'unresolved'], - 'uniqueing': 'off', - 'anywhere_on_report_path': False, - 'single_origin_report': False -} DEFAULT_OUTPUT_FORMATS = ["plaintext"] + USER_FORMATS @@ -448,6 +443,35 @@ def init_default(dest): "entirely in the files specified by the " "given --component.") + f_group.add_argument('--report-status', + nargs='*', + dest="report_status", + metavar='REPORT_STATUS', + default=init_default('report_status'), + help="R|Filter results by report statuses.\n" + "Reports can be assigned a report status of the " + "following values:\n" + "- Outstanding: Currently detected and " + "still unresolved reports.\n" + "- Closed: Reports marked as fixed, false " + "positive, or otherwise resolved.\n" + + warn_diff_mode) + + +def __add_filter_preset_argument(parser): + """Add the --filter-preset argument to the given parser.""" + parser.add_argument('--filter-preset', + type=str, + dest='filter_preset_name', + metavar='PRESET_NAME', + required=False, + default=argparse.SUPPRESS, + help="Use a pre-configured filter preset. The preset " + "is loaded from the server and applied to the " + "results. Use 'CodeChecker cmd filter-preset " + "list --url ' to see available " + "presets.") + def __register_results(parser): """ @@ -478,6 +502,7 @@ def __register_results(parser): "events, bug report points etc.") __add_filtering_arguments(parser, DEFAULT_FILTER_VALUES) + __add_filter_preset_argument(parser) def __register_diff(parser): @@ -543,6 +568,7 @@ def __register_diff(parser): "the reported defect.") __add_filtering_arguments(parser, DEFAULT_FILTER_VALUES, True) + __add_filter_preset_argument(parser) group = parser.add_argument_group( "comparison modes", @@ -623,6 +649,7 @@ def __register_sum(parser): default_filter_values = DEFAULT_FILTER_VALUES default_filter_values['uniqueing'] = 'on' __add_filtering_arguments(parser, default_filter_values) + __add_filter_preset_argument(parser) def __register_delete(parser): @@ -1530,6 +1557,62 @@ def __register_del(parser): __add_common_arguments(del_t, needs_product_url=False) +def __register_filter_presets(parser): + """ + Add argparse subcommand parser for the "filter preset management" action. + """ + + def __register_new(parser): + parser.add_argument('--name', + type=str, + dest='preset_name', + required=True, + metavar='PRESET_NAME', + help="Name of the filter preset to create.") + __add_filtering_arguments(parser) + + def __register_delete(parser): + """ + Add argparse subcommand parser for the "delete preset" action. + """ + parser.add_argument('--id', + type=int, + dest='preset_id', + required=True, + metavar='PRESET_ID', + help="ID of the filter preset to delete.") + + subcommands = parser.add_subparsers(title='available actions') + + # Create handlers for individual subcommands. + list_presets = subcommands.add_parser( + 'list', + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description="List all filter presets available on the server.", + help="List all filter presets.") + list_presets.set_defaults(func=filter_preset_client.handle_list_presets) + __add_common_arguments(list_presets, + output_formats=DEFAULT_OUTPUT_FORMATS) + + new_preset = subcommands.add_parser( + 'new', + formatter_class=arg.RawDescriptionDefaultHelpFormatter, + description="Create a new filter preset.", + help="Create a new filter preset.") + __register_new(new_preset) + new_preset.set_defaults(func=filter_preset_client.handle_new_preset) + __add_common_arguments(new_preset) + + delete_preset = subcommands.add_parser( + 'delete', + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description="Delete a filter preset from the server.", + help="Delete a filter preset.") + __register_delete(delete_preset) + delete_preset.set_defaults(func=filter_preset_client.handle_delete_preset) + __add_common_arguments(delete_preset) + + def add_arguments_to_parser(parser): """ Add the subcommand's arguments to the given argparse.ArgumentParser. @@ -1820,5 +1903,18 @@ def add_arguments_to_parser(parser): tasks.set_defaults(func=task_client.handle_tasks) __add_common_arguments(tasks, needs_product_url=False) + filter_preset = subcommands.add_parser( + 'filter-preset', + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description="Manage filter presets of a CodeChecker server. " + "Filter presets are named collections of filter " + "configurations that can be applied to the analysis " + "results of a run. Please see the individual " + "subcommands for details.", + help="Access subcommands related to configuring filter presets of a " + "CodeChecker server.") + __register_filter_presets(filter_preset) + __add_common_arguments(filter_preset) + # 'cmd' does not have a main() method in itself, as individual subcommands are # handled later on separately. diff --git a/web/client/codechecker_client/cmd_line_client.py b/web/client/codechecker_client/cmd_line_client.py index 8053472a23..b623fc5a76 100644 --- a/web/client/codechecker_client/cmd_line_client.py +++ b/web/client/codechecker_client/cmd_line_client.py @@ -10,7 +10,7 @@ """ -from collections import defaultdict, namedtuple +from collections import defaultdict from copy import deepcopy from datetime import datetime, timedelta import hashlib @@ -24,7 +24,6 @@ from codechecker_api.codeCheckerDBAccess_v6 import constants, ttypes from codechecker_api_shared.ttypes import RequestFailed - from codechecker_report_converter import twodim from codechecker_report_converter.report import File, Report, report_file, \ reports as reports_helper @@ -47,15 +46,14 @@ from .cmd_line import CmdLineOutputEncoder from .product import split_server_url +from .filter_defaults import DEFAULT_FILTER_VALUES + from . import suppress_file_handler # Needs to be set in the handler functions. LOG = None -BugPathLengthRange = namedtuple('BugPathLengthRange', ['min', 'max']) - - def init_logger(level, stream=None, logger_name='system'): logger.setup_logger(level, stream) global LOG @@ -466,15 +464,68 @@ def parse_report_filter(client, args): Parse and check attributes of the given report filter based on the arguments which is provided in the command line. Also, check if filter values are valid values. + + If a filter preset is specified (--filter-preset), it will be used + directly. Specifying additional CLI filter arguments together with + a preset is not allowed and will cause an error. """ - report_filter = parse_report_filter_offline(args) - if 'tag' in args: - run_history_filter = ttypes.RunHistoryFilter(tagNames=args.tag) - run_histories = client.getRunHistory(None, None, None, - run_history_filter) - if run_histories: - report_filter.runTag = [t.id for t in run_histories] + # CLI argument names that correspond to filter options. + filter_cli_args = [ + 'severity', 'detection_status', 'review_status', + 'checker_name', 'file_path', 'bug_path_length', + 'checker_msg', 'analyzer_name', 'component', + 'report_hash', 'open_reports_date', + 'detected_at', 'fixed_at', + 'detected_before', 'detected_after', + 'fixed_before', 'fixed_after', + 'tag', 'report_status', + ] + + # Load preset filter if specified + if 'filter_preset_name' in args: + conflicting = [ + a for a in filter_cli_args + if a in args and ( + a not in DEFAULT_FILTER_VALUES + or getattr(args, a) != DEFAULT_FILTER_VALUES[a] + ) + ] + + if conflicting: + LOG.error( + "Cannot combine --filter-preset with other filter " + "arguments (%s). Either use a preset or specify " + "filters on the command line, not both.", + ', '.join(f'--{a.replace("_", "-")}' for a in conflicting)) + sys.exit(1) + + preset_name = args.filter_preset_name + LOG.info("Loading filter preset '%s'...", preset_name) + + all_presets = client.listFilterPreset() + preset = next((p for p in all_presets if p.name == preset_name), None) + + if not preset: + LOG.error("Filter preset '%s' not found!", preset_name) + LOG.info( + "Use 'CodeChecker cmd filter-preset list' to see available " + "presets." + ) + sys.exit(1) + + report_filter = preset.reportFilter + else: + # No preset – build the filter from CLI arguments. + report_filter = parse_report_filter_offline(args) + + # Handle tags (requires API call to resolve tag names to IDs) + if 'tag' in args: + run_history_filter = ttypes.RunHistoryFilter(tagNames=args.tag) + run_histories = client.getRunHistory(None, None, None, + run_history_filter) + if run_histories: + report_filter.runTag = [t.id for t in run_histories] return report_filter @@ -517,8 +568,8 @@ def parse_report_filter_offline(args): len(path_lengths) > 1 and path_lengths[1].isdigit() else None report_filter.bugPathLength = \ - BugPathLengthRange(min=min_bug_path_length, - max=max_bug_path_length) + ttypes.BugPathLengthRange(min=min_bug_path_length, + max=max_bug_path_length) values_to_check = [ (report_filter.severity, ttypes.Severity._VALUES_TO_NAMES, 'severity'), @@ -532,7 +583,9 @@ def parse_report_filter_offline(args): [validate_filter_values(*x) for x in values_to_check]): sys.exit(1) - report_filter.isUnique = args.uniqueing == 'on' + report_filter.isUnique = getattr(args, + "uniqueing", + False) == "on" if 'checker_msg' in args: report_filter.checkerMsg = args.checker_msg @@ -582,17 +635,35 @@ def parse_report_filter_offline(args): report_filter.date = ttypes.ReportDate(detected=detected_at, fixed=fixed_at) - report_filter.fileMatchesAnyPoint = args.anywhere_on_report_path - report_filter.componentMatchesAnyPoint = args.anywhere_on_report_path - report_filter.fullReportPathInComponent = args.single_origin_report + report_filter.fileMatchesAnyPoint = getattr( + args, "anywhere_on_report_path", False) + report_filter.componentMatchesAnyPoint = getattr( + args, "anywhere_on_report_path", False) + report_filter.fullReportPathInComponent = getattr( + args, "single_origin_report", False) + + if 'report_status' in args: + report_filter.reportStatus = [ + ttypes.ReportStatus._NAMES_TO_VALUES[x.upper()] for x in + args.report_status] + + if 'run_name' in args: + report_filter.runName = args.run_name + + if 'run_tag' in args: + report_filter.runTag = args.run_tag + + if 'cleanup_plan' in args: + report_filter.cleanupPlanNames = args.cleanup_plan - if args.anywhere_on_report_path and \ + if getattr(args, "anywhere_on_report_path", False) and \ 'file_path' not in args and 'component' not in args: LOG.warning( 'The flag --anywhere-on-report-path is meaningful only if --file ' 'or --component is used.') - if args.single_origin_report and 'component' not in args: + if getattr(args, "single_origin_report", False) \ + and 'component' not in args: LOG.warning( 'The flag --single-origin-report is meaningful only if ' '--component is used.') diff --git a/web/client/codechecker_client/filter_defaults.py b/web/client/codechecker_client/filter_defaults.py new file mode 100644 index 0000000000..c6bbea2a9b --- /dev/null +++ b/web/client/codechecker_client/filter_defaults.py @@ -0,0 +1,21 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- +""" +Shared default values for CLI filter arguments. + +Extracted into its own module so that both ``cli/cmd.py`` and +``cmd_line_client.py`` can import it without creating a circular dependency. +""" + +DEFAULT_FILTER_VALUES = { + 'review_status': ['unreviewed', 'confirmed'], + 'detection_status': ['new', 'reopened', 'unresolved'], + 'uniqueing': 'off', + 'anywhere_on_report_path': False, + 'single_origin_report': False, +} diff --git a/web/client/codechecker_client/filter_preset_client.py b/web/client/codechecker_client/filter_preset_client.py new file mode 100644 index 0000000000..61ecbf6988 --- /dev/null +++ b/web/client/codechecker_client/filter_preset_client.py @@ -0,0 +1,380 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- +""" +Argument handlers for the 'CodeChecker cmd filter-preset' subcommands. +""" + +import json +import sys +from datetime import datetime +from codechecker_report_converter import twodim + +from codechecker_common import logger +from codechecker_common.util import thrift_to_json +from codechecker_api.codeCheckerDBAccess_v6 import ttypes + +from .client import setup_client +from .cmd_line_client import parse_report_filter_offline + +LOG = None + + +def init_logger(level, stream=None, logger_name='system'): + logger.setup_logger(level, stream) + global LOG + LOG = logger.get_logger(logger_name) + + +def display_preset_details(preset): + """ + Display the complete preset information including ID. + """ + + print("\nFilter Preset Created/Updated:") + print("=" * 60) + print(f"ID: {preset.id}") + print(f"Name: {preset.name}") + + print("\nFilter Configuration:") + if preset.reportFilter: + rf = preset.reportFilter + + if rf.filepath: + print(f" File paths: {', '.join(rf.filepath)}") + + if rf.checkerName: + print(f" Checker names: {', '.join(rf.checkerName)}") + + if rf.checkerMsg: + print(f" Checker messages: {', '.join(rf.checkerMsg)}") + + if rf.reportHash: + print(f" Report hashes: {', '.join(rf.reportHash)}") + + if rf.severity: + severity_names = [ + ttypes.Severity._VALUES_TO_NAMES.get(s, str(s)) + for s in rf.severity + ] + print(f" Severity: {', '.join(severity_names)}") + + if rf.reviewStatus: + review_names = [ + ttypes.ReviewStatus._VALUES_TO_NAMES.get(s, str(s)) + for s in rf.reviewStatus + ] + print(f" Review status: {', '.join(review_names)}") + + if rf.detectionStatus: + detection_names = [ + ttypes.DetectionStatus._VALUES_TO_NAMES.get(s, str(s)) + for s in rf.detectionStatus + ] + print(f" Detection status: {', '.join(detection_names)}") + + if rf.runHistoryTag: + print(f" Tags: {', '.join(rf.runHistoryTag)}") + + if rf.componentNames: + print(f" Components: {', '.join(rf.componentNames)}") + + if rf.analyzerNames: + print(f" Analyzer names: {', '.join(rf.analyzerNames)}") + + if rf.bugPathLength: + min_val = ( + rf.bugPathLength.min + if rf.bugPathLength.min is not None + else "any") + max_val = ( + rf.bugPathLength.max + if rf.bugPathLength.max is not None + else "any") + print(f" Bug path length: {min_val}:{max_val}") + + if rf.isUnique is not None: + print(f" Is unique: {'yes' if rf.isUnique else 'no'}") + + if rf.openReportsDate: + date_str = datetime.fromtimestamp( + rf.openReportsDate).strftime('%Y-%m-%d %H:%M:%S') + print(f" Open reports date: {date_str}") + + def _fmt(ts): + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") + + if rf.date: + if rf.date.detected: + print(" Detected date range:") + if rf.date.detected.after: + print(f" After: {_fmt(rf.date.detected.after)}") + if rf.date.detected.before: + print(f" Before: {_fmt(rf.date.detected.before)}") + + if rf.date.fixed: + print(" Fixed date range:") + if rf.date.fixed.after: + print(f" After: {_fmt(rf.date.fixed.after)}") + if rf.date.fixed.before: + print(f" Before: {_fmt(rf.date.fixed.before)}") + + if rf.runName: + print(f" Run names: {', '.join(rf.runName)}") + + if rf.runTag: + print(f" Run tags: {', '.join(str(t) for t in rf.runTag)}") + + if rf.cleanupPlanNames: + print(f" Cleanup plans: {', '.join(rf.cleanupPlanNames)}") + + if rf.fileMatchesAnyPoint: + print(" File matches any point: yes") + + if rf.componentMatchesAnyPoint: + print(" Component matches any point: yes") + + if rf.annotations: + for ann in rf.annotations: + print(f" Annotation: {ann.first}={ann.second}") + + if rf.reportStatus: + status_names = [ + ttypes.ReportStatus._VALUES_TO_NAMES.get(s, str(s)) + for s in rf.reportStatus + ] + print(f" Report status: {', '.join(status_names)}") + + if rf.fullReportPathInComponent: + print(" Full report path in component: yes") + + if not any([rf.filepath, rf.checkerName, rf.checkerMsg, + rf.reportHash, rf.severity, rf.reviewStatus, + rf.detectionStatus, rf.runHistoryTag, + rf.componentNames, rf.analyzerNames, + rf.bugPathLength, + rf.openReportsDate, rf.date, + rf.runName, rf.runTag, rf.cleanupPlanNames, + rf.fileMatchesAnyPoint, rf.componentMatchesAnyPoint, + rf.annotations, rf.reportStatus, + rf.fullReportPathInComponent]): + print(" (no filters configured)") + else: + print(" (no filters configured)") + + print("=" * 60) + + +def summarize_filters(report_filter): + """ + Create a summary string of active filters for display. + """ + + if not report_filter: + return '(none)' + + active = [] + rf = report_filter + + if rf.filepath: + active.append(f"filepath({len(rf.filepath)})") + if rf.checkerName: + active.append(f"checkerName({len(rf.checkerName)})") + if rf.checkerMsg: + active.append(f"checkerMsg({len(rf.checkerMsg)})") + if rf.reportHash: + active.append(f"reportHash({len(rf.reportHash)})") + if rf.severity: + active.append(f"severity({len(rf.severity)})") + if rf.reviewStatus: + active.append(f"reviewStatus({len(rf.reviewStatus)})") + if rf.detectionStatus: + active.append(f"detectionStatus({len(rf.detectionStatus)})") + if rf.runHistoryTag: + active.append(f"tag({len(rf.runHistoryTag)})") + if rf.componentNames: + active.append(f"components({len(rf.componentNames)})") + if rf.analyzerNames: + active.append(f"analyzers({len(rf.analyzerNames)})") + if rf.bugPathLength: + active.append("bugPathLength") + if rf.date: + active.append("dateRange") + if rf.isUnique is not None: + active.append("uniqueing") + if rf.runName: + active.append(f"runName({len(rf.runName)})") + if rf.runTag: + active.append(f"runTag({len(rf.runTag)})") + if rf.openReportsDate: + active.append("openReportsDate") + if rf.cleanupPlanNames: + active.append(f"cleanupPlans({len(rf.cleanupPlanNames)})") + if rf.fileMatchesAnyPoint: + active.append("fileMatchesAnyPoint") + if rf.componentMatchesAnyPoint: + active.append("componentMatchesAnyPoint") + if rf.annotations: + active.append(f"annotations({len(rf.annotations)})") + if rf.reportStatus: + active.append(f"reportStatus({len(rf.reportStatus)})") + if rf.fullReportPathInComponent: + active.append("fullReportPathInComponent") + + return ', '.join(active) if active else '(none)' + + +def handle_new_preset(args): + """ + Handler for creating or editing a filter preset. + """ + + init_logger(args.verbose if 'verbose' in args else None) + + client = setup_client(args.product_url) + + report_filter = parse_report_filter_offline(args) + + # Check if preset already exists + existing_presets = client.listFilterPreset() + existing_preset = next( + (p for p in existing_presets if p.name == args.preset_name), None) + + if existing_preset: + LOG.error( + "Filter preset '%s' already exists. " + "Use a different name or delete the " + "existing preset to create a new one.", + args.preset_name) + + sys.exit(1) + else: + # -1 tells the server to create a new preset + preset_id_to_send = -1 + + preset_filter = ttypes.FilterPreset( + preset_id_to_send, + args.preset_name, + report_filter + ) + try: + preset_id = client.storeFilterPreset(preset_filter) + + LOG.info("Filter preset '%s' saved with ID: %d", + args.preset_name, preset_id) + + action = "updated" if existing_preset else "created" + LOG.info( + "Filter preset '%s' %s successfully.", + args.preset_name, action) + + stored_preset = client.getFilterPreset(preset_id) + + if stored_preset: + display_preset_details(stored_preset) + else: + # This should never happen, but handle gracefully + LOG.warning("Preset was saved but could not be retrieved.") + LOG.info("Preset ID: %s, Name: %s", preset_id, args.preset_name) + except Exception as e: + LOG.error("An error occurred while saving the filter preset: %s", e) + sys.exit(1) + + +def handle_list_presets(args): + """ + Handler for listing all filter presets. + """ + + # If the given output format is not 'table', + # redirect logger's output to stderr + stream = None + if 'output_format' in args and args.output_format != 'table': + stream = 'stderr' + + init_logger(args.verbose if 'verbose' in args else None, stream) + + client = setup_client(args.product_url) + + # Get all presets + presets = client.listFilterPreset() + + if not presets: + LOG.info("No filter presets found.") + return + + if args.output_format == 'json': + _enum_maps = { + 'severity': ttypes.Severity._VALUES_TO_NAMES, + 'reviewStatus': ttypes.ReviewStatus._VALUES_TO_NAMES, + 'detectionStatus': ttypes.DetectionStatus._VALUES_TO_NAMES, + 'reportStatus': ttypes.ReportStatus._VALUES_TO_NAMES, + } + + output = [] + for preset in presets: + filter_dict = thrift_to_json(preset.reportFilter) or {} + + for key, names in _enum_maps.items(): + if key in filter_dict and isinstance( + filter_dict[key], list): + filter_dict[key] = [ + names.get(v, v) for v in filter_dict[key]] + + preset_dict = { + 'id': preset.id, + 'name': preset.name, + 'filters': filter_dict + } + output.append(preset_dict) + + print(json.dumps(output, indent=2)) + else: # plaintext, csv, table + header = ['ID', 'Name', 'Active Filters'] + rows = [] + for preset in presets: + filter_summary = summarize_filters(preset.reportFilter) + + rows.append(( + str(preset.id), + preset.name, + filter_summary + )) + + print(twodim.to_str(args.output_format, header, rows)) + + +def handle_delete_preset(args): + """ + Handler for deleting a filter preset by its ID. + """ + + init_logger(args.verbose if 'verbose' in args else None) + + client = setup_client(args.product_url) + + all_presets = client.listFilterPreset() + preset = next((p for p in all_presets if p.id == args.preset_id), None) + + if not preset: + LOG.error("Filter preset with ID %d does not exist!", + args.preset_id) + sys.exit(1) + try: + success = client.deleteFilterPreset(args.preset_id) + + if success: + LOG.info( + "Filter preset '%s' (ID: %d) " + "successfully deleted.", + preset.name, args.preset_id) + else: + LOG.error("An error occurred when deleting the filter preset.") + sys.exit(1) + except Exception as e: + LOG.error("An error occurred while deleting the filter preset: %s", e) + sys.exit(1) diff --git a/web/client/codechecker_client/helpers/results.py b/web/client/codechecker_client/helpers/results.py index 26a79c5eec..4013185077 100644 --- a/web/client/codechecker_client/helpers/results.py +++ b/web/client/codechecker_client/helpers/results.py @@ -168,6 +168,22 @@ def removeSourceComponent(self, name): # STORAGE RELATED API CALLS + @thrift_client_call + def storeFilterPreset(self, preset): + pass + + @thrift_client_call + def getFilterPreset(self, id): + pass + + @thrift_client_call + def deleteFilterPreset(self, id): + pass + + @thrift_client_call + def listFilterPreset(self): + pass + @thrift_client_call def getMissingContentHashes(self, file_hashes): pass diff --git a/web/codechecker_web/shared/version.py b/web/codechecker_web/shared/version.py index fa2de94d4c..8d8171bc34 100644 --- a/web/codechecker_web/shared/version.py +++ b/web/codechecker_web/shared/version.py @@ -20,7 +20,7 @@ # The newest supported minor version (value) for each supported major version # (key) in this particular build. SUPPORTED_VERSIONS = { - 6: 67 + 6: 68 } # Used by the client to automatically identify the latest major and minor diff --git a/web/server/codechecker_server/api/report_server.py b/web/server/codechecker_server/api/report_server.py index acd8207409..5875038407 100644 --- a/web/server/codechecker_server/api/report_server.py +++ b/web/server/codechecker_server/api/report_server.py @@ -51,6 +51,7 @@ from codechecker_api_shared.ttypes import ErrorCode, RequestFailed from codechecker_common import util +from codechecker_common.util import thrift_to_json from codechecker_common.logger import get_logger from codechecker_web.shared import webserver_context @@ -71,7 +72,7 @@ File, FileContent, \ Report, ReportAnnotations, ReportAnalysisInfo, ReviewStatus, \ Run, RunHistory, RunHistoryAnalysisInfo, RunLock, \ - SourceComponent, SourceComponentFile + SourceComponent, SourceComponentFile, FilterPreset from .common import exc_to_thrift_reqfail from .thrift_enum_helper import detection_status_enum, \ @@ -1437,6 +1438,70 @@ def remove_reports(session: DBSession, .delete(synchronize_session=False) +def transform_rf_db_to_thrift(rf_db): + """ + Transforms a ReportFilter DB object to a ReportFilter thrift object. + """ + rf_db = json.loads(rf_db) + + recreated_bug_path_length = None + if rf_db.get("bugPathLength"): + recreated_bug_path_length = ttypes.BugPathLengthRange( + min=rf_db["bugPathLength"].get("min"), + max=rf_db["bugPathLength"].get("max") + ) + + recreated_date = None + if rf_db.get("date"): + recreated_date = ttypes.ReportDate( + detected=ttypes.DateInterval(**rf_db["date"]["detected"]) + if rf_db["date"].get("detected") else None, + fixed=ttypes.DateInterval(**rf_db["date"]["fixed"]) + if rf_db["date"].get("fixed") else None + ) + + recreated_annotations = [] + if rf_db.get("annotations"): + for anno in rf_db["annotations"]: + recreated_annotations.append(ttypes.Pair( + first=anno.get("first"), + second=anno.get("second") + )) + + # NOTE: Update this mapping if new fields are added + # to the ReportFilter struct. + report_filter = ttypes.ReportFilter( + filepath=rf_db.get("filepath"), + checkerMsg=rf_db.get("checkerMsg"), + checkerName=rf_db.get("checkerName"), + reportHash=rf_db.get("reportHash"), + severity=rf_db.get("severity"), + reviewStatus=rf_db.get("reviewStatus"), + detectionStatus=rf_db.get("detectionStatus"), + runHistoryTag=rf_db.get("runHistoryTag"), + firstDetectionDate=rf_db.get("firstDetectionDate"), + fixDate=rf_db.get("fixDate"), + isUnique=rf_db.get("isUnique"), + runName=rf_db.get("runName"), + runTag=rf_db.get("runTag"), + componentNames=rf_db.get("componentNames"), + bugPathLength=recreated_bug_path_length, + date=recreated_date, + analyzerNames=rf_db.get("analyzerNames", None), + openReportsDate=rf_db.get("openReportsDate"), + cleanupPlanNames=rf_db.get("cleanupPlanNames"), + fileMatchesAnyPoint=rf_db.get( + "fileMatchesAnyPoint"), + componentMatchesAnyPoint=rf_db.get( + "componentMatchesAnyPoint"), + annotations=recreated_annotations, + reportStatus=rf_db.get("reportStatus"), + fullReportPathInComponent=rf_db.get( + "fullReportPathInComponent") + ) + return report_filter + + class ThriftRequestHandler: """ Connect to database and handle thrift client requests. @@ -1629,6 +1694,169 @@ def getRunData(self, run_filter, limit, offset, sort_mode): description=description)) return results + # Stores the given FilterPreset with the given id + # If the preset exists, it overwrites the name, and all preset values + # if the preset does not exist yet, it creates it with + # if the id is -1 a new preset filter is created + # the filter preset name must be unique. + # An error must be thrown if another preset exists with the same name. + # The encoding of the name must be unicode. (whitespaces allowed?) + # Returns: the id of the modified or created preset, -1 in case of error + # PERMISSION: PRODUCT_ADMIN + + @exc_to_thrift_reqfail + @timeit + def storeFilterPreset(self, filterpreset): + """ + Store a configured FilterPreset. + if the received preset has an id of -1, a new preset is created, + otherwise the existing preset with the given id is updated. + args: + filterpreset: object with: + - name (str): Human readable name of preset + - reportFilter: ReportFilter object itself + """ + self.__require_admin() + LOG.info("Storing filter preset in backend: %s", filterpreset.name) + try: + filter_id = filterpreset.id + name = filterpreset.name + report_filter = json.dumps( + thrift_to_json(filterpreset.reportFilter)) + + with DBSession(self._Session) as session: + # case for creating a new preset + existing_preset = session.query(FilterPreset).filter( + FilterPreset.preset_name == name + ).one_or_none() + + if filter_id == -1: + if existing_preset: + raise codechecker_api_shared.ttypes.RequestFailed( + codechecker_api_shared.ttypes.ErrorCode.DATABASE, + "A filter preset with name " + f"'{name}' already exists!") + + preset_entry = FilterPreset( + preset_name=name, + report_filter=report_filter) + + session.add(preset_entry) + session.commit() + return preset_entry.id + + # case for updating an existing preset + preset_entry = session.query(FilterPreset).filter( + FilterPreset.id == filter_id + ).one_or_none() + + if not preset_entry: + raise codechecker_api_shared.ttypes.RequestFailed( + codechecker_api_shared.ttypes.ErrorCode.DATABASE, + f"No filter preset found with id {filter_id}!") + + preset_entry.preset_name = name + preset_entry.report_filter = report_filter + session.commit() + return preset_entry.id + except Exception as ex: + session.rollback() + raise codechecker_api_shared.ttypes.RequestFailed( + codechecker_api_shared.ttypes.ErrorCode.DATABASE, + "CodeChecker could not store the filter preset: " + str(ex)) + + @exc_to_thrift_reqfail + @timeit + def deleteFilterPreset(self, preset_id): + """ + Delete a filter preset based on preset_id. + Returns the ID of the deleted preset. Raises an error if the + preset does not exist or could not be deleted. + """ + self.__require_admin() + LOG.info("Deleting filter preset by ID: %s", preset_id) + try: + with DBSession(self._Session) as session: + preset = session.query(FilterPreset). \ + filter(FilterPreset.id == preset_id).one_or_none() + + if not preset: + raise codechecker_api_shared.ttypes.RequestFailed( + codechecker_api_shared.ttypes.ErrorCode.DATABASE, + f"No filter preset found with id {preset_id}!") + + session.query(FilterPreset) \ + .filter(FilterPreset.id == preset_id) \ + .delete() + session.commit() + return preset_id + except codechecker_api_shared.ttypes.RequestFailed: + raise + except Exception as exc: + raise codechecker_api_shared.ttypes.RequestFailed( + codechecker_api_shared.ttypes.ErrorCode.DATABASE, + f"Could not delete filter preset with id {preset_id}: \ + {str(exc)}") + + @exc_to_thrift_reqfail + @timeit + def getFilterPreset(self, preset_id: int): + """ + Returns the FilterPreset identified by preset_id. + """ + self.__require_view() + LOG.info("Returning filter preset by ID: %s", preset_id) + + with DBSession(self._Session) as session: + preset = ( + session.query(FilterPreset) + .filter(FilterPreset.id == preset_id) + .one_or_none() + ) + + if preset is None: + raise codechecker_api_shared.ttypes.RequestFailed( + codechecker_api_shared.ttypes.ErrorCode.DATABASE, + f"No filter preset found with id {preset_id}!") + + report_filter = transform_rf_db_to_thrift(preset.report_filter) + + return ttypes.FilterPreset( + preset.id, preset.preset_name, report_filter) + + @exc_to_thrift_reqfail + @timeit + def listFilterPreset(self): + """ + Returns all filter presets stored for the product repository + """ + self.__require_view() + LOG.info("List back filter presets") + + try: + with DBSession(self._Session) as session: + all_presets = ( + session.query(FilterPreset).all() + ) + + if not all_presets: + return [] + + list_of_transformed_presets = [] + for preset in all_presets: + list_of_transformed_presets.append( + ttypes.FilterPreset( + preset.id, + preset.preset_name, + transform_rf_db_to_thrift( + preset.report_filter))) + + return list_of_transformed_presets + except Exception as ex: + raise codechecker_api_shared.ttypes.RequestFailed( + codechecker_api_shared.ttypes.ErrorCode.DATABASE, + "CodeChecker could not list a preset :", ex) + @exc_to_thrift_reqfail @timeit def getRunCount(self, run_filter): diff --git a/web/server/codechecker_server/database/run_db_model.py b/web/server/codechecker_server/database/run_db_model.py index 4d346c9513..f240ae7826 100644 --- a/web/server/codechecker_server/database/run_db_model.py +++ b/web/server/codechecker_server/database/run_db_model.py @@ -14,8 +14,8 @@ from typing import Optional from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, \ - LargeBinary, MetaData, String, UniqueConstraint, Table, Text -from sqlalchemy.orm import declarative_base + LargeBinary, MetaData, String, UniqueConstraint, Table, Text, JSON +from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship from sqlalchemy.sql.expression import true, false @@ -620,3 +620,15 @@ class CleanupPlanReportHash(Base): 'identifier': "RunDatabase", 'orm_meta': CC_META } + + +class FilterPreset(Base): + __tablename__ = 'filter_presets' + + id = Column(Integer, autoincrement=True, primary_key=True) + preset_name = Column(String(100), nullable=False, unique=True) + report_filter = Column(JSON, nullable=False) + + def __init__(self, preset_name, report_filter): + self.preset_name = preset_name + self.report_filter = report_filter diff --git a/web/server/codechecker_server/migrations/report/versions/24c9660f82b1_add_filter_presets_table.py b/web/server/codechecker_server/migrations/report/versions/24c9660f82b1_add_filter_presets_table.py new file mode 100644 index 0000000000..08a337916f --- /dev/null +++ b/web/server/codechecker_server/migrations/report/versions/24c9660f82b1_add_filter_presets_table.py @@ -0,0 +1,45 @@ +""" +add filter presets table + +Revision ID: 24c9660f82b1 +Revises: 198654dac219 +Create Date: 2026-02-26 11:31:18.868794 +""" + +from logging import getLogger + +from alembic import op +import sqlalchemy as sa + + +# Revision identifiers, used by Alembic. +revision = '24c9660f82b1' +down_revision = '198654dac219' +branch_labels = None +depends_on = None + + +def upgrade(): + LOG = getLogger("migration/report") + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'filter_presets', + sa.Column('id', sa.Integer(), + autoincrement=True, nullable=False), + sa.Column('preset_name', sa.String(length=100), + nullable=False), + sa.Column('report_filter', sa.JSON(), + nullable=False), + sa.PrimaryKeyConstraint( + 'id', name=op.f('pk_filter_presets')), + sa.UniqueConstraint( + 'preset_name', + name=op.f('uq_filter_presets_preset_name')) + ) + + +def downgrade(): + LOG = getLogger("migration/report") + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('filter_presets') + # ### end Alembic commands ### diff --git a/web/server/vue-cli/package-lock.json b/web/server/vue-cli/package-lock.json index 6d7066acfd..92c811b539 100644 --- a/web/server/vue-cli/package-lock.json +++ b/web/server/vue-cli/package-lock.json @@ -11,7 +11,7 @@ "@mdi/font": "^6.5.95", "chart.js": "^2.9.4", "chartjs-plugin-datalabels": "^0.7.0", - "codechecker-api": "file:../../api/js/codechecker-api-node/dist/codechecker-api-6.67.0.tgz", + "codechecker-api": "file:../../api/js/codechecker-api-node/dist/codechecker-api-6.68.0.tgz", "codemirror": "^5.65.0", "date-fns": "^2.28.0", "js-cookie": "^3.0.1", @@ -5059,9 +5059,9 @@ } }, "node_modules/codechecker-api": { - "version": "6.67.0", - "resolved": "file:../../api/js/codechecker-api-node/dist/codechecker-api-6.67.0.tgz", - "integrity": "sha512-zwk1Zxq3z2bYQL8HbCFuCAx0mFu1dB5aPFspErNFTKw5MISG5CpZiBtAo0v5TjIjmeu6CFvWqcPvUW+1oe7iWw==", + "version": "6.68.0", + "resolved": "file:../../api/js/codechecker-api-node/dist/codechecker-api-6.68.0.tgz", + "integrity": "sha512-J6WznsmyHYpHTMcHZM3W1yQ0ZTZyLMwnSfDeQRhf76kAFFBYuJ1lirc+ynOkoVQLpX2ChjNd5T+8jrjp40E0tw==", "license": "SEE LICENSE IN LICENSE", "dependencies": { "thrift": "0.13.0-hotfix.1" diff --git a/web/server/vue-cli/package.json b/web/server/vue-cli/package.json index 1a3966529d..d9302df279 100644 --- a/web/server/vue-cli/package.json +++ b/web/server/vue-cli/package.json @@ -29,7 +29,7 @@ "@mdi/font": "^6.5.95", "chart.js": "^2.9.4", "chartjs-plugin-datalabels": "^0.7.0", - "codechecker-api": "file:../../api/js/codechecker-api-node/dist/codechecker-api-6.67.0.tgz", + "codechecker-api": "file:../../api/js/codechecker-api-node/dist/codechecker-api-6.68.0.tgz", "codemirror": "^5.65.0", "date-fns": "^2.28.0", "js-cookie": "^3.0.1", diff --git a/web/server/vue-cli/src/components/Report/ReportFilter/Filters/PresetMenu.vue b/web/server/vue-cli/src/components/Report/ReportFilter/Filters/PresetMenu.vue new file mode 100644 index 0000000000..345cfd5fb9 --- /dev/null +++ b/web/server/vue-cli/src/components/Report/ReportFilter/Filters/PresetMenu.vue @@ -0,0 +1,250 @@ + + + + + \ No newline at end of file diff --git a/web/server/vue-cli/src/components/Report/ReportFilter/Filters/SourceComponentFilter.vue b/web/server/vue-cli/src/components/Report/ReportFilter/Filters/SourceComponentFilter.vue index bbf07a52dd..5c966051af 100644 --- a/web/server/vue-cli/src/components/Report/ReportFilter/Filters/SourceComponentFilter.vue +++ b/web/server/vue-cli/src/components/Report/ReportFilter/Filters/SourceComponentFilter.vue @@ -36,7 +36,7 @@