gozu 0.3.7__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.
- cli/__init__.py +3 -0
- cli/config_cmd/__init__.py +92 -0
- cli/config_cmd/edit.py +223 -0
- cli/config_lookup.py +87 -0
- cli/crash_handler.py +62 -0
- cli/help_links.py +38 -0
- cli/init_wizard/__init__.py +359 -0
- cli/init_wizard/env_step.py +85 -0
- cli/init_wizard/jira_step.py +87 -0
- cli/init_wizard/llm_step.py +20 -0
- cli/init_wizard/semgrep_step.py +21 -0
- cli/init_wizard/sonar_cloud.py +78 -0
- cli/init_wizard/sonar_local.py +118 -0
- cli/init_wizard/summary.py +50 -0
- cli/main.py +291 -0
- cli/prerequisites/__init__.py +22 -0
- cli/prerequisites/archive.py +106 -0
- cli/prerequisites/java.py +116 -0
- cli/prerequisites/semgrep.py +61 -0
- cli/prerequisites/sonar_scanner.py +108 -0
- cli/prerequisites/trivy.py +136 -0
- cli/prompts.py +49 -0
- cli/report.py +78 -0
- cli/scan_runner/__init__.py +197 -0
- cli/scan_runner/config_fields.py +28 -0
- cli/scan_runner/config_select.py +64 -0
- cli/scan_runner/scanner_exec.py +147 -0
- cli/scan_runner/semgrep_exec.py +24 -0
- cli/scan_runner/workflow_trigger.py +129 -0
- cli/stack/__init__.py +187 -0
- cli/stack/_stackfiles/Dockerfile +37 -0
- cli/stack/_stackfiles/__init__.py +0 -0
- cli/stack/_stackfiles/alembic.ini +160 -0
- cli/stack/_stackfiles/db/init_sonarqube_db.sql +29 -0
- cli/stack/_stackfiles/db/migrations/README +1 -0
- cli/stack/_stackfiles/db/migrations/env.py +85 -0
- cli/stack/_stackfiles/db/migrations/script.py.mako +28 -0
- cli/stack/_stackfiles/db/migrations/versions/0001_initial_schema.py +71 -0
- cli/stack/_stackfiles/db/migrations/versions/0002_add_project_key.py +32 -0
- cli/stack/_stackfiles/db/migrations/versions/0003_add_sonar_plan_and_branches.py +35 -0
- cli/stack/_stackfiles/db/migrations/versions/0004_add_ticket_claims.py +55 -0
- cli/stack/_stackfiles/db/migrations/versions/0005_add_ticket_destinations.py +67 -0
- cli/stack/_stackfiles/db/migrations/versions/0006_add_ticket_claims_status.py +34 -0
- cli/stack/_stackfiles/db/migrations/versions/0007_add_last_scan_tracking.py +38 -0
- cli/stack/_stackfiles/db/migrations/versions/0008_add_ticket_cap.py +32 -0
- cli/stack/_stackfiles/docker-compose.yml +140 -0
- cli/stack/_stackfiles/pyproject.toml +62 -0
- cli/stack/_stackfiles/uv.lock +1279 -0
- cli/stack/backup.py +77 -0
- cli/stack/cleanup.py +123 -0
- cli/stack/files.py +104 -0
- cli/stack/profiles.py +140 -0
- cli/stack/status_report.py +59 -0
- cli/stack/webhooks.py +53 -0
- cli/stack/wipe.py +117 -0
- cli/status.py +44 -0
- cli/wizard_engine.py +99 -0
- config/__init__.py +3 -0
- config/connection.py +41 -0
- config/crypto.py +60 -0
- config/migrations.py +100 -0
- config/store.py +221 -0
- config/ticket_destinations.py +122 -0
- core/__init__.py +3 -0
- core/constants.py +11 -0
- core/errors.py +55 -0
- core/models.py +94 -0
- github_action/__init__.py +3 -0
- github_action/main.py +180 -0
- gozu-0.3.7.dist-info/METADATA +490 -0
- gozu-0.3.7.dist-info/RECORD +121 -0
- gozu-0.3.7.dist-info/WHEEL +4 -0
- gozu-0.3.7.dist-info/entry_points.txt +3 -0
- llm/__init__.py +2 -0
- llm/enrich.py +147 -0
- llm/factory.py +19 -0
- receiver/__init__.py +3 -0
- receiver/app.py +141 -0
- receiver/starter.py +49 -0
- receiver/verify_signature.py +19 -0
- scanner/__init__.py +3 -0
- scanner/base.py +116 -0
- scanner/factory.py +78 -0
- scanner/html_text.py +63 -0
- scanner/screenshot.py +124 -0
- scanner/semgrep_local.py +186 -0
- scanner/sonarqube_classify.py +50 -0
- scanner/sonarqube_cloud.py +98 -0
- scanner/sonarqube_common.py +187 -0
- scanner/sonarqube_local.py +29 -0
- scanner/trivy_client.py +173 -0
- scripts/__init__.py +3 -0
- scripts/bootstrap_env.py +150 -0
- scripts/env_ports.py +70 -0
- scripts/fernet_safety.py +72 -0
- scripts/paths.py +29 -0
- temporal/__init__.py +3 -0
- temporal/activities/__init__.py +10 -0
- temporal/activities/capture_and_attach_screenshot.py +67 -0
- temporal/activities/create_tickets.py +148 -0
- temporal/activities/fetch_findings.py +74 -0
- temporal/activities/reconcile_resolved_findings.py +139 -0
- temporal/data_converter.py +11 -0
- temporal/models/__init__.py +6 -0
- temporal/models/create_tickets.py +20 -0
- temporal/models/fetch_findings.py +30 -0
- temporal/models/reconcile_resolved_findings.py +19 -0
- temporal/models/screenshot_attach.py +20 -0
- temporal/models/sonar_to_jira.py +51 -0
- temporal/worker.py +77 -0
- temporal/workflows/__init__.py +6 -0
- temporal/workflows/multi_branch_scan.py +90 -0
- temporal/workflows/reconcile_only.py +54 -0
- temporal/workflows/scan_to_ticket.py +208 -0
- ticket/AtlassianDocumentFormat.py +31 -0
- ticket/TicketClient.py +71 -0
- ticket/__init__.py +3 -0
- ticket/claims.py +134 -0
- ticket/factory.py +52 -0
- ticket/jira_client.py +434 -0
- ticket/jira_sprint.py +82 -0
cli/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Copyright (c) 2026 Calfus Inc.
|
|
2
|
+
# Author: Wasiullah Rafeeq S
|
|
3
|
+
#
|
|
4
|
+
# Depends on: src/config/store.py - listing/deleting saved configs
|
|
5
|
+
# Depends on: src/cli/config_lookup.py - resolving a config name typed by the user
|
|
6
|
+
# Depends on: src/cli/stack/__init__.py - ensuring the config store is ready before use
|
|
7
|
+
# Depends on: src/cli/stack/backup.py - backing up the database before a destructive delete
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
`gozu config list/edit/delete` - managing saved configs after `gozu init`
|
|
11
|
+
has created them, without needing to recreate one from scratch just to
|
|
12
|
+
fix a typo or remove a throwaway config. `edit` lives in its own module
|
|
13
|
+
(src/cli/config_cmd/edit.py) given its size; list/delete are small enough to
|
|
14
|
+
live here directly.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import questionary
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
import config.store as config_store
|
|
21
|
+
from cli.config_cmd.edit import edit_command
|
|
22
|
+
from cli.config_lookup import resolve_config_or_prompt
|
|
23
|
+
from cli.prompts import ask_or_exit
|
|
24
|
+
from cli.stack import ensure_config_store_ready
|
|
25
|
+
from cli.stack.backup import create_backup, next_backup_path
|
|
26
|
+
from cli.status import success, waiting
|
|
27
|
+
from scripts.paths import STACK_DIR
|
|
28
|
+
|
|
29
|
+
__all__ = ["delete_command", "edit_command", "list_command"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def list_command() -> None:
|
|
33
|
+
ensure_config_store_ready()
|
|
34
|
+
configs = config_store.list_configs()
|
|
35
|
+
if not configs:
|
|
36
|
+
typer.echo("No configs found - run `gozu init` to create one.")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
typer.echo("Configs:")
|
|
40
|
+
for config in configs:
|
|
41
|
+
typer.echo(
|
|
42
|
+
f" {config['name']:<20} scanner={config['scanner_type']} mode={config['scanner_mode']:<6} "
|
|
43
|
+
f"trigger={config['trigger_mode']}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def delete_command(name: str) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Deletes only this one config's own rows (its config_credentials
|
|
50
|
+
cascade via the FK - see config_store.delete_config()) - never the
|
|
51
|
+
ticket_destination it references, even if this was the last config
|
|
52
|
+
using it. A single y/N confirmation, not --wipe's typed-word ritual -
|
|
53
|
+
this is scoped to one config, not the whole store.
|
|
54
|
+
|
|
55
|
+
Still takes the same full-database backup --wipe does beforehand
|
|
56
|
+
(src/cli/stack/backup.py's create_backup()/next_backup_path(), reused
|
|
57
|
+
as-is - same 7-day retention, same file, no separate backup logic to
|
|
58
|
+
maintain) - a single config's blast radius doesn't warrant --wipe's
|
|
59
|
+
typed-word ritual, but losing one with nothing to restore from is the
|
|
60
|
+
exact incident --wipe's own backup step was added to prevent in the
|
|
61
|
+
first place, and that risk applies here too.
|
|
62
|
+
"""
|
|
63
|
+
ensure_config_store_ready()
|
|
64
|
+
config = resolve_config_or_prompt(name)
|
|
65
|
+
# Use the resolved config's own name from here on, not the (possibly
|
|
66
|
+
# wrong) `name` argument - resolve_config_or_prompt() may have picked
|
|
67
|
+
# a different config via its "did you mean" recovery picker.
|
|
68
|
+
name = config["name"]
|
|
69
|
+
|
|
70
|
+
typer.echo(f"This will delete config '{name}':")
|
|
71
|
+
typer.echo(f" scanner: {config['scanner_type']} ({config['scanner_mode']})")
|
|
72
|
+
typer.echo(f" trigger: {config['trigger_mode']}")
|
|
73
|
+
|
|
74
|
+
destination_id = config.get("ticket_destination_id")
|
|
75
|
+
if destination_id is not None:
|
|
76
|
+
destination = config_store.get_ticket_destination_by_id(destination_id)
|
|
77
|
+
destination_name = destination["name"] if destination else f"id={destination_id}"
|
|
78
|
+
typer.echo(f" ticket destination: '{destination_name}' (shared - this destination itself is NOT deleted)")
|
|
79
|
+
else:
|
|
80
|
+
typer.echo(" ticket destination: embedded credentials on this config (not shared with anything else)")
|
|
81
|
+
|
|
82
|
+
if not ask_or_exit(questionary.confirm(f"Delete '{name}'?", default=False)):
|
|
83
|
+
typer.echo("Cancelled - nothing was deleted.")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
backup_path = next_backup_path()
|
|
87
|
+
waiting("Backing up gozu's database before deleting ...")
|
|
88
|
+
create_backup(backup_path, STACK_DIR)
|
|
89
|
+
success(f"Backup written: {backup_path}")
|
|
90
|
+
|
|
91
|
+
config_store.delete_config(name)
|
|
92
|
+
success(f"Deleted config '{name}'.")
|
cli/config_cmd/edit.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# Copyright (c) 2026 Calfus Inc.
|
|
2
|
+
# Author: Wasiullah Rafeeq S
|
|
3
|
+
# Editor: Prakrit Mohanty
|
|
4
|
+
#
|
|
5
|
+
# Depends on: src/config/store.py - reading and updating a config's fields/credentials
|
|
6
|
+
# Depends on: src/cli/config_lookup.py - resolving a config name typed by the user
|
|
7
|
+
# Depends on: src/cli/init_wizard/jira_step.py - reusing its Jira credential prompt functions
|
|
8
|
+
# Depends on: src/cli/init_wizard/llm_step.py - reusing its Anthropic API key prompt function
|
|
9
|
+
# Depends on: src/cli/init_wizard/sonar_cloud.py - reusing its SonarQube Cloud credential prompt functions
|
|
10
|
+
# Depends on: src/cli/init_wizard/sonar_local.py - reusing its SonarQube Local credential prompt functions
|
|
11
|
+
# Depends on: src/cli/stack/__init__.py - ensuring the config store is ready before editing
|
|
12
|
+
# Depends on: src/cli/wizard_engine.py - the shared review/edit engine this command drives
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
`gozu config edit <name>` - reuses src/cli/wizard_engine.py, skipping
|
|
16
|
+
straight to the review screen pre-populated from the config's current
|
|
17
|
+
values. scanner_type/scanner_mode/sonar_plan/trigger_mode are structural
|
|
18
|
+
(decided once at `gozu init` time) and are never offered here.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import questionary
|
|
22
|
+
import typer
|
|
23
|
+
|
|
24
|
+
import config.store as config_store
|
|
25
|
+
from cli.config_lookup import resolve_config_or_prompt
|
|
26
|
+
from cli.init_wizard.jira_step import (
|
|
27
|
+
prompt_jira_api_token,
|
|
28
|
+
prompt_jira_email,
|
|
29
|
+
prompt_jira_project_key,
|
|
30
|
+
prompt_jira_url,
|
|
31
|
+
)
|
|
32
|
+
from cli.init_wizard.llm_step import prompt_anthropic_api_key
|
|
33
|
+
from cli.init_wizard.semgrep_step import prompt_project_label
|
|
34
|
+
from cli.init_wizard.sonar_cloud import (
|
|
35
|
+
prompt_premium_branches,
|
|
36
|
+
prompt_sonar_organization,
|
|
37
|
+
prompt_sonar_token_cloud,
|
|
38
|
+
)
|
|
39
|
+
from cli.init_wizard.sonar_local import prompt_project_key, prompt_sonar_token_local
|
|
40
|
+
from cli.prompts import ask_or_exit, generate_or_prompt_secret, prompt_text
|
|
41
|
+
from cli.stack import ensure_config_store_ready
|
|
42
|
+
from cli.status import success, warning
|
|
43
|
+
from cli.wizard_engine import WizardField, run_wizard
|
|
44
|
+
|
|
45
|
+
# Mirrors src/config/store.py's own _DESTINATION_RESOLVED_KEYS - decides
|
|
46
|
+
# whether to show the multi-config warning before writing.
|
|
47
|
+
_DESTINATION_RESOLVED_KEYS = {"jira_url", "jira_email", "jira_api_token", "jira_project_key"}
|
|
48
|
+
|
|
49
|
+
# Plain columns (config_store.update_config_fields()); every other
|
|
50
|
+
# editable field is a credential (update_config_credential()).
|
|
51
|
+
_PLAIN_COLUMN_KEYS = {"project_key", "branches", "ticket_cap"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _prompt_ticket_cap(current: int | None) -> int | None:
|
|
55
|
+
"""The persistent per-config default (`gozu run -t` is a separate,
|
|
56
|
+
one-off override). Blank always means "use the built-in fallback",
|
|
57
|
+
never an explicit 0."""
|
|
58
|
+
default = str(current) if current is not None else ""
|
|
59
|
+
while True:
|
|
60
|
+
answer = ask_or_exit(
|
|
61
|
+
questionary.text("Per-run ticket cap (blank = use the default):", default=default)
|
|
62
|
+
)
|
|
63
|
+
answer = answer.strip()
|
|
64
|
+
if not answer:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
value = int(answer)
|
|
68
|
+
except ValueError:
|
|
69
|
+
warning("Enter a positive whole number, or leave blank to use the default.")
|
|
70
|
+
continue
|
|
71
|
+
if value <= 0:
|
|
72
|
+
warning("Enter a positive whole number, or leave blank to use the default.")
|
|
73
|
+
continue
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_edit_fields(config: dict, state: dict) -> list[WizardField]:
|
|
78
|
+
credentials = config["credentials"]
|
|
79
|
+
scanner_type = config["scanner_type"]
|
|
80
|
+
scanner_mode = config["scanner_mode"]
|
|
81
|
+
trigger_mode = config["trigger_mode"]
|
|
82
|
+
|
|
83
|
+
state["ticket_cap"] = config.get("ticket_cap")
|
|
84
|
+
project_key_field = (
|
|
85
|
+
None
|
|
86
|
+
if scanner_type == "trivy"
|
|
87
|
+
# No project_key, no credentials, no branches at all - see
|
|
88
|
+
# src/cli/init_wizard/__init__.py's _build_fields() Trivy branch
|
|
89
|
+
# for why. Only ticket_cap and the shared Jira fields below apply.
|
|
90
|
+
else WizardField("project_key", "Project label", lambda: prompt_project_label(state.get("project_key", "")))
|
|
91
|
+
if scanner_type == "semgrep"
|
|
92
|
+
else WizardField(
|
|
93
|
+
"project_key", "SonarQube project key", lambda: prompt_project_key(state.get("project_key", ""))
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
fields = [
|
|
97
|
+
*([project_key_field] if project_key_field is not None else []),
|
|
98
|
+
WizardField(
|
|
99
|
+
"ticket_cap",
|
|
100
|
+
"Per-run ticket cap (blank = default)",
|
|
101
|
+
lambda: _prompt_ticket_cap(state.get("ticket_cap")),
|
|
102
|
+
),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
if project_key_field is not None:
|
|
106
|
+
state["project_key"] = config.get("project_key") or ""
|
|
107
|
+
|
|
108
|
+
if scanner_type in ("trivy", "semgrep"):
|
|
109
|
+
pass # no scanner credentials to edit - trivy needs none, semgrep's trigger_mode "direct" needs none
|
|
110
|
+
elif scanner_mode == "local":
|
|
111
|
+
state["sonar_token"] = credentials.get("sonar_token", "")
|
|
112
|
+
state["sonar_host_url"] = credentials.get("sonar_host_url", "")
|
|
113
|
+
fields += [
|
|
114
|
+
WizardField(
|
|
115
|
+
"sonar_token", "SonarQube token", lambda: prompt_sonar_token_local(state.get("sonar_token", "")), secret=True
|
|
116
|
+
),
|
|
117
|
+
WizardField(
|
|
118
|
+
"sonar_host_url",
|
|
119
|
+
"SonarQube host URL",
|
|
120
|
+
lambda: prompt_text("SonarQube host URL:", default=state.get("sonar_host_url", "")),
|
|
121
|
+
),
|
|
122
|
+
]
|
|
123
|
+
else:
|
|
124
|
+
state["sonar_token"] = credentials.get("sonar_token", "")
|
|
125
|
+
state["sonar_organization"] = credentials.get("sonar_organization", "")
|
|
126
|
+
state["branches"] = config.get("branches") or ""
|
|
127
|
+
fields += [
|
|
128
|
+
WizardField(
|
|
129
|
+
"sonar_token",
|
|
130
|
+
"SonarQube Cloud token",
|
|
131
|
+
lambda: prompt_sonar_token_cloud(state.get("sonar_token", "")),
|
|
132
|
+
secret=True,
|
|
133
|
+
),
|
|
134
|
+
WizardField(
|
|
135
|
+
"sonar_organization",
|
|
136
|
+
"SonarQube Cloud organization key",
|
|
137
|
+
lambda: prompt_sonar_organization(state.get("sonar_organization", "")),
|
|
138
|
+
),
|
|
139
|
+
# prompt_premium_branches()'s comma-join degrades to a single
|
|
140
|
+
# value for a one-branch string, so it's reused here rather than
|
|
141
|
+
# adding a third near-identical branches prompt just for edit.
|
|
142
|
+
WizardField(
|
|
143
|
+
"branches", "Branches to track", lambda: prompt_premium_branches(state.get("branches", "main"))
|
|
144
|
+
),
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
if trigger_mode == "webhook":
|
|
148
|
+
state["webhook_secret"] = credentials.get("webhook_secret", "")
|
|
149
|
+
fields.append(
|
|
150
|
+
WizardField("webhook_secret", "Webhook secret", lambda: generate_or_prompt_secret("Webhook secret"), secret=True)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
state["jira_url"] = credentials.get("jira_url", "")
|
|
154
|
+
state["jira_email"] = credentials.get("jira_email", "")
|
|
155
|
+
state["jira_api_token"] = credentials.get("jira_api_token", "")
|
|
156
|
+
state["jira_project_key"] = credentials.get("jira_project_key", "")
|
|
157
|
+
fields += [
|
|
158
|
+
WizardField("jira_url", "Jira URL", lambda: prompt_jira_url(state.get("jira_url", ""))),
|
|
159
|
+
WizardField("jira_email", "Jira account email", lambda: prompt_jira_email(state.get("jira_email", ""))),
|
|
160
|
+
WizardField(
|
|
161
|
+
"jira_api_token",
|
|
162
|
+
"Jira API token",
|
|
163
|
+
lambda: prompt_jira_api_token(state.get("jira_api_token", "")),
|
|
164
|
+
secret=True,
|
|
165
|
+
),
|
|
166
|
+
WizardField(
|
|
167
|
+
"jira_project_key", "Jira project key", lambda: prompt_jira_project_key(state.get("jira_project_key", ""))
|
|
168
|
+
),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
state["anthropic_api_key"] = credentials.get("anthropic_api_key", "")
|
|
172
|
+
fields.append(
|
|
173
|
+
WizardField(
|
|
174
|
+
"anthropic_api_key",
|
|
175
|
+
"Anthropic API key (optional)",
|
|
176
|
+
lambda: prompt_anthropic_api_key(state.get("anthropic_api_key", "")),
|
|
177
|
+
secret=True,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return fields
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def edit_command(name: str) -> None:
|
|
184
|
+
ensure_config_store_ready()
|
|
185
|
+
config = resolve_config_or_prompt(name)
|
|
186
|
+
# Use the resolved config's own name from here - resolve_config_or_prompt()
|
|
187
|
+
# may have picked a different one via its "did you mean" recovery picker.
|
|
188
|
+
name = config["name"]
|
|
189
|
+
|
|
190
|
+
state: dict = {}
|
|
191
|
+
fields = _build_edit_fields(config, state)
|
|
192
|
+
original = dict(state)
|
|
193
|
+
|
|
194
|
+
final_state = run_wizard(fields, state, walk_first=False)
|
|
195
|
+
changed_keys = [key for key, value in final_state.items() if original.get(key) != value]
|
|
196
|
+
|
|
197
|
+
if not changed_keys:
|
|
198
|
+
typer.echo("Nothing changed.")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
changed_destination_keys = [key for key in changed_keys if key in _DESTINATION_RESOLVED_KEYS]
|
|
202
|
+
destination_id = config.get("ticket_destination_id")
|
|
203
|
+
if changed_destination_keys and destination_id is not None:
|
|
204
|
+
affected_count = config_store.count_configs_using_destination(destination_id, exclude_config_name=name)
|
|
205
|
+
if affected_count > 0:
|
|
206
|
+
warning(
|
|
207
|
+
f"'{name}' shares its Jira credentials with {affected_count} other config(s) via a shared ticket "
|
|
208
|
+
f"destination - changing {', '.join(changed_destination_keys)} will affect them too, not just '{name}'."
|
|
209
|
+
)
|
|
210
|
+
if not ask_or_exit(questionary.confirm("Continue anyway?", default=False)):
|
|
211
|
+
typer.echo("Cancelled - nothing was changed.")
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
plain_field_updates = {key: final_state[key] for key in changed_keys if key in _PLAIN_COLUMN_KEYS}
|
|
215
|
+
if plain_field_updates:
|
|
216
|
+
config_store.update_config_fields(name, **plain_field_updates)
|
|
217
|
+
|
|
218
|
+
for key in changed_keys:
|
|
219
|
+
if key in _PLAIN_COLUMN_KEYS:
|
|
220
|
+
continue
|
|
221
|
+
config_store.update_config_credential(name, key, final_state[key])
|
|
222
|
+
|
|
223
|
+
success(f"Updated '{name}': {', '.join(changed_keys)}")
|
cli/config_lookup.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Copyright (c) 2026 Calfus Inc.
|
|
2
|
+
# Author: Wasiullah Rafeeq S
|
|
3
|
+
#
|
|
4
|
+
# Depends on: src/config/store.py - looking up and listing saved configs
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
Shared "a --config <name> was given but doesn't match anything" recovery,
|
|
8
|
+
used everywhere a config is looked up by name that the caller typed
|
|
9
|
+
explicitly (gozu run --config, gozu up --config, gozu config edit/delete)
|
|
10
|
+
- as opposed to src/cli/scan_runner/config_select.py's select_config(), which
|
|
11
|
+
this module is a sibling to, not a replacement for: select_config() also
|
|
12
|
+
handles the *no name given at all* picker (auto-select the only one,
|
|
13
|
+
or choose among several), which is a different situation from a name
|
|
14
|
+
that was given but wrong. Kept here instead of folded into
|
|
15
|
+
config_select.py so src/cli/config_cmd and src/cli/stack don't have to import a
|
|
16
|
+
module named for scan_runner just to reuse this.
|
|
17
|
+
|
|
18
|
+
Never falls through to any default behavior on a miss - either the user
|
|
19
|
+
picks a real config from what actually exists, or explicitly exits.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import questionary
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
import config.store as config_store
|
|
26
|
+
from cli.status import error
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_config_or_prompt(name: str) -> dict:
|
|
30
|
+
"""
|
|
31
|
+
Looks up a config by `name`. If found, returns it unchanged (the
|
|
32
|
+
normal, correct-name path). If not:
|
|
33
|
+
|
|
34
|
+
- Explains why the lookup failed ("No config named '<name>' found.")
|
|
35
|
+
- If no configs exist at all, says so plainly and points at
|
|
36
|
+
`gozu init` - there is nothing to pick from, so no picker is shown
|
|
37
|
+
- Otherwise shows a questionary.select() of every existing config
|
|
38
|
+
(name + scanner_mode + trigger_mode, the same informative format
|
|
39
|
+
select_config() uses for its own no-name-given picker), plus a
|
|
40
|
+
final "Exit" choice
|
|
41
|
+
|
|
42
|
+
Picking a real config returns it - same shape as if that name had
|
|
43
|
+
been passed correctly to begin with, so the caller proceeds exactly
|
|
44
|
+
as normal. Picking "Exit" (or Ctrl-C, which questionary also surfaces
|
|
45
|
+
as None) exits cleanly via typer.Exit - no traceback, no partial
|
|
46
|
+
side effects, and never a silent fall-through to some default config.
|
|
47
|
+
"""
|
|
48
|
+
config = config_store.get_config(name)
|
|
49
|
+
if config is not None:
|
|
50
|
+
return config
|
|
51
|
+
|
|
52
|
+
error(f"No config named '{name}' found.")
|
|
53
|
+
|
|
54
|
+
configs = config_store.list_configs()
|
|
55
|
+
if not configs:
|
|
56
|
+
typer.echo("No configs exist yet - run `gozu init` to create one.")
|
|
57
|
+
raise typer.Exit(code=1)
|
|
58
|
+
|
|
59
|
+
# A distinct sentinel, not None - questionary.Choice() defaults an
|
|
60
|
+
# unset `value` to its own `title`, so a bare `value=None` here would
|
|
61
|
+
# actually come back as the string "__exit__"... no, worse: it would
|
|
62
|
+
# come back as the literal title "Exit", indistinguishable from a
|
|
63
|
+
# real (if oddly named) config. Confirmed live: an earlier version of
|
|
64
|
+
# this using value=None surfaced "Config 'Exit' disappeared before it
|
|
65
|
+
# could be loaded." instead of exiting cleanly.
|
|
66
|
+
_EXIT = "__exit__"
|
|
67
|
+
answer = questionary.select(
|
|
68
|
+
"Did you mean one of these instead?",
|
|
69
|
+
choices=[
|
|
70
|
+
questionary.Choice(
|
|
71
|
+
title=f"{c['name']} ({c['scanner_mode']}, trigger={c['trigger_mode']})",
|
|
72
|
+
value=c["name"],
|
|
73
|
+
)
|
|
74
|
+
for c in configs
|
|
75
|
+
]
|
|
76
|
+
+ [questionary.Choice(title="Exit", value=_EXIT)],
|
|
77
|
+
).ask()
|
|
78
|
+
if answer is None or answer == _EXIT:
|
|
79
|
+
raise typer.Exit(code=1)
|
|
80
|
+
|
|
81
|
+
resolved = config_store.get_config(answer)
|
|
82
|
+
if resolved is None:
|
|
83
|
+
# Vanishingly unlikely (deleted between list_configs() and here), but
|
|
84
|
+
# get_config() can genuinely return None, so this has to be handled.
|
|
85
|
+
error(f"Config '{answer}' disappeared before it could be loaded.")
|
|
86
|
+
raise typer.Exit(code=1)
|
|
87
|
+
return resolved
|
cli/crash_handler.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Copyright (c) 2026 Calfus Inc.
|
|
2
|
+
# Author: Wasiullah Rafeeq S
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Last-resort handler for a genuinely unexpected exception escaping the
|
|
6
|
+
whole `gozu` invocation - only reached for exceptions that aren't Typer/
|
|
7
|
+
Click's own control-flow exceptions (typer.Exit, SystemExit,
|
|
8
|
+
KeyboardInterrupt), which already exited cleanly on their own.
|
|
9
|
+
|
|
10
|
+
Deliberately does NOT send email automatically - that would need gozu to
|
|
11
|
+
hold its own SMTP/API credentials, could silently fail exactly when
|
|
12
|
+
mail infra is what's broken, and risks an unreviewed traceback landing
|
|
13
|
+
in an inbox. Instead a pre-filled mailto: link is shown, and the raw
|
|
14
|
+
traceback only ever touches a timestamped file under LOGS_DIR.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import traceback
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from urllib.parse import quote
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
from core.constants import CONTACT_EMAILS
|
|
26
|
+
from scripts.paths import LOGS_DIR
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _write_traceback_log(exc: BaseException) -> Path:
|
|
30
|
+
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
32
|
+
log_path = LOGS_DIR / f"crash-{timestamp}.log"
|
|
33
|
+
log_path.write_text("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
|
|
34
|
+
return log_path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_mailto_link(log_path: Path) -> str:
|
|
38
|
+
"""mailto: links can't attach files or reliably fit a full traceback,
|
|
39
|
+
so the body just references the log file's path."""
|
|
40
|
+
recipients = ",".join(CONTACT_EMAILS)
|
|
41
|
+
subject = quote("Gozu error report")
|
|
42
|
+
body = quote(
|
|
43
|
+
"gozu hit an unexpected error.\n\n"
|
|
44
|
+
f"Full details were logged to: {log_path}\n\n"
|
|
45
|
+
"Please attach that file, or paste its contents below, before sending."
|
|
46
|
+
)
|
|
47
|
+
return f"mailto:{recipients}?subject={subject}&body={body}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def handle_unexpected_exception(exc: BaseException) -> None:
|
|
51
|
+
log_path = _write_traceback_log(exc)
|
|
52
|
+
mailto_link = _build_mailto_link(log_path)
|
|
53
|
+
|
|
54
|
+
typer.echo()
|
|
55
|
+
typer.echo("Something went wrong that gozu didn't expect.")
|
|
56
|
+
typer.echo(f"Full details were logged to: {log_path}")
|
|
57
|
+
typer.echo()
|
|
58
|
+
typer.echo("If you'd like to report this:")
|
|
59
|
+
# rich's link markup only emits the OSC 8 escape sequence for a real
|
|
60
|
+
# terminal; piped/redirected output gets plain visible text instead,
|
|
61
|
+
# never raw escape bytes dumped into a file.
|
|
62
|
+
Console().print(f" [link={mailto_link}]Report this error[/link]")
|
cli/help_links.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Copyright (c) 2026 Calfus Inc.
|
|
2
|
+
# Author: Wasiullah Rafeeq S
|
|
3
|
+
# Editor: Prakrit Mohanty
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Doc links shown before each credential prompt in the init wizard, so a user
|
|
7
|
+
who doesn't already know where e.g. a SonarQube token comes from isn't left
|
|
8
|
+
guessing. Every URL here was looked up live (Sonar/Atlassian docs), not
|
|
9
|
+
guessed - if a backend's docs move, update the entry here rather than
|
|
10
|
+
re-guessing a plausible-looking URL.
|
|
11
|
+
|
|
12
|
+
Keyed by a wizard-internal field name, not always the literal credential
|
|
13
|
+
key stored in config_credentials (sonar_token has two entries, since the
|
|
14
|
+
"how do I get one" instructions differ completely between self-hosted
|
|
15
|
+
SonarQube and SonarQube Cloud).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
HELP_LINKS: dict[str, str] = {
|
|
21
|
+
"sonar_token_local": "https://docs.sonarsource.com/sonarqube-server/latest/user-guide/managing-tokens/",
|
|
22
|
+
"sonar_token_cloud": "https://docs.sonarsource.com/sonarqube-cloud/managing-your-account/managing-tokens",
|
|
23
|
+
"sonar_organization": "https://docs.sonarsource.com/sonarqube-cloud/getting-started/viewing-organizations",
|
|
24
|
+
"sonar_project_key": "https://docs.sonarsource.com/sonarqube-server/project-administration/maintaining-project/changing-project-key",
|
|
25
|
+
"sonar_branch": "https://docs.sonarsource.com/sonarqube-cloud/analyzing-source-code/branch-analysis/branch-analysis",
|
|
26
|
+
"jira_api_token": "https://id.atlassian.com/manage-profile/security/api-tokens",
|
|
27
|
+
"jira_email": "https://id.atlassian.com/manage-profile/profile-and-visibility",
|
|
28
|
+
"jira_project_key": "https://confluence.atlassian.com/jirakb/how-to-get-project-id-from-the-jira-user-interface-827341414.html",
|
|
29
|
+
"webhook_secret": "https://docs.sonarsource.com/sonarqube-cloud/managing-your-projects/administering-your-projects/integrations/webhooks",
|
|
30
|
+
"anthropic_api_key": "https://platform.claude.com/settings/keys",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def print_help_link(field: str) -> None:
|
|
35
|
+
"""Print a dimmed `-> docs: <url>` line for `field`, if a link is registered for it."""
|
|
36
|
+
url = HELP_LINKS.get(field)
|
|
37
|
+
if url:
|
|
38
|
+
typer.secho(f" -> docs: {url}", dim=True)
|