shiplog-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shiplog/__init__.py +3 -0
- shiplog/__main__.py +6 -0
- shiplog/api.py +118 -0
- shiplog/auth.py +45 -0
- shiplog/cli.py +333 -0
- shiplog/config.py +47 -0
- shiplog/github.py +195 -0
- shiplog/render.py +91 -0
- shiplog_cli-0.1.0.dist-info/METADATA +158 -0
- shiplog_cli-0.1.0.dist-info/RECORD +13 -0
- shiplog_cli-0.1.0.dist-info/WHEEL +5 -0
- shiplog_cli-0.1.0.dist-info/entry_points.txt +2 -0
- shiplog_cli-0.1.0.dist-info/top_level.txt +1 -0
shiplog/__init__.py
ADDED
shiplog/__main__.py
ADDED
shiplog/api.py
ADDED
|
@@ -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
|
+
)
|
shiplog/auth.py
ADDED
|
@@ -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)
|
shiplog/cli.py
ADDED
|
@@ -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())
|
shiplog/config.py
ADDED
|
@@ -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
|
shiplog/github.py
ADDED
|
@@ -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)
|
shiplog/render.py
ADDED
|
@@ -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,13 @@
|
|
|
1
|
+
shiplog/__init__.py,sha256=KQMSQCYT4oVUAcqbJXXkrhY-M7__2Qm-hJkM6y1eIc0,107
|
|
2
|
+
shiplog/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
|
|
3
|
+
shiplog/api.py,sha256=29fjwuOJi8vCeFR70Xy5eHX8lUZACpd-LZB7pmT2aMU,3636
|
|
4
|
+
shiplog/auth.py,sha256=nq-iGQVa3cl_D1HRZoJ4M44XAz6K34oulJGb5BoV0ms,1076
|
|
5
|
+
shiplog/cli.py,sha256=K8b-yoi2zVIgmlz9Yq68TZm1HTNpy1PK4XbTaRIMxwE,11258
|
|
6
|
+
shiplog/config.py,sha256=_GRXOfDd9O1FAXpQGxx3TfwiFSDS5CSmBe6l5DXiZRo,1382
|
|
7
|
+
shiplog/github.py,sha256=sQeJKYIaTT00pLflHSpmta6u_WxnhOFeFL9Zsjb3Je4,5886
|
|
8
|
+
shiplog/render.py,sha256=fT8mSPf1tkQHjVopKR3p7-Mm9WBXGkv9IJJz3KpaVFs,2491
|
|
9
|
+
shiplog_cli-0.1.0.dist-info/METADATA,sha256=W5lQyffpIY6d6fYElD-09QAYjpI154Zl3LJzynVloes,4965
|
|
10
|
+
shiplog_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
shiplog_cli-0.1.0.dist-info/entry_points.txt,sha256=rr4GtabeWAnsGQ4UfeSHQYNw4YboxUrdIsPUOsg6JL8,45
|
|
12
|
+
shiplog_cli-0.1.0.dist-info/top_level.txt,sha256=AurA5LdlpUwimgmFfncWdWB-YvecmJCHhPRmCmHzJHo,8
|
|
13
|
+
shiplog_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
shiplog
|