Skip to content

Commit 5fe4afb

Browse files
ci: add GitHub Actions for PR checks and PyPI release
- pr.yml: test + ruff lint on pull requests - release.yml: on push to main, bump version, publish to PyPI, create GitHub release - Add ruff to dev deps, fix test assertions for Rich table output Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7823146 commit 5fe4afb

6 files changed

Lines changed: 257 additions & 9 deletions

File tree

.github/workflows/pr.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: PR Checks
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
concurrency:
8+
group: ${{ github.workflow }}-${{ github.ref }}
9+
cancel-in-progress: true
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
test:
16+
name: Run tests
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- name: Install uv
22+
uses: astral-sh/setup-uv@v5
23+
with:
24+
enable-cache: true
25+
26+
- name: Set up Python
27+
uses: actions/setup-python@v5
28+
with:
29+
python-version: "3.12"
30+
31+
- name: Install dependencies
32+
run: uv sync
33+
34+
- name: Run tests
35+
run: uv run pytest
36+
37+
lint:
38+
name: Lint code
39+
runs-on: ubuntu-latest
40+
steps:
41+
- uses: actions/checkout@v4
42+
43+
- name: Install uv
44+
uses: astral-sh/setup-uv@v5
45+
with:
46+
enable-cache: true
47+
48+
- name: Set up Python
49+
uses: actions/setup-python@v5
50+
with:
51+
python-version: "3.12"
52+
53+
- name: Install dependencies
54+
run: uv sync
55+
56+
- name: Lint
57+
run: uv run ruff check src tests

.github/workflows/release.yml

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths-ignore:
7+
- "*.md"
8+
- "docs/**"
9+
- "history/**"
10+
11+
concurrency:
12+
group: ${{ github.workflow }}-${{ github.ref }}
13+
cancel-in-progress: false
14+
15+
permissions:
16+
contents: write
17+
id-token: write
18+
19+
jobs:
20+
test:
21+
name: Run tests
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
26+
- name: Install uv
27+
uses: astral-sh/setup-uv@v5
28+
with:
29+
enable-cache: true
30+
31+
- name: Set up Python
32+
uses: actions/setup-python@v5
33+
with:
34+
python-version: "3.12"
35+
36+
- name: Install dependencies
37+
run: uv sync
38+
39+
- name: Run tests
40+
run: uv run pytest
41+
42+
version:
43+
name: Bump version and create tag
44+
needs: test
45+
runs-on: ubuntu-latest
46+
outputs:
47+
version: ${{ steps.versioning.outputs.version }}
48+
tag: ${{ steps.versioning.outputs.tag }}
49+
steps:
50+
- uses: actions/checkout@v4
51+
with:
52+
fetch-depth: 0
53+
54+
- name: Install uv
55+
uses: astral-sh/setup-uv@v5
56+
with:
57+
enable-cache: true
58+
59+
- name: Set up Python
60+
uses: actions/setup-python@v5
61+
with:
62+
python-version: "3.12"
63+
64+
- name: Configure Git
65+
run: |
66+
git config --global user.name "github-actions[bot]"
67+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
68+
69+
- name: Bump version and create tag
70+
id: versioning
71+
run: |
72+
uv version --bump patch
73+
NEW_VERSION=$(uv version --short)
74+
TAG="v$NEW_VERSION"
75+
sed -i "s/__version__ = .*/__version__ = \"$NEW_VERSION\"/" src/conversation_exporter/__init__.py
76+
git add pyproject.toml src/conversation_exporter/__init__.py
77+
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
78+
git tag -a "$TAG" -m "Release $TAG"
79+
git push origin main
80+
git push origin "$TAG"
81+
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
82+
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
83+
84+
pypi:
85+
name: Publish to PyPI
86+
needs: version
87+
runs-on: ubuntu-latest
88+
environment:
89+
name: pypi
90+
url: https://pypi.org/p/conversation-exporter
91+
steps:
92+
- uses: actions/checkout@v4
93+
94+
- name: Install uv
95+
uses: astral-sh/setup-uv@v5
96+
with:
97+
enable-cache: true
98+
99+
- name: Set up Python
100+
uses: actions/setup-python@v5
101+
with:
102+
python-version: "3.12"
103+
104+
- name: Build and publish to PyPI
105+
env:
106+
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
107+
run: |
108+
uv build
109+
uv publish --token $UV_PUBLISH_TOKEN
110+
111+
- name: Store distributions
112+
uses: actions/upload-artifact@v4
113+
with:
114+
name: python-package-distributions
115+
path: dist/
116+
117+
github-release:
118+
name: Create GitHub Release
119+
needs: [version, pypi]
120+
runs-on: ubuntu-latest
121+
steps:
122+
- name: Download distributions
123+
uses: actions/download-artifact@v4
124+
with:
125+
name: python-package-distributions
126+
path: dist/
127+
128+
- name: Sign distributions
129+
uses: sigstore/gh-action-sigstore-python@v3.0.0
130+
with:
131+
inputs: >-
132+
./dist/*.tar.gz
133+
./dist/*.whl
134+
135+
- name: Create release
136+
env:
137+
GITHUB_TOKEN: ${{ github.token }}
138+
run: >-
139+
gh release create '${{ needs.version.outputs.tag }}'
140+
--repo '${{ github.repository }}'
141+
--generate-notes
142+
dist/*

pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,19 @@ name = "conversation-exporter"
77
version = "0.1.0"
88
description = "Idempotent conversation exporter for Codex and other AI systems."
99
readme = "README.md"
10+
license = { text = "MIT" }
1011
requires-python = ">=3.11"
12+
keywords = ["codex", "claude", "cursor", "conversation", "export", "backup"]
13+
classifiers = [
14+
"License :: OSI Approved :: MIT License",
15+
"Programming Language :: Python :: 3",
16+
"Programming Language :: Python :: 3.11",
17+
"Programming Language :: Python :: 3.12",
18+
]
1119
dependencies = [
20+
"plumbrc>=1.0.0",
21+
"tantivy>=0.22",
22+
"textual>=8.0",
1223
"typer>=0.12.0",
1324
]
1425

@@ -18,6 +29,7 @@ convx = "conversation_exporter.cli:main"
1829
[dependency-groups]
1930
dev = [
2031
"pytest>=8.0.0",
32+
"ruff>=0.8.0",
2133
]
2234

2335
[tool.pytest.ini_options]

src/conversation_exporter/adapters/claude.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ def _parse_claude_jsonl(
7777

7878
timestamp = obj.get("timestamp")
7979
msg = obj.get("message", {})
80-
role = msg.get("role", "")
8180
content = msg.get("content", [])
8281

8382
if obj_type == "user":
@@ -107,7 +106,7 @@ def _parse_claude_jsonl(
107106
)
108107
elif content_type == "thinking":
109108
messages.append(
110-
NormalizedMessage(role="reasoning", text=text, timestamp=timestamp, kind="system")
109+
NormalizedMessage(role="reasoning", text=text, timestamp=timestamp, kind="thinking")
111110
)
112111
else:
113112
messages.append(

tests/test_integration_claude.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import re
45
import subprocess
56
import sys
67
from pathlib import Path
@@ -15,7 +16,7 @@ def _encode_path(p: Path) -> str:
1516

1617

1718
def _run_cli(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
18-
env = {"PYTHONPATH": str(ROOT / "src")}
19+
env = {"PYTHONPATH": str(ROOT / "src"), "NO_COLOR": "1"}
1920
return subprocess.run(
2021
[sys.executable, "-m", "conversation_exporter", *args],
2122
cwd=str(cwd or ROOT),
@@ -26,6 +27,13 @@ def _run_cli(args: list[str], cwd: Path | None = None) -> subprocess.CompletedPr
2627
)
2728

2829

30+
def _assert_backup_counts(stdout: str, exported: int | None = None, skipped: int | None = None) -> None:
31+
if exported is not None:
32+
assert re.search(rf"Exported\s+{exported}\b", stdout), f"Expected Exported {exported} in {stdout!r}"
33+
if skipped is not None:
34+
assert re.search(rf"Skipped\s+{skipped}\b", stdout), f"Expected Skipped {skipped} in {stdout!r}"
35+
36+
2937
def _init_git_repo(path: Path) -> None:
3038
path.mkdir(parents=True, exist_ok=True)
3139
subprocess.run(["git", "init", str(path)], check=True, capture_output=True, text=True)
@@ -119,7 +127,7 @@ def test_claude_backup_writes_session_folders(tmp_path: Path) -> None:
119127
"--system-name", "macbook-pro",
120128
])
121129
assert run.returncode == 0, run.stderr
122-
assert "exported=4" in run.stdout
130+
_assert_backup_counts(run.stdout, exported=4)
123131

124132
history = output_repo / "history" / "alice" / "claude" / "macbook-pro"
125133
session_dirs = sorted(history.rglob("index.md"))
@@ -148,7 +156,7 @@ def test_claude_backup_is_idempotent(tmp_path: Path) -> None:
148156
"--system-name", "macbook-pro",
149157
])
150158
assert run_one.returncode == 0, run_one.stderr
151-
assert "exported=4" in run_one.stdout
159+
_assert_backup_counts(run_one.stdout, exported=4)
152160

153161
run_two = _run_cli([
154162
"backup",
@@ -159,7 +167,7 @@ def test_claude_backup_is_idempotent(tmp_path: Path) -> None:
159167
"--system-name", "macbook-pro",
160168
])
161169
assert run_two.returncode == 0, run_two.stderr
162-
assert "skipped=4" in run_two.stdout
170+
_assert_backup_counts(run_two.stdout, skipped=4)
163171

164172

165173
def test_claude_sync_filters_to_repo_and_subfolders(tmp_path: Path) -> None:

tests/test_integration_sync.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
from __future__ import annotations
22

3+
import re
34
import subprocess
45
import sys
56
from pathlib import Path
67

78

89
ROOT = Path(__file__).resolve().parents[1]
910
FIXTURES = ROOT / "tests" / "fixtures" / "codex_sessions"
11+
FIXTURES_REDACT = ROOT / "tests" / "fixtures" / "codex_sessions_redact"
1012

1113

1214
def _run_cli(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
13-
env = {"PYTHONPATH": str(ROOT / "src")}
15+
env = {"PYTHONPATH": str(ROOT / "src"), "NO_COLOR": "1"}
1416
return subprocess.run(
1517
[sys.executable, "-m", "conversation_exporter", *args],
1618
cwd=str(cwd or ROOT),
@@ -21,6 +23,13 @@ def _run_cli(args: list[str], cwd: Path | None = None) -> subprocess.CompletedPr
2123
)
2224

2325

26+
def _assert_backup_counts(stdout: str, exported: int | None = None, skipped: int | None = None) -> None:
27+
if exported is not None:
28+
assert re.search(rf"Exported\s+{exported}\b", stdout), f"Expected Exported {exported} in {stdout!r}"
29+
if skipped is not None:
30+
assert re.search(rf"Skipped\s+{skipped}\b", stdout), f"Expected Skipped {skipped} in {stdout!r}"
31+
32+
2433
def _init_git_repo(path: Path) -> None:
2534
path.mkdir(parents=True, exist_ok=True)
2635
subprocess.run(["git", "init", str(path)], check=True, capture_output=True, text=True)
@@ -39,7 +48,7 @@ def test_backup_writes_expected_structure_and_is_idempotent(tmp_path: Path) -> N
3948
"--system-name", "macbook-pro",
4049
])
4150
assert run_one.returncode == 0, run_one.stderr
42-
assert "exported=2" in run_one.stdout
51+
_assert_backup_counts(run_one.stdout, exported=2)
4352

4453
target = output_repo / "history" / "alice" / "codex" / "macbook-pro" / "Code"
4554
markdown_files = sorted(target.rglob("*.md"))
@@ -56,7 +65,7 @@ def test_backup_writes_expected_structure_and_is_idempotent(tmp_path: Path) -> N
5665
"--system-name", "macbook-pro",
5766
])
5867
assert run_two.returncode == 0, run_two.stderr
59-
assert "skipped=2" in run_two.stdout
68+
_assert_backup_counts(run_two.stdout, skipped=2)
6069
assert len(sorted(target.rglob("*.md"))) == 2
6170
assert len(sorted(target.rglob(".*.json"))) == 2
6271

@@ -80,3 +89,24 @@ def test_sync_filters_to_current_git_repository(tmp_path: Path) -> None:
8089
history_root = project_repo / ".ai" / "history" / "alice" / "codex"
8190
markdown_files = sorted(history_root.rglob("*.md"))
8291
assert len(markdown_files) == 1
92+
93+
94+
def test_secrets_redacted_in_output(tmp_path: Path) -> None:
95+
output_repo = tmp_path / "backup-repo"
96+
_init_git_repo(output_repo)
97+
secret_literal = "sk-proj-redact-test-abc123xyz"
98+
99+
run = _run_cli([
100+
"backup",
101+
"--output-path", str(output_repo),
102+
"--source-system", "codex",
103+
"--input-path", str(FIXTURES_REDACT),
104+
"--user", "alice",
105+
"--system-name", "macbook-pro",
106+
])
107+
assert run.returncode == 0, run.stderr
108+
_assert_backup_counts(run.stdout, exported=1)
109+
110+
for md_path in (output_repo / "history").rglob("*.md"):
111+
content = md_path.read_text(encoding="utf-8")
112+
assert secret_literal not in content, f"Secret found in {md_path}"

0 commit comments

Comments
 (0)