Skip to content

Commit 01344e7

Browse files
silverwindclaude
andcommitted
simplify: dedup forge helpers, pyproject section loop, changelog parser
Extract forgeApiBase/ensureOk and add a label param to forgeFetch to collapse the three forge call sites. Extract pyprojectGet for the section-walking loop reused in uv.lock. Add locateChangelogEntry shared by the three changelog functions. Refactor baseVersionPromise to early-returns. Drop manual TTY check in colorize (styleText handles it via {stream: stderr}). Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
1 parent eb7cfb0 commit 01344e7

2 files changed

Lines changed: 95 additions & 112 deletions

File tree

index.ts

Lines changed: 92 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const rePrereleaseIdNum = /^([a-zA-Z0-9-]+)\.(\d+)$/;
1616
const reDatePattern = /(?<=[^0-9]|^)[0-9]{4}-[0-9]{2}-[0-9]{2}(?=[^0-9]|$)/g;
1717
const reDate = new RegExp(reDatePattern.source);
1818
const reReplaceString = /^s#([^#]+)#([^#]+)#(.*)$/;
19-
const pyprojectVersionSections: readonly string[] = ["project", "tool.poetry"];
19+
const pyprojectSections: readonly string[] = ["project", "tool.poetry"];
2020

2121
function stripV(str: string): string {
2222
return str[0] === "v" ? str.slice(1) : str;
@@ -87,13 +87,18 @@ export function readVersionFromPackageJson(projectRoot: string): string | null {
8787
return readVersionFile("package.json", projectRoot, content => JSON.parse(content).version);
8888
}
8989
90+
function pyprojectGet(content: string, key: string): string | undefined {
91+
for (const section of pyprojectSections) {
92+
const v = tomlGetString(content, section, key);
93+
if (v) return v;
94+
}
95+
return undefined;
96+
}
97+
9098
export function readVersionFromPyprojectToml(projectRoot: string): string | null {
9199
return readVersionFile("pyproject.toml", projectRoot, content => {
92-
for (const section of pyprojectVersionSections) {
93-
const v = tomlGetString(content, section, "version");
94-
if (v && isSemver(v)) return v;
95-
}
96-
return undefined;
100+
const v = pyprojectGet(content, "version");
101+
return v && isSemver(v) ? v : undefined;
97102
});
98103
}
99104
@@ -136,29 +141,33 @@ function updateHeadingDateInLines(lines: string[], index: number, date: string,
136141
return lines.join(eol);
137142
}
138143
139-
// Lenient about heading shape: matches "# 1.2.3", "## v1.2.3", "## [1.2.3]",
140-
// "## [1.2.3] - 2024-01-15", "## 1.2.3 (2024-01-15)", etc.
141-
export function readChangelogEntry(content: string, version: string): string | null {
144+
type ChangelogLocation = {lines: string[], head: {index: number, level: number}, eol: string};
145+
146+
function locateChangelogEntry(content: string, version: string): ChangelogLocation | null {
142147
const lines = content.split(reNewline);
143148
const head = findVersionHeading(lines, version);
144149
if (!head) return null;
145-
return extractEntry(lines, head);
150+
return {lines, head, eol: detectEol(content)};
151+
}
152+
153+
// Lenient about heading shape: matches "# 1.2.3", "## v1.2.3", "## [1.2.3]",
154+
// "## [1.2.3] - 2024-01-15", "## 1.2.3 (2024-01-15)", etc.
155+
export function readChangelogEntry(content: string, version: string): string | null {
156+
const loc = locateChangelogEntry(content, version);
157+
return loc ? extractEntry(loc.lines, loc.head) : null;
146158
}
147159
148160
export function updateChangelogHeadingDate(content: string, version: string, date: string): string | null {
149-
const lines = content.split(reNewline);
150-
const head = findVersionHeading(lines, version);
151-
if (!head) return null;
152-
return updateHeadingDateInLines(lines, head.index, date, detectEol(content));
161+
const loc = locateChangelogEntry(content, version);
162+
return loc ? updateHeadingDateInLines(loc.lines, loc.head.index, date, loc.eol) : null;
153163
}
154164
155165
function processChangelog(content: string, version: string, date: string): {entry: string, updated: string | null} | null {
156-
const lines = content.split(reNewline);
157-
const head = findVersionHeading(lines, version);
158-
if (!head) return null;
159-
const entry = extractEntry(lines, head);
166+
const loc = locateChangelogEntry(content, version);
167+
if (!loc) return null;
168+
const entry = extractEntry(loc.lines, loc.head);
160169
if (!entry) return null;
161-
return {entry, updated: updateHeadingDateInLines(lines, head.index, date, detectEol(content))};
170+
return {entry, updated: updateHeadingDateInLines(loc.lines, loc.head.index, date, loc.eol)};
162171
}
163172
164173
export async function removeIgnoredFiles(files: Array<string>, cwd?: string): Promise<Array<string>> {
@@ -215,7 +224,7 @@ export function getFileChanges({file, baseVersion, newVersion, replacements, dat
215224
section = /^\[([^[\]]+)\]/.exec(trimmed)?.[1].trim() ?? null;
216225
continue;
217226
}
218-
if (!section || !pyprojectVersionSections.includes(section)) continue;
227+
if (!section || !pyprojectSections.includes(section)) continue;
219228
const m = versionLine.exec(lines[i]);
220229
if (m) {
221230
lines[i] = `${m[1]}${newVersion}${m[2]}`;
@@ -225,7 +234,7 @@ export function getFileChanges({file, baseVersion, newVersion, replacements, dat
225234
newData = lines.join(eol);
226235
} else if (fileName === "uv.lock") {
227236
const projStr = readFileSync(file.replace(/uv\.lock$/, "pyproject.toml"), "utf8");
228-
const name = tomlGetString(projStr, "project", "name") ?? tomlGetString(projStr, "tool.poetry", "name");
237+
const name = pyprojectGet(projStr, "name");
229238
if (!name) throw new Error(`Could not determine project name from pyproject.toml for ${file}`);
230239
const re = new RegExp(`(\\[\\[package\\]\\]\r?\nname = "${esc(name)}"\r?\nversion = ").+?(")`);
231240
newData = oldData.replace(re, `$1${newVersion}$2`);
@@ -324,14 +333,19 @@ export async function getRepoInfo(cwd?: string, remote: string = "origin"): Prom
324333
}
325334
}
326335

327-
async function forgeFetch(method: string, url: string, authHeader: string, jsonBody?: string): Promise<Response> {
336+
async function forgeFetch(method: string, url: string, authHeader: string, label: string, jsonBody?: string): Promise<Response> {
328337
logVerbose(`${colorize(method, "magenta")} ${url}`);
329338
const init: RequestInit = {method, headers: {Authorization: authHeader}};
330339
if (jsonBody !== undefined) {
331340
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
332341
init.body = jsonBody;
333342
}
334-
const response = await fetch(url, init);
343+
let response: Response;
344+
try {
345+
response = await fetch(url, init);
346+
} catch (err: any) {
347+
throw new Error(`${label}: ${err.cause?.message || err.message || "Unknown error"}`);
348+
}
335349
logVerbose(`${colorize(String(response.status), response.ok ? "green" : "red")} ${url}`);
336350
return response;
337351
}
@@ -343,6 +357,16 @@ function authOrError(status: number, message: string): Error {
343357
return status === 401 || status === 403 ? new AuthRetryable(message) : new Error(message);
344358
}
345359

360+
function forgeApiBase(repoInfo: RepoInfo): string {
361+
const host = repoInfo.type === "github" ? "api.github.com" : `${repoInfo.host}/api/v1`;
362+
return `https://${host}/repos/${repoInfo.owner}/${repoInfo.repo}`;
363+
}
364+
365+
async function ensureOk(response: Response, label: string, allow404 = false): Promise<void> {
366+
if (response.ok || (allow404 && response.status === 404)) return;
367+
throw authOrError(response.status, `${label}: ${response.status} ${response.statusText}\n${await response.text()}`);
368+
}
369+
346370
async function withTokens<T>(
347371
isGithub: boolean,
348372
tokens: string[],
@@ -363,27 +387,15 @@ async function withTokens<T>(
363387
}
364388

365389
async function deleteMatchingDrafts(apiUrl: string, authHeader: string, tagName: string): Promise<number> {
366-
let listResponse: Response;
367-
try {
368-
listResponse = await forgeFetch("GET", `${apiUrl}?draft=true&limit=50&per_page=100`, authHeader);
369-
} catch (err: any) {
370-
throw new Error(`Failed to list releases: ${err.cause?.message || err.message || "Unknown error"}`);
371-
}
372-
if (!listResponse.ok) {
373-
throw authOrError(listResponse.status, `Failed to list releases: ${listResponse.status} ${listResponse.statusText}\n${await listResponse.text()}`);
374-
}
390+
const listLabel = "Failed to list releases";
391+
const listResponse = await forgeFetch("GET", `${apiUrl}?draft=true&limit=50&per_page=100`, authHeader, listLabel);
392+
await ensureOk(listResponse, listLabel);
375393
const releases = await listResponse.json() as Array<{id: number; tag_name: string; draft: boolean}>;
376394
const drafts = releases.filter(r => r.draft && r.tag_name === tagName);
377395
for (const draft of drafts) {
378-
let deleteResponse: Response;
379-
try {
380-
deleteResponse = await forgeFetch("DELETE", `${apiUrl}/${draft.id}`, authHeader);
381-
} catch (err: any) {
382-
throw new Error(`Failed to delete draft release ${draft.id}: ${err.cause?.message || err.message || "Unknown error"}`);
383-
}
384-
if (!deleteResponse.ok && deleteResponse.status !== 404) {
385-
throw authOrError(deleteResponse.status, `Failed to delete draft release ${draft.id}: ${deleteResponse.status} ${deleteResponse.statusText}\n${await deleteResponse.text()}`);
386-
}
396+
const label = `Failed to delete draft release ${draft.id}`;
397+
const deleteResponse = await forgeFetch("DELETE", `${apiUrl}/${draft.id}`, authHeader, label);
398+
await ensureOk(deleteResponse, label, true);
387399
console.info(`Deleted stale draft release for ${tagName}`);
388400
}
389401
return drafts.length;
@@ -392,27 +404,18 @@ async function deleteMatchingDrafts(apiUrl: string, authHeader: string, tagName:
392404
export type CreatedRelease = {id: number; html_url?: string};
393405

394406
export async function deleteForgeRelease(repoInfo: RepoInfo, releaseId: number, tokens: string[]): Promise<void> {
395-
const isGithub = repoInfo.type === "github";
396-
const apiHost = isGithub ? "api.github.com" : `${repoInfo.host}/api/v1`;
397-
const url = `https://${apiHost}/repos/${repoInfo.owner}/${repoInfo.repo}/releases/${releaseId}`;
407+
const url = `${forgeApiBase(repoInfo)}/releases/${releaseId}`;
408+
const label = `Failed to delete release ${releaseId}`;
398409

399-
await withTokens(isGithub, tokens, async (authHeader) => {
400-
let response: Response;
401-
try {
402-
response = await forgeFetch("DELETE", url, authHeader);
403-
} catch (err: any) {
404-
throw new Error(`Failed to delete release ${releaseId}: ${err.cause?.message || err.message || "Unknown error"}`);
405-
}
406-
if (response.ok || response.status === 404) return;
407-
throw authOrError(response.status, `Failed to delete release ${releaseId}: ${response.status} ${response.statusText}\n${await response.text()}`);
410+
await withTokens(repoInfo.type === "github", tokens, async (authHeader) => {
411+
const response = await forgeFetch("DELETE", url, authHeader, label);
412+
await ensureOk(response, label, true);
408413
});
409414
}
410415

411416
export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, body: string, tokens: string[]): Promise<CreatedRelease | null> {
412-
const isGithub = repoInfo.type === "github";
413-
const apiHost = isGithub ? "api.github.com" : `${repoInfo.host}/api/v1`;
414-
const apiUrl = `https://${apiHost}/repos/${repoInfo.owner}/${repoInfo.repo}/releases`;
415-
417+
const apiUrl = `${forgeApiBase(repoInfo)}/releases`;
418+
const label = "Failed to create release";
416419
const releaseBody = JSON.stringify({
417420
tag_name: tagName,
418421
name: tagName,
@@ -421,15 +424,9 @@ export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, bo
421424
prerelease: tagName.includes("-"),
422425
});
423426

424-
const post = async (authHeader: string) => {
425-
try {
426-
return await forgeFetch("POST", apiUrl, authHeader, releaseBody);
427-
} catch (err: any) {
428-
throw new Error(`Failed to create release: ${err.cause?.message || err.message || "Unknown error"}`);
429-
}
430-
};
427+
const post = (authHeader: string) => forgeFetch("POST", apiUrl, authHeader, label, releaseBody);
431428

432-
return withTokens(isGithub, tokens, async (authHeader) => {
429+
return withTokens(repoInfo.type === "github", tokens, async (authHeader) => {
433430
let response = await post(authHeader);
434431

435432
// Stale draft for the same tag blocks creation: Gitea returns 409 "Release is has no Tag",
@@ -439,13 +436,10 @@ export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, bo
439436
if (cleaned > 0) response = await post(authHeader);
440437
}
441438

442-
if (response.ok) {
443-
const result = await response.json();
444-
console.info(result.html_url ? `Created release: ${result.html_url}` : "Created release");
445-
return typeof result.id === "number" ? {id: result.id, html_url: result.html_url} : null;
446-
}
447-
448-
throw authOrError(response.status, `Failed to create release: ${response.status} ${response.statusText}\n${await response.text()}`);
439+
await ensureOk(response, label);
440+
const result = await response.json();
441+
console.info(result.html_url ? `Created release: ${result.html_url}` : "Created release");
442+
return typeof result.id === "number" ? {id: result.id, html_url: result.html_url} : null;
449443
});
450444
}
451445

@@ -529,9 +523,10 @@ async function main(): Promise<void> {
529523
const gitDir = findUp(".git", pwd);
530524
const projectRoot = gitDir ? dirname(gitDir) : pwd;
531525
const pushRemote = typeof args.remote === "string" ? args.remote : "origin";
532-
const repoInfoPromise = (!args.gitless && args.release) ? getRepoInfo(undefined, pushRemote) : null;
533-
const tokensPromise = repoInfoPromise?.then(info =>
534-
!info ? [] : info.type === "github" ? getGithubTokens() : getGiteaTokens());
526+
const wantRelease = !args.gitless && Boolean(args.release);
527+
const repoInfoPromise = wantRelease ? getRepoInfo(undefined, pushRemote) : null;
528+
const tokensPromise = wantRelease ? repoInfoPromise!.then(info =>
529+
!info ? [] : info.type === "github" ? getGithubTokens() : getGiteaTokens()) : null;
535530

536531
files = files.map(file => relative(pwd, file));
537532

@@ -546,49 +541,38 @@ async function main(): Promise<void> {
546541
}
547542

548543
const baseVersionPromise = (async (): Promise<{baseVersion: string, baseSource: string, describeTag: string}> => {
549-
let baseVersion = "";
550-
let baseSource = "";
551-
let describeTag = "";
552544
if (args.base) {
553545
const raw = String(args.base);
554546
if (!isSemver(raw)) throw new Error(`Invalid base version: ${raw}`);
555-
return {baseVersion: stripV(raw), baseSource: "--base", describeTag};
547+
return {baseVersion: stripV(raw), baseSource: "--base", describeTag: ""};
556548
}
549+
550+
let describeTag = "";
557551
if (!args.gitless) {
558552
try {
559553
const result = await exec("git", ["describe", "--tags", "--abbrev=0"]);
560554
describeTag = result.stdout.trim();
561555
if (isSemver(describeTag)) {
562-
baseVersion = stripV(describeTag);
563-
baseSource = "git describe";
556+
return {baseVersion: stripV(describeTag), baseSource: "git describe", describeTag};
564557
}
565558
} catch {}
566-
if (!baseVersion) {
567-
let stdout = "";
568-
try {
569-
({stdout} = await exec("git", ["tag", "--list", "--sort=-creatordate"]));
570-
} catch {}
559+
560+
try {
561+
const {stdout} = await exec("git", ["tag", "--list", "--sort=-creatordate"]);
571562
const tag = stdout.split(reNewline).map(v => v.trim()).find(t => t && isSemver(t));
572-
if (tag) {
573-
baseVersion = stripV(tag);
574-
baseSource = "git tag list";
575-
}
576-
}
577-
}
578-
if (!baseVersion) {
579-
baseVersion = readVersionFromPackageJson(projectRoot) || "";
580-
if (baseVersion) {
581-
baseSource = "package.json";
582-
} else {
583-
baseVersion = readVersionFromPyprojectToml(projectRoot) || "";
584-
if (baseVersion) baseSource = "pyproject.toml";
585-
}
586-
if (!baseVersion && !args.gitless) {
587-
baseVersion = "0.0.0";
588-
baseSource = "default";
589-
}
563+
if (tag) return {baseVersion: stripV(tag), baseSource: "git tag list", describeTag};
564+
} catch {}
590565
}
591-
return {baseVersion, baseSource, describeTag};
566+
567+
const pkgVer = readVersionFromPackageJson(projectRoot);
568+
if (pkgVer) return {baseVersion: pkgVer, baseSource: "package.json", describeTag};
569+
570+
const pyVer = readVersionFromPyprojectToml(projectRoot);
571+
if (pyVer) return {baseVersion: pyVer, baseSource: "pyproject.toml", describeTag};
572+
573+
if (!args.gitless) return {baseVersion: "0.0.0", baseSource: "default", describeTag};
574+
575+
return {baseVersion: "", baseSource: "", describeTag};
592576
})();
593577

594578
// resolve push branch early so detached HEAD fails before commit/tag
@@ -628,7 +612,7 @@ async function main(): Promise<void> {
628612
}
629613

630614
const msgs = (args.message || []).filter(msg => typeof msg === "string");
631-
const tagName = args["prefix"] ? `v${newVersion}` : newVersion;
615+
const tagName = args.prefix ? `v${newVersion}` : newVersion;
632616

633617
const changelogInfo = (() => {
634618
const path = findUp("CHANGELOG.md", projectRoot);
@@ -799,8 +783,8 @@ async function main(): Promise<void> {
799783
}
800784
}
801785

802-
if (repoInfoPromise) {
803-
const repoInfo = await repoInfoPromise;
786+
if (wantRelease) {
787+
const repoInfo = await repoInfoPromise!;
804788
if (!repoInfo) {
805789
throw new Error("Could not determine repository type from git remote. Only GitHub and Gitea repositories are supported for release creation.");
806790
}

utils.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {styleText} from "node:util";
55
export type Result = {stdout: string; stderr: string};
66

77
let verbose = false;
8-
const useColor = stderr.isTTY;
98

109
export function setVerbose(value: boolean): void {
1110
verbose = value;
@@ -24,7 +23,7 @@ export function logVerbose(message: string): void {
2423
}
2524

2625
export function colorize(text: string, color: "magenta" | "green" | "red"): string {
27-
return useColor ? styleText(color, text) : text;
26+
return styleText(color, text, {stream: stderr});
2827
}
2928

3029
function quoteArg(arg: string): string {
@@ -57,7 +56,7 @@ type ExecOptions = {
5756
export const reNewline = /\r?\n/;
5857

5958
export function detectEol(s: string): string {
60-
return /\r?\n/.exec(s)?.[0] ?? "\n";
59+
return reNewline.exec(s)?.[0] ?? "\n";
6160
}
6261

6362
export function tomlGetString(content: string, section: string, key: string): string | undefined {
@@ -84,7 +83,7 @@ export function exec(file: string, args: readonly string[], options?: ExecOption
8483
return new Promise((resolve, reject) => {
8584
const child = execFileCb(file, args as string[], {encoding: "utf8", shell: options?.shell, windowsHide: true, cwd: options?.cwd, env: options?.env}, (error, stdout, stderr) => {
8685
if (error) {
87-
reject(new SubprocessError(error.message.split(/\r?\n/)[0], stdout, stderr, typeof error.code === "number" ? error.code : null));
86+
reject(new SubprocessError(error.message.split(reNewline)[0], stdout, stderr, typeof error.code === "number" ? error.code : null));
8887
} else {
8988
resolve({stdout: stdout.trimEnd(), stderr: stderr.trimEnd()});
9089
}

0 commit comments

Comments
 (0)