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.
Files changed (121) hide show
  1. cli/__init__.py +3 -0
  2. cli/config_cmd/__init__.py +92 -0
  3. cli/config_cmd/edit.py +223 -0
  4. cli/config_lookup.py +87 -0
  5. cli/crash_handler.py +62 -0
  6. cli/help_links.py +38 -0
  7. cli/init_wizard/__init__.py +359 -0
  8. cli/init_wizard/env_step.py +85 -0
  9. cli/init_wizard/jira_step.py +87 -0
  10. cli/init_wizard/llm_step.py +20 -0
  11. cli/init_wizard/semgrep_step.py +21 -0
  12. cli/init_wizard/sonar_cloud.py +78 -0
  13. cli/init_wizard/sonar_local.py +118 -0
  14. cli/init_wizard/summary.py +50 -0
  15. cli/main.py +291 -0
  16. cli/prerequisites/__init__.py +22 -0
  17. cli/prerequisites/archive.py +106 -0
  18. cli/prerequisites/java.py +116 -0
  19. cli/prerequisites/semgrep.py +61 -0
  20. cli/prerequisites/sonar_scanner.py +108 -0
  21. cli/prerequisites/trivy.py +136 -0
  22. cli/prompts.py +49 -0
  23. cli/report.py +78 -0
  24. cli/scan_runner/__init__.py +197 -0
  25. cli/scan_runner/config_fields.py +28 -0
  26. cli/scan_runner/config_select.py +64 -0
  27. cli/scan_runner/scanner_exec.py +147 -0
  28. cli/scan_runner/semgrep_exec.py +24 -0
  29. cli/scan_runner/workflow_trigger.py +129 -0
  30. cli/stack/__init__.py +187 -0
  31. cli/stack/_stackfiles/Dockerfile +37 -0
  32. cli/stack/_stackfiles/__init__.py +0 -0
  33. cli/stack/_stackfiles/alembic.ini +160 -0
  34. cli/stack/_stackfiles/db/init_sonarqube_db.sql +29 -0
  35. cli/stack/_stackfiles/db/migrations/README +1 -0
  36. cli/stack/_stackfiles/db/migrations/env.py +85 -0
  37. cli/stack/_stackfiles/db/migrations/script.py.mako +28 -0
  38. cli/stack/_stackfiles/db/migrations/versions/0001_initial_schema.py +71 -0
  39. cli/stack/_stackfiles/db/migrations/versions/0002_add_project_key.py +32 -0
  40. cli/stack/_stackfiles/db/migrations/versions/0003_add_sonar_plan_and_branches.py +35 -0
  41. cli/stack/_stackfiles/db/migrations/versions/0004_add_ticket_claims.py +55 -0
  42. cli/stack/_stackfiles/db/migrations/versions/0005_add_ticket_destinations.py +67 -0
  43. cli/stack/_stackfiles/db/migrations/versions/0006_add_ticket_claims_status.py +34 -0
  44. cli/stack/_stackfiles/db/migrations/versions/0007_add_last_scan_tracking.py +38 -0
  45. cli/stack/_stackfiles/db/migrations/versions/0008_add_ticket_cap.py +32 -0
  46. cli/stack/_stackfiles/docker-compose.yml +140 -0
  47. cli/stack/_stackfiles/pyproject.toml +62 -0
  48. cli/stack/_stackfiles/uv.lock +1279 -0
  49. cli/stack/backup.py +77 -0
  50. cli/stack/cleanup.py +123 -0
  51. cli/stack/files.py +104 -0
  52. cli/stack/profiles.py +140 -0
  53. cli/stack/status_report.py +59 -0
  54. cli/stack/webhooks.py +53 -0
  55. cli/stack/wipe.py +117 -0
  56. cli/status.py +44 -0
  57. cli/wizard_engine.py +99 -0
  58. config/__init__.py +3 -0
  59. config/connection.py +41 -0
  60. config/crypto.py +60 -0
  61. config/migrations.py +100 -0
  62. config/store.py +221 -0
  63. config/ticket_destinations.py +122 -0
  64. core/__init__.py +3 -0
  65. core/constants.py +11 -0
  66. core/errors.py +55 -0
  67. core/models.py +94 -0
  68. github_action/__init__.py +3 -0
  69. github_action/main.py +180 -0
  70. gozu-0.3.7.dist-info/METADATA +490 -0
  71. gozu-0.3.7.dist-info/RECORD +121 -0
  72. gozu-0.3.7.dist-info/WHEEL +4 -0
  73. gozu-0.3.7.dist-info/entry_points.txt +3 -0
  74. llm/__init__.py +2 -0
  75. llm/enrich.py +147 -0
  76. llm/factory.py +19 -0
  77. receiver/__init__.py +3 -0
  78. receiver/app.py +141 -0
  79. receiver/starter.py +49 -0
  80. receiver/verify_signature.py +19 -0
  81. scanner/__init__.py +3 -0
  82. scanner/base.py +116 -0
  83. scanner/factory.py +78 -0
  84. scanner/html_text.py +63 -0
  85. scanner/screenshot.py +124 -0
  86. scanner/semgrep_local.py +186 -0
  87. scanner/sonarqube_classify.py +50 -0
  88. scanner/sonarqube_cloud.py +98 -0
  89. scanner/sonarqube_common.py +187 -0
  90. scanner/sonarqube_local.py +29 -0
  91. scanner/trivy_client.py +173 -0
  92. scripts/__init__.py +3 -0
  93. scripts/bootstrap_env.py +150 -0
  94. scripts/env_ports.py +70 -0
  95. scripts/fernet_safety.py +72 -0
  96. scripts/paths.py +29 -0
  97. temporal/__init__.py +3 -0
  98. temporal/activities/__init__.py +10 -0
  99. temporal/activities/capture_and_attach_screenshot.py +67 -0
  100. temporal/activities/create_tickets.py +148 -0
  101. temporal/activities/fetch_findings.py +74 -0
  102. temporal/activities/reconcile_resolved_findings.py +139 -0
  103. temporal/data_converter.py +11 -0
  104. temporal/models/__init__.py +6 -0
  105. temporal/models/create_tickets.py +20 -0
  106. temporal/models/fetch_findings.py +30 -0
  107. temporal/models/reconcile_resolved_findings.py +19 -0
  108. temporal/models/screenshot_attach.py +20 -0
  109. temporal/models/sonar_to_jira.py +51 -0
  110. temporal/worker.py +77 -0
  111. temporal/workflows/__init__.py +6 -0
  112. temporal/workflows/multi_branch_scan.py +90 -0
  113. temporal/workflows/reconcile_only.py +54 -0
  114. temporal/workflows/scan_to_ticket.py +208 -0
  115. ticket/AtlassianDocumentFormat.py +31 -0
  116. ticket/TicketClient.py +71 -0
  117. ticket/__init__.py +3 -0
  118. ticket/claims.py +134 -0
  119. ticket/factory.py +52 -0
  120. ticket/jira_client.py +434 -0
  121. ticket/jira_sprint.py +82 -0
cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 Calfus Inc.
2
+ # Author: Wasiullah Rafeeq S
3
+
@@ -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)