@varlock/bumpy 0.0.0 → 0.0.2

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,13 @@
1
+ {
2
+ "name": "bumpy",
3
+ "version": "0.0.1",
4
+ "description": "AI-assisted changeset creation for bumpy monorepo versioning",
5
+ "author": {
6
+ "name": "DMNO",
7
+ "url": "https://github.com/dmno-dev"
8
+ },
9
+ "repository": "https://github.com/dmno-dev/bumpy",
10
+ "license": "MIT",
11
+ "keywords": ["monorepo", "versioning", "changelog", "changesets"],
12
+ "skills": "./skills/"
13
+ }
@@ -0,0 +1,175 @@
1
+ import { n as log, o as __toESM, r as require_picocolors } from "./logger-C2dEe5Su.mjs";
2
+ import { n as exists, t as ensureDir } from "./fs-0AtnPUUe.mjs";
3
+ import { a as loadConfig, r as getBumpyDir, s as matchGlob } from "./config-BkwIEaQg.mjs";
4
+ import { t as discoverPackages } from "./workspace-CxEKakDm.mjs";
5
+ import { t as DependencyGraph } from "./dep-graph-E-9-eQ2J.mjs";
6
+ import { i as writeChangeset } from "./changeset-UCZdSRDv.mjs";
7
+ import { c as ot, d as yt, i as _t, l as pt, o as gt, r as Ot, s as mt, t as unwrap, u as wt } from "./clack-CDRCHrC-.mjs";
8
+ import { n as slugify, t as randomName } from "./names-Ck8cun7B.mjs";
9
+ import { resolve } from "node:path";
10
+ //#region src/commands/add.ts
11
+ var import_picocolors = /* @__PURE__ */ __toESM(require_picocolors(), 1);
12
+ const BUMP_CHOICES = [
13
+ {
14
+ label: "patch",
15
+ value: "patch"
16
+ },
17
+ {
18
+ label: "minor",
19
+ value: "minor"
20
+ },
21
+ {
22
+ label: "major",
23
+ value: "major"
24
+ },
25
+ {
26
+ label: "patch (isolated)",
27
+ value: "patch-isolated",
28
+ hint: "no cascade"
29
+ },
30
+ {
31
+ label: "minor (isolated)",
32
+ value: "minor-isolated",
33
+ hint: "no cascade"
34
+ }
35
+ ];
36
+ const CASCADE_CHOICES = [
37
+ {
38
+ label: "patch",
39
+ value: "patch"
40
+ },
41
+ {
42
+ label: "minor",
43
+ value: "minor"
44
+ },
45
+ {
46
+ label: "major",
47
+ value: "major"
48
+ }
49
+ ];
50
+ async function addCommand(rootDir, opts) {
51
+ const config = await loadConfig(rootDir);
52
+ const bumpyDir = getBumpyDir(rootDir);
53
+ await ensureDir(bumpyDir);
54
+ if (opts.empty) {
55
+ const filename = opts.name ? slugify(opts.name) : randomName();
56
+ const filePath = resolve(bumpyDir, `${filename}.md`);
57
+ const { writeText } = await import("./fs-0AtnPUUe.mjs").then((n) => n.r);
58
+ await writeText(filePath, "---\n---\n");
59
+ log.success(`Created empty changeset: .bumpy/${filename}.md`);
60
+ return;
61
+ }
62
+ let releases;
63
+ let summary;
64
+ let filename;
65
+ if (opts.packages) {
66
+ releases = parsePackagesFlag(opts.packages);
67
+ summary = opts.message || "";
68
+ filename = opts.name ? slugify(opts.name) : randomName();
69
+ } else {
70
+ mt(import_picocolors.default.bgCyan(import_picocolors.default.black(" bumpy add ")));
71
+ const pkgs = await discoverPackages(rootDir, config);
72
+ const depGraph = new DependencyGraph(pkgs);
73
+ if (pkgs.size === 0) {
74
+ pt("No managed packages found in this workspace.");
75
+ process.exit(1);
76
+ }
77
+ const selected = unwrap(await yt({
78
+ message: "Which packages should be included in this changeset?",
79
+ options: [...pkgs.values()].map((pkg) => ({
80
+ label: pkg.name,
81
+ value: pkg.name,
82
+ hint: pkg.version
83
+ })),
84
+ required: true
85
+ }));
86
+ releases = [];
87
+ for (const name of selected) {
88
+ const bumpType = unwrap(await _t({
89
+ message: `Bump type for ${import_picocolors.default.cyan(name)}`,
90
+ options: BUMP_CHOICES
91
+ }));
92
+ const release = {
93
+ name,
94
+ type: bumpType
95
+ };
96
+ if (!bumpType.endsWith("-isolated")) {
97
+ const dependents = depGraph.getDependents(name);
98
+ const cascadeTargets = pkgs.get(name).bumpy?.cascadeTo;
99
+ if (dependents.length > 0 || cascadeTargets) {
100
+ if (unwrap(await ot({
101
+ message: `${import_picocolors.default.cyan(name)} has ${import_picocolors.default.bold(String(dependents.length))} dependents. Specify explicit cascades?`,
102
+ initialValue: false
103
+ }))) {
104
+ const allTargets = /* @__PURE__ */ new Set();
105
+ for (const d of dependents) allTargets.add(d.name);
106
+ if (cascadeTargets) {
107
+ for (const pattern of Object.keys(cascadeTargets)) for (const [pName] of pkgs) if (matchGlob(pName, pattern)) allTargets.add(pName);
108
+ }
109
+ const cascadeSelected = unwrap(await yt({
110
+ message: "Which packages should cascade?",
111
+ options: [...allTargets].map((n) => ({
112
+ label: n,
113
+ value: n
114
+ })),
115
+ required: false
116
+ }));
117
+ if (cascadeSelected.length > 0) {
118
+ const cascadeBump = unwrap(await _t({
119
+ message: "Cascade bump type",
120
+ options: CASCADE_CHOICES
121
+ }));
122
+ const cascade = {};
123
+ for (const target of cascadeSelected) cascade[target] = cascadeBump;
124
+ release.cascade = cascade;
125
+ }
126
+ }
127
+ }
128
+ }
129
+ releases.push(release);
130
+ }
131
+ summary = unwrap(await Ot({
132
+ message: "Summary (what changed and why)",
133
+ placeholder: "A short description of the change",
134
+ validate: (value) => {
135
+ if (!value || !value.trim()) return "Summary is required";
136
+ }
137
+ }));
138
+ const defaultName = randomName();
139
+ filename = slugify(unwrap(await Ot({
140
+ message: "Changeset name",
141
+ placeholder: defaultName,
142
+ defaultValue: defaultName,
143
+ validate: (value) => {
144
+ if (!value) return void 0;
145
+ if (!slugify(value)) return "Name must contain at least one alphanumeric character";
146
+ }
147
+ }))) || defaultName;
148
+ }
149
+ if (await exists(resolve(bumpyDir, `${filename}.md`))) filename = `${filename}-${Date.now()}`;
150
+ await writeChangeset(rootDir, filename, releases, summary);
151
+ if (opts.packages) {
152
+ log.success(`Created changeset: .bumpy/${filename}.md`);
153
+ for (const r of releases) log.dim(` ${r.name}: ${r.type}${formatCascade(r)}`);
154
+ } else {
155
+ wt(releases.map((r) => `${import_picocolors.default.cyan(r.name)} ${import_picocolors.default.dim("→")} ${import_picocolors.default.bold(r.type)}${formatCascade(r)}`).join("\n"), "Changeset");
156
+ gt(import_picocolors.default.green(`Created .bumpy/${filename}.md`));
157
+ }
158
+ }
159
+ function formatCascade(r) {
160
+ if (!("cascade" in r) || Object.keys(r.cascade).length === 0) return "";
161
+ const parts = Object.entries(r.cascade).map(([k, v]) => `${k}:${v}`);
162
+ return import_picocolors.default.dim(` (cascade: ${parts.join(", ")})`);
163
+ }
164
+ function parsePackagesFlag(input) {
165
+ return input.split(",").map((entry) => {
166
+ const [name, type] = entry.trim().split(":");
167
+ if (!name || !type) throw new Error(`Invalid package format: "${entry}". Expected "name:bumpType"`);
168
+ return {
169
+ name: name.trim(),
170
+ type: type.trim()
171
+ };
172
+ });
173
+ }
174
+ //#endregion
175
+ export { addCommand };
@@ -0,0 +1,82 @@
1
+ import { n as log } from "./logger-C2dEe5Su.mjs";
2
+ import { l as writeText, n as exists, t as ensureDir } from "./fs-0AtnPUUe.mjs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ //#region src/commands/ai.ts
7
+ const SUPPORTED_TARGETS = [
8
+ "opencode",
9
+ "cursor",
10
+ "codex"
11
+ ];
12
+ async function aiSetupCommand(rootDir, opts) {
13
+ const target = opts.target;
14
+ if (!target) {
15
+ log.error(`Please specify a target: bumpy ai setup --target <${SUPPORTED_TARGETS.join("|")}>`);
16
+ log.dim(" Claude Code users: install the plugin instead — claude plugin install @varlock/bumpy");
17
+ process.exit(1);
18
+ }
19
+ if (!SUPPORTED_TARGETS.includes(target)) {
20
+ log.error(`Unknown target: "${target}". Supported: ${SUPPORTED_TARGETS.join(", ")}`);
21
+ process.exit(1);
22
+ }
23
+ const promptContent = await loadPromptTemplate();
24
+ switch (target) {
25
+ case "opencode":
26
+ await setupOpenCode(rootDir, promptContent);
27
+ break;
28
+ case "cursor":
29
+ await setupCursor(rootDir, promptContent);
30
+ break;
31
+ case "codex":
32
+ await setupCodex(rootDir, promptContent);
33
+ break;
34
+ }
35
+ }
36
+ async function loadPromptTemplate() {
37
+ return (await readFile(resolve(dirname(fileURLToPath(import.meta.url)), "../../skills/add-change/SKILL.md"), "utf-8")).replace(/^---\n[\s\S]*?\n---\n\n?/, "");
38
+ }
39
+ /** Install as an OpenCode custom command */
40
+ async function setupOpenCode(rootDir, promptContent) {
41
+ const commandsDir = resolve(rootDir, ".opencode", "commands");
42
+ const targetPath = resolve(commandsDir, "add-bumpy-change.md");
43
+ await ensureDir(commandsDir);
44
+ if (await exists(targetPath)) log.warn(".opencode/commands/add-bumpy-change.md already exists — overwriting");
45
+ await writeText(targetPath, `---
46
+ description: Create a bumpy changeset to track package version bumps
47
+ ---
48
+
49
+ ${promptContent}`);
50
+ log.success("Installed OpenCode command");
51
+ log.dim(" Created .opencode/commands/add-bumpy-change.md");
52
+ log.dim(" Usage: type /add-bumpy-change in OpenCode");
53
+ }
54
+ /** Install as a Cursor rule */
55
+ async function setupCursor(rootDir, promptContent) {
56
+ const rulesDir = resolve(rootDir, ".cursor", "rules");
57
+ const targetPath = resolve(rulesDir, "add-bumpy-change.mdc");
58
+ await ensureDir(rulesDir);
59
+ if (await exists(targetPath)) log.warn(".cursor/rules/add-bumpy-change.mdc already exists — overwriting");
60
+ await writeText(targetPath, `---
61
+ description: Create a bumpy changeset to track package version bumps
62
+ globs:
63
+ alwaysApply: false
64
+ ---
65
+
66
+ ${promptContent}`);
67
+ log.success("Installed Cursor rule");
68
+ log.dim(" Created .cursor/rules/add-bumpy-change.mdc");
69
+ log.dim(" The rule will be suggested when relevant, or you can reference it manually");
70
+ }
71
+ /** Install as a Codex instruction */
72
+ async function setupCodex(rootDir, promptContent) {
73
+ const targetPath = resolve(rootDir, ".codex", "add-bumpy-change.md");
74
+ await ensureDir(resolve(rootDir, ".codex"));
75
+ if (await exists(targetPath)) log.warn(".codex/add-bumpy-change.md already exists — overwriting");
76
+ await writeText(targetPath, promptContent);
77
+ log.success("Installed Codex instruction");
78
+ log.dim(" Created .codex/add-bumpy-change.md");
79
+ log.dim(" Reference this file in your AGENTS.md or pass it as context");
80
+ }
81
+ //#endregion
82
+ export { aiSetupCommand };
@@ -0,0 +1,137 @@
1
+ import { n as log } from "./logger-C2dEe5Su.mjs";
2
+ import { a as readJson, c as writeJson, l as writeText, n as exists, o as readText } from "./fs-0AtnPUUe.mjs";
3
+ import { t as deleteChangesets } from "./changeset-UCZdSRDv.mjs";
4
+ import { resolve } from "node:path";
5
+ //#region src/core/changelog.ts
6
+ /** Default formatter — version heading, date, bullet points */
7
+ const defaultFormatter = (ctx) => {
8
+ const { release, changesets, date } = ctx;
9
+ const lines = [];
10
+ lines.push(`## ${release.newVersion}`);
11
+ lines.push("");
12
+ lines.push(`_${date}_`);
13
+ lines.push("");
14
+ const relevantChangesets = changesets.filter((cs) => release.changesets.includes(cs.id));
15
+ if (relevantChangesets.length > 0) {
16
+ for (const cs of relevantChangesets) if (cs.summary) {
17
+ const summaryLines = cs.summary.split("\n");
18
+ lines.push(`- ${summaryLines[0]}`);
19
+ for (let i = 1; i < summaryLines.length; i++) if (summaryLines[i].trim()) lines.push(` ${summaryLines[i]}`);
20
+ }
21
+ }
22
+ if (release.isDependencyBump && relevantChangesets.length === 0) lines.push("- Updated dependencies");
23
+ if (release.isCascadeBump && !release.isDependencyBump && relevantChangesets.length === 0) lines.push("- Version bump via cascade rule");
24
+ lines.push("");
25
+ return lines.join("\n");
26
+ };
27
+ const BUILTIN_FORMATTERS = {
28
+ default: defaultFormatter,
29
+ github: async () => {
30
+ const { createGithubFormatter } = await import("./changelog-github-Du62krXi.mjs");
31
+ return createGithubFormatter();
32
+ }
33
+ };
34
+ /**
35
+ * Load a changelog formatter from config.
36
+ * Supports: "default", "./path/to/formatter.ts", or a module name.
37
+ */
38
+ async function loadFormatter(changelog, rootDir) {
39
+ const [name, options] = Array.isArray(changelog) ? changelog : [changelog, {}];
40
+ if (typeof name === "string" && BUILTIN_FORMATTERS[name]) {
41
+ const builtin = BUILTIN_FORMATTERS[name];
42
+ if (typeof builtin === "function" && builtin.length === 0) return builtin();
43
+ return builtin;
44
+ }
45
+ if (name === "github") {
46
+ const { createGithubFormatter } = await import("./changelog-github-Du62krXi.mjs");
47
+ return createGithubFormatter(options);
48
+ }
49
+ if (typeof name === "string") try {
50
+ let modulePath;
51
+ if (name.startsWith(".")) {
52
+ modulePath = resolve(rootDir, name);
53
+ if (!modulePath.startsWith(rootDir + "/")) throw new Error(`Changelog formatter path "${name}" resolves outside the project root`);
54
+ } else modulePath = name;
55
+ const mod = await import(modulePath);
56
+ const exported = mod.default || mod.changelogFormatter;
57
+ if (typeof exported === "function") {
58
+ const result = exported(options);
59
+ if (typeof result === "function") return result;
60
+ return exported;
61
+ }
62
+ throw new Error(`Changelog module "${name}" does not export a function`);
63
+ } catch (err) {
64
+ log.warn(`Failed to load changelog formatter "${name}": ${err instanceof Error ? err.message : err}`);
65
+ log.warn("Falling back to default formatter");
66
+ return defaultFormatter;
67
+ }
68
+ return defaultFormatter;
69
+ }
70
+ /** Generate a changelog entry using the configured formatter */
71
+ async function generateChangelogEntry(release, changesets, formatter = defaultFormatter, date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0]) {
72
+ return formatter({
73
+ release,
74
+ changesets,
75
+ date
76
+ });
77
+ }
78
+ /** Prepend a new entry to an existing CHANGELOG.md content */
79
+ function prependToChangelog(existingContent, newEntry) {
80
+ const headerMatch = existingContent.match(/^# /m);
81
+ if (headerMatch && headerMatch.index !== void 0) {
82
+ const afterTitle = existingContent.indexOf("\n##");
83
+ if (afterTitle !== -1) return existingContent.slice(0, afterTitle + 1) + "\n" + newEntry + existingContent.slice(afterTitle + 1);
84
+ return existingContent.trimEnd() + "\n\n" + newEntry;
85
+ }
86
+ return "# Changelog\n\n" + newEntry;
87
+ }
88
+ //#endregion
89
+ //#region src/core/apply-release-plan.ts
90
+ /** Apply the release plan: bump versions, update changelogs, delete changesets */
91
+ async function applyReleasePlan(releasePlan, packages, rootDir, config) {
92
+ const releaseMap = new Map(releasePlan.releases.map((r) => [r.name, r]));
93
+ const formatter = await loadFormatter(config.changelog, rootDir);
94
+ for (const release of releasePlan.releases) {
95
+ const pkgJsonPath = resolve(packages.get(release.name).dir, "package.json");
96
+ const pkgJson = await readJson(pkgJsonPath);
97
+ pkgJson.version = release.newVersion;
98
+ for (const depField of [
99
+ "dependencies",
100
+ "devDependencies",
101
+ "peerDependencies",
102
+ "optionalDependencies"
103
+ ]) {
104
+ const deps = pkgJson[depField];
105
+ if (!deps) continue;
106
+ for (const [depName, range] of Object.entries(deps)) {
107
+ const depRelease = releaseMap.get(depName);
108
+ if (!depRelease) continue;
109
+ deps[depName] = updateRange(range, depRelease.newVersion);
110
+ }
111
+ }
112
+ await writeJson(pkgJsonPath, pkgJson);
113
+ }
114
+ for (const release of releasePlan.releases) {
115
+ const changelogPath = resolve(packages.get(release.name).dir, "CHANGELOG.md");
116
+ const entry = await generateChangelogEntry(release, releasePlan.changesets, formatter);
117
+ let existingContent = "";
118
+ if (await exists(changelogPath)) existingContent = await readText(changelogPath);
119
+ await writeText(changelogPath, prependToChangelog(existingContent, entry));
120
+ }
121
+ await deleteChangesets(rootDir, releasePlan.changesets.map((cs) => cs.id));
122
+ }
123
+ /** Update a version range to include a new version, preserving the range prefix */
124
+ function updateRange(range, newVersion) {
125
+ let protocol = "";
126
+ let cleanRange = range;
127
+ const protoMatch = range.match(/^(workspace:|catalog:)/);
128
+ if (protoMatch) {
129
+ protocol = protoMatch[1];
130
+ cleanRange = range.slice(protocol.length);
131
+ }
132
+ const prefix = cleanRange.match(/^(\^|~|>=|>|<=|<|=)?/)?.[1] ?? "^";
133
+ if (cleanRange === "*" || cleanRange === "") return range;
134
+ return `${protocol}${prefix}${newVersion}`;
135
+ }
136
+ //#endregion
137
+ export { prependToChangelog as a, loadFormatter as i, defaultFormatter as n, generateChangelogEntry as r, applyReleasePlan as t };
@@ -0,0 +1,193 @@
1
+ import { o as tryRunArgs } from "./shell-Dj7JRD_q.mjs";
2
+ //#region src/core/changelog-github.ts
3
+ /**
4
+ * GitHub-enhanced changelog formatter.
5
+ * Adds PR links, commit links, and contributor attribution when git/gh info is available.
6
+ *
7
+ * Usage in config:
8
+ * "changelog": "github"
9
+ * "changelog": ["github", { "repo": "dmno-dev/bumpy" }]
10
+ * "changelog": ["github", { "repo": "dmno-dev/bumpy", "internalAuthors": ["theoephraim"] }]
11
+ */
12
+ function createGithubFormatter(options = {}) {
13
+ const internalAuthorsSet = new Set((options.internalAuthors ?? []).map((a) => a.toLowerCase()));
14
+ return async (ctx) => {
15
+ const { release, changesets, date } = ctx;
16
+ const repoSlug = options.repo ?? detectRepo();
17
+ const serverUrl = process.env.GITHUB_SERVER_URL || "https://github.com";
18
+ const lines = [];
19
+ lines.push(`## ${release.newVersion}`);
20
+ lines.push("");
21
+ lines.push(`_${date}_`);
22
+ lines.push("");
23
+ const relevantChangesets = changesets.filter((cs) => release.changesets.includes(cs.id));
24
+ if (relevantChangesets.length > 0) for (const cs of relevantChangesets) {
25
+ if (!cs.summary) continue;
26
+ const { cleanSummary, overrides } = extractSummaryMeta(cs.summary);
27
+ const gitInfo = resolveChangesetInfo(cs.id, repoSlug, serverUrl, overrides);
28
+ const summaryLines = cleanSummary.split("\n");
29
+ const firstLine = linkifyIssueRefs(summaryLines[0], serverUrl, repoSlug);
30
+ const prefix = formatPrefix(gitInfo, serverUrl, repoSlug, internalAuthorsSet);
31
+ lines.push(`-${prefix ? ` ${prefix} -` : ""} ${firstLine}`);
32
+ for (let i = 1; i < summaryLines.length; i++) if (summaryLines[i].trim()) lines.push(` ${linkifyIssueRefs(summaryLines[i], serverUrl, repoSlug)}`);
33
+ }
34
+ if (release.isDependencyBump && relevantChangesets.length === 0) lines.push("- Updated dependencies");
35
+ if (release.isCascadeBump && !release.isDependencyBump && relevantChangesets.length === 0) lines.push("- Version bump via cascade rule");
36
+ lines.push("");
37
+ return lines.join("\n");
38
+ };
39
+ }
40
+ /**
41
+ * Extract metadata lines (pr, commit, author) from a changeset summary.
42
+ * These override git-derived info, matching the behavior of @changesets/changelog-github.
43
+ */
44
+ function extractSummaryMeta(summary) {
45
+ const overrides = {};
46
+ return {
47
+ cleanSummary: summary.replace(/^\s*(?:pr|pull|pull\s+request):\s*#?(\d+)/im, (_, pr) => {
48
+ const num = Number(pr);
49
+ if (!isNaN(num)) overrides.pr = num;
50
+ return "";
51
+ }).replace(/^\s*commit:\s*([^\s]+)/im, (_, commit) => {
52
+ overrides.commit = commit;
53
+ return "";
54
+ }).replace(/^\s*(?:author|user):\s*@?([^\s]+)/gim, (_, user) => {
55
+ overrides.authors ??= [];
56
+ overrides.authors.push(user);
57
+ return "";
58
+ }).trim(),
59
+ overrides
60
+ };
61
+ }
62
+ /**
63
+ * Resolve PR, commit, and author info for a changeset.
64
+ * Summary overrides take precedence over git-derived info.
65
+ */
66
+ function resolveChangesetInfo(changesetId, repo, serverUrl, overrides) {
67
+ if (overrides.pr !== void 0) {
68
+ const prInfo = lookupPr(overrides.pr, repo);
69
+ return {
70
+ prNumber: overrides.pr,
71
+ prUrl: prInfo?.url ?? `${serverUrl}/${repo}/pull/${overrides.pr}`,
72
+ commitHash: overrides.commit ?? prInfo?.commitHash,
73
+ author: overrides.authors?.[0] ?? prInfo?.author
74
+ };
75
+ }
76
+ const gitInfo = findChangesetCommitInfo(changesetId, repo);
77
+ return {
78
+ prNumber: gitInfo?.prNumber,
79
+ prUrl: gitInfo?.prUrl,
80
+ commitHash: overrides.commit ?? gitInfo?.commitHash,
81
+ author: overrides.authors?.[0] ?? gitInfo?.author
82
+ };
83
+ }
84
+ /** Look up a PR by number using gh CLI */
85
+ function lookupPr(prNumber, repo) {
86
+ try {
87
+ const ghArgs = [
88
+ "gh",
89
+ "pr",
90
+ "view",
91
+ String(prNumber),
92
+ "--json",
93
+ "url,author,mergeCommit"
94
+ ];
95
+ if (repo) ghArgs.push("--repo", repo);
96
+ const result = tryRunArgs(ghArgs);
97
+ if (!result) return null;
98
+ const pr = JSON.parse(result);
99
+ return {
100
+ url: pr.url,
101
+ author: pr.author?.login,
102
+ commitHash: pr.mergeCommit?.oid
103
+ };
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+ /**
109
+ * Find the PR that introduced a changeset file by checking git log
110
+ * for the commit that added the file, then looking up the PR.
111
+ */
112
+ function findChangesetCommitInfo(changesetId, repo) {
113
+ try {
114
+ const commitOutput = tryRunArgs([
115
+ "git",
116
+ "log",
117
+ "--diff-filter=A",
118
+ "--format=%H",
119
+ "--",
120
+ `.bumpy/${changesetId}.md`,
121
+ `.changeset/${changesetId}.md`
122
+ ]);
123
+ if (!commitOutput) return null;
124
+ const commitHash = commitOutput.split("\n")[0].trim();
125
+ if (!commitHash) return null;
126
+ const ghArgs = [
127
+ "gh",
128
+ "pr",
129
+ "list",
130
+ "--search",
131
+ commitHash,
132
+ "--state",
133
+ "merged",
134
+ "--json",
135
+ "number,url,author",
136
+ "--jq",
137
+ ".[0]"
138
+ ];
139
+ if (repo) ghArgs.push("--repo", repo);
140
+ const prJson = tryRunArgs(ghArgs);
141
+ if (!prJson) return { commitHash };
142
+ const pr = JSON.parse(prJson);
143
+ if (!pr.number) return { commitHash };
144
+ return {
145
+ prNumber: pr.number,
146
+ prUrl: pr.url,
147
+ commitHash,
148
+ author: pr.author?.login
149
+ };
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Build the prefix portion of a changelog line: PR link, commit link, thanks.
156
+ * Matches the format used by @changesets/changelog-github.
157
+ */
158
+ function formatPrefix(info, serverUrl, repo, internalAuthors) {
159
+ const parts = [];
160
+ if (info.prNumber && info.prUrl) parts.push(`[#${info.prNumber}](${info.prUrl})`);
161
+ if (info.commitHash && repo) {
162
+ const short = info.commitHash.slice(0, 7);
163
+ parts.push(`[\`${short}\`](${serverUrl}/${repo}/commit/${info.commitHash})`);
164
+ }
165
+ if (info.author && !internalAuthors.has(info.author.toLowerCase())) parts.push(`Thanks [@${info.author}](${serverUrl}/${info.author})!`);
166
+ return parts.join(" ");
167
+ }
168
+ /**
169
+ * Linkify bare issue/PR references like #123 in text,
170
+ * but skip references already inside markdown links.
171
+ */
172
+ function linkifyIssueRefs(line, serverUrl, repo) {
173
+ if (!repo) return line;
174
+ return line.replace(/\[.*?\]\(.*?\)|\B#([1-9]\d*)\b/g, (match, issue) => issue ? `[#${issue}](${serverUrl}/${repo}/issues/${issue})` : match);
175
+ }
176
+ /** Try to detect the repo slug from the gh CLI */
177
+ function detectRepo() {
178
+ try {
179
+ return tryRunArgs([
180
+ "gh",
181
+ "repo",
182
+ "view",
183
+ "--json",
184
+ "nameWithOwner",
185
+ "--jq",
186
+ ".nameWithOwner"
187
+ ])?.trim() || void 0;
188
+ } catch {
189
+ return;
190
+ }
191
+ }
192
+ //#endregion
193
+ export { createGithubFormatter };