bevor-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. bevor_cli/__init__.py +0 -0
  2. bevor_cli/annotations/__init__.py +0 -0
  3. bevor_cli/annotations/confirmations.py +16 -0
  4. bevor_cli/annotations/query.py +25 -0
  5. bevor_cli/apps/agent.py +10 -0
  6. bevor_cli/apps/analyses.py +47 -0
  7. bevor_cli/apps/chat.py +16 -0
  8. bevor_cli/apps/codes.py +18 -0
  9. bevor_cli/apps/findings.py +22 -0
  10. bevor_cli/apps/main.py +61 -0
  11. bevor_cli/apps/nodes.py +17 -0
  12. bevor_cli/apps/projects.py +14 -0
  13. bevor_cli/apps/sessions.py +13 -0
  14. bevor_cli/apps/workspaces.py +16 -0
  15. bevor_cli/client.py +157 -0
  16. bevor_cli/commands/__init__.py +0 -0
  17. bevor_cli/commands/agent/__init__.py +0 -0
  18. bevor_cli/commands/agent/materialze_cmd.py +65 -0
  19. bevor_cli/commands/agent/setup_cmd.py +18 -0
  20. bevor_cli/commands/analyses/__init__.py +0 -0
  21. bevor_cli/commands/analyses/active.py +42 -0
  22. bevor_cli/commands/analyses/chats/__init__.py +0 -0
  23. bevor_cli/commands/analyses/chats/get.py +39 -0
  24. bevor_cli/commands/analyses/chats/list.py +64 -0
  25. bevor_cli/commands/analyses/chats/messages.py +41 -0
  26. bevor_cli/commands/analyses/chats/new.py +44 -0
  27. bevor_cli/commands/analyses/chats/resume.py +43 -0
  28. bevor_cli/commands/analyses/chats/session.py +295 -0
  29. bevor_cli/commands/analyses/child.py +81 -0
  30. bevor_cli/commands/analyses/commit.py +49 -0
  31. bevor_cli/commands/analyses/create.py +105 -0
  32. bevor_cli/commands/analyses/delete.py +65 -0
  33. bevor_cli/commands/analyses/diff.py +87 -0
  34. bevor_cli/commands/analyses/entrypoints.py +56 -0
  35. bevor_cli/commands/analyses/findings.py +129 -0
  36. bevor_cli/commands/analyses/fork.py +47 -0
  37. bevor_cli/commands/analyses/list.py +85 -0
  38. bevor_cli/commands/analyses/merge.py +46 -0
  39. bevor_cli/commands/analyses/retry.py +77 -0
  40. bevor_cli/commands/analyses/run.py +87 -0
  41. bevor_cli/commands/analyses/select.py +85 -0
  42. bevor_cli/commands/analyses/set_head.py +45 -0
  43. bevor_cli/commands/analyses/transitions.py +90 -0
  44. bevor_cli/commands/analyses/view.py +94 -0
  45. bevor_cli/commands/auth/__init__.py +7 -0
  46. bevor_cli/commands/auth/login.py +135 -0
  47. bevor_cli/commands/auth/logout.py +23 -0
  48. bevor_cli/commands/auth/whoami.py +21 -0
  49. bevor_cli/commands/codes/__init__.py +0 -0
  50. bevor_cli/commands/codes/list.py +90 -0
  51. bevor_cli/commands/codes/push.py +76 -0
  52. bevor_cli/commands/codes/select.py +93 -0
  53. bevor_cli/commands/codes/view.py +80 -0
  54. bevor_cli/commands/context/__init__.py +92 -0
  55. bevor_cli/commands/findings/__init__.py +0 -0
  56. bevor_cli/commands/findings/acknowledge.py +28 -0
  57. bevor_cli/commands/findings/add.py +236 -0
  58. bevor_cli/commands/findings/bulk.py +110 -0
  59. bevor_cli/commands/findings/delete.py +28 -0
  60. bevor_cli/commands/findings/edit.py +109 -0
  61. bevor_cli/commands/findings/get.py +62 -0
  62. bevor_cli/commands/findings/interactive.py +329 -0
  63. bevor_cli/commands/findings/list.py +108 -0
  64. bevor_cli/commands/findings/revert.py +28 -0
  65. bevor_cli/commands/graph/__init__.py +0 -0
  66. bevor_cli/commands/graph/call_chain.py +48 -0
  67. bevor_cli/commands/graph/edges.py +59 -0
  68. bevor_cli/commands/graph/interactive.py +207 -0
  69. bevor_cli/commands/graph/renderer.py +135 -0
  70. bevor_cli/commands/link/__init__.py +52 -0
  71. bevor_cli/commands/link/helpers.py +26 -0
  72. bevor_cli/commands/nodes/__init__.py +0 -0
  73. bevor_cli/commands/nodes/get.py +52 -0
  74. bevor_cli/commands/nodes/list.py +75 -0
  75. bevor_cli/commands/nodes/source.py +40 -0
  76. bevor_cli/commands/projects/__init__.py +0 -0
  77. bevor_cli/commands/projects/create.py +54 -0
  78. bevor_cli/commands/projects/list.py +57 -0
  79. bevor_cli/commands/projects/select.py +75 -0
  80. bevor_cli/commands/projects/view.py +49 -0
  81. bevor_cli/commands/sessions/__init__.py +0 -0
  82. bevor_cli/commands/sessions/list.py +36 -0
  83. bevor_cli/commands/sessions/revoke.py +35 -0
  84. bevor_cli/commands/workspaces/__init__.py +0 -0
  85. bevor_cli/commands/workspaces/list.py +50 -0
  86. bevor_cli/commands/workspaces/pick.py +20 -0
  87. bevor_cli/commands/workspaces/select.py +68 -0
  88. bevor_cli/commands/workspaces/view.py +48 -0
  89. bevor_cli/configuration/creds.py +52 -0
  90. bevor_cli/configuration/settings.py +54 -0
  91. bevor_cli/configuration/state.py +113 -0
  92. bevor_cli/findings_yaml.py +291 -0
  93. bevor_cli/main.py +33 -0
  94. bevor_cli/output.py +69 -0
  95. bevor_cli/select.py +64 -0
  96. bevor_cli/settings.py +4 -0
  97. bevor_cli-0.1.0.dist-info/METADATA +14 -0
  98. bevor_cli-0.1.0.dist-info/RECORD +100 -0
  99. bevor_cli-0.1.0.dist-info/WHEEL +4 -0
  100. bevor_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,110 @@
1
+ """``bevor findings bulk`` / ``bevor agent submit``."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import List, Optional, cast
6
+
7
+ import typer
8
+ from bevor_sdk.helpers.converters.sarif import sarif_to_bevor
9
+ from bevor_sdk.types import ID
10
+ from bevor_sdk.types.requests.security import FindingBodyDict
11
+ from bevor_sdk.types.requests.security.findings import AddFindingBodyDict
12
+
13
+ from bevor_cli import output
14
+ from bevor_cli.client import BevorInternalClient
15
+ from bevor_cli.configuration.creds import Credentials
16
+ from bevor_cli.configuration.state import State
17
+ from bevor_cli.findings_yaml import FindingYamlParser
18
+
19
+ _YAML_SUFFIXES = frozenset({".yaml", ".yml"})
20
+
21
+
22
+ def _read(path: Path) -> str:
23
+ try:
24
+ return path.read_text(encoding="utf-8")
25
+ except OSError as exc:
26
+ output.error(f"Cannot read file: {exc}")
27
+ raise typer.Exit(1) from exc
28
+
29
+
30
+ def bulk_findings(
31
+ source: Path = typer.Argument(
32
+ ..., help="A .json file, .sarif file, .yaml/.yml file, or a directory of YAML files."
33
+ ),
34
+ version: Optional[str] = typer.Option(
35
+ None,
36
+ "--version",
37
+ help="Version id to stage against (default: configured analysis head).",
38
+ ),
39
+ ) -> None:
40
+ """Import many findings at once to the configured analysis head, or ``--version`` if given
41
+
42
+ ``source`` is dispatched by type:
43
+
44
+ - a directory: every ``.yaml``/``.yml`` file in it is parsed, each of which
45
+ may itself contain multiple findings (``---``-separated documents).
46
+ - a ``.yaml``/``.yml`` file: parsed the same way, as a single source.
47
+ - a ``.sarif`` file: converted from SARIF into Bevor findings.
48
+ """
49
+
50
+ creds = Credentials.load()
51
+ creds.require_token()
52
+
53
+ state = State.load()
54
+ workspace_id = state.validate_workspace()
55
+
56
+ client = BevorInternalClient(
57
+ creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
58
+ )
59
+
60
+ body = AddFindingBodyDict(findings=[])
61
+ version_id: ID | None = None
62
+ if version is None:
63
+ analysis_id = state.validate_analysis()
64
+ else:
65
+ version_id = ID(version)
66
+ v_ = client.analysis_versions.get(version_id)
67
+ analysis_id = v_.analysis_meta.id
68
+ body["version_id"] = version_id
69
+
70
+ if source.is_dir():
71
+ findings = FindingYamlParser.from_directory(source).parse_many()
72
+ elif source.suffix.lower() in _YAML_SUFFIXES:
73
+ if not source.is_file():
74
+ output.error(f"File does not exist: {source}")
75
+ raise typer.Exit(1)
76
+ findings = FindingYamlParser.from_file(source).parse_many()
77
+ elif source.suffix.lower() == ".sarif":
78
+ if not source.is_file():
79
+ output.error(f"File does not exist: {source}")
80
+ raise typer.Exit(1)
81
+ try:
82
+ sarif_raw = json.loads(_read(source))
83
+ except json.JSONDecodeError as exc:
84
+ output.error(f"Invalid JSON in {source.name}: {exc}")
85
+ raise typer.Exit(1) from exc
86
+ try:
87
+ findings = sarif_to_bevor(sarif_raw)
88
+ except ValueError as exc:
89
+ output.error(f"Invalid sarif format {exc.args}")
90
+ raise typer.Exit(1)
91
+ elif source.suffix.lower() == ".json":
92
+ if not source.is_file():
93
+ output.error(f"File does not exist: {source}")
94
+ raise typer.Exit(1)
95
+ try:
96
+ raw = json.loads(_read(source))
97
+ except json.JSONDecodeError as exc:
98
+ output.error(f"Could not read JSON: {exc}")
99
+ raise typer.Exit(1) from exc
100
+ findings = cast(List[FindingBodyDict], raw["findings"])
101
+ else:
102
+ output.error(
103
+ "Unsupported source (use a .json file, .sarif file, .yaml/.yml file, or a directory of YAML files)."
104
+ )
105
+ raise typer.Exit(1)
106
+
107
+ body["findings"] = findings
108
+
109
+ findings_added = client.analyses.add_findings(analysis_id=analysis_id, body=body)
110
+ output.success(f"Imported {len(findings_added.results)} finding(s) onto version {version_id or 'head'}.")
@@ -0,0 +1,28 @@
1
+ """``bevor analyses findings delete``."""
2
+
3
+ import typer
4
+ from bevor_sdk.types import ID
5
+
6
+ from bevor_cli import output
7
+ from bevor_cli.client import BevorInternalClient
8
+ from bevor_cli.configuration.creds import Credentials
9
+ from bevor_cli.configuration.state import State
10
+
11
+
12
+ def delete_finding(
13
+ finding_id: str = typer.Argument(..., help="Finding id to delete."),
14
+ ) -> None:
15
+ """Stage a finding deletion."""
16
+ creds = Credentials.load()
17
+ creds.require_token()
18
+
19
+ state = State.load()
20
+ workspace_id = state.validate_workspace()
21
+
22
+ client = BevorInternalClient(
23
+ creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
24
+ )
25
+
26
+ client.findings.delete(ID(finding_id))
27
+
28
+ output.success("Finding deletion staged.")
@@ -0,0 +1,109 @@
1
+ """``bevor analyses findings edit``."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import click
7
+ import typer
8
+ from bevor_sdk.types import ID
9
+ from bevor_sdk.types.requests.security import EditFindingBodyDict
10
+
11
+ from bevor_cli import output
12
+ from bevor_cli.client import BevorInternalClient
13
+ from bevor_cli.configuration.creds import Credentials
14
+ from bevor_cli.configuration.state import State
15
+ from bevor_cli.findings_yaml import FindingFormatError, FindingYamlParser, ParsedFindingDoc
16
+
17
+
18
+ def _interactive_edit_doc(client: BevorInternalClient, *, finding_id: str) -> ParsedFindingDoc:
19
+ existing = client.findings.get(ID(finding_id))
20
+
21
+ ftype = typer.prompt("Type", default=str(existing.type)).strip()
22
+ lvl = typer.prompt("Level", default=str(existing.level)).strip()
23
+ fname = typer.prompt("Name", default=existing.name).strip()
24
+
25
+ typer.echo("", err=True)
26
+ typer.echo(
27
+ "Opening your $EDITOR — edit explanation (Markdown ok). Save and exit to continue.",
28
+ err=True,
29
+ )
30
+ explanation = click.edit(existing.explanation)
31
+ if explanation is None:
32
+ output.error("Aborted.")
33
+ raise typer.Exit(1)
34
+ body_st = explanation.strip()
35
+ if not body_st:
36
+ output.error("Explanation cannot be empty.")
37
+ raise typer.Exit(1)
38
+
39
+ rec_raw = typer.prompt(
40
+ "Recommendation (optional, empty to clear)",
41
+ default=existing.recommendation or "",
42
+ show_default=False,
43
+ ).strip()
44
+
45
+ yaml_doc: dict[str, Any] = {
46
+ "type": ftype,
47
+ "level": lvl,
48
+ "name": fname,
49
+ "explanation": body_st,
50
+ }
51
+ if rec_raw:
52
+ yaml_doc["recommendation"] = rec_raw
53
+
54
+ return ParsedFindingDoc(index=1, yaml=yaml_doc)
55
+
56
+
57
+ def edit_finding(
58
+ finding_id: str = typer.Argument(..., help="Finding id to edit."),
59
+ file: Path | None = typer.Option(
60
+ None,
61
+ "--file",
62
+ "-f",
63
+ help="YAML with one mapping: ``type``, ``level``, ``name``, ``explanation``; optional "
64
+ "``recommendation`` / ``reference``.",
65
+ ),
66
+ ) -> None:
67
+ """Stage an edit to a finding (`--file` YAML or interactive matching current metadata)."""
68
+
69
+ creds = Credentials.load()
70
+ creds.require_token()
71
+
72
+ state = State.load()
73
+ workspace_id = state.validate_workspace()
74
+
75
+ client = BevorInternalClient(
76
+ creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
77
+ )
78
+
79
+ if file is not None:
80
+ try:
81
+ payload, err = FindingYamlParser.from_file(file).parse_edit()
82
+ except FindingFormatError as exc:
83
+ output.error(
84
+ str(exc), hint="One YAML mapping per file (same layout as findings add ``--file``)."
85
+ )
86
+ raise typer.Exit(1) from exc
87
+ else:
88
+ payload, err = FindingYamlParser.validate_edit_doc(
89
+ _interactive_edit_doc(client, finding_id=finding_id)
90
+ )
91
+
92
+ if err:
93
+ output.error(err)
94
+ raise typer.Exit(1)
95
+ assert payload is not None
96
+
97
+ client.findings.edit_metadata(
98
+ ID(finding_id),
99
+ EditFindingBodyDict(
100
+ type=payload["type"],
101
+ level=payload["level"],
102
+ name=str(payload["name"]),
103
+ explanation=str(payload["explanation"]),
104
+ recommendation=payload.get("recommendation"),
105
+ poc=payload.get("poc"),
106
+ ),
107
+ )
108
+
109
+ output.success("Finding edit staged.")
@@ -0,0 +1,62 @@
1
+ """``bevor analyses findings get`` — same lookup as ``bevor findings get``."""
2
+
3
+ from typing import Mapping
4
+
5
+ import typer
6
+ from bevor_sdk.types import ID
7
+ from bevor_sdk.types.literals import FindingLevel
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from bevor_cli.annotations.confirmations import JSON_OUT
12
+ from bevor_cli.client import BevorInternalClient
13
+ from bevor_cli.configuration.creds import Credentials
14
+ from bevor_cli.configuration.state import State
15
+ from bevor_cli.output import console
16
+
17
+ LEVEL_COLORS: Mapping[FindingLevel, str] = {
18
+ "critical": "red",
19
+ "high": "bright_red",
20
+ "medium": "yellow",
21
+ "low": "blue",
22
+ }
23
+
24
+
25
+ def get_finding(
26
+ finding_id: str = typer.Argument(..., help="Finding ID."), json_out: JSON_OUT = False
27
+ ) -> None:
28
+ """Get the finding details."""
29
+ creds = Credentials.load()
30
+ creds.require_token()
31
+
32
+ state = State.load()
33
+ workspace_id = state.validate_workspace()
34
+
35
+ client = BevorInternalClient(
36
+ creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
37
+ )
38
+
39
+ finding = client.findings.get(ID(finding_id))
40
+
41
+ if json_out:
42
+ typer.echo(finding.model_dump_json(indent=2))
43
+ return
44
+
45
+ level_style = LEVEL_COLORS[finding.level]
46
+
47
+ table = Table(show_header=False, box=None, padding=(0, 1))
48
+ table.add_column(style="bold")
49
+ table.add_column()
50
+
51
+ table.add_row("ID", str(finding.id))
52
+ table.add_row("Code", str(finding.code_id))
53
+ table.add_row("Name", finding.name)
54
+ table.add_row("Type", str(finding.type))
55
+ table.add_row("Level", Text(finding.level, style=level_style))
56
+ table.add_row("Explanation", finding.explanation)
57
+ if finding.recommendation:
58
+ table.add_row("Recommendation", finding.recommendation)
59
+ if finding.poc:
60
+ table.add_row("POC (unverified)", finding.poc)
61
+
62
+ console.print(table)
@@ -0,0 +1,329 @@
1
+ """Interactive + static Rich display for findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textwrap import wrap
6
+ from typing import Callable, List
7
+
8
+ import click
9
+ import typer
10
+ from bevor_sdk.types.literals import FindingDraftOperation
11
+ from bevor_sdk.types.responses.security import FindingSchema
12
+ from rich.console import Group
13
+ from rich.live import Live
14
+ from rich.panel import Panel
15
+ from rich.rule import Rule
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+
19
+ from bevor_cli import output
20
+ from bevor_cli.client import BevorInternalClient
21
+ from bevor_cli.commands.findings.get import LEVEL_COLORS
22
+
23
+ _LIST_WINDOW = 8
24
+ _MIN_DETAIL_LINES = 6
25
+
26
+ _OP_COLORS = {"add": "green", "edit": "yellow", "delete": "red"}
27
+
28
+
29
+ def _op_badge(operation: FindingDraftOperation | None) -> Text:
30
+ if not operation:
31
+ return Text("—", style="dim")
32
+ return Text(operation.upper(), style=f"bold {_OP_COLORS.get(operation, 'white')}")
33
+
34
+
35
+ def _nav_delta(key: str) -> int:
36
+ if not key:
37
+ return 0
38
+ if len(key) == 1 and key.lower() in ("j", "l"):
39
+ return 1
40
+ if len(key) == 1 and key.lower() in ("k", "h"):
41
+ return -1
42
+ if key.endswith("B"): # down arrow
43
+ return 1
44
+ if key.endswith("A"): # up arrow
45
+ return -1
46
+ return 0
47
+
48
+
49
+ def _action_hints(finding: FindingSchema) -> str:
50
+ parts = ["↑↓/j/k move", "q quit"]
51
+ if finding.actions.edit.is_allowed:
52
+ parts.append("e edit")
53
+ if finding.actions.revert.is_allowed:
54
+ parts.append("r revert")
55
+ if finding.actions.delete.is_allowed:
56
+ parts.append("x delete")
57
+ return " ".join(parts)
58
+
59
+
60
+ def _fit_text(text: str, *, max_lines: int, width: int) -> Text:
61
+ if not text:
62
+ return Text("—")
63
+ lines = wrap(text, width=width)
64
+ if len(lines) <= max_lines:
65
+ return Text("\n".join(lines))
66
+ clipped = lines[:max_lines]
67
+ clipped[-1] = clipped[-1][: max(0, width - 1)] + "…"
68
+ return Text("\n".join(clipped))
69
+
70
+
71
+ def _detail_text_budget() -> int:
72
+ height = output.console.size.height
73
+ list_rows = _LIST_WINDOW + 5 # header, caption, borders
74
+ overhead = list_rows + 14 # context panel, rule, hints
75
+ return max(_MIN_DETAIL_LINES, height - overhead)
76
+
77
+
78
+ def _findings_table(
79
+ rows: List[FindingSchema],
80
+ focus: int | None = None,
81
+ *,
82
+ windowed: bool = False,
83
+ ) -> Group | Table:
84
+ if not windowed or focus is None:
85
+ table = Table("Level", "Type", "Name", "Original", "ID")
86
+ for i, finding in enumerate(rows):
87
+ style = "reverse" if focus is not None and i == focus else ""
88
+ table.add_row(
89
+ Text(finding.level, style=LEVEL_COLORS[finding.level]),
90
+ finding.type,
91
+ finding.name,
92
+ str(finding.origin_finding_id == finding.id),
93
+ str(finding.id),
94
+ style=style,
95
+ )
96
+ return table
97
+
98
+ start = max(0, min(focus - _LIST_WINDOW // 2, len(rows) - _LIST_WINDOW))
99
+ end = min(len(rows), start + _LIST_WINDOW)
100
+ start = max(0, end - _LIST_WINDOW)
101
+
102
+ above = start
103
+ below = len(rows) - end
104
+ table = Table(
105
+ "Level",
106
+ "Type",
107
+ "Name",
108
+ "Original",
109
+ "ID",
110
+ caption=f"↑ {above} {focus + 1}/{len(rows)} ↓ {below}",
111
+ caption_justify="center",
112
+ )
113
+ for i in range(start, end):
114
+ finding = rows[i]
115
+ table.add_row(
116
+ Text(finding.level, style=LEVEL_COLORS[finding.level]),
117
+ finding.type,
118
+ finding.name,
119
+ str(finding.origin_finding_id == finding.id),
120
+ str(finding.id),
121
+ style="reverse" if i == focus else "",
122
+ )
123
+ for _ in range(_LIST_WINDOW - (end - start)):
124
+ table.add_row("", "", "", "", "", style="dim")
125
+
126
+ return table
127
+
128
+
129
+ def _findings_table_with_ops(
130
+ rows: List[FindingSchema],
131
+ focus: int | None = None,
132
+ *,
133
+ windowed: bool = False,
134
+ ) -> Group | Table:
135
+ if not windowed or focus is None:
136
+ table = Table("Op", "Level", "Type", "Name", "Original", "ID")
137
+ for i, finding in enumerate(rows):
138
+ style = "reverse" if focus is not None and i == focus else ""
139
+ table.add_row(
140
+ _op_badge(finding.operation),
141
+ Text(finding.level, style=LEVEL_COLORS[finding.level]),
142
+ finding.type,
143
+ finding.name,
144
+ str(finding.origin_finding_id == finding.id),
145
+ str(finding.id),
146
+ style=style,
147
+ )
148
+ return table
149
+
150
+ start = max(0, min(focus - _LIST_WINDOW // 2, len(rows) - _LIST_WINDOW))
151
+ end = min(len(rows), start + _LIST_WINDOW)
152
+ start = max(0, end - _LIST_WINDOW)
153
+
154
+ above = start
155
+ below = len(rows) - end
156
+ table = Table(
157
+ "Op",
158
+ "Level",
159
+ "Type",
160
+ "Name",
161
+ "Original",
162
+ "ID",
163
+ caption=f"↑ {above} {focus + 1}/{len(rows)} ↓ {below}",
164
+ caption_justify="center",
165
+ )
166
+ for i in range(start, end):
167
+ finding = rows[i]
168
+ table.add_row(
169
+ _op_badge(finding.operation),
170
+ Text(finding.level, style=LEVEL_COLORS[finding.level]),
171
+ finding.type,
172
+ finding.name,
173
+ str(finding.origin_finding_id == finding.id),
174
+ str(finding.id),
175
+ style="reverse" if i == focus else "",
176
+ )
177
+ for _ in range(_LIST_WINDOW - (end - start)):
178
+ table.add_row("", "", "", "", "", "", style="dim")
179
+
180
+ return table
181
+
182
+
183
+ def _kv_table(*, dim: bool = False) -> Table:
184
+ table = Table(show_header=False, box=None, padding=(0, 1))
185
+ label_style = "dim bold" if dim else "bold"
186
+ value_style = "dim" if dim else ""
187
+ table.add_column(style=label_style, no_wrap=True)
188
+ table.add_column(style=value_style)
189
+ return table
190
+
191
+
192
+ def _context_panel(finding: FindingSchema) -> Panel:
193
+ author = finding.created_by_user.username or str(finding.created_by_user.id)
194
+ meta = _kv_table(dim=True)
195
+ meta.add_row("ID", str(finding.id))
196
+ meta.add_row("Analysis", str(finding.version.analysis_id))
197
+ meta.add_row("Project", str(finding.code_id))
198
+ meta.add_row("Code version", str(finding.code_id))
199
+ meta.add_row("Created", str(finding.created_at.date()))
200
+ meta.add_row("Created by", author)
201
+ meta.add_row("Node", str(finding.node_id))
202
+ return Panel(meta, title="Context", border_style="dim", padding=(0, 1))
203
+
204
+
205
+ def _finding_panel(finding: FindingSchema, *, text_budget: int) -> Panel:
206
+ width = max(40, output.console.size.width - 8)
207
+ header = _kv_table()
208
+ header.add_row("Level", Text(finding.level, style=LEVEL_COLORS[finding.level]))
209
+ header.add_row("Type", str(finding.type))
210
+ header.add_row("Acknowledged", str(finding.is_acknowledged))
211
+
212
+ sections = 1 + bool(finding.recommendation) + bool(finding.poc)
213
+ body_lines = max(2, text_budget - sections * 2 - 4)
214
+
215
+ body: list[Text | Table] = [header, Text("")]
216
+ body.append(Text("Explanation", style="bold"))
217
+ body.append(_fit_text(finding.explanation or "", max_lines=body_lines, width=width))
218
+ if finding.recommendation:
219
+ body.extend(
220
+ [
221
+ Text(""),
222
+ Text("Recommendation", style="bold"),
223
+ _fit_text(finding.recommendation, max_lines=3, width=width),
224
+ ]
225
+ )
226
+ if finding.poc:
227
+ body.extend(
228
+ [
229
+ Text(""),
230
+ Text("POC", style="bold"),
231
+ _fit_text(finding.poc, max_lines=2, width=width),
232
+ ]
233
+ )
234
+
235
+ return Panel(Group(*body), title=finding.name, border_style="cyan", padding=(0, 1))
236
+
237
+
238
+ def _detail_panel(finding: FindingSchema) -> Group:
239
+ return Group(
240
+ _context_panel(finding),
241
+ Rule(style="dim"),
242
+ _finding_panel(finding, text_budget=_detail_text_budget()),
243
+ )
244
+
245
+
246
+ def _render(rows: List[FindingSchema], focus: int) -> Group:
247
+ return Group(
248
+ _findings_table(rows, focus=focus, windowed=True),
249
+ _detail_panel(rows[focus]),
250
+ Text("↑↓ or j/k move q quit", style="dim"),
251
+ )
252
+
253
+
254
+ def print_findings_table(rows: List[FindingSchema]) -> None:
255
+ table = _findings_table(rows)
256
+ assert isinstance(table, Table)
257
+ output.console.print(table)
258
+
259
+
260
+ def print_findings_table_with_ops(rows: List[FindingSchema]) -> None:
261
+ table = _findings_table_with_ops(rows)
262
+ assert isinstance(table, Table)
263
+ output.console.print(table)
264
+
265
+
266
+ def browse_findings_with_actions(
267
+ client: BevorInternalClient,
268
+ refetch: Callable[[], List[FindingSchema]],
269
+ rows: List[FindingSchema],
270
+ ) -> None:
271
+ focus = 0
272
+
273
+ rows = list(rows)
274
+
275
+ def render() -> Group:
276
+ f = rows[focus]
277
+ return Group(
278
+ _findings_table_with_ops(rows, focus=focus, windowed=True),
279
+ _detail_panel(f),
280
+ Text(_action_hints(f), style="dim"),
281
+ )
282
+
283
+ with Live(
284
+ render(),
285
+ console=output.console,
286
+ transient=True,
287
+ auto_refresh=False,
288
+ vertical_overflow="visible",
289
+ ) as live:
290
+ while rows:
291
+ try:
292
+ ch = click.getchar()
293
+ except (EOFError, KeyboardInterrupt):
294
+ raise typer.Abort from None
295
+
296
+ key = ch if isinstance(ch, str) else ch.decode("utf-8", "replace")
297
+ if key.lower() == "q":
298
+ return
299
+
300
+ f = rows[focus]
301
+
302
+ if key.lower() == "r" and f.actions.revert.is_allowed:
303
+ live.stop()
304
+ if typer.confirm(f"Revert staged {f.operation} on {f.name!r}?"):
305
+ client.findings.revert(f.id)
306
+ rows = refetch()
307
+ focus = min(focus, len(rows) - 1)
308
+ live.start()
309
+ if not rows:
310
+ return
311
+ live.update(render(), refresh=True)
312
+ continue
313
+
314
+ if key.lower() == "x" and f.actions.delete.is_allowed:
315
+ live.stop()
316
+ if typer.confirm(f"Stage a deletion for {f.name!r}?"):
317
+ client.findings.delete(f.id)
318
+ rows = refetch()
319
+ focus = min(focus, len(rows) - 1)
320
+ live.start()
321
+ if not rows:
322
+ return
323
+ live.update(render(), refresh=True)
324
+ continue
325
+
326
+ delta = _nav_delta(key)
327
+ if delta:
328
+ focus = (focus + delta) % len(rows)
329
+ live.update(render(), refresh=True)