feat: projects command — find the project IDs the config is missing
Reading gizmo_id during normalization fixed attribution, but it cannot answer "which projects am I missing?": normalization only runs on conversations being exported, and a normal run skips everything already cached. Discovering the gaps would have meant --force re-exporting the whole archive. `ai-chat-exporter projects` does it directly. It lists conversations, collects the g-p- ids they belong to, resolves display names, and prints a table marking which are absent from .env plus a paste-ready CHATGPT_PROJECT_IDS line. --write applies it; --deep falls back to one detail request per conversation when the listing does not carry gizmo_id (unverified which shape this account returns, so the command reports which path it took rather than assuming). This matters beyond tidiness: attribution is now self-correcting, but the listing pass still needs the ids. Conversations that live only inside a project never appear in the default listing, so an unlisted project's chats are not merely misfiled — they are never fetched. 6 CLI tests: reporting, the already-configured case, the g-p- guard, --deep, the hint when --deep is needed, and --write. 324 pass.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"""CLI-level tests using Click's CliRunner — no live API calls required."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
@@ -297,3 +299,119 @@ class TestCanaryCommand:
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "No web-API provider tokens" in result.output
|
||||
|
||||
|
||||
class TestProjectsCommand:
|
||||
"""`projects` discovers project IDs missing from CHATGPT_PROJECT_IDS."""
|
||||
|
||||
def _patch_provider(self, monkeypatch, summaries, details=None, names=None):
|
||||
import src.providers.chatgpt as chatgpt_mod
|
||||
|
||||
names = names or {}
|
||||
details = details or {}
|
||||
|
||||
class FakeProvider:
|
||||
def __init__(self, **kwargs):
|
||||
self._project_ids = kwargs.get("project_ids") or []
|
||||
|
||||
def fetch_all_conversations(self, since=None):
|
||||
return summaries
|
||||
|
||||
def get_conversation(self, conv_id):
|
||||
return details.get(conv_id, {})
|
||||
|
||||
def _fetch_project_name(self, gizmo_id):
|
||||
return names.get(gizmo_id, gizmo_id)
|
||||
|
||||
monkeypatch.setattr(chatgpt_mod, "ChatGPTProvider", FakeProvider)
|
||||
return FakeProvider
|
||||
|
||||
def _env(self, tmp_path, **extra):
|
||||
# Clears the ToS gate and the first-run doctor check.
|
||||
cache = Cache(tmp_path)
|
||||
cache.acknowledge_tos()
|
||||
cache.mark_exported("chatgpt", "dummy", {"updated_at": "2024-01-01T00:00:00Z"})
|
||||
env = {
|
||||
"CHATGPT_SESSION_TOKEN": "eyJtesttoken",
|
||||
"CACHE_DIR": str(tmp_path),
|
||||
"EXPORT_DIR": str(tmp_path / "exports"),
|
||||
}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
def test_reports_project_absent_from_config(self, tmp_path, monkeypatch):
|
||||
self._patch_provider(
|
||||
monkeypatch,
|
||||
summaries=[{"id": "c1", "gizmo_id": "g-p-missing"}],
|
||||
names={"g-p-missing": "Tech Questions"},
|
||||
)
|
||||
result = CliRunner(mix_stderr=True).invoke(
|
||||
cli, ["--no-log-file", "projects"], env=self._env(tmp_path)
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Tech Questions" in result.output
|
||||
assert "g-p-missing" in result.output
|
||||
assert "CHATGPT_PROJECT_IDS=g-p-missing" in result.output
|
||||
|
||||
def test_quiet_when_everything_is_configured(self, tmp_path, monkeypatch):
|
||||
self._patch_provider(
|
||||
monkeypatch,
|
||||
summaries=[{"id": "c1", "gizmo_id": "g-p-known"}],
|
||||
names={"g-p-known": "Known"},
|
||||
)
|
||||
result = CliRunner(mix_stderr=True).invoke(
|
||||
cli,
|
||||
["--no-log-file", "projects"],
|
||||
env=self._env(tmp_path, CHATGPT_PROJECT_IDS="g-p-known"),
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "already configured" in result.output
|
||||
|
||||
def test_custom_gpt_ids_are_ignored(self, tmp_path, monkeypatch):
|
||||
self._patch_provider(
|
||||
monkeypatch,
|
||||
summaries=[{"id": "c1", "gizmo_id": "g-notaproject"}],
|
||||
)
|
||||
result = CliRunner(mix_stderr=True).invoke(
|
||||
cli, ["--no-log-file", "projects"], env=self._env(tmp_path)
|
||||
)
|
||||
assert "g-notaproject" not in result.output
|
||||
|
||||
def test_deep_reads_conversation_details(self, tmp_path, monkeypatch):
|
||||
self._patch_provider(
|
||||
monkeypatch,
|
||||
summaries=[{"id": "c1"}], # listing carries no gizmo_id
|
||||
details={"c1": {"gizmo_id": "g-p-fromdetail"}},
|
||||
names={"g-p-fromdetail": "Found Deep"},
|
||||
)
|
||||
result = CliRunner(mix_stderr=True).invoke(
|
||||
cli, ["--no-log-file", "projects", "--deep"], env=self._env(tmp_path)
|
||||
)
|
||||
assert "Found Deep" in result.output
|
||||
assert "CHATGPT_PROJECT_IDS=g-p-fromdetail" in result.output
|
||||
|
||||
def test_without_deep_says_so_rather_than_reporting_nothing(self, tmp_path, monkeypatch):
|
||||
self._patch_provider(
|
||||
monkeypatch,
|
||||
summaries=[{"id": "c1"}],
|
||||
details={"c1": {"gizmo_id": "g-p-fromdetail"}},
|
||||
)
|
||||
result = CliRunner(mix_stderr=True).invoke(
|
||||
cli, ["--no-log-file", "projects"], env=self._env(tmp_path)
|
||||
)
|
||||
assert "--deep" in result.output
|
||||
|
||||
def test_write_updates_env(self, tmp_path, monkeypatch):
|
||||
self._patch_provider(
|
||||
monkeypatch,
|
||||
summaries=[{"id": "c1", "gizmo_id": "g-p-new"}],
|
||||
names={"g-p-new": "New Project"},
|
||||
)
|
||||
runner = CliRunner(mix_stderr=True)
|
||||
with runner.isolated_filesystem(temp_dir=tmp_path) as fs:
|
||||
result = runner.invoke(
|
||||
cli, ["--no-log-file", "projects", "--write"], env=self._env(tmp_path)
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
env_text = (Path(fs) / ".env").read_text(encoding="utf-8")
|
||||
assert "CHATGPT_PROJECT_IDS=g-p-new" in env_text
|
||||
|
||||
Reference in New Issue
Block a user