cassis-cli 1.5.1__tar.gz → 1.7.0__tar.gz
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.
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/PKG-INFO +11 -5
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/README.md +10 -4
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/api.py +206 -7
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/common.py +14 -0
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/eval.py +15 -6
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/guide.py +1 -1
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/issues.py +193 -18
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/main.py +2 -0
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/ontology_design_guide.md +9 -0
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/schema.py +32 -9
- cassis_cli-1.7.0/cassis_cli/source_changes.py +191 -0
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/status.py +5 -0
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/pyproject.toml +1 -1
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/LICENSE +0 -0
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/NOTICE +0 -0
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/__init__.py +0 -0
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/ontology.py +0 -0
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/projects.py +0 -0
- {cassis_cli-1.5.1 → cassis_cli-1.7.0}/cassis_cli/verify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cassis-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0
|
|
4
4
|
Summary: Validate, test and evaluate your Cassis ontology from your terminal, then publish it
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -35,10 +35,11 @@ Validate, test and evaluate your ontology from your terminal, then publish it. T
|
|
|
35
35
|
- `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
|
|
36
36
|
- `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
|
|
37
37
|
- `cassis eval list-cases` and `cassis eval delete-case` maintain the suite: list the current cases with their ids, and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the ontology has since changed).
|
|
38
|
-
- `cassis schema push` uploads a DDL file to detect source-schema changes on a DDL-only project (same as the webapp's "Update from DDL" button): Cassis diffs the DDL against the ontology and surfaces added, dropped, and changed objects in Ontology > Review > Data source for approval. Waits for the detection run to finish and exits 0 only when it completed — the schema is stored and applied atomically with run completion, so exit 0 means the DDL parsed and the project now uses it.
|
|
38
|
+
- `cassis schema push` uploads a DDL file to detect source-schema changes on a DDL-only project (same as the webapp's "Update from DDL" button): Cassis diffs the DDL against the ontology and surfaces added, dropped, and changed objects in Ontology > Review > Data source for approval. The file speaks only for the schemas it contains — a partial export (one schema of many) never removes the others; pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. Waits for the detection run to finish and exits 0 only when it completed — the schema is stored and applied atomically with run completion, so exit 0 means the DDL parsed and the project now uses it.
|
|
39
39
|
- `cassis projects list` lists the projects your API key can reach — id (what `--project` and `CASSIS_PROJECT_ID` take), name, published ontology version, and data-source dialect — so a pipeline or agent can discover the project id from the terminal instead of fishing it out of a webapp URL.
|
|
40
|
-
- `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, and how your local git HEAD relates to the published commit (in sync / N commits ahead / diverged). `cassis status --watch` polls until the published commit matches your local HEAD — e.g. right after merging a PR whose CI publishes the ontology — instead of watching the GitHub Actions tab.
|
|
40
|
+
- `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, how many Data source review items are pending (with the breaking count, when there are any), and how your local git HEAD relates to the published commit (in sync / N commits ahead / diverged). `cassis status --watch` polls until the published commit matches your local HEAD — e.g. right after merging a PR whose CI publishes the ontology — instead of watching the GitHub Actions tab.
|
|
41
41
|
- `cassis issues` triages the issues Cassis raised on the project — what it found wrong while answering questions (an ontology gap, missing data) — without leaving the checkout: `issues list` (filterable by status, impact and cause), `issues show <id>` for the diagnosis, suggested action and the occurrences behind it, `issues evidence <id> <occurrence-id>` for what the agent actually saw, and `issues resolve` / `dismiss` / `reopen` once you've acted on it.
|
|
42
|
+
- `cassis source-changes` reads the project's Data source review queue — the schema drift Cassis detected between the source and what the ontology tracks: `source-changes list` (paginated, pending by default, breaking severity flagged) and `source-changes show <id>` for one change's impact references and suggested edit. Read-only: approving or dismissing stays in the webapp; fix headlessly by editing the ontology files and opening a pull request.
|
|
42
43
|
- `cassis verify` runs the full local gate in one verb — `ontology fmt --check`, `ontology check`, `eval run` — stopping at the first failure. One command in a checkout ("is this change safe to merge?"), one job in CI. `--no-eval` skips the eval suite.
|
|
43
44
|
|
|
44
45
|
## Install
|
|
@@ -131,6 +132,11 @@ cassis schema push schema.sql
|
|
|
131
132
|
# List the projects the API key can reach (id, name, published version, dialect):
|
|
132
133
|
cassis projects list
|
|
133
134
|
|
|
135
|
+
# Refresh the issues from the conversations nobody has analyzed yet (the same pass as the
|
|
136
|
+
# webapp's "Analyze conversations" button; waits for the result, --no-wait returns at once,
|
|
137
|
+
# and Ctrl-C cancels the run server-side and exits 130):
|
|
138
|
+
cassis issues analyze
|
|
139
|
+
|
|
134
140
|
# Triage the issues Cassis raised (filter by --status/--impact/--cause; --json for raw output):
|
|
135
141
|
cassis issues list --status open
|
|
136
142
|
cassis issues show 019f0000-0000-7000-8000-0000000000e1
|
|
@@ -189,9 +195,9 @@ cassis ontology fmt --check
|
|
|
189
195
|
| Code | Meaning |
|
|
190
196
|
| ---- | ------------------------------------------------------------------------------ |
|
|
191
197
|
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
|
|
192
|
-
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project) |
|
|
198
|
+
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
|
|
193
199
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
194
|
-
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
|
|
200
|
+
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run or issue analysis already active, out of credits, or `--timeout` reached |
|
|
195
201
|
|
|
196
202
|
Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
|
|
197
203
|
20,000 ontology files / 100 MB total (path + content bytes) — sized for ontologies of
|
|
@@ -13,10 +13,11 @@ Validate, test and evaluate your ontology from your terminal, then publish it. T
|
|
|
13
13
|
- `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
|
|
14
14
|
- `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
|
|
15
15
|
- `cassis eval list-cases` and `cassis eval delete-case` maintain the suite: list the current cases with their ids, and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the ontology has since changed).
|
|
16
|
-
- `cassis schema push` uploads a DDL file to detect source-schema changes on a DDL-only project (same as the webapp's "Update from DDL" button): Cassis diffs the DDL against the ontology and surfaces added, dropped, and changed objects in Ontology > Review > Data source for approval. Waits for the detection run to finish and exits 0 only when it completed — the schema is stored and applied atomically with run completion, so exit 0 means the DDL parsed and the project now uses it.
|
|
16
|
+
- `cassis schema push` uploads a DDL file to detect source-schema changes on a DDL-only project (same as the webapp's "Update from DDL" button): Cassis diffs the DDL against the ontology and surfaces added, dropped, and changed objects in Ontology > Review > Data source for approval. The file speaks only for the schemas it contains — a partial export (one schema of many) never removes the others; pass `--complete` when it is the project's complete source schema so schemas absent from it are treated as dropped. Waits for the detection run to finish and exits 0 only when it completed — the schema is stored and applied atomically with run completion, so exit 0 means the DDL parsed and the project now uses it.
|
|
17
17
|
- `cassis projects list` lists the projects your API key can reach — id (what `--project` and `CASSIS_PROJECT_ID` take), name, published ontology version, and data-source dialect — so a pipeline or agent can discover the project id from the terminal instead of fishing it out of a webapp URL.
|
|
18
|
-
- `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, and how your local git HEAD relates to the published commit (in sync / N commits ahead / diverged). `cassis status --watch` polls until the published commit matches your local HEAD — e.g. right after merging a PR whose CI publishes the ontology — instead of watching the GitHub Actions tab.
|
|
18
|
+
- `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, how many Data source review items are pending (with the breaking count, when there are any), and how your local git HEAD relates to the published commit (in sync / N commits ahead / diverged). `cassis status --watch` polls until the published commit matches your local HEAD — e.g. right after merging a PR whose CI publishes the ontology — instead of watching the GitHub Actions tab.
|
|
19
19
|
- `cassis issues` triages the issues Cassis raised on the project — what it found wrong while answering questions (an ontology gap, missing data) — without leaving the checkout: `issues list` (filterable by status, impact and cause), `issues show <id>` for the diagnosis, suggested action and the occurrences behind it, `issues evidence <id> <occurrence-id>` for what the agent actually saw, and `issues resolve` / `dismiss` / `reopen` once you've acted on it.
|
|
20
|
+
- `cassis source-changes` reads the project's Data source review queue — the schema drift Cassis detected between the source and what the ontology tracks: `source-changes list` (paginated, pending by default, breaking severity flagged) and `source-changes show <id>` for one change's impact references and suggested edit. Read-only: approving or dismissing stays in the webapp; fix headlessly by editing the ontology files and opening a pull request.
|
|
20
21
|
- `cassis verify` runs the full local gate in one verb — `ontology fmt --check`, `ontology check`, `eval run` — stopping at the first failure. One command in a checkout ("is this change safe to merge?"), one job in CI. `--no-eval` skips the eval suite.
|
|
21
22
|
|
|
22
23
|
## Install
|
|
@@ -109,6 +110,11 @@ cassis schema push schema.sql
|
|
|
109
110
|
# List the projects the API key can reach (id, name, published version, dialect):
|
|
110
111
|
cassis projects list
|
|
111
112
|
|
|
113
|
+
# Refresh the issues from the conversations nobody has analyzed yet (the same pass as the
|
|
114
|
+
# webapp's "Analyze conversations" button; waits for the result, --no-wait returns at once,
|
|
115
|
+
# and Ctrl-C cancels the run server-side and exits 130):
|
|
116
|
+
cassis issues analyze
|
|
117
|
+
|
|
112
118
|
# Triage the issues Cassis raised (filter by --status/--impact/--cause; --json for raw output):
|
|
113
119
|
cassis issues list --status open
|
|
114
120
|
cassis issues show 019f0000-0000-7000-8000-0000000000e1
|
|
@@ -167,9 +173,9 @@ cassis ontology fmt --check
|
|
|
167
173
|
| Code | Meaning |
|
|
168
174
|
| ---- | ------------------------------------------------------------------------------ |
|
|
169
175
|
| 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
|
|
170
|
-
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project) |
|
|
176
|
+
| 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project; issues: no such issue or occurrence in the project; issues analyze: failed or cancelled analysis run; schema push: failed or cancelled detection run, or the project won't accept the push (a run is already in flight, or it is warehouse-connected rather than DDL-only); source-changes show: no such change in the project) |
|
|
171
177
|
| 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
|
|
172
|
-
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
|
|
178
|
+
| 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another eval run or issue analysis already active, out of credits, or `--timeout` reached |
|
|
173
179
|
|
|
174
180
|
Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
|
|
175
181
|
20,000 ontology files / 100 MB total (path + content bytes) — sized for ontologies of
|
|
@@ -218,6 +218,12 @@ def post_ontology_import(
|
|
|
218
218
|
if response.status_code >= 400:
|
|
219
219
|
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
220
220
|
result = _parse_json_response(response, url)
|
|
221
|
+
# The import runs as a background job on the server; once the response
|
|
222
|
+
# stream has started the status code is fixed at 200, so an in-job failure
|
|
223
|
+
# arrives as a body carrying only an ``error`` key (deliberately not the
|
|
224
|
+
# success shape, so older CLIs fail loudly instead of reporting success).
|
|
225
|
+
if isinstance(result, dict) and "error" in result and "domain_count" not in result:
|
|
226
|
+
raise UploadValidationError(str(result["error"]))
|
|
221
227
|
if not isinstance(result, dict) or not all(
|
|
222
228
|
key in result for key in ("domain_count", "table_count", "join_count", "metric_count", "published_version")
|
|
223
229
|
):
|
|
@@ -235,7 +241,10 @@ def get_ontology_export(
|
|
|
235
241
|
"""GET /api/ci/projects/{project_id}/ontology/export and return the files tree."""
|
|
236
242
|
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/export"
|
|
237
243
|
try:
|
|
238
|
-
|
|
244
|
+
# Whole-tree download: serializing thousands of tables takes the
|
|
245
|
+
# server the better part of a minute, so the default budget is the
|
|
246
|
+
# thing that breaks first — use the tree ceiling.
|
|
247
|
+
with _client(timeout=ONTOLOGY_TREE_TIMEOUT_SECONDS, transport=transport) as client:
|
|
239
248
|
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
240
249
|
except httpx.HTTPError as exc:
|
|
241
250
|
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
@@ -354,13 +363,18 @@ def post_detect_from_ddl(
|
|
|
354
363
|
api_key: str,
|
|
355
364
|
project_id: str,
|
|
356
365
|
ddl: str,
|
|
366
|
+
complete_source: bool = False,
|
|
357
367
|
transport: Optional[httpx.BaseTransport] = None,
|
|
358
368
|
) -> dict[str, Any]:
|
|
359
369
|
"""POST /api/ci/projects/{project_id}/source-changes/detect-from-ddl and return the run record."""
|
|
360
370
|
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/detect-from-ddl"
|
|
361
371
|
try:
|
|
362
372
|
with _client(transport=transport) as client:
|
|
363
|
-
response = client.post(
|
|
373
|
+
response = client.post(
|
|
374
|
+
url,
|
|
375
|
+
json={"ddl": ddl, "complete_source": complete_source},
|
|
376
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
377
|
+
)
|
|
364
378
|
except httpx.HTTPError as exc:
|
|
365
379
|
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
366
380
|
|
|
@@ -429,7 +443,9 @@ def post_eval_run_start(
|
|
|
429
443
|
if case_ids is not None:
|
|
430
444
|
body["test_case_ids"] = case_ids
|
|
431
445
|
try:
|
|
432
|
-
|
|
446
|
+
# May carry the whole ontology tree in `files` — same budget as the
|
|
447
|
+
# other whole-tree endpoints.
|
|
448
|
+
with _client(timeout=ONTOLOGY_TREE_TIMEOUT_SECONDS, transport=transport) as client:
|
|
433
449
|
response = client.post(url, json=body, headers={"Authorization": f"Bearer {api_key}"})
|
|
434
450
|
except httpx.HTTPError as exc:
|
|
435
451
|
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
@@ -619,7 +635,11 @@ def post_eval_run_cancel(
|
|
|
619
635
|
|
|
620
636
|
|
|
621
637
|
class IssueNotFoundError(ApiError):
|
|
622
|
-
"""The project has no issue
|
|
638
|
+
"""The project has no issue, occurrence or issue-analysis run with this id.
|
|
639
|
+
|
|
640
|
+
Raised off the server's exact 404 detail, so it separates "this id doesn't
|
|
641
|
+
exist" from "this project isn't in scope for your key", which 404s too.
|
|
642
|
+
"""
|
|
623
643
|
|
|
624
644
|
|
|
625
645
|
def _get_issue_json(
|
|
@@ -629,8 +649,14 @@ def _get_issue_json(
|
|
|
629
649
|
*,
|
|
630
650
|
params: Optional[dict[str, str]] = None,
|
|
631
651
|
not_found_message: Optional[str] = None,
|
|
652
|
+
not_found_detail: str = "Issue not found",
|
|
632
653
|
) -> Any:
|
|
633
|
-
"""GET an issues URL with the shared error mapping.
|
|
654
|
+
"""GET an issues URL with the shared error mapping.
|
|
655
|
+
|
|
656
|
+
`not_found_detail` is the server's exact 404 detail for "this id does not
|
|
657
|
+
exist in the project" — `"Issue not found"` for issues and occurrences,
|
|
658
|
+
`"Issue-analysis run not found"` for analysis runs.
|
|
659
|
+
"""
|
|
634
660
|
try:
|
|
635
661
|
with _client(transport=transport) as client:
|
|
636
662
|
response = client.get(url, params=params, headers={"Authorization": f"Bearer {api_key}"})
|
|
@@ -641,8 +667,8 @@ def _get_issue_json(
|
|
|
641
667
|
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
642
668
|
# Exact-match wire contract with the issue endpoints' 404 detail (see the
|
|
643
669
|
# issue routes in backend/app/endpoints/ci.py): it distinguishes a missing
|
|
644
|
-
# issue or
|
|
645
|
-
if response.status_code == 404 and not_found_message and _detail_or_text(response) ==
|
|
670
|
+
# issue, occurrence or run (exit 1) from a project-scope 404 (exit 3).
|
|
671
|
+
if response.status_code == 404 and not_found_message and _detail_or_text(response) == not_found_detail:
|
|
646
672
|
raise IssueNotFoundError(not_found_message)
|
|
647
673
|
if response.status_code in (403, 404):
|
|
648
674
|
raise _project_scope_error(response)
|
|
@@ -745,6 +771,80 @@ def post_issue_status(
|
|
|
745
771
|
return result
|
|
746
772
|
|
|
747
773
|
|
|
774
|
+
class SourceChangeNotFoundError(ApiError):
|
|
775
|
+
"""The project has no source change with this id."""
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def get_source_changes(
|
|
779
|
+
*,
|
|
780
|
+
api_url: str,
|
|
781
|
+
api_key: str,
|
|
782
|
+
project_id: str,
|
|
783
|
+
status: Optional[str] = None,
|
|
784
|
+
limit: Optional[int] = None,
|
|
785
|
+
offset: Optional[int] = None,
|
|
786
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
787
|
+
) -> dict[str, Any]:
|
|
788
|
+
"""GET /api/ci/projects/{project_id}/source-changes and return the {items, total} page."""
|
|
789
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes"
|
|
790
|
+
params: dict[str, str] = {}
|
|
791
|
+
if status:
|
|
792
|
+
params["status"] = status
|
|
793
|
+
if limit is not None:
|
|
794
|
+
params["limit"] = str(limit)
|
|
795
|
+
if offset is not None:
|
|
796
|
+
params["offset"] = str(offset)
|
|
797
|
+
try:
|
|
798
|
+
with _client(transport=transport) as client:
|
|
799
|
+
response = client.get(url, params=params or None, headers={"Authorization": f"Bearer {api_key}"})
|
|
800
|
+
except httpx.HTTPError as exc:
|
|
801
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
802
|
+
|
|
803
|
+
if response.status_code == 401:
|
|
804
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
805
|
+
if response.status_code in (403, 404):
|
|
806
|
+
raise _project_scope_error(response)
|
|
807
|
+
if response.status_code >= 400:
|
|
808
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
809
|
+
result = _parse_json_response(response, url)
|
|
810
|
+
if not isinstance(result, dict) or not isinstance(result.get("items"), list) or "total" not in result:
|
|
811
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
812
|
+
return result
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def get_source_change(
|
|
816
|
+
*,
|
|
817
|
+
api_url: str,
|
|
818
|
+
api_key: str,
|
|
819
|
+
project_id: str,
|
|
820
|
+
change_id: str,
|
|
821
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
822
|
+
) -> dict[str, Any]:
|
|
823
|
+
"""GET /api/ci/projects/{project_id}/source-changes/{change_id} and return the full change."""
|
|
824
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/{change_id}"
|
|
825
|
+
try:
|
|
826
|
+
with _client(transport=transport) as client:
|
|
827
|
+
response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
828
|
+
except httpx.HTTPError as exc:
|
|
829
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
830
|
+
|
|
831
|
+
if response.status_code == 401:
|
|
832
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
833
|
+
# Exact-match wire contract with the source-change endpoints' 404 detail
|
|
834
|
+
# (see backend/app/endpoints/ci.py): it distinguishes a missing change
|
|
835
|
+
# (exit 1) from a project-scope 404 (exit 3).
|
|
836
|
+
if response.status_code == 404 and _detail_or_text(response) == "Source change not found":
|
|
837
|
+
raise SourceChangeNotFoundError(f"No source change {change_id} in project {project_id}.")
|
|
838
|
+
if response.status_code in (403, 404):
|
|
839
|
+
raise _project_scope_error(response)
|
|
840
|
+
if response.status_code >= 400:
|
|
841
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
842
|
+
result = _parse_json_response(response, url)
|
|
843
|
+
if not isinstance(result, dict) or "id" not in result:
|
|
844
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
845
|
+
return result
|
|
846
|
+
|
|
847
|
+
|
|
748
848
|
def post_ontology_fmt(
|
|
749
849
|
*,
|
|
750
850
|
api_url: str,
|
|
@@ -835,3 +935,102 @@ def post_ontology_test(
|
|
|
835
935
|
if not isinstance(result, dict) or "status" not in result:
|
|
836
936
|
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
837
937
|
return result
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
class IssueAnalysisActiveError(ApiError):
|
|
941
|
+
"""An issue-analysis run is already in flight for the project (HTTP 409)."""
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
class NothingToAnalyzeError(ApiError):
|
|
945
|
+
"""Every conversation on the project has already been analyzed (HTTP 422)."""
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
_ANALYSIS_RUN_KEYS = ("id", "status", "total_chats", "chats_analyzed", "occurrences_created", "issues_touched")
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _check_analysis_run_shape(result: Any, url: str) -> dict[str, Any]:
|
|
952
|
+
if not isinstance(result, dict) or not all(key in result for key in _ANALYSIS_RUN_KEYS):
|
|
953
|
+
raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
|
|
954
|
+
return result
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def post_issue_analysis_start(
|
|
958
|
+
*,
|
|
959
|
+
api_url: str,
|
|
960
|
+
api_key: str,
|
|
961
|
+
project_id: str,
|
|
962
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
963
|
+
) -> dict[str, Any]:
|
|
964
|
+
"""POST /api/ci/projects/{project_id}/issue-analysis/runs and return the run record."""
|
|
965
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/issue-analysis/runs"
|
|
966
|
+
try:
|
|
967
|
+
with _client(transport=transport) as client:
|
|
968
|
+
response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
969
|
+
except httpx.HTTPError as exc:
|
|
970
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
971
|
+
|
|
972
|
+
if response.status_code == 401:
|
|
973
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
974
|
+
if response.status_code == 409:
|
|
975
|
+
raise IssueAnalysisActiveError(
|
|
976
|
+
"An issue analysis is already running for this project — wait for it to finish, "
|
|
977
|
+
"or cancel it from the webapp's Issues page."
|
|
978
|
+
)
|
|
979
|
+
if response.status_code == 422 and "analyz" in response.text.lower():
|
|
980
|
+
# The route takes no body, so its 422 is the server's "nothing to
|
|
981
|
+
# analyze" answer; any other 422 falls through to the generic error.
|
|
982
|
+
raise NothingToAnalyzeError("Nothing to analyze: every conversation on this project has already been analyzed.")
|
|
983
|
+
if response.status_code in (403, 404):
|
|
984
|
+
raise _project_scope_error(response)
|
|
985
|
+
if response.status_code >= 400:
|
|
986
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
987
|
+
return _check_analysis_run_shape(_parse_json_response(response, url), url)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def get_issue_analysis_run(
|
|
991
|
+
*,
|
|
992
|
+
api_url: str,
|
|
993
|
+
api_key: str,
|
|
994
|
+
project_id: str,
|
|
995
|
+
run_id: str,
|
|
996
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
997
|
+
) -> dict[str, Any]:
|
|
998
|
+
"""GET /api/ci/projects/{project_id}/issue-analysis/runs/{run_id} and return the run record."""
|
|
999
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/issue-analysis/runs/{run_id}"
|
|
1000
|
+
return _check_analysis_run_shape(
|
|
1001
|
+
_get_issue_json(
|
|
1002
|
+
url,
|
|
1003
|
+
api_key,
|
|
1004
|
+
transport,
|
|
1005
|
+
not_found_message=f"No issue-analysis run {run_id} in this project.",
|
|
1006
|
+
not_found_detail="Issue-analysis run not found",
|
|
1007
|
+
),
|
|
1008
|
+
url,
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def post_issue_analysis_cancel(
|
|
1013
|
+
*,
|
|
1014
|
+
api_url: str,
|
|
1015
|
+
api_key: str,
|
|
1016
|
+
project_id: str,
|
|
1017
|
+
run_id: str,
|
|
1018
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
1019
|
+
) -> None:
|
|
1020
|
+
"""POST /api/ci/projects/{project_id}/issue-analysis/runs/{run_id}/cancel."""
|
|
1021
|
+
url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/issue-analysis/runs/{run_id}/cancel"
|
|
1022
|
+
try:
|
|
1023
|
+
with _client(transport=transport) as client:
|
|
1024
|
+
response = client.post(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
1025
|
+
except httpx.HTTPError as exc:
|
|
1026
|
+
raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
|
|
1027
|
+
if response.status_code == 401:
|
|
1028
|
+
raise AuthError("The Cassis API rejected the API key (invalid or expired).")
|
|
1029
|
+
# Same exact-match contract as `get_issue_analysis_run`: a missing run is
|
|
1030
|
+
# not a project-scope problem.
|
|
1031
|
+
if response.status_code == 404 and _detail_or_text(response) == "Issue-analysis run not found":
|
|
1032
|
+
raise IssueNotFoundError(f"No issue-analysis run {run_id} in this project.")
|
|
1033
|
+
if response.status_code in (403, 404):
|
|
1034
|
+
raise _project_scope_error(response)
|
|
1035
|
+
if response.status_code >= 400:
|
|
1036
|
+
raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
|
|
@@ -20,6 +20,9 @@ EXIT_OK = 0
|
|
|
20
20
|
EXIT_VALIDATION_FAILED = 1
|
|
21
21
|
EXIT_USAGE = 2
|
|
22
22
|
EXIT_TRANSPORT = 3
|
|
23
|
+
# Conventional "terminated by SIGINT" code: the user pressed Ctrl-C while a
|
|
24
|
+
# command was waiting on a server-side run (which the command cancels first).
|
|
25
|
+
EXIT_INTERRUPTED = 130
|
|
23
26
|
|
|
24
27
|
# Request ceilings of the /api/ci file-tree endpoints, mirrored so oversized
|
|
25
28
|
# trees fail fast with a clear message before any upload. Sized for ~10,000
|
|
@@ -30,6 +33,17 @@ MAX_FILES = 20_000
|
|
|
30
33
|
MAX_TOTAL_BYTES = 100 * 1024 * 1024
|
|
31
34
|
|
|
32
35
|
|
|
36
|
+
def api_failure(exc: Exception) -> "typer.Exit":
|
|
37
|
+
"""Print a transport/API error and return the transport exit code."""
|
|
38
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
39
|
+
return typer.Exit(EXIT_TRANSPORT)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def one_line(value: object) -> str:
|
|
43
|
+
"""Collapse a possibly-multiline value into one trimmed line for list rows."""
|
|
44
|
+
return str(value or "").replace("\n", " ").strip()
|
|
45
|
+
|
|
46
|
+
|
|
33
47
|
def is_ontology_file(rel_path: str) -> bool:
|
|
34
48
|
"""Whether a base-relative path is an ontology file the server reads.
|
|
35
49
|
|
|
@@ -30,6 +30,7 @@ from cassis_cli.api import (
|
|
|
30
30
|
)
|
|
31
31
|
from cassis_cli.common import (
|
|
32
32
|
DEFAULT_BASE_PATH,
|
|
33
|
+
EXIT_INTERRUPTED,
|
|
33
34
|
EXIT_OK,
|
|
34
35
|
EXIT_TRANSPORT,
|
|
35
36
|
EXIT_USAGE,
|
|
@@ -41,7 +42,6 @@ from cassis_cli.common import (
|
|
|
41
42
|
|
|
42
43
|
app = typer.Typer(no_args_is_help=True, help="Eval commands.")
|
|
43
44
|
|
|
44
|
-
EXIT_INTERRUPTED = 130
|
|
45
45
|
|
|
46
46
|
# Result statuses that count as "passed"; everything else is a failure or error.
|
|
47
47
|
_PASSED = "passed"
|
|
@@ -134,6 +134,9 @@ def _print_results_table(results: list[dict[str, Any]]) -> None:
|
|
|
134
134
|
|
|
135
135
|
def _print_summary(run: dict[str, Any]) -> None:
|
|
136
136
|
summary = run.get("summary") or {}
|
|
137
|
+
if summary.get("error"):
|
|
138
|
+
# A failed run carries its reason here (e.g. the worker was lost).
|
|
139
|
+
typer.secho(f"Run error: {str(summary['error'])[:300]}", fg=typer.colors.RED)
|
|
137
140
|
total = summary.get("total", run.get("total_cases"))
|
|
138
141
|
passed = summary.get("passed", 0)
|
|
139
142
|
accuracy = summary.get("accuracy")
|
|
@@ -498,7 +501,8 @@ def run(
|
|
|
498
501
|
typer.echo(f"Follow it at: {run_url}")
|
|
499
502
|
raise typer.Exit(EXIT_OK)
|
|
500
503
|
|
|
501
|
-
|
|
504
|
+
# To stderr under --json so `cassis eval run --json | jq` gets only the record.
|
|
505
|
+
typer.echo(f"Eval run {run_id} started: {total} cases against {ontology_label!r}.", err=json_output)
|
|
502
506
|
|
|
503
507
|
try:
|
|
504
508
|
final_run, results = _wait_for_run(
|
|
@@ -509,13 +513,15 @@ def run(
|
|
|
509
513
|
total=total,
|
|
510
514
|
poll_interval=poll_interval,
|
|
511
515
|
timeout=timeout,
|
|
516
|
+
json_output=json_output,
|
|
512
517
|
)
|
|
513
518
|
except KeyboardInterrupt:
|
|
514
|
-
|
|
515
|
-
typer.echo("
|
|
519
|
+
# To stderr under --json, for the same reason as `issues analyze`.
|
|
520
|
+
typer.echo("", err=json_output)
|
|
521
|
+
typer.echo("Interrupted — cancelling the run...", err=json_output)
|
|
516
522
|
try:
|
|
517
523
|
post_eval_run_cancel(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
|
|
518
|
-
typer.echo("Run cancelled.")
|
|
524
|
+
typer.echo("Run cancelled.", err=json_output)
|
|
519
525
|
except ApiError as exc:
|
|
520
526
|
typer.secho(f"Could not cancel the run: {exc}", fg=typer.colors.RED, err=True)
|
|
521
527
|
raise typer.Exit(EXIT_INTERRUPTED)
|
|
@@ -550,9 +556,12 @@ def _wait_for_run(
|
|
|
550
556
|
total: int,
|
|
551
557
|
poll_interval: float,
|
|
552
558
|
timeout: float,
|
|
559
|
+
json_output: bool = False,
|
|
553
560
|
) -> "tuple[dict[str, Any], list[dict[str, Any]]]":
|
|
554
561
|
"""Poll the run until terminal. Returns (run, results).
|
|
555
562
|
|
|
563
|
+
Progress lines go to stderr under --json so stdout stays the JSON record.
|
|
564
|
+
|
|
556
565
|
Exits 3 directly on timeout, on an auth failure (fail fast — retrying a
|
|
557
566
|
revoked key can't succeed), or after several consecutive poll failures.
|
|
558
567
|
The server-side run keeps going in all three cases.
|
|
@@ -587,7 +596,7 @@ def _wait_for_run(
|
|
|
587
596
|
passed = sum(1 for r in results if r.get("status") == _PASSED)
|
|
588
597
|
line = f"{done}/{total} cases done — {passed} ✓ {done - passed} ✗"
|
|
589
598
|
if line != last_line:
|
|
590
|
-
typer.echo(line)
|
|
599
|
+
typer.echo(line, err=json_output)
|
|
591
600
|
last_line = line
|
|
592
601
|
|
|
593
602
|
if run_record.get("status") in _TERMINAL_RUN_STATUSES:
|
|
@@ -31,7 +31,7 @@ GUIDE_FILENAME = "AGENTS.md"
|
|
|
31
31
|
# Monotonic version of the doctrine text below. Bump it whenever
|
|
32
32
|
# ontology_design_guide.md changes (a backend test enforces the pairing) — it
|
|
33
33
|
# is what lets an older writer recognize a newer guide and leave it alone.
|
|
34
|
-
DOCTRINE_VERSION =
|
|
34
|
+
DOCTRINE_VERSION = 6
|
|
35
35
|
|
|
36
36
|
# Must stay byte-identical to backend/app/services/ontology_guide.py::_BANNER —
|
|
37
37
|
# the server-side git export writes the same file, and differing banners would
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
Issues are what the product found wrong while answering questions (an ontology
|
|
4
4
|
gap, missing data). Listing, reading the evidence behind an occurrence, and
|
|
5
5
|
resolving or dismissing them from a checkout keeps the fix loop next to the
|
|
6
|
-
ontology files instead of in the webapp
|
|
6
|
+
ontology files instead of in the webapp; `analyze` refreshes them from the
|
|
7
|
+
conversations that arrived since the last pass, without waiting for the nightly one.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
10
11
|
|
|
11
12
|
import json
|
|
13
|
+
import time
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import Any, Optional
|
|
14
16
|
|
|
@@ -18,17 +20,24 @@ from cassis_cli.api import (
|
|
|
18
20
|
ApiError,
|
|
19
21
|
AuthError,
|
|
20
22
|
IssueNotFoundError,
|
|
23
|
+
NothingToAnalyzeError,
|
|
21
24
|
get_issue,
|
|
25
|
+
get_issue_analysis_run,
|
|
22
26
|
get_issue_evidence,
|
|
23
27
|
get_issues,
|
|
28
|
+
post_issue_analysis_cancel,
|
|
29
|
+
post_issue_analysis_start,
|
|
24
30
|
post_issue_status,
|
|
25
31
|
)
|
|
26
32
|
from cassis_cli.common import (
|
|
27
33
|
DEFAULT_BASE_PATH,
|
|
34
|
+
EXIT_INTERRUPTED,
|
|
28
35
|
EXIT_OK,
|
|
29
36
|
EXIT_TRANSPORT,
|
|
30
37
|
EXIT_USAGE,
|
|
31
38
|
EXIT_VALIDATION_FAILED,
|
|
39
|
+
api_failure,
|
|
40
|
+
one_line,
|
|
32
41
|
require_api_key,
|
|
33
42
|
resolve_project_id,
|
|
34
43
|
)
|
|
@@ -81,20 +90,11 @@ def _validate_choice(value: Optional[str], allowed: "tuple[str, ...]", flag: str
|
|
|
81
90
|
raise typer.Exit(EXIT_USAGE)
|
|
82
91
|
|
|
83
92
|
|
|
84
|
-
def _api_failure(exc: ApiError) -> "typer.Exit":
|
|
85
|
-
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
86
|
-
return typer.Exit(EXIT_TRANSPORT)
|
|
87
|
-
|
|
88
|
-
|
|
89
93
|
def _not_found_failure(exc: IssueNotFoundError) -> "typer.Exit":
|
|
90
94
|
typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
|
|
91
95
|
return typer.Exit(EXIT_VALIDATION_FAILED)
|
|
92
96
|
|
|
93
97
|
|
|
94
|
-
def _one_line(value: Any) -> str:
|
|
95
|
-
return str(value or "").replace("\n", " ").strip()
|
|
96
|
-
|
|
97
|
-
|
|
98
98
|
def _field(label: str, value: Any, *, blank_line: bool = False) -> None:
|
|
99
99
|
"""Print a labelled block, indenting a multi-line value under its label."""
|
|
100
100
|
if value in (None, "", [], {}):
|
|
@@ -145,7 +145,7 @@ def list_issues(
|
|
|
145
145
|
cause=cause,
|
|
146
146
|
)
|
|
147
147
|
except (AuthError, ApiError) as exc:
|
|
148
|
-
raise
|
|
148
|
+
raise api_failure(exc) from exc
|
|
149
149
|
|
|
150
150
|
if json_output:
|
|
151
151
|
typer.echo(json.dumps(issues, indent=2))
|
|
@@ -159,7 +159,7 @@ def list_issues(
|
|
|
159
159
|
occurrences = issue.get("occurrence_count_cache") or 0
|
|
160
160
|
typer.echo(
|
|
161
161
|
f"{issue.get('id')} {issue.get('impact')} x{occurrences} "
|
|
162
|
-
f"{issue.get('status')} {
|
|
162
|
+
f"{issue.get('status')} {one_line(issue.get('title'))}"
|
|
163
163
|
)
|
|
164
164
|
raise typer.Exit(EXIT_OK)
|
|
165
165
|
|
|
@@ -188,14 +188,14 @@ def show(
|
|
|
188
188
|
except IssueNotFoundError as exc:
|
|
189
189
|
raise _not_found_failure(exc) from exc
|
|
190
190
|
except (AuthError, ApiError) as exc:
|
|
191
|
-
raise
|
|
191
|
+
raise api_failure(exc) from exc
|
|
192
192
|
|
|
193
193
|
if json_output:
|
|
194
194
|
typer.echo(json.dumps(issue, indent=2))
|
|
195
195
|
raise typer.Exit(EXIT_OK)
|
|
196
196
|
|
|
197
197
|
occurrences = issue.get("occurrences") or []
|
|
198
|
-
typer.echo(f"{issue.get('id')} {
|
|
198
|
+
typer.echo(f"{issue.get('id')} {one_line(issue.get('title'))}")
|
|
199
199
|
typer.echo(
|
|
200
200
|
f"{issue.get('status')} {issue.get('impact')} {issue.get('cause')} "
|
|
201
201
|
f"{issue.get('occurrence_count_cache', len(occurrences))} occurrence(s)"
|
|
@@ -208,7 +208,7 @@ def show(
|
|
|
208
208
|
typer.echo("")
|
|
209
209
|
typer.echo("Occurrences:")
|
|
210
210
|
for occurrence in occurrences:
|
|
211
|
-
typer.echo(f" {occurrence.get('id')} {
|
|
211
|
+
typer.echo(f" {occurrence.get('id')} {one_line(occurrence.get('symptom'))}")
|
|
212
212
|
raise typer.Exit(EXIT_OK)
|
|
213
213
|
|
|
214
214
|
|
|
@@ -245,7 +245,7 @@ def evidence(
|
|
|
245
245
|
except IssueNotFoundError as exc:
|
|
246
246
|
raise _not_found_failure(exc) from exc
|
|
247
247
|
except (AuthError, ApiError) as exc:
|
|
248
|
-
raise
|
|
248
|
+
raise api_failure(exc) from exc
|
|
249
249
|
|
|
250
250
|
if json_output:
|
|
251
251
|
typer.echo(json.dumps(record, indent=2))
|
|
@@ -281,7 +281,7 @@ def _set_status(
|
|
|
281
281
|
except IssueNotFoundError as exc:
|
|
282
282
|
raise _not_found_failure(exc) from exc
|
|
283
283
|
except (AuthError, ApiError) as exc:
|
|
284
|
-
raise
|
|
284
|
+
raise api_failure(exc) from exc
|
|
285
285
|
|
|
286
286
|
typer.secho(f"✓ Issue {issue_id} is now {issue.get('status', status)}.", fg=typer.colors.GREEN)
|
|
287
287
|
raise typer.Exit(EXIT_OK)
|
|
@@ -296,7 +296,7 @@ def resolve(
|
|
|
296
296
|
api_url: str = _API_URL_OPTION,
|
|
297
297
|
base_path: str = _BASE_PATH_OPTION,
|
|
298
298
|
) -> None:
|
|
299
|
-
"""Mark an issue resolved — the ontology change that fixes it
|
|
299
|
+
"""Mark an issue resolved — the ontology change that fixes it is published.
|
|
300
300
|
|
|
301
301
|
Exits 0 on success, 1 when the issue does not exist in the project, 2 on
|
|
302
302
|
usage errors, 3 on transport/API errors.
|
|
@@ -360,3 +360,178 @@ def reopen(
|
|
|
360
360
|
api_url=api_url,
|
|
361
361
|
base_path=base_path,
|
|
362
362
|
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
_TERMINAL_ANALYSIS_STATUSES = frozenset({"completed", "failed", "cancelled"})
|
|
366
|
+
|
|
367
|
+
# Consecutive poll failures tolerated before giving up — same tolerance as
|
|
368
|
+
# `eval run`: a transient blip must not abandon a multi-minute run, a
|
|
369
|
+
# permanently broken poll must not spin until --timeout.
|
|
370
|
+
_MAX_CONSECUTIVE_POLL_FAILURES = 5
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@app.command()
|
|
374
|
+
def analyze(
|
|
375
|
+
path: Path = _PATH_OPTION,
|
|
376
|
+
project_id: Optional[str] = _PROJECT_OPTION,
|
|
377
|
+
api_key: Optional[str] = _API_KEY_OPTION,
|
|
378
|
+
api_url: str = _API_URL_OPTION,
|
|
379
|
+
base_path: str = _BASE_PATH_OPTION,
|
|
380
|
+
wait: bool = typer.Option(
|
|
381
|
+
True,
|
|
382
|
+
"--wait/--no-wait",
|
|
383
|
+
help="Wait for the analysis to finish and print its summary (default), or just print the run id.",
|
|
384
|
+
),
|
|
385
|
+
poll_interval: float = typer.Option(5.0, "--poll-interval", help="Seconds between status polls."),
|
|
386
|
+
timeout: float = typer.Option(1800.0, "--timeout", help="Give up waiting after this many seconds."),
|
|
387
|
+
json_output: bool = typer.Option(False, "--json", help="Print the final run record as raw JSON."),
|
|
388
|
+
) -> None:
|
|
389
|
+
"""Analyze the conversations nobody has analyzed yet, turning what went wrong into issues.
|
|
390
|
+
|
|
391
|
+
The same pass as the webapp's "Analyze conversations" button — it runs on
|
|
392
|
+
Cassis's workers, so a Ctrl-C or a lost connection never kills it — started
|
|
393
|
+
on demand instead of waiting for the nightly one. When every conversation is
|
|
394
|
+
already analyzed the command is a no-op and exits 0, so a job re-running it
|
|
395
|
+
on a quiet project stays green. Exits 0 when the run completes, 1 when it
|
|
396
|
+
fails or is cancelled, 2 on usage errors, 3 on transport errors, when a run
|
|
397
|
+
is already in flight, or on --timeout (the run keeps going server-side).
|
|
398
|
+
Ctrl-C cancels the run and exits 130.
|
|
399
|
+
"""
|
|
400
|
+
api_key = require_api_key(api_key)
|
|
401
|
+
project_id = resolve_project_id(project_id, path / Path(base_path), quiet=json_output)
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
run = post_issue_analysis_start(api_url=api_url, api_key=api_key, project_id=project_id)
|
|
405
|
+
except NothingToAnalyzeError as exc:
|
|
406
|
+
if json_output:
|
|
407
|
+
typer.echo(json.dumps({"run": None, "message": str(exc)}, indent=2))
|
|
408
|
+
else:
|
|
409
|
+
typer.echo(str(exc))
|
|
410
|
+
raise typer.Exit(EXIT_OK) from exc
|
|
411
|
+
except (AuthError, ApiError) as exc: # covers IssueAnalysisActiveError too
|
|
412
|
+
raise api_failure(exc) from exc
|
|
413
|
+
|
|
414
|
+
run_id = str(run["id"])
|
|
415
|
+
total = int(run.get("total_chats") or 0)
|
|
416
|
+
|
|
417
|
+
if not wait:
|
|
418
|
+
if json_output:
|
|
419
|
+
typer.echo(json.dumps({"run": run}, indent=2))
|
|
420
|
+
else:
|
|
421
|
+
typer.echo(f"Analysis run {run_id} started: {total} conversation(s) to analyze.")
|
|
422
|
+
typer.echo("It keeps going server-side; the results land on the webapp's Issues page.")
|
|
423
|
+
raise typer.Exit(EXIT_OK)
|
|
424
|
+
|
|
425
|
+
if not json_output:
|
|
426
|
+
typer.echo(f"Analysis run {run_id} started: {total} conversation(s) to analyze.")
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
final_run = _wait_for_analysis(
|
|
430
|
+
api_url=api_url,
|
|
431
|
+
api_key=api_key,
|
|
432
|
+
project_id=project_id,
|
|
433
|
+
run_id=run_id,
|
|
434
|
+
poll_interval=poll_interval,
|
|
435
|
+
timeout=timeout,
|
|
436
|
+
quiet=json_output,
|
|
437
|
+
)
|
|
438
|
+
except KeyboardInterrupt:
|
|
439
|
+
# To stderr under --json: stdout is the machine's, and these lines would
|
|
440
|
+
# land in front of the JSON document a caller is parsing.
|
|
441
|
+
typer.echo("", err=json_output)
|
|
442
|
+
typer.echo("Interrupted — cancelling the analysis...", err=json_output)
|
|
443
|
+
try:
|
|
444
|
+
post_issue_analysis_cancel(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
|
|
445
|
+
typer.echo("Analysis cancelled. Conversations analyzed so far keep their occurrences.", err=json_output)
|
|
446
|
+
except ApiError as exc:
|
|
447
|
+
typer.secho(f"Could not cancel the analysis: {exc}", fg=typer.colors.RED, err=True)
|
|
448
|
+
raise typer.Exit(EXIT_INTERRUPTED)
|
|
449
|
+
|
|
450
|
+
if json_output:
|
|
451
|
+
typer.echo(json.dumps({"run": final_run}, indent=2))
|
|
452
|
+
else:
|
|
453
|
+
_print_analysis_outcome(final_run)
|
|
454
|
+
|
|
455
|
+
if final_run.get("status") == "completed":
|
|
456
|
+
raise typer.Exit(EXIT_OK)
|
|
457
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _wait_for_analysis(
|
|
461
|
+
*,
|
|
462
|
+
api_url: str,
|
|
463
|
+
api_key: str,
|
|
464
|
+
project_id: str,
|
|
465
|
+
run_id: str,
|
|
466
|
+
poll_interval: float,
|
|
467
|
+
timeout: float,
|
|
468
|
+
quiet: bool,
|
|
469
|
+
) -> dict[str, Any]:
|
|
470
|
+
"""Poll the run until terminal and return its final record.
|
|
471
|
+
|
|
472
|
+
Exits 3 directly on timeout, on an auth failure (fail fast — retrying a
|
|
473
|
+
revoked key can't succeed), or after several consecutive poll failures.
|
|
474
|
+
The server-side run keeps going in all three cases.
|
|
475
|
+
"""
|
|
476
|
+
deadline = time.monotonic() + timeout
|
|
477
|
+
last_line = ""
|
|
478
|
+
failures = 0
|
|
479
|
+
while time.monotonic() < deadline:
|
|
480
|
+
try:
|
|
481
|
+
run = get_issue_analysis_run(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
|
|
482
|
+
except AuthError as exc:
|
|
483
|
+
typer.secho(f"{exc} The analysis keeps going server-side.", fg=typer.colors.RED, err=True)
|
|
484
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
485
|
+
except IssueNotFoundError as exc:
|
|
486
|
+
# The run is gone (purged): nothing to keep polling for.
|
|
487
|
+
raise _not_found_failure(exc) from exc
|
|
488
|
+
except ApiError as exc:
|
|
489
|
+
failures += 1
|
|
490
|
+
if failures >= _MAX_CONSECUTIVE_POLL_FAILURES:
|
|
491
|
+
typer.secho(
|
|
492
|
+
f"Polling failed {failures} times in a row ({exc}). "
|
|
493
|
+
"Giving up — the analysis keeps going server-side; see the webapp's Issues page.",
|
|
494
|
+
fg=typer.colors.RED,
|
|
495
|
+
err=True,
|
|
496
|
+
)
|
|
497
|
+
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
498
|
+
typer.secho(f"(poll failed, retrying: {exc})", fg=typer.colors.YELLOW, err=True)
|
|
499
|
+
time.sleep(poll_interval)
|
|
500
|
+
continue
|
|
501
|
+
failures = 0
|
|
502
|
+
|
|
503
|
+
line = (
|
|
504
|
+
f"{run.get('chats_analyzed', 0)}/{run.get('total_chats', 0)} conversations analyzed — "
|
|
505
|
+
f"{run.get('occurrences_created', 0)} occurrence(s) found"
|
|
506
|
+
)
|
|
507
|
+
if not quiet and line != last_line:
|
|
508
|
+
typer.echo(line)
|
|
509
|
+
last_line = line
|
|
510
|
+
|
|
511
|
+
if run.get("status") in _TERMINAL_ANALYSIS_STATUSES:
|
|
512
|
+
return run
|
|
513
|
+
time.sleep(poll_interval)
|
|
514
|
+
|
|
515
|
+
typer.secho(
|
|
516
|
+
f"Timed out after {timeout:.0f}s waiting for analysis run {run_id}. "
|
|
517
|
+
"The analysis keeps going server-side — see the webapp's Issues page.",
|
|
518
|
+
fg=typer.colors.YELLOW,
|
|
519
|
+
err=True,
|
|
520
|
+
)
|
|
521
|
+
raise typer.Exit(EXIT_TRANSPORT)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _print_analysis_outcome(run: dict[str, Any]) -> None:
|
|
525
|
+
status = run.get("status")
|
|
526
|
+
if status == "completed":
|
|
527
|
+
typer.echo("")
|
|
528
|
+
typer.echo(
|
|
529
|
+
f"Analysis complete: {run.get('chats_analyzed', 0)} conversation(s) analyzed, "
|
|
530
|
+
f"{run.get('occurrences_created', 0)} occurrence(s) found, "
|
|
531
|
+
f"{run.get('issues_touched', 0)} issue(s) created or updated."
|
|
532
|
+
)
|
|
533
|
+
typer.echo("Review them with `cassis issues list`.")
|
|
534
|
+
elif status == "cancelled":
|
|
535
|
+
typer.secho("Analysis cancelled before it finished.", fg=typer.colors.YELLOW, err=True)
|
|
536
|
+
else:
|
|
537
|
+
typer.secho(f"Analysis failed: {run.get('error') or 'unknown error'}", fg=typer.colors.RED, err=True)
|
|
@@ -9,6 +9,7 @@ from cassis_cli.issues import app as issues_app
|
|
|
9
9
|
from cassis_cli.ontology import app as ontology_app
|
|
10
10
|
from cassis_cli.projects import app as projects_app
|
|
11
11
|
from cassis_cli.schema import app as schema_app
|
|
12
|
+
from cassis_cli.source_changes import app as source_changes_app
|
|
12
13
|
from cassis_cli.status import status
|
|
13
14
|
from cassis_cli.verify import verify
|
|
14
15
|
|
|
@@ -24,6 +25,7 @@ app.add_typer(eval_app, name="eval")
|
|
|
24
25
|
app.add_typer(schema_app, name="schema")
|
|
25
26
|
app.add_typer(projects_app, name="projects")
|
|
26
27
|
app.add_typer(issues_app, name="issues")
|
|
28
|
+
app.add_typer(source_changes_app, name="source-changes")
|
|
27
29
|
app.command()(status)
|
|
28
30
|
app.command()(verify)
|
|
29
31
|
|
|
@@ -440,6 +440,15 @@ checkout:
|
|
|
440
440
|
Merging the pull request syncs and publishes the ontology; nothing reaches
|
|
441
441
|
production answers until then.
|
|
442
442
|
|
|
443
|
+
When the change came from a Cassis issue, merging is still not the last step.
|
|
444
|
+
Confirm the published version contains it (`cassis status`, or
|
|
445
|
+
`get_project_status` over MCP), then propose resolving that issue —
|
|
446
|
+
`cassis issues resolve <id>`, or `update_issue_status`. Propose it to whoever
|
|
447
|
+
owns the project: resolving needs the editor or admin role, and it records an
|
|
448
|
+
outcome without changing any ontology. Never resolve before publication, and
|
|
449
|
+
never silently — an issue nobody resolves stays in the triage queue and reads
|
|
450
|
+
as a gap that was never fixed.
|
|
451
|
+
|
|
443
452
|
---
|
|
444
453
|
|
|
445
454
|
*This guide is the single canonical home for Cassis ontology modeling doctrine.
|
|
@@ -175,17 +175,26 @@ def push(
|
|
|
175
175
|
envvar="CASSIS_BASE_PATH",
|
|
176
176
|
help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
|
|
177
177
|
),
|
|
178
|
+
complete_source: bool = typer.Option(
|
|
179
|
+
False,
|
|
180
|
+
"--complete",
|
|
181
|
+
help="The file is the project's complete source schema: schemas absent from it are treated as dropped. "
|
|
182
|
+
"Without it, the upload only speaks for the schemas it contains.",
|
|
183
|
+
),
|
|
178
184
|
poll_interval: float = typer.Option(5.0, "--poll-interval", help="Seconds between polls."),
|
|
179
185
|
timeout: float = typer.Option(600.0, "--timeout", help="Give up waiting after this many seconds."),
|
|
180
186
|
json_output: bool = typer.Option(False, "--json", help="Print the run record as raw JSON."),
|
|
181
187
|
) -> None:
|
|
182
188
|
"""Upload a DDL file to detect source-schema changes (same as the webapp's "Update from DDL").
|
|
183
189
|
|
|
184
|
-
The DDL must contain at least one CREATE TABLE statement
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
The DDL must contain at least one CREATE TABLE statement. Cassis diffs it
|
|
191
|
+
against the ontology: added, dropped, and changed objects appear in
|
|
192
|
+
Ontology > Review > Data source for approval. The file speaks only for the
|
|
193
|
+
schemas it contains — a partial export (one schema of many) never removes
|
|
194
|
+
the others; pass --complete when the file is the project's complete source
|
|
195
|
+
schema so schemas absent from it are treated as dropped. Re-uploading a
|
|
196
|
+
corrected DDL supersedes the previous one. Only works on DDL-only projects
|
|
197
|
+
(no warehouse connection).
|
|
189
198
|
|
|
190
199
|
Always waits for the detection run to finish: the server parses the DDL
|
|
191
200
|
inside the run (a large file takes a while, and an unparseable one fails
|
|
@@ -211,7 +220,9 @@ def push(
|
|
|
211
220
|
raise typer.Exit(EXIT_USAGE)
|
|
212
221
|
|
|
213
222
|
try:
|
|
214
|
-
run = post_detect_from_ddl(
|
|
223
|
+
run = post_detect_from_ddl(
|
|
224
|
+
api_url=api_url, api_key=api_key, project_id=project_id, ddl=ddl_text, complete_source=complete_source
|
|
225
|
+
)
|
|
215
226
|
except SourceChangeConflictError as exc:
|
|
216
227
|
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
217
228
|
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
@@ -220,7 +231,8 @@ def push(
|
|
|
220
231
|
raise typer.Exit(EXIT_TRANSPORT) from exc
|
|
221
232
|
|
|
222
233
|
run_id = run["run_id"]
|
|
223
|
-
|
|
234
|
+
# To stderr under --json so `cassis schema push --json | jq` gets only the record.
|
|
235
|
+
typer.echo(f"Detection run started: {run_id}", err=json_output)
|
|
224
236
|
|
|
225
237
|
run = _wait_for_detection_run(
|
|
226
238
|
api_url=api_url,
|
|
@@ -237,10 +249,21 @@ def push(
|
|
|
237
249
|
run_status = run.get("status")
|
|
238
250
|
if run_status == "completed":
|
|
239
251
|
summary = run.get("summary") or {}
|
|
240
|
-
total = summary.get("
|
|
252
|
+
total = summary.get("reviewable_total")
|
|
253
|
+
if total is None:
|
|
254
|
+
total = sum(summary.get(key, 0) for key in ("changes_created", "changes_updated", "changes_reopened"))
|
|
255
|
+
if summary.get("partial_upload_suspected"):
|
|
256
|
+
typer.secho(
|
|
257
|
+
"Note: the file drops most of the tracked tables in the schemas it covers, which"
|
|
258
|
+
" often means a partial export. Removals of modeled tables wait for review;"
|
|
259
|
+
" re-push a complete export to undo unintended drops.",
|
|
260
|
+
fg=typer.colors.YELLOW,
|
|
261
|
+
err=True,
|
|
262
|
+
)
|
|
241
263
|
typer.secho(
|
|
242
|
-
f"✓ Detection completed: {total} change(s)
|
|
264
|
+
f"✓ Detection completed: {total} change(s) to review." if total else "✓ Detection completed: no changes.",
|
|
243
265
|
fg=typer.colors.GREEN,
|
|
266
|
+
err=json_output,
|
|
244
267
|
)
|
|
245
268
|
raise typer.Exit(EXIT_OK)
|
|
246
269
|
if run_status == "failed":
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""`cassis source-changes` — read the Data source review queue from the terminal.
|
|
2
|
+
|
|
3
|
+
Source changes are the schema drift Cassis detected between the data source
|
|
4
|
+
and what the ontology tracks (tables/columns added, removed, renamed,
|
|
5
|
+
retyped). Reading them from a checkout lets an agent see that a breaking
|
|
6
|
+
`column_removed` card is pending against a table it is editing — the fix then
|
|
7
|
+
happens in the ontology files via a pull request. Read-only by design:
|
|
8
|
+
reviewing (approve applies ontology edits, dismiss mutes the table) stays in
|
|
9
|
+
the webapp.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from cassis_cli.api import (
|
|
20
|
+
DEFAULT_API_URL,
|
|
21
|
+
ApiError,
|
|
22
|
+
AuthError,
|
|
23
|
+
SourceChangeNotFoundError,
|
|
24
|
+
get_source_change,
|
|
25
|
+
get_source_changes,
|
|
26
|
+
)
|
|
27
|
+
from cassis_cli.common import (
|
|
28
|
+
DEFAULT_BASE_PATH,
|
|
29
|
+
EXIT_OK,
|
|
30
|
+
EXIT_USAGE,
|
|
31
|
+
EXIT_VALIDATION_FAILED,
|
|
32
|
+
api_failure,
|
|
33
|
+
one_line,
|
|
34
|
+
require_api_key,
|
|
35
|
+
resolve_project_id,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(no_args_is_help=True, help="Read the project's Data source review queue.")
|
|
39
|
+
|
|
40
|
+
STATUSES = ("pending", "approved", "rejected", "superseded")
|
|
41
|
+
|
|
42
|
+
_PATH_OPTION = typer.Option(
|
|
43
|
+
Path("."),
|
|
44
|
+
"--path",
|
|
45
|
+
help="Repository checkout root (holds <base-path>/project.yml for the --project default).",
|
|
46
|
+
)
|
|
47
|
+
_PROJECT_OPTION = typer.Option(
|
|
48
|
+
None,
|
|
49
|
+
"--project",
|
|
50
|
+
envvar="CASSIS_PROJECT_ID",
|
|
51
|
+
help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
|
|
52
|
+
)
|
|
53
|
+
_API_KEY_OPTION = typer.Option(
|
|
54
|
+
None,
|
|
55
|
+
"--api-key",
|
|
56
|
+
envvar="CASSIS_API_KEY",
|
|
57
|
+
help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
|
|
58
|
+
)
|
|
59
|
+
_API_URL_OPTION = typer.Option(
|
|
60
|
+
DEFAULT_API_URL,
|
|
61
|
+
"--api-url",
|
|
62
|
+
envvar="CASSIS_API_URL",
|
|
63
|
+
help="Cassis API base URL.",
|
|
64
|
+
)
|
|
65
|
+
_BASE_PATH_OPTION = typer.Option(
|
|
66
|
+
DEFAULT_BASE_PATH,
|
|
67
|
+
"--base-path",
|
|
68
|
+
envvar="CASSIS_BASE_PATH",
|
|
69
|
+
help="Repository directory the ontology is exported under (holds project.yml for the --project default).",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _target(change: dict[str, Any]) -> str:
|
|
74
|
+
parts = [change.get("target_schema"), change.get("target_table"), change.get("target_column")]
|
|
75
|
+
return ".".join(str(p) for p in parts if p)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command(name="list")
|
|
79
|
+
def list_source_changes(
|
|
80
|
+
status: Optional[str] = typer.Option(
|
|
81
|
+
None, "--status", help=f"Filter by status ({', '.join(STATUSES)}). Defaults to pending."
|
|
82
|
+
),
|
|
83
|
+
limit: int = typer.Option(100, "--limit", min=1, max=500, help="Page size."),
|
|
84
|
+
offset: int = typer.Option(0, "--offset", min=0, help="Page start, newest first."),
|
|
85
|
+
path: Path = _PATH_OPTION,
|
|
86
|
+
project_id: Optional[str] = _PROJECT_OPTION,
|
|
87
|
+
api_key: Optional[str] = _API_KEY_OPTION,
|
|
88
|
+
api_url: str = _API_URL_OPTION,
|
|
89
|
+
base_path: str = _BASE_PATH_OPTION,
|
|
90
|
+
json_output: bool = typer.Option(False, "--json", help="Print the page as raw JSON ({items, total})."),
|
|
91
|
+
) -> None:
|
|
92
|
+
"""List the pending Data source review items, newest first.
|
|
93
|
+
|
|
94
|
+
Prints each change's id, type, severity, status and target; the id is what
|
|
95
|
+
`cassis source-changes show` takes. A `breaking` severity means a curated
|
|
96
|
+
ontology object references the changed source object. Exits 0 on success,
|
|
97
|
+
2 on usage errors, 3 on transport/API errors.
|
|
98
|
+
"""
|
|
99
|
+
if status is not None and status not in STATUSES:
|
|
100
|
+
typer.secho(f"--status must be one of {', '.join(STATUSES)}, got {status!r}.", fg=typer.colors.RED, err=True)
|
|
101
|
+
raise typer.Exit(EXIT_USAGE)
|
|
102
|
+
api_key = require_api_key(api_key)
|
|
103
|
+
project_id = resolve_project_id(project_id, path / Path(base_path), quiet=json_output)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
page = get_source_changes(
|
|
107
|
+
api_url=api_url,
|
|
108
|
+
api_key=api_key,
|
|
109
|
+
project_id=project_id,
|
|
110
|
+
status=status,
|
|
111
|
+
limit=limit,
|
|
112
|
+
offset=offset,
|
|
113
|
+
)
|
|
114
|
+
except (AuthError, ApiError) as exc:
|
|
115
|
+
raise api_failure(exc) from exc
|
|
116
|
+
|
|
117
|
+
if json_output:
|
|
118
|
+
typer.echo(json.dumps(page, indent=2))
|
|
119
|
+
raise typer.Exit(EXIT_OK)
|
|
120
|
+
|
|
121
|
+
items = page.get("items") or []
|
|
122
|
+
total = page.get("total", len(items))
|
|
123
|
+
if not items:
|
|
124
|
+
typer.echo("No source changes match." if status else "No pending source changes.")
|
|
125
|
+
raise typer.Exit(EXIT_OK)
|
|
126
|
+
|
|
127
|
+
for change in items:
|
|
128
|
+
typer.echo(
|
|
129
|
+
f"{change.get('id')} {change.get('change_type')} {change.get('severity')} "
|
|
130
|
+
f"{change.get('status')} {_target(change)}"
|
|
131
|
+
)
|
|
132
|
+
shown = len(items)
|
|
133
|
+
if offset + shown < total:
|
|
134
|
+
typer.echo(f"Showing {shown} of {total} (use --offset {offset + shown} for the next page).")
|
|
135
|
+
raise typer.Exit(EXIT_OK)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def show(
|
|
140
|
+
change_id: str = typer.Argument(..., help="Id of the change to show (from `cassis source-changes list`)."),
|
|
141
|
+
path: Path = _PATH_OPTION,
|
|
142
|
+
project_id: Optional[str] = _PROJECT_OPTION,
|
|
143
|
+
api_key: Optional[str] = _API_KEY_OPTION,
|
|
144
|
+
api_url: str = _API_URL_OPTION,
|
|
145
|
+
base_path: str = _BASE_PATH_OPTION,
|
|
146
|
+
json_output: bool = typer.Option(False, "--json", help="Print the change as raw JSON."),
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Show one Data source review item: its impact and the suggested edit.
|
|
149
|
+
|
|
150
|
+
`impact` lists the curated ontology objects referencing the changed source
|
|
151
|
+
object; the suggested edit describes what approving in the webapp would do
|
|
152
|
+
— make the equivalent edit in the ontology files to fix headlessly. Exits
|
|
153
|
+
0 on success, 1 when the change does not exist in the project, 2 on usage
|
|
154
|
+
errors, 3 on transport/API errors.
|
|
155
|
+
"""
|
|
156
|
+
api_key = require_api_key(api_key)
|
|
157
|
+
project_id = resolve_project_id(project_id, path / Path(base_path), quiet=json_output)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
change = get_source_change(api_url=api_url, api_key=api_key, project_id=project_id, change_id=change_id)
|
|
161
|
+
except SourceChangeNotFoundError as exc:
|
|
162
|
+
typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
|
|
163
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
164
|
+
except (AuthError, ApiError) as exc:
|
|
165
|
+
raise api_failure(exc) from exc
|
|
166
|
+
|
|
167
|
+
if json_output:
|
|
168
|
+
typer.echo(json.dumps(change, indent=2))
|
|
169
|
+
raise typer.Exit(EXIT_OK)
|
|
170
|
+
|
|
171
|
+
typer.echo(f"{change.get('id')} {change.get('change_type')} {change.get('severity')} {change.get('status')}")
|
|
172
|
+
typer.echo(f"Target: {_target(change)}")
|
|
173
|
+
typer.echo(f"Raised {change.get('times_raised', 1)}x, last {change.get('last_detected_at')}")
|
|
174
|
+
impact = change.get("impact") or []
|
|
175
|
+
if impact:
|
|
176
|
+
typer.echo("")
|
|
177
|
+
typer.echo("Impact:")
|
|
178
|
+
for ref in impact:
|
|
179
|
+
line = f" {ref.get('kind')} {ref.get('confidence')} {one_line(ref.get('object_label'))}"
|
|
180
|
+
detail = ref.get("detail")
|
|
181
|
+
if detail:
|
|
182
|
+
line += f" — {one_line(detail)}"
|
|
183
|
+
typer.echo(line)
|
|
184
|
+
edit = change.get("suggested_edit") or {}
|
|
185
|
+
summary = edit.get("human_summary") if isinstance(edit, dict) else None
|
|
186
|
+
if summary:
|
|
187
|
+
typer.echo("")
|
|
188
|
+
typer.echo(f"Suggested edit: {one_line(summary)}")
|
|
189
|
+
for note in edit.get("manual_review") or []:
|
|
190
|
+
typer.echo(f" Manual review: {one_line(note)}")
|
|
191
|
+
raise typer.Exit(EXIT_OK)
|
|
@@ -87,6 +87,11 @@ def _render(status_record: "dict[str, Any]", comparison_text: str) -> None:
|
|
|
87
87
|
typer.echo(f"Git sync: {git_sync['provider']} {git_sync['repo']} (path {git_sync['base_path']})")
|
|
88
88
|
else:
|
|
89
89
|
typer.echo("Git sync: not configured")
|
|
90
|
+
pending = status_record.get("pending_source_changes")
|
|
91
|
+
if pending and pending.get("total"):
|
|
92
|
+
breaking = pending.get("breaking") or 0
|
|
93
|
+
breaking_text = f", {breaking} breaking" if breaking else ""
|
|
94
|
+
typer.echo(f"Source changes pending review: {pending['total']}{breaking_text} (cassis source-changes list)")
|
|
90
95
|
typer.echo(f"Local checkout: {comparison_text}")
|
|
91
96
|
|
|
92
97
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|