shiplog-cli 0.1.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.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: shiplog-cli
3
+ Version: 0.1.0
4
+ Summary: Turn merged GitHub pull requests into clean, customer-facing changelogs.
5
+ Author-email: Siddharth Mahajan <siddharthmahajan65@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://shiplogbeta.arksoft.xyz
8
+ Project-URL: Repository, https://github.com/20sid02/shiplog
9
+ Project-URL: Issues, https://github.com/20sid02/shiplog/issues
10
+ Keywords: changelog,github,ai,release-notes,cli,pull-request
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Documentation
22
+ Classifier: Topic :: Software Development :: Version Control :: Git
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: requests>=2.31
26
+ Requires-Dist: rich>=13.0
27
+
28
+ # Shiplog CLI
29
+
30
+ Turn merged GitHub pull requests into clean, customer-facing changelogs.
31
+
32
+ Shiplog reads merged PRs from a repository, runs each one through the Shiplog AI
33
+ transform, and emits a publish-ready changelog grouped by category
34
+ (New / Improved / Fixed / Infrastructure).
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install shiplog
40
+ ```
41
+
42
+ Requires Python 3.9+.
43
+
44
+ ## Quick start
45
+
46
+ ```bash
47
+ # 1. Save your API key (get one at https://shiplogbeta.arksoft.xyz)
48
+ shiplog login
49
+
50
+ # 2. Generate a changelog — auto-detects the repo from git origin
51
+ cd your-project
52
+ shiplog generate
53
+
54
+ # 3. Or name any repo explicitly
55
+ shiplog generate owner/repo --days 30 -o CHANGELOG.md
56
+ ```
57
+
58
+ ## Authentication
59
+
60
+ ### Shiplog API key
61
+
62
+ The CLI sends your API key with every generation request. Save it once:
63
+
64
+ ```bash
65
+ shiplog login sk_live_abc123...
66
+ ```
67
+
68
+ Or set `SHIPLOG_API_KEY` in your environment. Without a key, the CLI works in
69
+ demo mode (limited to 5 generations per day).
70
+
71
+ Other auth commands:
72
+
73
+ ```bash
74
+ shiplog whoami # show current key
75
+ shiplog logout # remove saved key
76
+ ```
77
+
78
+ ### GitHub token
79
+
80
+ For the `generate` command, Shiplog needs read access to the repo's pull
81
+ requests. It resolves a GitHub token in this order:
82
+
83
+ 1. `--token` flag
84
+ 2. `$GITHUB_TOKEN` / `$GH_TOKEN`
85
+ 3. `gh auth token` (if the GitHub CLI is installed and logged in)
86
+
87
+ Public repos work without a token but are subject to tighter rate limits.
88
+
89
+ ## Usage
90
+
91
+ ### Generate a changelog from a repo
92
+
93
+ The repo argument is optional — inside a git repo, Shiplog uses the `origin`
94
+ remote automatically:
95
+
96
+ ```bash
97
+ # Run inside your repo — no arguments needed
98
+ shiplog generate
99
+
100
+ # Or name any repo explicitly
101
+ shiplog generate owner/repo
102
+
103
+ # Everything merged in the last 30 days, written to a file
104
+ shiplog generate --days 30 -o CHANGELOG.md
105
+
106
+ # Since a specific date, only PRs merged into main, as JSON
107
+ shiplog generate owner/repo --since 2026-06-01 --base main --format json
108
+ ```
109
+
110
+ Bot-authored PRs (dependabot, renovate, …) are skipped by default so the
111
+ changelog stays customer-facing. Generation runs in parallel and automatically
112
+ retries on rate limits, so large repos finish fast without dropping entries.
113
+
114
+ Options:
115
+
116
+ | Flag | Description |
117
+ |------|-------------|
118
+ | `--token` | GitHub token (overrides env / gh CLI) |
119
+ | `--base` | Only PRs merged into this base branch |
120
+ | `--since YYYY-MM-DD` | Only PRs merged since this date |
121
+ | `--days N` | Only PRs merged in the last N days |
122
+ | `--limit N` | Max PRs to include (default 20) |
123
+ | `--format markdown\|json` | Output format (default markdown) |
124
+ | `--output, -o` | Write to a file instead of stdout |
125
+ | `--title` | Custom heading for the changelog |
126
+ | `--no-group` | List chronologically instead of by category |
127
+ | `--no-links` | Omit PR number links |
128
+ | `--include-bots` | Include bot PRs (off by default) |
129
+ | `--concurrency N` | Parallel generation requests (default 8) |
130
+
131
+ ### Transform a single PR (no GitHub needed)
132
+
133
+ Handy for testing or one-off entries — mirrors the website demo.
134
+
135
+ ```bash
136
+ shiplog single "fix: pagination offset bug in list endpoint" \
137
+ --body "Closes #412. Switched to cursor-based pagination."
138
+ ```
139
+
140
+ Add `--format markdown` or `--format json` for machine-readable output.
141
+
142
+ ## Configuration
143
+
144
+ | Env var | Purpose |
145
+ |---------|---------|
146
+ | `SHIPLOG_API_KEY` | Shiplog API key (alternative to `shiplog login`) |
147
+ | `SHIPLOG_API_BASE` | Override the generation API URL |
148
+ | `SHIPLOG_CONFIG_DIR` | Override config directory (default `~/.config/shiplog`) |
149
+ | `GITHUB_TOKEN` / `GH_TOKEN` | GitHub auth |
150
+
151
+ ## How it works
152
+
153
+ ```
154
+ GitHub PRs ──▶ Shiplog API (/api/generate) ──▶ {category, title, body} ──▶ Markdown / JSON
155
+ ```
156
+
157
+ The generation API is a Cloudflare Worker. Authenticated users get metered
158
+ access based on their plan. Anonymous users get a small demo allowance.
@@ -0,0 +1,131 @@
1
+ # Shiplog CLI
2
+
3
+ Turn merged GitHub pull requests into clean, customer-facing changelogs.
4
+
5
+ Shiplog reads merged PRs from a repository, runs each one through the Shiplog AI
6
+ transform, and emits a publish-ready changelog grouped by category
7
+ (New / Improved / Fixed / Infrastructure).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install shiplog
13
+ ```
14
+
15
+ Requires Python 3.9+.
16
+
17
+ ## Quick start
18
+
19
+ ```bash
20
+ # 1. Save your API key (get one at https://shiplogbeta.arksoft.xyz)
21
+ shiplog login
22
+
23
+ # 2. Generate a changelog — auto-detects the repo from git origin
24
+ cd your-project
25
+ shiplog generate
26
+
27
+ # 3. Or name any repo explicitly
28
+ shiplog generate owner/repo --days 30 -o CHANGELOG.md
29
+ ```
30
+
31
+ ## Authentication
32
+
33
+ ### Shiplog API key
34
+
35
+ The CLI sends your API key with every generation request. Save it once:
36
+
37
+ ```bash
38
+ shiplog login sk_live_abc123...
39
+ ```
40
+
41
+ Or set `SHIPLOG_API_KEY` in your environment. Without a key, the CLI works in
42
+ demo mode (limited to 5 generations per day).
43
+
44
+ Other auth commands:
45
+
46
+ ```bash
47
+ shiplog whoami # show current key
48
+ shiplog logout # remove saved key
49
+ ```
50
+
51
+ ### GitHub token
52
+
53
+ For the `generate` command, Shiplog needs read access to the repo's pull
54
+ requests. It resolves a GitHub token in this order:
55
+
56
+ 1. `--token` flag
57
+ 2. `$GITHUB_TOKEN` / `$GH_TOKEN`
58
+ 3. `gh auth token` (if the GitHub CLI is installed and logged in)
59
+
60
+ Public repos work without a token but are subject to tighter rate limits.
61
+
62
+ ## Usage
63
+
64
+ ### Generate a changelog from a repo
65
+
66
+ The repo argument is optional — inside a git repo, Shiplog uses the `origin`
67
+ remote automatically:
68
+
69
+ ```bash
70
+ # Run inside your repo — no arguments needed
71
+ shiplog generate
72
+
73
+ # Or name any repo explicitly
74
+ shiplog generate owner/repo
75
+
76
+ # Everything merged in the last 30 days, written to a file
77
+ shiplog generate --days 30 -o CHANGELOG.md
78
+
79
+ # Since a specific date, only PRs merged into main, as JSON
80
+ shiplog generate owner/repo --since 2026-06-01 --base main --format json
81
+ ```
82
+
83
+ Bot-authored PRs (dependabot, renovate, …) are skipped by default so the
84
+ changelog stays customer-facing. Generation runs in parallel and automatically
85
+ retries on rate limits, so large repos finish fast without dropping entries.
86
+
87
+ Options:
88
+
89
+ | Flag | Description |
90
+ |------|-------------|
91
+ | `--token` | GitHub token (overrides env / gh CLI) |
92
+ | `--base` | Only PRs merged into this base branch |
93
+ | `--since YYYY-MM-DD` | Only PRs merged since this date |
94
+ | `--days N` | Only PRs merged in the last N days |
95
+ | `--limit N` | Max PRs to include (default 20) |
96
+ | `--format markdown\|json` | Output format (default markdown) |
97
+ | `--output, -o` | Write to a file instead of stdout |
98
+ | `--title` | Custom heading for the changelog |
99
+ | `--no-group` | List chronologically instead of by category |
100
+ | `--no-links` | Omit PR number links |
101
+ | `--include-bots` | Include bot PRs (off by default) |
102
+ | `--concurrency N` | Parallel generation requests (default 8) |
103
+
104
+ ### Transform a single PR (no GitHub needed)
105
+
106
+ Handy for testing or one-off entries — mirrors the website demo.
107
+
108
+ ```bash
109
+ shiplog single "fix: pagination offset bug in list endpoint" \
110
+ --body "Closes #412. Switched to cursor-based pagination."
111
+ ```
112
+
113
+ Add `--format markdown` or `--format json` for machine-readable output.
114
+
115
+ ## Configuration
116
+
117
+ | Env var | Purpose |
118
+ |---------|---------|
119
+ | `SHIPLOG_API_KEY` | Shiplog API key (alternative to `shiplog login`) |
120
+ | `SHIPLOG_API_BASE` | Override the generation API URL |
121
+ | `SHIPLOG_CONFIG_DIR` | Override config directory (default `~/.config/shiplog`) |
122
+ | `GITHUB_TOKEN` / `GH_TOKEN` | GitHub auth |
123
+
124
+ ## How it works
125
+
126
+ ```
127
+ GitHub PRs ──▶ Shiplog API (/api/generate) ──▶ {category, title, body} ──▶ Markdown / JSON
128
+ ```
129
+
130
+ The generation API is a Cloudflare Worker. Authenticated users get metered
131
+ access based on their plan. Anonymous users get a small demo allowance.
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "shiplog-cli"
3
+ version = "0.1.0"
4
+ description = "Turn merged GitHub pull requests into clean, customer-facing changelogs."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = "MIT"
8
+ authors = [{ name = "Siddharth Mahajan", email = "siddharthmahajan65@gmail.com" }]
9
+ keywords = ["changelog", "github", "ai", "release-notes", "cli", "pull-request"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.9",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Software Development :: Documentation",
22
+ "Topic :: Software Development :: Version Control :: Git",
23
+ ]
24
+ dependencies = [
25
+ "requests>=2.31",
26
+ "rich>=13.0",
27
+ ]
28
+
29
+ [project.scripts]
30
+ shiplog = "shiplog.cli:main"
31
+
32
+ [project.urls]
33
+ Homepage = "https://shiplogbeta.arksoft.xyz"
34
+ Repository = "https://github.com/20sid02/shiplog"
35
+ Issues = "https://github.com/20sid02/shiplog/issues"
36
+
37
+ [build-system]
38
+ requires = ["setuptools>=68"]
39
+ build-backend = "setuptools.build_meta"
40
+
41
+ [tool.setuptools.packages.find]
42
+ include = ["shiplog*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Shiplog — turn merged GitHub pull requests into customer-facing changelogs."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,118 @@
1
+ """Client for the Shiplog generation API (the Cloudflare Worker /api/generate)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from dataclasses import dataclass
8
+
9
+ import requests
10
+
11
+ VALID_CATEGORIES = ("New", "Improved", "Fixed", "Infrastructure")
12
+
13
+ # Generation runs on a shared, rate-limited model, so transient 429/502
14
+ # rate-limit responses are expected under concurrency. We retry, honoring the
15
+ # server's suggested wait, rather than dropping the PR from the changelog.
16
+ _MAX_RETRIES = 5
17
+ _MAX_BACKOFF_SECONDS = 30.0
18
+ _RETRY_HINT = re.compile(r"try again in ([\d.]+)\s*(ms|s)", re.IGNORECASE)
19
+
20
+
21
+ def _is_rate_limited(status: int, body_text: str) -> bool:
22
+ return status == 429 or (status == 502 and "rate limit" in body_text.lower())
23
+
24
+
25
+ def _retry_after_seconds(resp: requests.Response, attempt: int) -> float:
26
+ """Wait time before the next retry: Retry-After header, then a parsed
27
+ 'try again in Xs' hint, then exponential backoff."""
28
+ header = resp.headers.get("Retry-After")
29
+ if header:
30
+ try:
31
+ return min(float(header), _MAX_BACKOFF_SECONDS)
32
+ except ValueError:
33
+ pass
34
+ m = _RETRY_HINT.search(resp.text or "")
35
+ if m:
36
+ value = float(m.group(1))
37
+ if m.group(2).lower() == "ms":
38
+ value /= 1000.0
39
+ return min(value + 0.25, _MAX_BACKOFF_SECONDS) # small cushion
40
+ return min(2.0 ** attempt, _MAX_BACKOFF_SECONDS)
41
+
42
+
43
+ class GenerationError(RuntimeError):
44
+ pass
45
+
46
+
47
+ @dataclass
48
+ class ChangelogEntry:
49
+ category: str
50
+ title: str
51
+ body: str
52
+
53
+
54
+ def generate_entry(
55
+ title: str,
56
+ body: str,
57
+ api_base: str,
58
+ session: requests.Session | None = None,
59
+ timeout: int = 60,
60
+ api_key: str | None = None,
61
+ groq_key: str | None = None,
62
+ provider: str | None = None,
63
+ ) -> ChangelogEntry:
64
+ """Transform a PR title/body into a customer-facing changelog entry."""
65
+ if not title.strip():
66
+ raise GenerationError("PR title is required.")
67
+
68
+ sess = session or requests.Session()
69
+ headers = {}
70
+ if api_key:
71
+ headers["Authorization"] = f"Bearer {api_key}"
72
+
73
+ resp = None
74
+ for attempt in range(_MAX_RETRIES + 1):
75
+ try:
76
+ payload = {"title": title, "body": body}
77
+ if groq_key:
78
+ payload["userKey"] = groq_key
79
+ if provider:
80
+ payload["provider"] = provider
81
+ resp = sess.post(
82
+ f"{api_base}/api/generate",
83
+ json=payload,
84
+ headers=headers,
85
+ timeout=timeout,
86
+ )
87
+ except requests.RequestException as exc:
88
+ if attempt < _MAX_RETRIES:
89
+ time.sleep(min(2.0 ** attempt, _MAX_BACKOFF_SECONDS))
90
+ continue
91
+ raise GenerationError(f"Could not reach Shiplog API: {exc}") from exc
92
+
93
+ if _is_rate_limited(resp.status_code, resp.text) and attempt < _MAX_RETRIES:
94
+ time.sleep(_retry_after_seconds(resp, attempt))
95
+ continue
96
+ break
97
+
98
+ if not resp.ok:
99
+ detail = ""
100
+ try:
101
+ detail = resp.json().get("error", "")
102
+ except ValueError:
103
+ detail = resp.text[:200]
104
+ raise GenerationError(f"API error {resp.status_code}: {detail}")
105
+
106
+ try:
107
+ data = resp.json()
108
+ except ValueError as exc:
109
+ raise GenerationError("API returned non-JSON response.") from exc
110
+
111
+ category = data.get("category", "New")
112
+ if category not in VALID_CATEGORIES:
113
+ category = "New"
114
+ return ChangelogEntry(
115
+ category=category,
116
+ title=data.get("title", "") or title,
117
+ body=data.get("body", "") or "",
118
+ )
@@ -0,0 +1,45 @@
1
+ """Local storage for the Shiplog API key."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ _CONFIG_DIR = Path(os.environ.get("SHIPLOG_CONFIG_DIR", "~/.config/shiplog")).expanduser()
10
+ _CONFIG_FILE = _CONFIG_DIR / "config.json"
11
+
12
+
13
+ def _read_config() -> dict:
14
+ if not _CONFIG_FILE.exists():
15
+ return {}
16
+ try:
17
+ return json.loads(_CONFIG_FILE.read_text())
18
+ except (json.JSONDecodeError, OSError):
19
+ return {}
20
+
21
+
22
+ def _write_config(data: dict) -> None:
23
+ _CONFIG_DIR.mkdir(parents=True, exist_ok=True)
24
+ _CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
25
+ os.chmod(_CONFIG_FILE, 0o600)
26
+
27
+
28
+ def save_api_key(api_key: str) -> Path:
29
+ cfg = _read_config()
30
+ cfg["api_key"] = api_key.strip()
31
+ _write_config(cfg)
32
+ return _CONFIG_FILE
33
+
34
+
35
+ def get_api_key() -> str | None:
36
+ env = os.environ.get("SHIPLOG_API_KEY")
37
+ if env:
38
+ return env.strip()
39
+ return _read_config().get("api_key")
40
+
41
+
42
+ def clear_api_key() -> None:
43
+ cfg = _read_config()
44
+ cfg.pop("api_key", None)
45
+ _write_config(cfg)
@@ -0,0 +1,333 @@
1
+ """Shiplog command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from datetime import datetime, timedelta, timezone
9
+
10
+ import requests
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
14
+
15
+ from . import __version__, config
16
+ from .api import GenerationError, generate_entry
17
+ from .auth import clear_api_key, get_api_key, save_api_key
18
+ from .github import GitHubError, PullRequest, detect_repo, fetch_merged_prs, parse_repo
19
+ from .render import RenderedItem, to_json, to_markdown
20
+
21
+ console = Console()
22
+ err_console = Console(stderr=True)
23
+
24
+ _TAG_STYLE = {
25
+ "New": "bold green",
26
+ "Improved": "bold cyan",
27
+ "Fixed": "bold yellow",
28
+ "Infrastructure": "bold magenta",
29
+ }
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ parser = _build_parser()
34
+ args = parser.parse_args(argv)
35
+ if not getattr(args, "func", None):
36
+ parser.print_help()
37
+ return 1
38
+ try:
39
+ return args.func(args)
40
+ except (GitHubError, GenerationError) as exc:
41
+ err_console.print(f"[bold red]Error:[/] {exc}")
42
+ return 1
43
+ except KeyboardInterrupt:
44
+ err_console.print("\n[dim]Aborted.[/]")
45
+ return 130
46
+
47
+
48
+ def _build_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ prog="shiplog",
51
+ description="Turn merged GitHub pull requests into customer-facing changelogs.",
52
+ )
53
+ parser.add_argument("--version", action="version", version=f"shiplog {__version__}")
54
+ parser.add_argument(
55
+ "--api-base",
56
+ help="Override the Shiplog generation API base URL.",
57
+ )
58
+ parser.add_argument(
59
+ "--groq-key",
60
+ help="Use your own Groq API key (free at console.groq.com). "
61
+ "Bypasses Shiplog usage limits. Also reads GROQ_API_KEY env var.",
62
+ )
63
+ parser.add_argument(
64
+ "--provider",
65
+ choices=["groq", "openai", "anthropic"],
66
+ help="LLM provider to use with your own key (auto-detected from key prefix if omitted).",
67
+ )
68
+ sub = parser.add_subparsers(dest="command")
69
+
70
+ # generate
71
+ g = sub.add_parser(
72
+ "generate",
73
+ help="Fetch merged PRs from a repo and build a changelog.",
74
+ )
75
+ g.add_argument(
76
+ "repo",
77
+ nargs="?",
78
+ help="Repository as 'owner/name' or a GitHub URL. "
79
+ "Defaults to the current git repo's origin remote.",
80
+ )
81
+ g.add_argument("--token", help="GitHub token (else GITHUB_TOKEN / gh auth token).")
82
+ g.add_argument("--base", help="Only PRs merged into this base branch (e.g. main).")
83
+ since = g.add_mutually_exclusive_group()
84
+ since.add_argument("--since", help="Only PRs merged since this date (YYYY-MM-DD).")
85
+ since.add_argument(
86
+ "--days", type=int, help="Only PRs merged in the last N days."
87
+ )
88
+ g.add_argument(
89
+ "--limit", type=int, default=20, help="Max PRs to include (default 20)."
90
+ )
91
+ g.add_argument(
92
+ "--format",
93
+ choices=["markdown", "json"],
94
+ default="markdown",
95
+ help="Output format (default markdown).",
96
+ )
97
+ g.add_argument("--output", "-o", help="Write to a file instead of stdout.")
98
+ g.add_argument("--title", help="Heading for the changelog (markdown only).")
99
+ g.add_argument(
100
+ "--no-group",
101
+ action="store_true",
102
+ help="List entries chronologically instead of grouping by category.",
103
+ )
104
+ g.add_argument(
105
+ "--no-links", action="store_true", help="Omit PR number links."
106
+ )
107
+ g.add_argument(
108
+ "--include-bots",
109
+ action="store_true",
110
+ help="Include bot PRs (dependabot, renovate, …), skipped by default.",
111
+ )
112
+ g.add_argument(
113
+ "--concurrency",
114
+ type=int,
115
+ default=8,
116
+ help="Parallel generation requests (default 8).",
117
+ )
118
+ g.set_defaults(func=_cmd_generate)
119
+
120
+ # single
121
+ s = sub.add_parser(
122
+ "single",
123
+ help="Transform a single PR title/body (no GitHub needed).",
124
+ )
125
+ s.add_argument("title", help="PR title.")
126
+ s.add_argument("--body", default="", help="Optional PR description.")
127
+ s.add_argument(
128
+ "--format",
129
+ choices=["pretty", "markdown", "json"],
130
+ default="pretty",
131
+ help="Output format (default pretty).",
132
+ )
133
+ s.set_defaults(func=_cmd_single)
134
+
135
+ # login
136
+ li = sub.add_parser("login", help="Save your Shiplog API key.")
137
+ li.add_argument("key", nargs="?", help="API key (or paste when prompted).")
138
+ li.set_defaults(func=_cmd_login)
139
+
140
+ # logout
141
+ lo = sub.add_parser("logout", help="Remove saved API key.")
142
+ lo.set_defaults(func=_cmd_logout)
143
+
144
+ # whoami
145
+ w = sub.add_parser("whoami", help="Show the currently saved API key.")
146
+ w.set_defaults(func=_cmd_whoami)
147
+
148
+ return parser
149
+
150
+
151
+ def _resolve_groq_key(args: argparse.Namespace) -> str | None:
152
+ import os
153
+ return getattr(args, 'groq_key', None) or os.environ.get('GROQ_API_KEY') or None
154
+
155
+
156
+ def _resolve_provider(args: argparse.Namespace) -> str | None:
157
+ return getattr(args, 'provider', None) or None
158
+
159
+
160
+ def _cmd_login(args: argparse.Namespace) -> int:
161
+ key = args.key
162
+ if not key:
163
+ key = console.input("[bold]Enter your Shiplog API key:[/] ").strip()
164
+ if not key:
165
+ err_console.print("[red]No key provided.[/]")
166
+ return 1
167
+ path = save_api_key(key)
168
+ console.print(f"[green]✓[/] API key saved to [dim]{path}[/]")
169
+ return 0
170
+
171
+
172
+ def _cmd_logout(_args: argparse.Namespace) -> int:
173
+ clear_api_key()
174
+ console.print("[green]✓[/] API key removed.")
175
+ return 0
176
+
177
+
178
+ def _cmd_whoami(_args: argparse.Namespace) -> int:
179
+ key = get_api_key()
180
+ if not key:
181
+ console.print("[yellow]Not logged in.[/] Run [bold]shiplog login[/] to save your API key.")
182
+ return 1
183
+ masked = key[:12] + "…" + key[-4:]
184
+ console.print(f"[green]Logged in.[/] Key: [dim]{masked}[/]")
185
+ return 0
186
+
187
+
188
+ def _cmd_single(args: argparse.Namespace) -> int:
189
+ api_base = config.api_base(args.api_base)
190
+ api_key = get_api_key()
191
+ groq_key = _resolve_groq_key(args)
192
+ provider = _resolve_provider(args)
193
+ with console.status("[dim]Generating changelog entry…[/]"):
194
+ entry = generate_entry(args.title, args.body, api_base=api_base, api_key=api_key, groq_key=groq_key, provider=provider)
195
+
196
+ item = RenderedItem(entry=entry, pr_number=None, pr_url="", merged_at=None)
197
+ if args.format == "json":
198
+ console.print_json(to_json([item]))
199
+ elif args.format == "markdown":
200
+ console.print(to_markdown([item], group_by_category=False))
201
+ else:
202
+ _print_entry(entry)
203
+ return 0
204
+
205
+
206
+ def _cmd_generate(args: argparse.Namespace) -> int:
207
+ if args.repo:
208
+ owner, repo = parse_repo(args.repo)
209
+ else:
210
+ detected = detect_repo()
211
+ if not detected:
212
+ raise GitHubError(
213
+ "No repo given and couldn't detect one from git. "
214
+ "Pass 'owner/name' or run inside a GitHub repo."
215
+ )
216
+ owner, repo = detected
217
+ err_console.print(f"[dim]Using detected repo [bold]{owner}/{repo}[/].[/]")
218
+
219
+ token = config.resolve_github_token(args.token)
220
+ api_base = config.api_base(args.api_base)
221
+
222
+ since = _resolve_since(args)
223
+
224
+ with console.status(f"[dim]Fetching merged PRs from {owner}/{repo}…[/]"):
225
+ prs = fetch_merged_prs(
226
+ owner,
227
+ repo,
228
+ token=token,
229
+ since=since,
230
+ limit=args.limit,
231
+ base_branch=args.base,
232
+ include_bots=args.include_bots,
233
+ )
234
+
235
+ if not prs:
236
+ err_console.print("[yellow]No merged PRs matched your filters.[/]")
237
+ return 0
238
+
239
+ api_key = get_api_key()
240
+ groq_key = _resolve_groq_key(args)
241
+ provider = _resolve_provider(args)
242
+ items = _generate_all(prs, api_base, concurrency=args.concurrency, api_key=api_key, groq_key=groq_key, provider=provider)
243
+
244
+ if args.format == "json":
245
+ output = to_json(items)
246
+ else:
247
+ heading = args.title or f"{repo} — Changelog"
248
+ output = to_markdown(
249
+ items,
250
+ heading=heading,
251
+ group_by_category=not args.no_group,
252
+ show_pr_links=not args.no_links,
253
+ )
254
+
255
+ if args.output:
256
+ with open(args.output, "w", encoding="utf-8") as fh:
257
+ fh.write(output if output.endswith("\n") else output + "\n")
258
+ console.print(
259
+ f"[green]✓[/] Wrote {len(items)} entr"
260
+ f"{'y' if len(items) == 1 else 'ies'} to [bold]{args.output}[/]"
261
+ )
262
+ else:
263
+ # Plain print so piping/redirection stays clean.
264
+ print(output)
265
+ return 0
266
+
267
+
268
+ def _generate_all(
269
+ prs: list[PullRequest], api_base: str, concurrency: int = 8, api_key: str | None = None, groq_key: str | None = None, provider: str | None = None
270
+ ) -> list[RenderedItem]:
271
+ session = requests.Session()
272
+ results: list[RenderedItem | None] = [None] * len(prs)
273
+ failures = 0
274
+ workers = max(1, min(concurrency, len(prs)))
275
+
276
+ def work(idx: int, pr: PullRequest) -> tuple[int, RenderedItem | str]:
277
+ try:
278
+ entry = generate_entry(pr.title, pr.body, api_base=api_base, session=session, api_key=api_key, groq_key=groq_key, provider=provider)
279
+ return idx, RenderedItem(
280
+ entry=entry,
281
+ pr_number=pr.number,
282
+ pr_url=pr.url,
283
+ merged_at=pr.merged_at,
284
+ )
285
+ except GenerationError as exc:
286
+ return idx, f"#{pr.number}: {exc}"
287
+
288
+ with Progress(
289
+ SpinnerColumn(),
290
+ TextColumn("[progress.description]{task.description}"),
291
+ BarColumn(),
292
+ TextColumn("{task.completed}/{task.total}"),
293
+ console=err_console,
294
+ transient=True,
295
+ ) as progress:
296
+ task = progress.add_task("Transforming PRs…", total=len(prs))
297
+ with ThreadPoolExecutor(max_workers=workers) as pool:
298
+ futures = [pool.submit(work, i, pr) for i, pr in enumerate(prs)]
299
+ for fut in futures:
300
+ idx, outcome = fut.result()
301
+ if isinstance(outcome, RenderedItem):
302
+ results[idx] = outcome
303
+ else:
304
+ failures += 1
305
+ err_console.print(f"[yellow]skip {outcome}[/]")
306
+ progress.advance(task)
307
+
308
+ if failures:
309
+ err_console.print(f"[yellow]{failures} PR(s) could not be transformed.[/]")
310
+ # Drop failures, keep original (newest-first) order.
311
+ return [item for item in results if item is not None]
312
+
313
+
314
+ def _resolve_since(args: argparse.Namespace) -> datetime | None:
315
+ if args.days is not None:
316
+ return datetime.now(timezone.utc) - timedelta(days=args.days)
317
+ if args.since:
318
+ try:
319
+ return datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
320
+ except ValueError:
321
+ raise GitHubError(f"Invalid --since date {args.since!r}. Use YYYY-MM-DD.")
322
+ return None
323
+
324
+
325
+ def _print_entry(entry) -> None:
326
+ style = _TAG_STYLE.get(entry.category, "bold white")
327
+ header = f"[{style}]{entry.category}[/]"
328
+ body = f"[bold]{entry.title}[/]\n\n{entry.body}"
329
+ console.print(Panel(body, title=header, border_style=style, expand=False))
330
+
331
+
332
+ if __name__ == "__main__":
333
+ sys.exit(main())
@@ -0,0 +1,47 @@
1
+ """Configuration and credential resolution for the Shiplog CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+
9
+ # Default Shiplog generation API (the deployed Cloudflare Worker).
10
+ # Override with the SHIPLOG_API_BASE environment variable or --api-base.
11
+ DEFAULT_API_BASE = "https://shiplog-api.siddharthmahajan65.workers.dev"
12
+
13
+
14
+ def api_base(override: str | None = None) -> str:
15
+ return (override or os.environ.get("SHIPLOG_API_BASE") or DEFAULT_API_BASE).rstrip("/")
16
+
17
+
18
+ def resolve_github_token(override: str | None = None) -> str | None:
19
+ """Resolve a GitHub token.
20
+
21
+ Order: explicit flag -> $GITHUB_TOKEN -> $GH_TOKEN -> `gh auth token`.
22
+ Returns None if nothing is available (public repos still work unauthenticated,
23
+ just with tighter rate limits).
24
+ """
25
+ if override:
26
+ return override
27
+ for var in ("GITHUB_TOKEN", "GH_TOKEN"):
28
+ val = os.environ.get(var)
29
+ if val:
30
+ return val.strip()
31
+ return _gh_cli_token()
32
+
33
+
34
+ def _gh_cli_token() -> str | None:
35
+ if not shutil.which("gh"):
36
+ return None
37
+ try:
38
+ out = subprocess.run(
39
+ ["gh", "auth", "token"],
40
+ capture_output=True,
41
+ text=True,
42
+ timeout=10,
43
+ )
44
+ except (subprocess.SubprocessError, OSError):
45
+ return None
46
+ token = out.stdout.strip()
47
+ return token or None
@@ -0,0 +1,195 @@
1
+ """Fetch merged pull requests from the GitHub REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+
11
+ import requests
12
+
13
+ GITHUB_API = "https://api.github.com"
14
+ _PAGE_SIZE = 100
15
+
16
+
17
+ class GitHubError(RuntimeError):
18
+ pass
19
+
20
+
21
+ @dataclass
22
+ class PullRequest:
23
+ number: int
24
+ title: str
25
+ body: str
26
+ merged_at: datetime
27
+ author: str
28
+ url: str
29
+ labels: list[str]
30
+
31
+
32
+ def parse_repo(value: str) -> tuple[str, str]:
33
+ """Accept 'owner/name' or a full GitHub URL and return (owner, name)."""
34
+ value = value.strip()
35
+ m = re.search(r"github\.com[/:]([^/]+)/([^/.]+)", value)
36
+ if m:
37
+ return m.group(1), m.group(2)
38
+ parts = value.split("/")
39
+ if len(parts) == 2 and all(parts):
40
+ return parts[0], parts[1]
41
+ raise GitHubError(f"Could not parse repo from {value!r}. Use 'owner/name'.")
42
+
43
+
44
+ def detect_repo(cwd: str | None = None) -> tuple[str, str] | None:
45
+ """Infer 'owner/name' from the current git repo's origin remote.
46
+
47
+ Returns None when not in a git repo, git is unavailable, or the remote
48
+ isn't a GitHub URL.
49
+ """
50
+ if not shutil.which("git"):
51
+ return None
52
+ try:
53
+ out = subprocess.run(
54
+ ["git", "remote", "get-url", "origin"],
55
+ capture_output=True,
56
+ text=True,
57
+ timeout=5,
58
+ cwd=cwd,
59
+ )
60
+ except (subprocess.SubprocessError, OSError):
61
+ return None
62
+ url = out.stdout.strip()
63
+ if out.returncode != 0 or not url:
64
+ return None
65
+ try:
66
+ return parse_repo(url)
67
+ except GitHubError:
68
+ return None
69
+
70
+
71
+ def _headers(token: str | None) -> dict[str, str]:
72
+ headers = {
73
+ "Accept": "application/vnd.github+json",
74
+ "X-GitHub-Api-Version": "2022-11-28",
75
+ "User-Agent": "shiplog-cli",
76
+ }
77
+ if token:
78
+ headers["Authorization"] = f"Bearer {token}"
79
+ return headers
80
+
81
+
82
+ def is_bot(author: str) -> bool:
83
+ """Heuristic: GitHub apps end in '[bot]'; also catch common automation accounts."""
84
+ lowered = author.lower()
85
+ return lowered.endswith("[bot]") or lowered in {
86
+ "dependabot",
87
+ "renovate",
88
+ "renovate-bot",
89
+ "github-actions",
90
+ "snyk-bot",
91
+ "greenkeeper",
92
+ "imgbot",
93
+ "pre-commit-ci",
94
+ }
95
+
96
+
97
+ def fetch_merged_prs(
98
+ owner: str,
99
+ repo: str,
100
+ token: str | None = None,
101
+ since: datetime | None = None,
102
+ limit: int | None = None,
103
+ base_branch: str | None = None,
104
+ include_bots: bool = False,
105
+ ) -> list[PullRequest]:
106
+ """Return merged PRs newest-first, optionally filtered by merge date / count.
107
+
108
+ Lists closed PRs sorted by `updated` descending and keeps the merged ones.
109
+ Bot-authored PRs (dependabot, renovate, …) are skipped unless include_bots.
110
+ Stops paging early once we pass the `since` cutoff.
111
+ """
112
+ session = requests.Session()
113
+ session.headers.update(_headers(token))
114
+
115
+ results: list[PullRequest] = []
116
+ page = 1
117
+ while True:
118
+ params = {
119
+ "state": "closed",
120
+ "sort": "updated",
121
+ "direction": "desc",
122
+ "per_page": _PAGE_SIZE,
123
+ "page": page,
124
+ }
125
+ if base_branch:
126
+ params["base"] = base_branch
127
+
128
+ resp = session.get(
129
+ f"{GITHUB_API}/repos/{owner}/{repo}/pulls",
130
+ params=params,
131
+ timeout=30,
132
+ )
133
+ if resp.status_code == 404:
134
+ raise GitHubError(
135
+ f"Repo {owner}/{repo} not found (or no access). "
136
+ "Provide a token for private repos."
137
+ )
138
+ if resp.status_code == 401:
139
+ raise GitHubError("GitHub rejected the token (401). Check your credentials.")
140
+ if resp.status_code == 403 and "rate limit" in resp.text.lower():
141
+ raise GitHubError(
142
+ "GitHub rate limit hit. Set a token via --token or GITHUB_TOKEN."
143
+ )
144
+ if not resp.ok:
145
+ raise GitHubError(f"GitHub API error {resp.status_code}: {resp.text[:200]}")
146
+
147
+ batch = resp.json()
148
+ if not batch:
149
+ break
150
+
151
+ reached_cutoff = False
152
+ for pr in batch:
153
+ if not pr.get("merged_at"):
154
+ continue
155
+ merged_at = _parse_dt(pr["merged_at"])
156
+ if since and merged_at < since:
157
+ # Sorted by updated desc, not merged desc, so don't break outright —
158
+ # but a whole page older than the cutoff means we're done.
159
+ reached_cutoff = True
160
+ continue
161
+ author = (pr.get("user") or {}).get("login", "unknown")
162
+ if not include_bots and is_bot(author):
163
+ continue
164
+ results.append(
165
+ PullRequest(
166
+ number=pr["number"],
167
+ title=pr["title"] or "",
168
+ body=pr.get("body") or "",
169
+ merged_at=merged_at,
170
+ author=author,
171
+ url=pr.get("html_url", ""),
172
+ labels=[l["name"] for l in pr.get("labels", [])],
173
+ )
174
+ )
175
+ if limit and len(results) >= limit:
176
+ results.sort(key=lambda p: p.merged_at, reverse=True)
177
+ return results[:limit]
178
+
179
+ # Heuristic stop: if the oldest item on this page predates the cutoff,
180
+ # later pages (older still) can't contain newer merges.
181
+ if since and reached_cutoff and batch:
182
+ oldest = _parse_dt(batch[-1]["updated_at"])
183
+ if oldest < since:
184
+ break
185
+
186
+ if len(batch) < _PAGE_SIZE:
187
+ break
188
+ page += 1
189
+
190
+ results.sort(key=lambda p: p.merged_at, reverse=True)
191
+ return results
192
+
193
+
194
+ def _parse_dt(value: str) -> datetime:
195
+ return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
@@ -0,0 +1,91 @@
1
+ """Render generated changelog entries to Markdown or JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+
9
+ from .api import VALID_CATEGORIES, ChangelogEntry
10
+
11
+ _EMOJI = {
12
+ "New": "✨",
13
+ "Improved": "⚡",
14
+ "Fixed": "🐛",
15
+ "Infrastructure": "🛠️",
16
+ }
17
+
18
+ # Display order for grouped output.
19
+ _ORDER = ("New", "Improved", "Fixed", "Infrastructure")
20
+
21
+
22
+ @dataclass
23
+ class RenderedItem:
24
+ """A generated entry paired with its source PR metadata."""
25
+
26
+ entry: ChangelogEntry
27
+ pr_number: int | None
28
+ pr_url: str
29
+ merged_at: datetime | None
30
+
31
+
32
+ def to_markdown(
33
+ items: list[RenderedItem],
34
+ heading: str | None = None,
35
+ group_by_category: bool = True,
36
+ show_pr_links: bool = True,
37
+ ) -> str:
38
+ lines: list[str] = []
39
+ if heading:
40
+ lines.append(f"# {heading}")
41
+ lines.append("")
42
+
43
+ if group_by_category:
44
+ buckets: dict[str, list[RenderedItem]] = {c: [] for c in _ORDER}
45
+ for it in items:
46
+ buckets.setdefault(it.entry.category, []).append(it)
47
+ for category in _ORDER:
48
+ bucket = buckets.get(category) or []
49
+ if not bucket:
50
+ continue
51
+ lines.append(f"## {_EMOJI.get(category, '')} {category}".strip())
52
+ lines.append("")
53
+ for it in bucket:
54
+ lines.extend(_item_lines(it, show_pr_links))
55
+ lines.append("")
56
+ else:
57
+ for it in items:
58
+ lines.extend(_item_lines(it, show_pr_links, with_category=True))
59
+
60
+ return "\n".join(lines).rstrip() + "\n"
61
+
62
+
63
+ def _item_lines(it: RenderedItem, show_pr_links: bool, with_category: bool = False) -> list[str]:
64
+ prefix = f"**[{it.entry.category}]** " if with_category else ""
65
+ suffix = ""
66
+ if show_pr_links and it.pr_number:
67
+ if it.pr_url:
68
+ suffix = f" ([#{it.pr_number}]({it.pr_url}))"
69
+ else:
70
+ suffix = f" (#{it.pr_number})"
71
+ return [
72
+ f"### {prefix}{it.entry.title}{suffix}",
73
+ "",
74
+ it.entry.body,
75
+ "",
76
+ ]
77
+
78
+
79
+ def to_json(items: list[RenderedItem]) -> str:
80
+ payload = [
81
+ {
82
+ "category": it.entry.category,
83
+ "title": it.entry.title,
84
+ "body": it.entry.body,
85
+ "pr_number": it.pr_number,
86
+ "pr_url": it.pr_url,
87
+ "merged_at": it.merged_at.isoformat() if it.merged_at else None,
88
+ }
89
+ for it in items
90
+ ]
91
+ return json.dumps(payload, indent=2)
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: shiplog-cli
3
+ Version: 0.1.0
4
+ Summary: Turn merged GitHub pull requests into clean, customer-facing changelogs.
5
+ Author-email: Siddharth Mahajan <siddharthmahajan65@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://shiplogbeta.arksoft.xyz
8
+ Project-URL: Repository, https://github.com/20sid02/shiplog
9
+ Project-URL: Issues, https://github.com/20sid02/shiplog/issues
10
+ Keywords: changelog,github,ai,release-notes,cli,pull-request
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Documentation
22
+ Classifier: Topic :: Software Development :: Version Control :: Git
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: requests>=2.31
26
+ Requires-Dist: rich>=13.0
27
+
28
+ # Shiplog CLI
29
+
30
+ Turn merged GitHub pull requests into clean, customer-facing changelogs.
31
+
32
+ Shiplog reads merged PRs from a repository, runs each one through the Shiplog AI
33
+ transform, and emits a publish-ready changelog grouped by category
34
+ (New / Improved / Fixed / Infrastructure).
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install shiplog
40
+ ```
41
+
42
+ Requires Python 3.9+.
43
+
44
+ ## Quick start
45
+
46
+ ```bash
47
+ # 1. Save your API key (get one at https://shiplogbeta.arksoft.xyz)
48
+ shiplog login
49
+
50
+ # 2. Generate a changelog — auto-detects the repo from git origin
51
+ cd your-project
52
+ shiplog generate
53
+
54
+ # 3. Or name any repo explicitly
55
+ shiplog generate owner/repo --days 30 -o CHANGELOG.md
56
+ ```
57
+
58
+ ## Authentication
59
+
60
+ ### Shiplog API key
61
+
62
+ The CLI sends your API key with every generation request. Save it once:
63
+
64
+ ```bash
65
+ shiplog login sk_live_abc123...
66
+ ```
67
+
68
+ Or set `SHIPLOG_API_KEY` in your environment. Without a key, the CLI works in
69
+ demo mode (limited to 5 generations per day).
70
+
71
+ Other auth commands:
72
+
73
+ ```bash
74
+ shiplog whoami # show current key
75
+ shiplog logout # remove saved key
76
+ ```
77
+
78
+ ### GitHub token
79
+
80
+ For the `generate` command, Shiplog needs read access to the repo's pull
81
+ requests. It resolves a GitHub token in this order:
82
+
83
+ 1. `--token` flag
84
+ 2. `$GITHUB_TOKEN` / `$GH_TOKEN`
85
+ 3. `gh auth token` (if the GitHub CLI is installed and logged in)
86
+
87
+ Public repos work without a token but are subject to tighter rate limits.
88
+
89
+ ## Usage
90
+
91
+ ### Generate a changelog from a repo
92
+
93
+ The repo argument is optional — inside a git repo, Shiplog uses the `origin`
94
+ remote automatically:
95
+
96
+ ```bash
97
+ # Run inside your repo — no arguments needed
98
+ shiplog generate
99
+
100
+ # Or name any repo explicitly
101
+ shiplog generate owner/repo
102
+
103
+ # Everything merged in the last 30 days, written to a file
104
+ shiplog generate --days 30 -o CHANGELOG.md
105
+
106
+ # Since a specific date, only PRs merged into main, as JSON
107
+ shiplog generate owner/repo --since 2026-06-01 --base main --format json
108
+ ```
109
+
110
+ Bot-authored PRs (dependabot, renovate, …) are skipped by default so the
111
+ changelog stays customer-facing. Generation runs in parallel and automatically
112
+ retries on rate limits, so large repos finish fast without dropping entries.
113
+
114
+ Options:
115
+
116
+ | Flag | Description |
117
+ |------|-------------|
118
+ | `--token` | GitHub token (overrides env / gh CLI) |
119
+ | `--base` | Only PRs merged into this base branch |
120
+ | `--since YYYY-MM-DD` | Only PRs merged since this date |
121
+ | `--days N` | Only PRs merged in the last N days |
122
+ | `--limit N` | Max PRs to include (default 20) |
123
+ | `--format markdown\|json` | Output format (default markdown) |
124
+ | `--output, -o` | Write to a file instead of stdout |
125
+ | `--title` | Custom heading for the changelog |
126
+ | `--no-group` | List chronologically instead of by category |
127
+ | `--no-links` | Omit PR number links |
128
+ | `--include-bots` | Include bot PRs (off by default) |
129
+ | `--concurrency N` | Parallel generation requests (default 8) |
130
+
131
+ ### Transform a single PR (no GitHub needed)
132
+
133
+ Handy for testing or one-off entries — mirrors the website demo.
134
+
135
+ ```bash
136
+ shiplog single "fix: pagination offset bug in list endpoint" \
137
+ --body "Closes #412. Switched to cursor-based pagination."
138
+ ```
139
+
140
+ Add `--format markdown` or `--format json` for machine-readable output.
141
+
142
+ ## Configuration
143
+
144
+ | Env var | Purpose |
145
+ |---------|---------|
146
+ | `SHIPLOG_API_KEY` | Shiplog API key (alternative to `shiplog login`) |
147
+ | `SHIPLOG_API_BASE` | Override the generation API URL |
148
+ | `SHIPLOG_CONFIG_DIR` | Override config directory (default `~/.config/shiplog`) |
149
+ | `GITHUB_TOKEN` / `GH_TOKEN` | GitHub auth |
150
+
151
+ ## How it works
152
+
153
+ ```
154
+ GitHub PRs ──▶ Shiplog API (/api/generate) ──▶ {category, title, body} ──▶ Markdown / JSON
155
+ ```
156
+
157
+ The generation API is a Cloudflare Worker. Authenticated users get metered
158
+ access based on their plan. Anonymous users get a small demo allowance.
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ shiplog/__init__.py
4
+ shiplog/__main__.py
5
+ shiplog/api.py
6
+ shiplog/auth.py
7
+ shiplog/cli.py
8
+ shiplog/config.py
9
+ shiplog/github.py
10
+ shiplog/render.py
11
+ shiplog_cli.egg-info/PKG-INFO
12
+ shiplog_cli.egg-info/SOURCES.txt
13
+ shiplog_cli.egg-info/dependency_links.txt
14
+ shiplog_cli.egg-info/entry_points.txt
15
+ shiplog_cli.egg-info/requires.txt
16
+ shiplog_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shiplog = shiplog.cli:main
@@ -0,0 +1,2 @@
1
+ requests>=2.31
2
+ rich>=13.0
@@ -0,0 +1,2 @@
1
+ shiplog
2
+ shiplog-test