github-search-cli 9.3.0 → 9.4.0
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.
- package/CHANGELOG.md +192 -0
- package/README.md +28 -52
- package/bin/ghfind.js +55 -11
- package/dist/cli.js +787 -145
- package/package.json +3 -1
- package/release-notes/9.3.0.md +29 -0
- package/scripts/postinstall.js +82 -15
- package/src/bookmarks.ts +13 -0
- package/src/cli.ts +240 -51
- package/src/doctor.ts +287 -0
- package/src/error-report.ts +157 -0
- package/src/help.ts +17 -2
- package/src/landing.ts +104 -0
- package/src/notifications.ts +2 -1
- package/src/releases.ts +144 -0
- package/src/tips.ts +22 -7
- package/src/toast.ts +69 -0
- package/src/tui.ts +38 -1
- package/src/types.ts +3 -1
- package/src/update-check.ts +53 -0
package/src/releases.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release tracker — checks bookmarked repos for new GitHub releases.
|
|
3
|
+
*
|
|
4
|
+
* Decisions (map 003):
|
|
5
|
+
* - Signal: new GitHub releases only (`GET /repos/{o}/{r}/releases?per_page=5`).
|
|
6
|
+
* - Seen state: `lastSeenAt` per bookmark; "new" = published after it.
|
|
7
|
+
* - Rate budget: etag conditional requests + token when present.
|
|
8
|
+
* - Prereleases shown (tagged `[pre]`), drafts never.
|
|
9
|
+
*
|
|
10
|
+
* ponytail: sequential fetches — fine for ≤100 bookmarks at launch;
|
|
11
|
+
* parallelize with p-limit if users report slow startup.
|
|
12
|
+
*/
|
|
13
|
+
import type { Bookmark } from "./types";
|
|
14
|
+
import { readJSON, writeJSON } from "./storage";
|
|
15
|
+
import { getBookmarks, getLastSeen, markSeen } from "./bookmarks";
|
|
16
|
+
import { addNotification } from "./notifications";
|
|
17
|
+
import { loadConfig } from "./config";
|
|
18
|
+
|
|
19
|
+
const RELEASES_FILE = "release-cache.json";
|
|
20
|
+
const PER_REPO = 5;
|
|
21
|
+
|
|
22
|
+
export interface TrackedRelease {
|
|
23
|
+
id: number;
|
|
24
|
+
tagName: string;
|
|
25
|
+
name: string;
|
|
26
|
+
url: string;
|
|
27
|
+
publishedAt: string;
|
|
28
|
+
prerelease: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Persisted cache: etag + last-known releases per repo. Only what the feed needs. */
|
|
32
|
+
interface RepoReleaseState {
|
|
33
|
+
etag?: string;
|
|
34
|
+
checkedAt: number;
|
|
35
|
+
releases: TrackedRelease[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type ReleaseCache = Record<string, RepoReleaseState>;
|
|
39
|
+
|
|
40
|
+
function loadCache(): ReleaseCache {
|
|
41
|
+
return readJSON<ReleaseCache>(RELEASES_FILE, {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function saveCache(cache: ReleaseCache): void {
|
|
45
|
+
writeJSON(RELEASES_FILE, cache);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toTracked(raw: Array<Record<string, unknown>>): TrackedRelease[] {
|
|
49
|
+
return raw
|
|
50
|
+
.filter((r) => r.draft !== true) // drafts: never shown
|
|
51
|
+
.map((r) => ({
|
|
52
|
+
id: r.id as number,
|
|
53
|
+
tagName: r.tag_name as string,
|
|
54
|
+
name: (r.name as string) || (r.tag_name as string),
|
|
55
|
+
url: r.html_url as string,
|
|
56
|
+
publishedAt: (r.published_at as string) || "",
|
|
57
|
+
prerelease: r.prerelease === true,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Fetch /releases for one repo using its cached etag. */
|
|
62
|
+
async function fetchReleases(
|
|
63
|
+
fullName: string,
|
|
64
|
+
etag: string | undefined,
|
|
65
|
+
token: string | undefined,
|
|
66
|
+
): Promise<{ status: number; etag?: string; releases?: TrackedRelease[] }> {
|
|
67
|
+
const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
|
|
68
|
+
if (etag) headers["If-None-Match"] = etag;
|
|
69
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
70
|
+
const res = await fetch(`https://api.github.com/repos/${fullName}/releases?per_page=${PER_REPO}`, { headers });
|
|
71
|
+
if (res.status === 304 || !res.ok) return { status: res.status };
|
|
72
|
+
return {
|
|
73
|
+
status: 200,
|
|
74
|
+
etag: res.headers.get("etag") ?? undefined,
|
|
75
|
+
releases: toTracked((await res.json()) as Array<Record<string, unknown>>),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check all bookmarks for releases newer than their lastSeenAt.
|
|
81
|
+
* Returns one result per bookmark so callers can render feeds or counts.
|
|
82
|
+
*/
|
|
83
|
+
export async function checkReleases(
|
|
84
|
+
opts: { token?: string } = {},
|
|
85
|
+
): Promise<Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }>> {
|
|
86
|
+
const bookmarks = getBookmarks();
|
|
87
|
+
const cache = loadCache();
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
const token = opts.token ?? config.githubToken ?? process.env.GITHUB_TOKEN;
|
|
90
|
+
const results: Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }> = [];
|
|
91
|
+
|
|
92
|
+
for (const bm of bookmarks) {
|
|
93
|
+
try {
|
|
94
|
+
const prev = cache[bm.repo.fullName];
|
|
95
|
+
const lastSeen = getLastSeen(bm.repo.fullName);
|
|
96
|
+
const isNew = (r: TrackedRelease) =>
|
|
97
|
+
!r.publishedAt || new Date(r.publishedAt).getTime() > lastSeen;
|
|
98
|
+
|
|
99
|
+
const { status, etag, releases } = await fetchReleases(bm.repo.fullName, prev?.etag, token);
|
|
100
|
+
|
|
101
|
+
if (status === 304 && prev) {
|
|
102
|
+
// Unchanged upstream; recompute against lastSeenAt (it may have moved).
|
|
103
|
+
results.push({ fullName: bm.repo.fullName, newReleases: prev.releases.filter(isNew) });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (status !== 200 || !releases) {
|
|
107
|
+
results.push({ fullName: bm.repo.fullName, newReleases: [], error: `HTTP ${status}` });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
cache[bm.repo.fullName] = { checkedAt: Date.now(), etag, releases };
|
|
111
|
+
results.push({ fullName: bm.repo.fullName, newReleases: releases.filter(isNew) });
|
|
112
|
+
} catch (err) {
|
|
113
|
+
results.push({ fullName: bm.repo.fullName, newReleases: [], error: (err as Error).message });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
saveCache(cache);
|
|
118
|
+
return results;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Check + push unseen releases into the notification center, then mark them seen. Returns per-repo results. */
|
|
122
|
+
export async function checkAndNotify(opts: { token?: string } = {}): Promise<Array<{ fullName: string; newReleases: TrackedRelease[]; error?: string }>> {
|
|
123
|
+
const results = await checkReleases(opts);
|
|
124
|
+
for (const r of results) {
|
|
125
|
+
for (const rel of r.newReleases) {
|
|
126
|
+
addNotification(
|
|
127
|
+
"release",
|
|
128
|
+
`${r.fullName} released ${rel.tagName}${rel.prerelease ? " [pre]" : ""}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (r.newReleases.length > 0) markSeen(r.fullName);
|
|
132
|
+
}
|
|
133
|
+
return results;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** All cached releases across bookmarks, newest first (for the Releases panel / CLI). */
|
|
137
|
+
export function allCachedReleases(): Array<TrackedRelease & { fullName: string }> {
|
|
138
|
+
const cache = loadCache();
|
|
139
|
+
const bookmarks = getBookmarks();
|
|
140
|
+
return Object.entries(cache)
|
|
141
|
+
.filter(([fullName]) => bookmarks.some((b) => b.repo.fullName === fullName))
|
|
142
|
+
.flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName })))
|
|
143
|
+
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
|
|
144
|
+
}
|
package/src/tips.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Startup tips — rotate through helpful hints.
|
|
3
3
|
*/
|
|
4
|
+
|
|
5
|
+
/** Rotates tips without repeating the same one twice in a row. */
|
|
6
|
+
export class TipRotator {
|
|
7
|
+
private tips: string[];
|
|
8
|
+
private lastIndex = -1;
|
|
9
|
+
|
|
10
|
+
constructor(tips?: string[]) {
|
|
11
|
+
this.tips = tips ?? TIPS;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
next(): string {
|
|
15
|
+
let idx: number;
|
|
16
|
+
do {
|
|
17
|
+
idx = Math.floor(Math.random() * this.tips.length);
|
|
18
|
+
} while (idx === this.lastIndex && this.tips.length > 1);
|
|
19
|
+
this.lastIndex = idx;
|
|
20
|
+
return this.tips[idx];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
4
24
|
const TIPS = [
|
|
5
25
|
"💡 Tip: Press ? to see all keyboard shortcuts",
|
|
6
26
|
"💡 Tip: Use Ctrl+R to recall past searches",
|
|
@@ -14,14 +34,9 @@ const TIPS = [
|
|
|
14
34
|
"💡 Tip: Press Ctrl+P to copy a repo link to clipboard",
|
|
15
35
|
];
|
|
16
36
|
|
|
17
|
-
|
|
37
|
+
export const defaultRotator = new TipRotator();
|
|
18
38
|
|
|
19
39
|
/** Get the next tip (never repeats the same tip twice in a row). */
|
|
20
40
|
export function nextTip(): string {
|
|
21
|
-
|
|
22
|
-
do {
|
|
23
|
-
idx = Math.floor(Math.random() * TIPS.length);
|
|
24
|
-
} while (idx === lastIndex && TIPS.length > 1);
|
|
25
|
-
lastIndex = idx;
|
|
26
|
-
return TIPS[idx];
|
|
41
|
+
return defaultRotator.next();
|
|
27
42
|
}
|
package/src/toast.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Toast popup — absolute-positioned card pinned top-right, auto-hides. */
|
|
2
|
+
import { BoxRenderable, TextRenderable } from "@opentui/core";
|
|
3
|
+
|
|
4
|
+
/** Only the color keys the toast reads (lazily, so runtime theme swaps apply). */
|
|
5
|
+
export interface ToastColors {
|
|
6
|
+
green: string;
|
|
7
|
+
yellow: string;
|
|
8
|
+
text: string;
|
|
9
|
+
surfaceDim: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createToast(
|
|
13
|
+
renderer: Parameters<BoxRenderable["constructor"]>[0],
|
|
14
|
+
root: { add(child: unknown): unknown },
|
|
15
|
+
colors: ToastColors,
|
|
16
|
+
) {
|
|
17
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
18
|
+
const box = new BoxRenderable(renderer, {
|
|
19
|
+
visible: false,
|
|
20
|
+
position: "absolute",
|
|
21
|
+
top: 1,
|
|
22
|
+
right: 2,
|
|
23
|
+
height: 3,
|
|
24
|
+
border: true,
|
|
25
|
+
borderStyle: "rounded",
|
|
26
|
+
borderColor: colors.green,
|
|
27
|
+
backgroundColor: colors.surfaceDim,
|
|
28
|
+
zIndex: 100,
|
|
29
|
+
});
|
|
30
|
+
const text = new TextRenderable(renderer, {
|
|
31
|
+
content: "",
|
|
32
|
+
color: colors.text,
|
|
33
|
+
backgroundColor: colors.surfaceDim,
|
|
34
|
+
height: 1,
|
|
35
|
+
});
|
|
36
|
+
box.add(text);
|
|
37
|
+
root.add(box);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
/** Show a message; accent picks the border color. Re-showing resets the timer. */
|
|
41
|
+
show(msg: string, ms = 2000, accent?: string): void {
|
|
42
|
+
text.content = msg;
|
|
43
|
+
box.borderColor = accent ?? colors.green;
|
|
44
|
+
// Message + paddingX(2) + side borders(2)
|
|
45
|
+
box.width = msg.length + 4;
|
|
46
|
+
box.visible = true;
|
|
47
|
+
renderer.requestRender();
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
timer = setTimeout(() => {
|
|
50
|
+
box.visible = false;
|
|
51
|
+
renderer.requestRender();
|
|
52
|
+
}, ms);
|
|
53
|
+
},
|
|
54
|
+
hide(): void {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
if (!box.visible) return;
|
|
57
|
+
box.visible = false;
|
|
58
|
+
renderer.requestRender();
|
|
59
|
+
},
|
|
60
|
+
get visible(): boolean {
|
|
61
|
+
return box.visible;
|
|
62
|
+
},
|
|
63
|
+
get borderColor(): string {
|
|
64
|
+
return box.borderColor as string;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type Toast = ReturnType<typeof createToast>;
|
package/src/tui.ts
CHANGED
|
@@ -113,6 +113,9 @@ import {
|
|
|
113
113
|
snoozeUpdateNotices,
|
|
114
114
|
suppressUpdateNotices,
|
|
115
115
|
performUpdate,
|
|
116
|
+
fetchReleaseNotes,
|
|
117
|
+
readUpdateState,
|
|
118
|
+
recordPreUpdateState,
|
|
116
119
|
} from "./update-check";
|
|
117
120
|
import { debugLog } from "./storage";
|
|
118
121
|
const colors = {
|
|
@@ -1527,6 +1530,33 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1527
1530
|
showOverlay("leader");
|
|
1528
1531
|
}
|
|
1529
1532
|
|
|
1533
|
+
// ponytail: post-upgrade panel — fires when lastInstalledVersion != current version
|
|
1534
|
+
// (no network, just reads bundled release notes for the new version)
|
|
1535
|
+
function checkPostUpgradePanel() {
|
|
1536
|
+
const state = readUpdateState();
|
|
1537
|
+
if (state.suppressed) return;
|
|
1538
|
+
if (!cachedVersion) {
|
|
1539
|
+
try {
|
|
1540
|
+
const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"));
|
|
1541
|
+
cachedVersion = pkg.version;
|
|
1542
|
+
} catch {
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const currentVersion = cachedVersion;
|
|
1547
|
+
if (!state.lastInstalledVersion) return;
|
|
1548
|
+
if (state.lastInstalledVersion === currentVersion) return;
|
|
1549
|
+
// We just upgraded — show notes for the new version
|
|
1550
|
+
const notes = fetchReleaseNotes(currentVersion);
|
|
1551
|
+
const notesBlock = notes
|
|
1552
|
+
? renderMarkdown(notes, { width: 60 })
|
|
1553
|
+
: " No release notes found for this version.";
|
|
1554
|
+
updateSelectedOption = 0;
|
|
1555
|
+
updateText.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n\n${notesBlock}`;
|
|
1556
|
+
renderUpdateOptions();
|
|
1557
|
+
showOverlay("update");
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1530
1560
|
async function checkForUpdateAndShow(force: boolean = false) {
|
|
1531
1561
|
if (!force && !shouldCheckUpdate()) return;
|
|
1532
1562
|
if (cachedVersion === null) {
|
|
@@ -1544,7 +1574,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1544
1574
|
markUpdateChecked(latest ?? undefined);
|
|
1545
1575
|
if (latest) {
|
|
1546
1576
|
updateSelectedOption = 0;
|
|
1547
|
-
|
|
1577
|
+
const notes = fetchReleaseNotes(latest);
|
|
1578
|
+
const notesBlock = notes
|
|
1579
|
+
? renderMarkdown(notes, { width: 60 })
|
|
1580
|
+
: ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n Run "npm install -g ghfind"`;
|
|
1581
|
+
updateText.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n${notesBlock}`;
|
|
1548
1582
|
renderUpdateOptions();
|
|
1549
1583
|
showOverlay("update");
|
|
1550
1584
|
} else {
|
|
@@ -2577,6 +2611,8 @@ ${pack.description ?? ""}`;
|
|
|
2577
2611
|
const action = ["now", "later", "never"][updateSelectedOption] as "now" | "later" | "never" | undefined;
|
|
2578
2612
|
if (action === "now") {
|
|
2579
2613
|
showOverlay("none");
|
|
2614
|
+
// ponytail: record the running version so the next launch detects the upgrade
|
|
2615
|
+
recordPreUpdateState(cachedVersion ?? "");
|
|
2580
2616
|
performUpdate().then((ok) => {
|
|
2581
2617
|
if (ok) {
|
|
2582
2618
|
setStatus("✓ Update complete — restart ghfind");
|
|
@@ -2772,6 +2808,7 @@ ${pack.description ?? ""}`;
|
|
|
2772
2808
|
showLanding(true);
|
|
2773
2809
|
}
|
|
2774
2810
|
checkForUpdateAndShow();
|
|
2811
|
+
checkPostUpgradePanel();
|
|
2775
2812
|
renderer.start();
|
|
2776
2813
|
if (session && session.mode) {
|
|
2777
2814
|
if (currentMode === "trending") {
|
package/src/types.ts
CHANGED
|
@@ -104,6 +104,8 @@ export interface Bookmark {
|
|
|
104
104
|
savedAt: number;
|
|
105
105
|
tags: string[];
|
|
106
106
|
notes?: string;
|
|
107
|
+
/** Releases published after this are "unseen". Set when the Releases panel is viewed. */
|
|
108
|
+
lastSeenAt?: number;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
/** A named, saved search. */
|
|
@@ -120,7 +122,7 @@ export interface SavedSearch {
|
|
|
120
122
|
|
|
121
123
|
/** Session state restored on startup. */
|
|
122
124
|
export interface SessionState {
|
|
123
|
-
mode: "search" | "trending" | "packages";
|
|
125
|
+
mode: "search" | "trending" | "packages" | "bookmarks";
|
|
124
126
|
query: string;
|
|
125
127
|
sort: SortStrategy;
|
|
126
128
|
limit: number;
|
package/src/update-check.ts
CHANGED
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { readJSON, writeJSON, debugLog } from "./storage";
|
|
8
8
|
import { stateDir } from "./config";
|
|
9
|
+
import { readFileSync } from "fs";
|
|
10
|
+
import { join, dirname } from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
9
12
|
|
|
10
13
|
const REGISTRY_URL = "https://registry.npmjs.org/github-search-cli/latest";
|
|
11
14
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
12
15
|
const REQUEST_TIMEOUT_MS = 5000;
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const PKG_DIR = join(__dirname, "..");
|
|
13
18
|
|
|
14
19
|
export interface UpdateState {
|
|
15
20
|
/** Timestamp of last successful check. */
|
|
@@ -20,6 +25,8 @@ export interface UpdateState {
|
|
|
20
25
|
suppressed: boolean;
|
|
21
26
|
/** Latest version found on last check (for display). */
|
|
22
27
|
latestVersion?: string;
|
|
28
|
+
/** Version at time of last successful install (populated before performUpdate). */
|
|
29
|
+
lastInstalledVersion?: string;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
const STATE_FILE = "update-state.json";
|
|
@@ -160,3 +167,49 @@ export async function performUpdate(): Promise<boolean> {
|
|
|
160
167
|
return false;
|
|
161
168
|
}
|
|
162
169
|
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Record the current version as "last installed" before performing an update.
|
|
174
|
+
* This lets the post-upgrade startup detect the version change and show notes.
|
|
175
|
+
*/
|
|
176
|
+
export function recordPreUpdateState(currentVersion: string): void {
|
|
177
|
+
writeUpdateState({
|
|
178
|
+
...readUpdateState(),
|
|
179
|
+
lastInstalledVersion: currentVersion,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Fetch release notes for a given version.
|
|
185
|
+
* Tries release-notes/<version>.md first, then falls back to CHANGELOG.md section.
|
|
186
|
+
* Returns the markdown text or null if not found.
|
|
187
|
+
*/
|
|
188
|
+
export function fetchReleaseNotes(version: string): string | null {
|
|
189
|
+
// ponytail: embedded notes are the primary source — they ship in the tarball, no network
|
|
190
|
+
try {
|
|
191
|
+
const notesPath = join(PKG_DIR, "release-notes", `${version}.md`);
|
|
192
|
+
const md = readFileSync(notesPath, "utf-8");
|
|
193
|
+
if (md.trim().length > 0) return md;
|
|
194
|
+
} catch {
|
|
195
|
+
// File doesn't exist for this version — fall through to CHANGELOG
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ponytail: CHANGELOG.md fallback — parse section for this version
|
|
199
|
+
try {
|
|
200
|
+
const changelogPath = join(PKG_DIR, "CHANGELOG.md");
|
|
201
|
+
const changelog = readFileSync(changelogPath, "utf-8");
|
|
202
|
+
// ponytail: find "## [version]" header, slice until next "## "
|
|
203
|
+
const header = "## [" + version + "]";
|
|
204
|
+
const start = changelog.indexOf(header);
|
|
205
|
+
if (start === -1) return null;
|
|
206
|
+
const bodyStart = start + header.length;
|
|
207
|
+
const rest = changelog.substring(bodyStart);
|
|
208
|
+
const nextHeaderIdx = rest.indexOf("\n## ");
|
|
209
|
+
const end = nextHeaderIdx === -1 ? changelog.length : bodyStart + nextHeaderIdx;
|
|
210
|
+
return changelog.substring(bodyStart, end).trim();
|
|
211
|
+
} catch {
|
|
212
|
+
// CHANGELOG.md doesn't exist either
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|