@jens_astrup/release-manager 0.1.0-alpha.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,94 @@
1
+ import { execa } from 'execa';
2
+ /**
3
+ * Run a git command in the given project directory, returning trimmed stdout.
4
+ */
5
+ async function git(args, opts) {
6
+ const { stdout } = await execa('git', args, { cwd: opts.cwd });
7
+ return stdout.trim();
8
+ }
9
+ export async function ensureCleanWorkingDir(opts) {
10
+ const status = await git(['status', '--porcelain', '-uno'], opts);
11
+ if (status.length > 0) {
12
+ throw new Error('Working directory is not clean. Please commit or stash changes.');
13
+ }
14
+ }
15
+ export async function fetchAndCheckout(branch, opts) {
16
+ await git(['fetch', 'origin', branch], opts);
17
+ await git(['checkout', branch], opts);
18
+ await git(['pull', 'origin', branch], opts);
19
+ }
20
+ export async function deleteLocalBranch(branch, opts) {
21
+ try {
22
+ await git(['branch', '-D', branch], opts);
23
+ }
24
+ catch {
25
+ // not present locally
26
+ }
27
+ }
28
+ export async function deleteRemoteBranchIfExists(branch, opts) {
29
+ try {
30
+ const refs = await git(['ls-remote', '--heads', 'origin', branch], opts);
31
+ if (refs.length > 0) {
32
+ await git(['push', 'origin', '--delete', branch], opts);
33
+ }
34
+ }
35
+ catch {
36
+ // ignore
37
+ }
38
+ }
39
+ export async function createBranchAndPush(branch, commitMessage, filesToAdd, opts) {
40
+ await git(['checkout', '-b', branch], opts);
41
+ if (filesToAdd.length > 0) {
42
+ await git(['add', ...filesToAdd], opts);
43
+ }
44
+ await git(['commit', '-m', commitMessage], opts);
45
+ await git(['push', 'origin', branch], opts);
46
+ }
47
+ export async function getCommitsBetween(base, head, opts) {
48
+ await git(['fetch', 'origin', base], opts);
49
+ let raw = '';
50
+ try {
51
+ raw = await git(['log', `${base}..${head}`, '--pretty=format:%H|%s'], opts);
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ if (!raw)
57
+ return [];
58
+ return raw
59
+ .split('\n')
60
+ .filter(Boolean)
61
+ .map((line) => {
62
+ const [sha, ...rest] = line.split('|');
63
+ return { sha: sha ?? 'unknown', message: rest.join('|') || 'No commit message' };
64
+ });
65
+ }
66
+ export function extractLinearIssueId(commitMessage) {
67
+ const m = commitMessage.match(/([A-Z][A-Z0-9]+-\d+)/);
68
+ return m ? m[1] : undefined;
69
+ }
70
+ export async function isGitRepo(opts) {
71
+ try {
72
+ await git(['rev-parse', '--is-inside-work-tree'], opts);
73
+ return true;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /**
80
+ * Returns "owner/repo" inferred from the origin remote URL, if possible.
81
+ */
82
+ export async function inferGithubRepo(opts) {
83
+ try {
84
+ const url = await git(['remote', 'get-url', 'origin'], opts);
85
+ // Supports both git@github.com:owner/repo.git and https://github.com/owner/repo.git
86
+ const m = url.match(/github\.com[:/]([^/]+)\/([^/.]+)(?:\.git)?$/i);
87
+ if (!m)
88
+ return null;
89
+ return { owner: m[1] ?? '', repo: m[2] ?? '' };
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
@@ -0,0 +1,46 @@
1
+ import { Octokit } from 'octokit';
2
+ import { requireEnv } from './env.js';
3
+ function octokit() {
4
+ return new Octokit({ auth: requireEnv('GITHUB_TOKEN') });
5
+ }
6
+ export async function createPullRequest(args) {
7
+ const client = octokit();
8
+ const { data } = await client.rest.pulls.create(args);
9
+ return data.html_url;
10
+ }
11
+ /**
12
+ * Fetch closed issues for the given repo, optionally filtered by label and milestone.
13
+ * Used when issueSource === 'github'.
14
+ */
15
+ export async function getClosedIssues(args) {
16
+ const client = octokit();
17
+ // Resolve milestone title to number if provided
18
+ let milestoneNumber;
19
+ if (args.milestone) {
20
+ const { data: milestones } = await client.rest.issues.listMilestones({
21
+ owner: args.owner,
22
+ repo: args.repo,
23
+ state: 'all',
24
+ per_page: 100
25
+ });
26
+ const found = milestones.find((m) => m.title === args.milestone);
27
+ if (found)
28
+ milestoneNumber = found.number;
29
+ }
30
+ const { data: issues } = await client.rest.issues.listForRepo({
31
+ owner: args.owner,
32
+ repo: args.repo,
33
+ state: 'closed',
34
+ labels: args.label,
35
+ milestone: milestoneNumber !== undefined ? String(milestoneNumber) : undefined,
36
+ per_page: 100
37
+ });
38
+ return issues
39
+ .filter((i) => !i.pull_request)
40
+ .map((i) => ({
41
+ title: i.title,
42
+ description: i.body ?? undefined,
43
+ url: i.html_url,
44
+ identifier: `#${i.number}`
45
+ }));
46
+ }
@@ -0,0 +1,40 @@
1
+ import { LinearClient } from '@linear/sdk';
2
+ import { requireEnv } from './env.js';
3
+ function linear() {
4
+ // Support both env var names used historically in the source scripts.
5
+ const apiKey = process.env.LINEAR_API_KEY ?? process.env.LINEAR_API_TOKEN;
6
+ if (!apiKey) {
7
+ throw new Error('LINEAR_API_KEY environment variable is not set');
8
+ }
9
+ return new LinearClient({ apiKey });
10
+ }
11
+ export async function getReadyForDeployIssues(stateId) {
12
+ if (!stateId) {
13
+ throw new Error('Linear "ready-for-deploy" workflow state ID is not configured. Set it in Settings.');
14
+ }
15
+ const client = linear();
16
+ const state = await client.workflowState(stateId);
17
+ const issues = await state.issues();
18
+ return issues.nodes.map((i) => ({
19
+ title: i.title,
20
+ description: i.description ?? undefined,
21
+ url: i.url,
22
+ identifier: i.identifier
23
+ }));
24
+ }
25
+ export async function getIssueDescription(issueId) {
26
+ try {
27
+ const client = linear();
28
+ const issue = await client.issue(issueId);
29
+ return issue.description || issue.title;
30
+ }
31
+ catch {
32
+ return 'Failed to fetch issue description';
33
+ }
34
+ }
35
+ /** Used to pre-validate that the API key is at least readable. */
36
+ export async function pingLinear() {
37
+ requireEnv('LINEAR_API_KEY');
38
+ const client = linear();
39
+ await client.viewer;
40
+ }
@@ -0,0 +1,100 @@
1
+ import OpenAI from 'openai';
2
+ import { z } from 'zod';
3
+ import { requireEnv } from './env.js';
4
+ const RELEASE_NOTES_SYSTEM = `
5
+ You write the release notes for new versions of a software product.
6
+
7
+ You are given a list of issues that have been closed in the current release, with the title, description, and URL.
8
+
9
+ You should take the title and description of each issue and write a concise summary of the changes made.
10
+
11
+ The release notes should be in the following format:
12
+
13
+ ## v1.0.0
14
+ - [Issue Description] (Closes [ISSUE-ID](Issue URL))
15
+ `;
16
+ function client() {
17
+ return new OpenAI({ apiKey: requireEnv('OPENAI_API_KEY') });
18
+ }
19
+ function formatIssueNotes(issues) {
20
+ return issues
21
+ .map((i) => `---\n${i.title}\n${i.description ?? ''}\n${i.url}\n---`)
22
+ .join('\n');
23
+ }
24
+ /**
25
+ * Generate release notes from a set of issues. Reports streaming progress
26
+ * (0..1) so the UI can render a determinate progress bar.
27
+ */
28
+ export async function generateReleaseNotes(args, onProgress) {
29
+ const openai = client();
30
+ const userMessage = `New version: ${args.version}\n\n------\n\n${formatIssueNotes(args.issues)}`;
31
+ // Use streaming so we can drive a progress bar by tokens received.
32
+ // We don't know total tokens in advance, so we estimate against an
33
+ // expectation of ~600 output tokens and cap at 0.95 until done.
34
+ const EXPECTED_TOKENS = 600;
35
+ let tokenCount = 0;
36
+ const stream = await openai.chat.completions.create({
37
+ model: args.model,
38
+ stream: true,
39
+ messages: [
40
+ { role: 'system', content: RELEASE_NOTES_SYSTEM },
41
+ { role: 'user', content: userMessage }
42
+ ]
43
+ });
44
+ let full = '';
45
+ for await (const chunk of stream) {
46
+ const delta = chunk.choices[0]?.delta?.content ?? '';
47
+ if (delta) {
48
+ full += delta;
49
+ tokenCount += Math.max(1, Math.ceil(delta.length / 4));
50
+ if (onProgress) {
51
+ const ratio = 1 - Math.exp(-tokenCount / EXPECTED_TOKENS);
52
+ onProgress(Math.min(0.95, ratio));
53
+ }
54
+ }
55
+ }
56
+ if (onProgress)
57
+ onProgress(1);
58
+ if (!full.trim()) {
59
+ throw new Error('No release notes returned from OpenAI');
60
+ }
61
+ return full;
62
+ }
63
+ const versionResponseSchema = z.object({
64
+ type: z.enum(['major', 'minor', 'patch'])
65
+ });
66
+ /**
67
+ * Suggest a version bump (major/minor/patch) from a list of commits, using OpenAI.
68
+ * Falls back to "patch" on any error.
69
+ */
70
+ export async function suggestBumpType(commits, model) {
71
+ if (commits.length === 0)
72
+ return 'patch';
73
+ const openai = client();
74
+ const descriptions = commits.map((c) => `- ${c.description}`).join('\n');
75
+ const systemMessage = `You analyze commit changes and determine the appropriate version bump type (major, minor, or patch) following semantic versioning.
76
+ - Major version bump (breaking change): incompatible API changes or substantial user-facing changes.
77
+ - Minor version bump: backward compatible new functionality.
78
+ - Patch version bump: backward compatible bug fixes.
79
+ Respond with a JSON object: {"type":"major"|"minor"|"patch"}.`;
80
+ const userMessage = `Based on the following changes, determine if this should be a major, minor, or patch version bump:\n\n${descriptions}`;
81
+ try {
82
+ const response = await openai.chat.completions.create({
83
+ model,
84
+ temperature: 0.2,
85
+ response_format: { type: 'json_object' },
86
+ messages: [
87
+ { role: 'system', content: systemMessage },
88
+ { role: 'user', content: userMessage }
89
+ ]
90
+ });
91
+ const raw = response.choices[0]?.message?.content ?? '{}';
92
+ const parsed = versionResponseSchema.safeParse(JSON.parse(raw));
93
+ if (parsed.success)
94
+ return parsed.data.type;
95
+ return 'patch';
96
+ }
97
+ catch {
98
+ return 'patch';
99
+ }
100
+ }
@@ -0,0 +1,69 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import semver from 'semver';
4
+ /**
5
+ * Read the current version from package.json in the project directory.
6
+ */
7
+ export async function getCurrentVersion(projectDir) {
8
+ const pkgPath = join(projectDir, 'package.json');
9
+ const raw = await readFile(pkgPath, 'utf-8');
10
+ const pkg = JSON.parse(raw);
11
+ if (!pkg.version) {
12
+ throw new Error('No "version" field found in package.json');
13
+ }
14
+ return pkg.version;
15
+ }
16
+ /**
17
+ * Compute the next version given a bump type. For alpha/beta we use
18
+ * semver's prerelease behavior, which auto-increments existing tagged versions:
19
+ * 1.2.3 + alpha => 1.2.4-alpha.0
20
+ * 1.2.4-alpha.0 + alpha => 1.2.4-alpha.1
21
+ * 1.2.3 + beta => 1.2.4-beta.0
22
+ *
23
+ * For "explicit", the caller must pass `explicitVersion`.
24
+ */
25
+ export function computeNextVersion(current, type, explicitVersion) {
26
+ if (type === 'explicit') {
27
+ if (!explicitVersion) {
28
+ throw new Error('An explicit version is required when bump type is "explicit"');
29
+ }
30
+ const cleaned = semver.clean(explicitVersion) ?? explicitVersion.replace(/^v/, '');
31
+ if (!semver.valid(cleaned)) {
32
+ throw new Error(`"${explicitVersion}" is not a valid semver version`);
33
+ }
34
+ return cleaned;
35
+ }
36
+ if (type === 'alpha' || type === 'beta') {
37
+ const next = semver.inc(current, 'prerelease', type);
38
+ if (!next)
39
+ throw new Error(`Failed to compute prerelease bump from ${current}`);
40
+ return next;
41
+ }
42
+ const next = semver.inc(current, type);
43
+ if (!next)
44
+ throw new Error(`Failed to compute ${type} bump from ${current}`);
45
+ return next;
46
+ }
47
+ /**
48
+ * Update package.json with the new version, preserving formatting as best as possible.
49
+ */
50
+ export async function writeVersion(projectDir, version) {
51
+ const pkgPath = join(projectDir, 'package.json');
52
+ const raw = await readFile(pkgPath, 'utf-8');
53
+ const pkg = JSON.parse(raw);
54
+ pkg.version = version;
55
+ // preserve trailing newline if present
56
+ const trailingNewline = raw.endsWith('\n') ? '\n' : '';
57
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + trailingNewline, 'utf-8');
58
+ }
59
+ /**
60
+ * Validate a user-supplied explicit version string.
61
+ */
62
+ export function isValidExplicitVersion(input) {
63
+ const cleaned = semver.clean(input) ?? input.replace(/^v/, '');
64
+ return semver.valid(cleaned) !== null;
65
+ }
66
+ export function isPrerelease(version) {
67
+ const parsed = semver.parse(version);
68
+ return Boolean(parsed && parsed.prerelease.length > 0);
69
+ }
@@ -0,0 +1,127 @@
1
+ import { runBuildCheck } from './build.js';
2
+ import { createBranchAndPush, deleteLocalBranch, deleteRemoteBranchIfExists, ensureCleanWorkingDir, extractLinearIssueId, fetchAndCheckout, getCommitsBetween } from './git.js';
3
+ import { createPullRequest, getClosedIssues } from './github.js';
4
+ import { getIssueDescription, getReadyForDeployIssues } from './linear.js';
5
+ import { generateReleaseNotes, suggestBumpType as openaiSuggestBumpType } from './openai.js';
6
+ import { computeNextVersion, getCurrentVersion, writeVersion } from './version.js';
7
+ /**
8
+ * Full version-bump flow: clean check -> fetch develop -> build -> bump
9
+ * package.json -> push branch -> open PR.
10
+ */
11
+ export async function prepareVersionFlow(args) {
12
+ const { config, bumpType, explicitVersion, skipBuild, onUpdate } = args;
13
+ const cwd = config.projectDir;
14
+ const developBranch = config.github.developBranch;
15
+ // Step: check working dir
16
+ onUpdate('clean', { status: 'running' });
17
+ await ensureCleanWorkingDir({ cwd });
18
+ onUpdate('clean', { status: 'done' });
19
+ // Step: refresh develop
20
+ onUpdate('fetch', { status: 'running' });
21
+ await fetchAndCheckout(developBranch, { cwd });
22
+ onUpdate('fetch', { status: 'done' });
23
+ // Step: build check
24
+ if (skipBuild || !config.buildCheckEnabled) {
25
+ onUpdate('build', { status: 'skipped' });
26
+ }
27
+ else {
28
+ onUpdate('build', { status: 'running', progress: 0 });
29
+ await runBuildCheck(config.buildCommand, cwd, (p) => onUpdate('build', { progress: p.progress, detail: p.line }));
30
+ onUpdate('build', { status: 'done', progress: 1 });
31
+ }
32
+ // Step: compute and write version
33
+ onUpdate('bump', { status: 'running' });
34
+ const current = await getCurrentVersion(cwd);
35
+ const nextVersion = computeNextVersion(current, bumpType, explicitVersion);
36
+ await writeVersion(cwd, nextVersion);
37
+ const versionBranch = `v${nextVersion}`;
38
+ onUpdate('bump', { status: 'done', detail: `${current} -> ${nextVersion}` });
39
+ // Step: clean up old branches with same name
40
+ onUpdate('cleanup', { status: 'running' });
41
+ await deleteLocalBranch(versionBranch, { cwd });
42
+ await deleteRemoteBranchIfExists(versionBranch, { cwd });
43
+ onUpdate('cleanup', { status: 'done' });
44
+ // Step: branch + push
45
+ onUpdate('push', { status: 'running' });
46
+ await createBranchAndPush(versionBranch, `chore: bump version to ${nextVersion}`, ['package.json'], { cwd });
47
+ onUpdate('push', { status: 'done' });
48
+ // Step: open PR
49
+ onUpdate('pr', { status: 'running' });
50
+ const pullRequestUrl = await createPullRequest({
51
+ owner: config.github.owner,
52
+ repo: config.github.repo,
53
+ title: `Bump version to ${nextVersion}`,
54
+ body: `Version bump to ${nextVersion}`,
55
+ head: versionBranch,
56
+ base: developBranch
57
+ });
58
+ onUpdate('pr', { status: 'done', detail: pullRequestUrl });
59
+ return { version: nextVersion, pullRequestUrl };
60
+ }
61
+ /**
62
+ * Create a release: pull issues from configured source -> AI notes ->
63
+ * develop -> main PR.
64
+ */
65
+ export async function createReleaseFlow(args) {
66
+ const { config, onUpdate } = args;
67
+ const cwd = config.projectDir;
68
+ // Step: refresh develop
69
+ onUpdate('fetch', { status: 'running' });
70
+ await fetchAndCheckout(config.github.developBranch, { cwd });
71
+ onUpdate('fetch', { status: 'done' });
72
+ // Step: gather issues from configured source
73
+ onUpdate('issues', { status: 'running' });
74
+ let issues = [];
75
+ if (config.issueSource === 'linear') {
76
+ issues = await getReadyForDeployIssues(config.linear?.readyForDeployStateId ?? '');
77
+ }
78
+ else {
79
+ issues = await getClosedIssues({
80
+ owner: config.github.owner,
81
+ repo: config.github.repo,
82
+ label: config.github.issueLabel,
83
+ milestone: config.github.milestone
84
+ });
85
+ }
86
+ onUpdate('issues', {
87
+ status: 'done',
88
+ detail: `${issues.length} issue${issues.length === 1 ? '' : 's'}`
89
+ });
90
+ // Step: AI notes
91
+ onUpdate('notes', { status: 'running', progress: 0 });
92
+ const version = await getCurrentVersion(cwd);
93
+ const notes = await generateReleaseNotes({ version, issues, model: config.openaiModel }, (p) => onUpdate('notes', { progress: p }));
94
+ onUpdate('notes', { status: 'done', progress: 1 });
95
+ // Step: PR
96
+ onUpdate('pr', { status: 'running' });
97
+ const url = await createPullRequest({
98
+ owner: config.github.owner,
99
+ repo: config.github.repo,
100
+ title: `v${version}`,
101
+ body: notes,
102
+ head: config.github.developBranch,
103
+ base: config.github.mainBranch
104
+ });
105
+ onUpdate('pr', { status: 'done', detail: url });
106
+ return { pullRequestUrl: url };
107
+ }
108
+ /**
109
+ * Build CommitInfo[] from develop..main and resolve Linear descriptions.
110
+ */
111
+ export async function gatherCommitsForSuggestion(config) {
112
+ const commits = await getCommitsBetween(config.github.mainBranch, config.github.developBranch, { cwd: config.projectDir });
113
+ const enriched = await Promise.all(commits.map(async (c) => {
114
+ const linearIssueId = extractLinearIssueId(c.message);
115
+ let description = c.message;
116
+ if (config.issueSource === 'linear' && linearIssueId) {
117
+ description = await getIssueDescription(linearIssueId);
118
+ }
119
+ return { ...c, linearIssueId, description };
120
+ }));
121
+ return enriched;
122
+ }
123
+ export async function suggestBump(config) {
124
+ const commits = await gatherCommitsForSuggestion(config);
125
+ const bumpType = await openaiSuggestBumpType(commits, config.openaiModel);
126
+ return { bumpType, commitCount: commits.length };
127
+ }