@makispps/releasejet 1.0.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.
@@ -0,0 +1,28 @@
1
+ # ReleaseJet Configuration
2
+ # Copy to .releasejet.yml and customize for your project.
3
+ # Or run: releasejet init
4
+
5
+ # GitLab instance URL
6
+ gitlab:
7
+ url: https://gitlab.example.com
8
+
9
+ # Client definitions (omit for single-client repos)
10
+ # clients:
11
+ # - prefix: mobile
12
+ # label: MOBILE
13
+ # - prefix: web
14
+ # label: WEB
15
+
16
+ # Category label mappings
17
+ # Key: the label name in GitLab
18
+ # Value: the section heading in release notes
19
+ categories:
20
+ feature: "New Features"
21
+ bug: "Bug Fixes"
22
+ improvement: "Improvements"
23
+ breaking-change: "Breaking Changes"
24
+
25
+ # How to handle uncategorized issues
26
+ # "lenient" — include under "Other" with a warning (default)
27
+ # "strict" — fail release generation
28
+ uncategorized: lenient
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mavroudis Papas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ <img width="480" height="120" alt="lockup-light-1x" src="https://github.com/user-attachments/assets/1fd84e91-86f3-4f62-bad7-8bf4b72b517f" />
2
+
3
+ Automated release notes generator for GitLab and GitHub. Collects closed issues (or merged pull requests) between Git tags, categorizes them by label, and publishes formatted release notes.
4
+
5
+ ## Features
6
+
7
+ - **GitLab and GitHub support** — works with both providers out of the box
8
+ - **Issues or Pull Requests** — generate notes from closed issues (default) or merged PRs (GitHub)
9
+ - **Multi-client repos** — filter by client label (e.g., `mobile-v1.0.0`, `web-v2.0.0`)
10
+ - **Single-client repos** — just use `v<semver>` tags
11
+ - **Configurable categories** — map labels to sections (features, bugs, improvements, etc.)
12
+ - **CI/CD integration** — runs automatically on tag push via GitLab CI or GitHub Actions
13
+ - **Strict/lenient modes** — enforce labeling or allow uncategorized issues under "Other"
14
+ - **Milestone detection** — automatically links the most common milestone in release notes
15
+
16
+ ## Quick Start
17
+
18
+ ```bash
19
+ npm install -g releasejet
20
+
21
+ # Interactive setup (detects provider from git remote)
22
+ releasejet init
23
+
24
+ # Preview release notes
25
+ releasejet generate --tag v1.0.0
26
+
27
+ # Generate and publish
28
+ releasejet generate --tag v1.0.0 --publish
29
+ ```
30
+
31
+ ## Configuration
32
+
33
+ Create `.releasejet.yml` in your project root (or run `releasejet init`):
34
+
35
+ ```yaml
36
+ provider:
37
+ type: github # 'gitlab' or 'github'
38
+ url: https://github.com
39
+
40
+ # GitHub-only: generate notes from issues or pull requests
41
+ source: issues # 'issues' (default) or 'pull_requests'
42
+
43
+ # For multi-client repos (omit for single-client)
44
+ clients:
45
+ - prefix: mobile
46
+ label: MOBILE
47
+ - prefix: web
48
+ label: WEB
49
+
50
+ categories:
51
+ feature: "New Features"
52
+ bug: "Bug Fixes"
53
+ improvement: "Improvements"
54
+ breaking-change: "Breaking Changes"
55
+
56
+ uncategorized: lenient # or "strict" to enforce labeling
57
+ ```
58
+
59
+ ## CI/CD Integration
60
+
61
+ ### GitHub Actions
62
+
63
+ Run `releasejet init` and select CI setup, or add `.github/workflows/release-notes.yml`:
64
+
65
+ ```yaml
66
+ name: Release Notes
67
+ on:
68
+ push:
69
+ tags:
70
+ - '**'
71
+ jobs:
72
+ release-notes:
73
+ runs-on: ubuntu-latest
74
+ permissions:
75
+ contents: write
76
+ steps:
77
+ - uses: actions/checkout@v4
78
+ - uses: actions/setup-node@v4
79
+ with:
80
+ node-version: '20'
81
+ - run: npm install -g releasejet
82
+ - run: releasejet generate --tag "${{ github.ref_name }}" --publish
83
+ env:
84
+ RELEASEJET_TOKEN: ${{ secrets.RELEASEJET_TOKEN }}
85
+ ```
86
+
87
+ Set `RELEASEJET_TOKEN` as a repository secret (Settings > Secrets > Actions).
88
+
89
+ ### GitLab CI
90
+
91
+ Add to your `.gitlab-ci.yml` (or run `releasejet ci enable`):
92
+
93
+ ```yaml
94
+ release-notes:
95
+ stage: deploy
96
+ image: node:20-alpine
97
+ rules:
98
+ - if: $CI_COMMIT_TAG
99
+ before_script:
100
+ - npm install -g releasejet
101
+ script:
102
+ - releasejet generate --tag "$CI_COMMIT_TAG" --publish
103
+ ```
104
+
105
+ Set `GITLAB_API_TOKEN` (or `RELEASEJET_TOKEN`) as a CI/CD variable with `api` scope.
106
+
107
+ ## Authentication
108
+
109
+ Token resolution order:
110
+
111
+ 1. `RELEASEJET_TOKEN` env var (works for both providers)
112
+ 2. Provider-specific env var: `GITLAB_API_TOKEN` or `GITHUB_TOKEN`
113
+ 3. Stored credentials from `~/.releasejet/credentials.yml`
114
+
115
+ ## Tag Format
116
+
117
+ | Repo type | Format | Example |
118
+ |-----------|--------|---------|
119
+ | Multi-client | `<prefix>-v<semver>` | `mobile-v1.2.0` |
120
+ | Single-client | `v<semver>` | `v1.2.0` |
121
+
122
+ ## Commands
123
+
124
+ | Command | Description |
125
+ |---------|-------------|
126
+ | `releasejet init` | Interactive setup wizard |
127
+ | `releasejet generate --tag <tag>` | Generate release notes |
128
+ | `releasejet generate --tag <tag> --publish` | Generate and publish release |
129
+ | `releasejet validate` | Check issues for proper labeling |
130
+ | `releasejet ci enable` | Add CI configuration to `.gitlab-ci.yml` |
131
+ | `releasejet ci disable` | Remove CI configuration |
132
+
133
+ ### Generate Flags
134
+
135
+ | Flag | Description |
136
+ |------|-------------|
137
+ | `--publish` | Publish as a release on the provider |
138
+ | `--dry-run` | Preview without publishing |
139
+ | `--format <format>` | Output format: `markdown` (default) or `json` |
140
+ | `--config <path>` | Custom config file path |
141
+ | `--debug` | Show debug information |
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,19 @@
1
+ name: Release Notes
2
+ on:
3
+ push:
4
+ tags:
5
+ - '**'
6
+ jobs:
7
+ release-notes:
8
+ runs-on: ubuntu-latest
9
+ permissions:
10
+ contents: write
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: '20'
16
+ - run: npm install -g releasejet
17
+ - run: releasejet generate --tag "${{ github.ref_name }}" --publish
18
+ env:
19
+ RELEASEJET_TOKEN: ${{ secrets.RELEASEJET_TOKEN }}
@@ -0,0 +1,22 @@
1
+ # ReleaseJet — Shared GitLab CI Template
2
+ #
3
+ # Include in your project's .gitlab-ci.yml:
4
+ #
5
+ # include:
6
+ # - project: 'tools/releasejet'
7
+ # file: '/ci/release-notes.yml'
8
+ #
9
+ # variables:
10
+ # RELEASEJET_TOKEN: $GITLAB_API_TOKEN
11
+
12
+ release-notes:
13
+ stage: deploy
14
+ image: node:20-alpine
15
+ rules:
16
+ - if: $CI_COMMIT_TAG
17
+ variables:
18
+ RELEASEJET_TOKEN: ${RELEASEJET_TOKEN}
19
+ before_script:
20
+ - npm install -g releasejet
21
+ script:
22
+ - releasejet generate --tag "$CI_COMMIT_TAG" --publish
package/dist/cli.js ADDED
@@ -0,0 +1,967 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/core/config.ts
7
+ import { readFile } from "fs/promises";
8
+ import { parse as parseYaml } from "yaml";
9
+ var DEFAULT_CATEGORIES = {
10
+ feature: "New Features",
11
+ bug: "Bug Fixes",
12
+ improvement: "Improvements",
13
+ "breaking-change": "Breaking Changes"
14
+ };
15
+ var DEFAULT_CONFIG = {
16
+ provider: { type: "gitlab", url: "" },
17
+ source: "issues",
18
+ clients: [],
19
+ categories: { ...DEFAULT_CATEGORIES },
20
+ uncategorized: "lenient"
21
+ };
22
+ async function loadConfig(configPath = ".releasejet.yml") {
23
+ let raw;
24
+ try {
25
+ const content = await readFile(configPath, "utf-8");
26
+ raw = parseYaml(content) ?? {};
27
+ } catch (err) {
28
+ if (err.code === "ENOENT") {
29
+ return {
30
+ ...DEFAULT_CONFIG,
31
+ clients: [],
32
+ categories: { ...DEFAULT_CONFIG.categories }
33
+ };
34
+ }
35
+ throw err;
36
+ }
37
+ return mergeWithDefaults(raw);
38
+ }
39
+ function mergeWithDefaults(raw) {
40
+ const clients = raw.clients;
41
+ const categories = raw.categories;
42
+ const uncategorized = raw.uncategorized;
43
+ const source = raw.source;
44
+ const providerRaw = raw.provider;
45
+ const gitlabRaw = raw.gitlab;
46
+ let provider;
47
+ if (providerRaw) {
48
+ provider = {
49
+ type: providerRaw.type === "github" ? "github" : "gitlab",
50
+ url: providerRaw.url ?? ""
51
+ };
52
+ } else if (gitlabRaw) {
53
+ provider = {
54
+ type: "gitlab",
55
+ url: gitlabRaw.url ?? ""
56
+ };
57
+ } else {
58
+ provider = { type: "gitlab", url: "" };
59
+ }
60
+ return {
61
+ provider,
62
+ source: source === "pull_requests" ? "pull_requests" : "issues",
63
+ clients: Array.isArray(clients) ? clients : [],
64
+ categories: categories ?? { ...DEFAULT_CATEGORIES },
65
+ uncategorized: uncategorized === "strict" ? "strict" : "lenient"
66
+ };
67
+ }
68
+
69
+ // src/core/git.ts
70
+ import { execSync } from "child_process";
71
+ function getRemoteUrl() {
72
+ return execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
73
+ }
74
+ function resolveHostUrl(remoteUrl) {
75
+ const sshMatch = remoteUrl.match(/^git@([^:]+):/);
76
+ if (sshMatch) return `https://${sshMatch[1]}`;
77
+ const httpsMatch = remoteUrl.match(/^(https?:\/\/[^/]+)/);
78
+ if (httpsMatch) return httpsMatch[1];
79
+ throw new Error(`Cannot parse host URL from remote: ${remoteUrl}`);
80
+ }
81
+ function resolveProjectInfo(remoteUrl) {
82
+ if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY) {
83
+ return {
84
+ hostUrl: process.env.GITHUB_SERVER_URL,
85
+ projectPath: process.env.GITHUB_REPOSITORY
86
+ };
87
+ }
88
+ if (process.env.CI_SERVER_URL && process.env.CI_PROJECT_PATH) {
89
+ return {
90
+ hostUrl: process.env.CI_SERVER_URL,
91
+ projectPath: process.env.CI_PROJECT_PATH
92
+ };
93
+ }
94
+ return {
95
+ hostUrl: resolveHostUrl(remoteUrl),
96
+ projectPath: resolveProjectPath(remoteUrl)
97
+ };
98
+ }
99
+ function resolveProjectPath(remoteUrl) {
100
+ const sshMatch = remoteUrl.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
101
+ if (sshMatch) return sshMatch[1];
102
+ const httpsMatch = remoteUrl.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
103
+ if (httpsMatch) return httpsMatch[1];
104
+ throw new Error(`Cannot parse project path from remote: ${remoteUrl}`);
105
+ }
106
+ function detectProviderFromRemote(remoteUrl) {
107
+ return remoteUrl.includes("github.com") ? "github" : "gitlab";
108
+ }
109
+
110
+ // src/core/tag-parser.ts
111
+ import * as semver from "semver";
112
+ function parseTag(tag) {
113
+ const multiMatch = tag.match(/^(.+?)-v(.+)$/);
114
+ if (multiMatch) {
115
+ const [, prefix, versionPart] = multiMatch;
116
+ const coerced = semver.coerce(versionPart);
117
+ if (coerced) {
118
+ const suffix = versionPart.slice(coerced.version.length) || null;
119
+ return { raw: tag, prefix, version: coerced.version, suffix };
120
+ }
121
+ }
122
+ const singleMatch = tag.match(/^v(.+)$/);
123
+ if (singleMatch) {
124
+ const [, versionPart] = singleMatch;
125
+ const coerced = semver.coerce(versionPart);
126
+ if (coerced) {
127
+ const suffix = versionPart.slice(coerced.version.length) || null;
128
+ return { raw: tag, prefix: null, version: coerced.version, suffix };
129
+ }
130
+ }
131
+ throw new Error(
132
+ `Invalid tag format: "${tag}". Expected <prefix>-v<semver> or v<semver>.`
133
+ );
134
+ }
135
+ function findPreviousTag(allTags, current) {
136
+ const candidates = allTags.filter((t) => t.prefix === current.prefix && t.raw !== current.raw).filter((t) => t.suffix === null).filter((t) => semver.lt(t.version, current.version)).sort((a, b) => {
137
+ const cmp = semver.rcompare(a.version, b.version);
138
+ if (cmp !== 0) return cmp;
139
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
140
+ });
141
+ return candidates[0] ?? null;
142
+ }
143
+
144
+ // src/core/issue-collector.ts
145
+ async function collectIssues(client, projectPath, currentTag, previousTag, config, debug = () => {
146
+ }) {
147
+ const clientLabel = currentTag.prefix ? config.clients.find((c) => c.prefix === currentTag.prefix)?.label : void 0;
148
+ debug("Client label filter:", clientLabel ?? "none (single-client)");
149
+ debug("API query: state=closed, updatedAfter=" + (previousTag?.createdAt ?? "none"));
150
+ const fetchOptions = {
151
+ state: "closed",
152
+ updatedAfter: previousTag?.createdAt,
153
+ labels: clientLabel
154
+ };
155
+ const issues = config.source === "pull_requests" ? await client.listPullRequests(projectPath, fetchOptions) : await client.listIssues(projectPath, fetchOptions);
156
+ debug(`API returned ${issues.length} issues:`);
157
+ for (const issue of issues) {
158
+ debug(` #${issue.number} "${issue.title}" closedAt=${issue.closedAt} labels=[${issue.labels.join(", ")}]`);
159
+ }
160
+ const filtered = issues.filter((issue) => {
161
+ if (!issue.closedAt) return false;
162
+ const closed = new Date(issue.closedAt).getTime();
163
+ const before = new Date(currentTag.createdAt).getTime();
164
+ if (closed > before) return false;
165
+ if (previousTag) {
166
+ const after = new Date(previousTag.createdAt).getTime();
167
+ if (closed <= after) return false;
168
+ }
169
+ return true;
170
+ });
171
+ debug(`After closedAt filter: ${filtered.length} issues remain`);
172
+ const categoryLabels = Object.keys(config.categories);
173
+ const categorized = {};
174
+ const uncategorized = [];
175
+ for (const issue of filtered) {
176
+ const matchedLabel = issue.labels.find(
177
+ (l) => categoryLabels.includes(l)
178
+ );
179
+ if (matchedLabel) {
180
+ const heading = config.categories[matchedLabel];
181
+ if (!categorized[heading]) categorized[heading] = [];
182
+ categorized[heading].push(issue);
183
+ } else {
184
+ uncategorized.push(issue);
185
+ }
186
+ }
187
+ return { categorized, uncategorized };
188
+ }
189
+ function detectMilestone(issues) {
190
+ const allIssues = [
191
+ ...Object.values(issues.categorized).flat(),
192
+ ...issues.uncategorized
193
+ ];
194
+ const counts = /* @__PURE__ */ new Map();
195
+ for (const issue of allIssues) {
196
+ if (issue.milestone) {
197
+ const existing = counts.get(issue.milestone.title);
198
+ counts.set(issue.milestone.title, {
199
+ count: (existing?.count ?? 0) + 1,
200
+ url: issue.milestone.url
201
+ });
202
+ }
203
+ }
204
+ if (counts.size === 0) return null;
205
+ const [title, { url }] = [...counts.entries()].sort((a, b) => b[1].count - a[1].count)[0];
206
+ return { title, url };
207
+ }
208
+
209
+ // src/core/formatter.ts
210
+ function formatReleaseNotes(data, config) {
211
+ const lines = [];
212
+ const title = data.clientPrefix ? `${data.clientPrefix.toUpperCase()} v${data.version}` : `v${data.version}`;
213
+ lines.push(`## ${title}`);
214
+ lines.push("");
215
+ const tagUrl = config.provider.type === "github" ? `${data.projectUrl}/releases/tag/${data.tagName}` : `${data.projectUrl}/-/tags/${data.tagName}`;
216
+ const metaParts = [
217
+ `**Released:** ${data.date}`,
218
+ `**Tag:** [${data.tagName}](${tagUrl})`
219
+ ];
220
+ if (data.milestone) {
221
+ metaParts.push(`**Milestone:** [${data.milestone.title}](${data.milestone.url})`);
222
+ }
223
+ const issuesSummary = data.uncategorizedCount > 0 ? `${data.totalCount} closed | ${data.uncategorizedCount} uncategorized` : `${data.totalCount} closed`;
224
+ metaParts.push(`**Issues:** ${issuesSummary}`);
225
+ lines.push(metaParts.join(" | "));
226
+ lines.push("");
227
+ lines.push("---");
228
+ lines.push("");
229
+ const categoryOrder = Object.values(config.categories);
230
+ for (const heading of categoryOrder) {
231
+ const issues = data.issues.categorized[heading];
232
+ if (!issues || issues.length === 0) continue;
233
+ lines.push(`#### ${heading}`);
234
+ for (const issue of issues) {
235
+ lines.push(`- ${issue.title} (#${issue.number})`);
236
+ }
237
+ lines.push("");
238
+ }
239
+ if (data.issues.uncategorized.length > 0 && config.uncategorized === "lenient") {
240
+ lines.push("#### Other");
241
+ for (const issue of data.issues.uncategorized) {
242
+ lines.push(`- ${issue.title} (#${issue.number})`);
243
+ }
244
+ lines.push("");
245
+ }
246
+ lines.push("---");
247
+ lines.push("");
248
+ lines.push("*Generated by [ReleaseJet](https://github.com/makisp/releasejet)*");
249
+ return lines.join("\n");
250
+ }
251
+
252
+ // src/gitlab/client.ts
253
+ import { Gitlab } from "@gitbeaker/rest";
254
+ function createGitLabClient(url, token) {
255
+ const api = new Gitlab({ host: url, token });
256
+ return {
257
+ async listTags(projectPath) {
258
+ const tags = await api.Tags.all(projectPath);
259
+ return tags.map((t) => ({
260
+ name: t.name,
261
+ createdAt: t.created_at ?? t.commit?.created_at ?? ""
262
+ }));
263
+ },
264
+ async listIssues(projectPath, options) {
265
+ const params = {
266
+ projectId: projectPath,
267
+ state: options.state ?? "closed"
268
+ };
269
+ if (options.updatedAfter) params.updatedAfter = options.updatedAfter;
270
+ if (options.labels) params.labels = options.labels;
271
+ const issues = await api.Issues.all(params);
272
+ return issues.map((i) => ({
273
+ number: i.iid,
274
+ title: i.title,
275
+ labels: i.labels,
276
+ closedAt: i.closed_at ?? "",
277
+ webUrl: i.web_url,
278
+ milestone: i.milestone ? { title: i.milestone.title, url: i.milestone.web_url } : null
279
+ }));
280
+ },
281
+ async listPullRequests(_projectPath, _options) {
282
+ throw new Error("listPullRequests is not supported by the GitLab provider");
283
+ },
284
+ async createRelease(projectPath, options) {
285
+ const params = {
286
+ tag_name: options.tagName,
287
+ name: options.name,
288
+ description: options.description
289
+ };
290
+ if (options.milestones?.length) {
291
+ params.milestones = options.milestones;
292
+ }
293
+ await api.ProjectReleases.create(projectPath, params);
294
+ },
295
+ async listMilestones(projectPath, options) {
296
+ const params = {};
297
+ if (options?.search) params.search = options.search;
298
+ if (options?.state) params.state = options.state;
299
+ const milestones = await api.ProjectMilestones.all(projectPath, params);
300
+ return milestones.map((m) => ({
301
+ id: m.id,
302
+ title: m.title,
303
+ state: m.state
304
+ }));
305
+ }
306
+ };
307
+ }
308
+
309
+ // src/github/client.ts
310
+ import { Octokit } from "@octokit/rest";
311
+ function parseOwnerRepo(projectPath) {
312
+ const [owner, repo] = projectPath.split("/");
313
+ return { owner, repo };
314
+ }
315
+ function createGitHubClient(url, token) {
316
+ const baseUrl = url && url !== "https://github.com" ? `${url.replace(/\/$/, "")}/api/v3` : void 0;
317
+ const octokit = new Octokit({ auth: token, baseUrl });
318
+ return {
319
+ async listTags(projectPath) {
320
+ const { owner, repo } = parseOwnerRepo(projectPath);
321
+ const { data: tags } = await octokit.repos.listTags({ owner, repo, per_page: 100 });
322
+ const result = [];
323
+ for (const tag of tags) {
324
+ const { data: commit } = await octokit.repos.getCommit({ owner, repo, ref: tag.commit.sha });
325
+ result.push({
326
+ name: tag.name,
327
+ createdAt: commit.commit.committer?.date ?? ""
328
+ });
329
+ }
330
+ return result;
331
+ },
332
+ async listIssues(projectPath, options) {
333
+ const { owner, repo } = parseOwnerRepo(projectPath);
334
+ const params = {
335
+ owner,
336
+ repo,
337
+ state: options.state ?? "closed",
338
+ per_page: 100
339
+ };
340
+ if (options.updatedAfter) params.since = options.updatedAfter;
341
+ if (options.labels) params.labels = options.labels;
342
+ const { data: issues } = await octokit.issues.listForRepo(params);
343
+ return issues.filter((i) => !i.pull_request).map((i) => ({
344
+ number: i.number,
345
+ title: i.title,
346
+ labels: i.labels.map((l) => typeof l === "string" ? l : l.name),
347
+ closedAt: i.closed_at ?? "",
348
+ webUrl: i.html_url,
349
+ milestone: i.milestone ? { title: i.milestone.title, url: i.milestone.html_url } : null
350
+ }));
351
+ },
352
+ async listPullRequests(projectPath, options) {
353
+ const { owner, repo } = parseOwnerRepo(projectPath);
354
+ const params = {
355
+ owner,
356
+ repo,
357
+ state: options.state ?? "closed",
358
+ per_page: 100
359
+ };
360
+ const { data: prs } = await octokit.pulls.list(params);
361
+ return prs.filter((pr) => pr.merged_at !== null).map((pr) => ({
362
+ number: pr.number,
363
+ title: pr.title,
364
+ labels: pr.labels.map((l) => typeof l === "string" ? l : l.name),
365
+ closedAt: pr.closed_at ?? "",
366
+ webUrl: pr.html_url,
367
+ milestone: pr.milestone ? { title: pr.milestone.title, url: pr.milestone.html_url } : null
368
+ }));
369
+ },
370
+ async createRelease(projectPath, options) {
371
+ const { owner, repo } = parseOwnerRepo(projectPath);
372
+ await octokit.repos.createRelease({
373
+ owner,
374
+ repo,
375
+ tag_name: options.tagName,
376
+ name: options.name,
377
+ body: options.description
378
+ });
379
+ },
380
+ async listMilestones(projectPath, options) {
381
+ const { owner, repo } = parseOwnerRepo(projectPath);
382
+ const params = { owner, repo, per_page: 100 };
383
+ if (options?.state) params.state = options.state;
384
+ const { data: milestones } = await octokit.issues.listMilestones(params);
385
+ return milestones.map((m) => ({
386
+ id: m.number,
387
+ title: m.title,
388
+ state: m.state
389
+ }));
390
+ }
391
+ };
392
+ }
393
+
394
+ // src/providers/factory.ts
395
+ function createClient(config, token) {
396
+ if (config.provider.type === "github") {
397
+ return createGitHubClient(config.provider.url, token);
398
+ }
399
+ return createGitLabClient(config.provider.url, token);
400
+ }
401
+
402
+ // src/cli/auth.ts
403
+ import { readFile as readFile2 } from "fs/promises";
404
+ import { homedir } from "os";
405
+ import { join } from "path";
406
+ import { parse as parseYaml2 } from "yaml";
407
+ async function resolveToken(providerType) {
408
+ const envToken = process.env.RELEASEJET_TOKEN;
409
+ if (envToken) return envToken;
410
+ const providerEnvVar = providerType === "github" ? "GITHUB_TOKEN" : "GITLAB_API_TOKEN";
411
+ const providerToken = process.env[providerEnvVar];
412
+ if (providerToken) return providerToken;
413
+ try {
414
+ const credPath = join(homedir(), ".releasejet", "credentials.yml");
415
+ const content = (await readFile2(credPath, "utf-8")).trim();
416
+ const creds = parseYaml2(content);
417
+ if (creds?.[providerType]) return creds[providerType];
418
+ } catch {
419
+ }
420
+ try {
421
+ const legacyPath = join(homedir(), ".releasejet", "credentials");
422
+ const stored = (await readFile2(legacyPath, "utf-8")).trim();
423
+ if (stored) return stored;
424
+ } catch {
425
+ }
426
+ const providerName = providerType === "github" ? "GitHub" : "GitLab";
427
+ throw new Error(
428
+ `${providerName} API token not found. Set RELEASEJET_TOKEN environment variable or run \`releasejet init\`.`
429
+ );
430
+ }
431
+
432
+ // src/cli/prompts.ts
433
+ import { select } from "@inquirer/prompts";
434
+ async function promptForUncategorized(issues, config) {
435
+ console.log(
436
+ `
437
+ \u26A0 ${issues.uncategorized.length} uncategorized issues found:
438
+ `
439
+ );
440
+ const categoryChoices = Object.entries(config.categories).map(
441
+ ([label, heading]) => ({
442
+ name: `${heading} (${label})`,
443
+ value: label
444
+ })
445
+ );
446
+ const toProcess = [...issues.uncategorized];
447
+ issues.uncategorized.length = 0;
448
+ for (const issue of toProcess) {
449
+ const action = await select({
450
+ message: `#${issue.number} - ${issue.title}:`,
451
+ choices: [
452
+ ...categoryChoices,
453
+ { name: "Skip (exclude)", value: "skip" },
454
+ { name: "Other (uncategorized)", value: "other" }
455
+ ]
456
+ });
457
+ if (action === "skip") continue;
458
+ if (action === "other") {
459
+ issues.uncategorized.push(issue);
460
+ } else {
461
+ const heading = config.categories[action];
462
+ if (!issues.categorized[heading]) issues.categorized[heading] = [];
463
+ issues.categorized[heading].push(issue);
464
+ }
465
+ }
466
+ }
467
+
468
+ // src/cli/commands/generate.ts
469
+ function registerGenerateCommand(program2) {
470
+ program2.command("generate").description("Generate release notes for a tag").requiredOption("--tag <tag>", "Git tag to generate release notes for").option("--publish", "Publish release", false).option("--dry-run", "Preview without publishing", false).option("--format <format>", "Output format (markdown|json)", "markdown").option("--config <path>", "Config file path", ".releasejet.yml").option("--debug", "Show debug information", false).action(async (options) => {
471
+ await runGenerate(options);
472
+ });
473
+ }
474
+ async function runGenerate(options) {
475
+ const debug = options.debug ? (...args) => console.error("[DEBUG]", ...args) : () => {
476
+ };
477
+ const config = await loadConfig(options.config);
478
+ debug("Config loaded:", JSON.stringify(config, null, 2));
479
+ const remoteUrl = process.env.CI_SERVER_URL || process.env.GITHUB_SERVER_URL ? "" : getRemoteUrl();
480
+ const { hostUrl: detectedUrl, projectPath } = resolveProjectInfo(remoteUrl);
481
+ const hostUrl = config.provider.url || detectedUrl;
482
+ debug("Host URL:", hostUrl);
483
+ debug("Project path:", projectPath);
484
+ const token = await resolveToken(config.provider.type);
485
+ const client = createClient(config, token);
486
+ const currentParsed = parseTag(options.tag);
487
+ debug("Parsed tag:", JSON.stringify(currentParsed));
488
+ const apiTags = await client.listTags(projectPath);
489
+ debug("All remote tags:", apiTags.map((t) => `${t.name} (${t.createdAt})`).join(", "));
490
+ const allTags = apiTags.map((t) => {
491
+ try {
492
+ const parsed = parseTag(t.name);
493
+ return { ...parsed, createdAt: t.createdAt };
494
+ } catch {
495
+ return null;
496
+ }
497
+ }).filter((t) => t !== null);
498
+ const currentTag = allTags.find((t) => t.raw === options.tag);
499
+ if (!currentTag) {
500
+ throw new Error(
501
+ `Tag "${options.tag}" not found in remote repository.`
502
+ );
503
+ }
504
+ debug("Current tag:", JSON.stringify(currentTag));
505
+ const previousTag = findPreviousTag(allTags, currentTag);
506
+ debug("Previous tag:", previousTag ? JSON.stringify(previousTag) : "none (first release)");
507
+ debug("Date range:", previousTag?.createdAt ?? "beginning", "->", currentTag.createdAt);
508
+ const issues = await collectIssues(
509
+ client,
510
+ projectPath,
511
+ currentTag,
512
+ previousTag,
513
+ config,
514
+ debug
515
+ );
516
+ if (issues.uncategorized.length > 0) {
517
+ if (process.stdin.isTTY) {
518
+ await promptForUncategorized(issues, config);
519
+ } else if (config.uncategorized === "strict") {
520
+ console.error("Error: Uncategorized issues found (strict mode):");
521
+ for (const issue of issues.uncategorized) {
522
+ console.error(` #${issue.number} - ${issue.title}`);
523
+ }
524
+ process.exitCode = 1;
525
+ return;
526
+ }
527
+ }
528
+ const milestone = detectMilestone(issues);
529
+ const totalCount = Object.values(issues.categorized).reduce(
530
+ (sum, arr) => sum + arr.length,
531
+ 0
532
+ ) + issues.uncategorized.length;
533
+ const data = {
534
+ tagName: options.tag,
535
+ version: currentParsed.version,
536
+ clientPrefix: currentParsed.prefix,
537
+ date: currentTag.createdAt.split("T")[0],
538
+ milestone,
539
+ projectUrl: `${hostUrl}/${projectPath}`,
540
+ issues,
541
+ totalCount,
542
+ uncategorizedCount: issues.uncategorized.length
543
+ };
544
+ if (options.format === "json") {
545
+ console.log(JSON.stringify(data, null, 2));
546
+ } else {
547
+ const markdown = formatReleaseNotes(data, config);
548
+ console.log(markdown);
549
+ if (options.publish && !options.dryRun) {
550
+ const releaseName = currentParsed.prefix ? `${currentParsed.prefix.toUpperCase()} v${currentParsed.version}` : `v${currentParsed.version}`;
551
+ await client.createRelease(projectPath, {
552
+ tagName: options.tag,
553
+ name: releaseName,
554
+ description: markdown,
555
+ milestones: milestone ? [milestone] : void 0
556
+ });
557
+ console.log(`
558
+ \u2713 Release published for ${options.tag}`);
559
+ }
560
+ }
561
+ }
562
+
563
+ // src/cli/commands/validate.ts
564
+ function registerValidateCommand(program2) {
565
+ program2.command("validate").description("Check open issues for proper labeling").option("--config <path>", "Config file path", ".releasejet.yml").action(async (options) => {
566
+ await runValidate(options);
567
+ });
568
+ }
569
+ async function runValidate(options) {
570
+ const config = await loadConfig(options.config);
571
+ const remoteUrl = getRemoteUrl();
572
+ const hostUrl = config.provider.url || resolveHostUrl(remoteUrl);
573
+ const projectPath = resolveProjectPath(remoteUrl);
574
+ const token = await resolveToken(config.provider.type);
575
+ const client = createClient(config, token);
576
+ const issues = await client.listIssues(projectPath, {
577
+ state: "opened"
578
+ });
579
+ const categoryLabels = Object.keys(config.categories);
580
+ const clientLabels = config.clients.map((c) => c.label);
581
+ const isMultiClient = clientLabels.length > 0;
582
+ const problems = [];
583
+ for (const issue of issues) {
584
+ const missing = [];
585
+ if (isMultiClient && !issue.labels.some((l) => clientLabels.includes(l))) {
586
+ missing.push("client label");
587
+ }
588
+ if (!issue.labels.some((l) => categoryLabels.includes(l))) {
589
+ missing.push("category label");
590
+ }
591
+ if (missing.length > 0) {
592
+ problems.push({ number: issue.number, title: issue.title, missing });
593
+ }
594
+ }
595
+ if (problems.length === 0) {
596
+ console.log("\u2713 All open issues are properly labeled.");
597
+ return;
598
+ }
599
+ console.log(`\u26A0 ${problems.length} issues with missing labels:
600
+ `);
601
+ for (const p of problems) {
602
+ console.log(` #${p.number} - ${p.title}`);
603
+ console.log(` Missing: ${p.missing.join(", ")}`);
604
+ }
605
+ process.exitCode = 1;
606
+ }
607
+
608
+ // src/cli/commands/init.ts
609
+ import { readFile as readFile3, writeFile, mkdir } from "fs/promises";
610
+ import { homedir as homedir2 } from "os";
611
+ import { join as join2 } from "path";
612
+ import { input, confirm, select as select2 } from "@inquirer/prompts";
613
+ import { stringify as stringifyYaml, parse as parseYaml3 } from "yaml";
614
+
615
+ // src/core/ci.ts
616
+ var CI_MARKER_START = "# --- ReleaseJet CI (managed by releasejet) ---";
617
+ var CI_MARKER_END = "# --- End ReleaseJet CI ---";
618
+ var DEFAULT_TAGS = ["short-duration"];
619
+ function generateCiBlock(tags) {
620
+ const tagLines = tags.map((t) => ` - ${t}`).join("\n");
621
+ return [
622
+ CI_MARKER_START,
623
+ "release-notes:",
624
+ " stage: deploy",
625
+ " image: node:20-alpine",
626
+ " rules:",
627
+ " - if: $CI_COMMIT_TAG",
628
+ " tags:",
629
+ tagLines,
630
+ " before_script:",
631
+ " - npm install -g releasejet",
632
+ " script:",
633
+ ' - releasejet generate --tag "$CI_COMMIT_TAG" --publish',
634
+ CI_MARKER_END
635
+ ].join("\n");
636
+ }
637
+ function hasCiBlock(content) {
638
+ const start = content.indexOf(CI_MARKER_START);
639
+ const end = content.indexOf(CI_MARKER_END);
640
+ return start !== -1 && end !== -1 && start < end;
641
+ }
642
+ function appendCiBlock(existingContent, block) {
643
+ const trimmed = existingContent.trimEnd();
644
+ if (trimmed.length === 0) return block + "\n";
645
+ return trimmed + "\n\n" + block + "\n";
646
+ }
647
+ function removeCiBlock(content) {
648
+ const startIdx = content.indexOf(CI_MARKER_START);
649
+ const endIdx = content.indexOf(CI_MARKER_END);
650
+ if (startIdx === -1 || endIdx === -1) return content;
651
+ if (startIdx > endIdx) return content;
652
+ const before = content.substring(0, startIdx);
653
+ const after = content.substring(endIdx + CI_MARKER_END.length);
654
+ const cleaned = (before + after).replace(/\n{3,}/g, "\n\n").trim();
655
+ return cleaned;
656
+ }
657
+
658
+ // src/cli/commands/init.ts
659
+ var GITHUB_ACTIONS_TEMPLATE = `name: Release Notes
660
+ on:
661
+ push:
662
+ tags:
663
+ - '**'
664
+ jobs:
665
+ release-notes:
666
+ runs-on: ubuntu-latest
667
+ permissions:
668
+ contents: write
669
+ steps:
670
+ - uses: actions/checkout@v4
671
+ - uses: actions/setup-node@v4
672
+ with:
673
+ node-version: '20'
674
+ - run: npm install -g releasejet
675
+ - run: releasejet generate --tag "\${{ github.ref_name }}" --publish
676
+ env:
677
+ RELEASEJET_TOKEN: \${{ secrets.RELEASEJET_TOKEN }}
678
+ `;
679
+ function registerInitCommand(program2) {
680
+ program2.command("init").description("Interactive setup for ReleaseJet").action(async () => {
681
+ await runInit();
682
+ });
683
+ }
684
+ async function runInit() {
685
+ console.log("\u{1F680} ReleaseJet Setup\n");
686
+ let detectedProvider = "gitlab";
687
+ let defaultUrl = "";
688
+ try {
689
+ const remoteUrl = getRemoteUrl();
690
+ detectedProvider = detectProviderFromRemote(remoteUrl);
691
+ defaultUrl = resolveHostUrl(remoteUrl);
692
+ } catch {
693
+ }
694
+ const providerType = await select2({
695
+ message: "Which provider are you using?",
696
+ choices: [
697
+ { name: "GitLab", value: "gitlab" },
698
+ { name: "GitHub", value: "github" }
699
+ ],
700
+ default: detectedProvider
701
+ });
702
+ const urlDefault = providerType === "github" ? defaultUrl || "https://github.com" : defaultUrl || "https://gitlab.example.com";
703
+ const providerUrl = await input({
704
+ message: providerType === "github" ? "GitHub URL:" : "GitLab instance URL:",
705
+ default: urlDefault
706
+ });
707
+ let source;
708
+ if (providerType === "github") {
709
+ source = await select2({
710
+ message: "Generate release notes from:",
711
+ choices: [
712
+ { name: "Issues", value: "issues" },
713
+ { name: "Pull requests", value: "pull_requests" }
714
+ ],
715
+ default: "issues"
716
+ });
717
+ }
718
+ const isMultiClient = await confirm({
719
+ message: "Is this a multi-client repository?",
720
+ default: false
721
+ });
722
+ const clients = [];
723
+ if (isMultiClient) {
724
+ let addMore = true;
725
+ while (addMore) {
726
+ const prefix = await input({
727
+ message: 'Client tag prefix (e.g., "mobile"):'
728
+ });
729
+ const label = await input({
730
+ message: `Label for "${prefix}" (e.g., "MOBILE"):`,
731
+ default: prefix.toUpperCase()
732
+ });
733
+ clients.push({ prefix, label });
734
+ addMore = await confirm({
735
+ message: "Add another client?",
736
+ default: false
737
+ });
738
+ }
739
+ }
740
+ const uncategorized = await select2({
741
+ message: "How to handle uncategorized issues?",
742
+ choices: [
743
+ {
744
+ name: 'Lenient \u2014 include under "Other" with a warning',
745
+ value: "lenient"
746
+ },
747
+ {
748
+ name: "Strict \u2014 fail release generation",
749
+ value: "strict"
750
+ }
751
+ ]
752
+ });
753
+ const defaultCategories = {
754
+ feature: "New Features",
755
+ bug: "Bug Fixes",
756
+ improvement: "Improvements",
757
+ "breaking-change": "Breaking Changes"
758
+ };
759
+ const categoryMode = await select2({
760
+ message: "Issue categories (editable later in .releasejet.yml):",
761
+ choices: [
762
+ {
763
+ name: "Use defaults (feature, bug, improvement, breaking-change)",
764
+ value: "defaults"
765
+ },
766
+ {
767
+ name: "Keep defaults and add custom categories",
768
+ value: "extend"
769
+ },
770
+ {
771
+ name: "Define only my own categories (ignore defaults)",
772
+ value: "custom"
773
+ }
774
+ ]
775
+ });
776
+ let categories;
777
+ if (categoryMode === "defaults") {
778
+ categories = { ...defaultCategories };
779
+ } else {
780
+ categories = categoryMode === "extend" ? { ...defaultCategories } : {};
781
+ const existing = new Set(Object.keys(categories));
782
+ let needsAtLeastOne = categoryMode === "custom";
783
+ while (true) {
784
+ const label = await input({
785
+ message: needsAtLeastOne ? "At least one category is required. Label (as it appears in GitLab):" : "Label (as it appears in GitLab, or press Enter when done):"
786
+ });
787
+ if (!label.trim()) {
788
+ if (categoryMode === "custom" && Object.keys(categories).length === 0) {
789
+ needsAtLeastOne = true;
790
+ continue;
791
+ }
792
+ break;
793
+ }
794
+ if (existing.has(label.trim())) {
795
+ continue;
796
+ }
797
+ const heading = await input({
798
+ message: `Section heading in release notes:`
799
+ });
800
+ const finalLabel = label.trim();
801
+ const finalHeading = heading.trim() || finalLabel.charAt(0).toUpperCase() + finalLabel.slice(1);
802
+ categories[finalLabel] = finalHeading;
803
+ existing.add(finalLabel);
804
+ needsAtLeastOne = false;
805
+ console.log(` Added: ${finalLabel} \u2192 "${finalHeading}"`);
806
+ }
807
+ }
808
+ const config = {
809
+ provider: { type: providerType, url: providerUrl },
810
+ categories,
811
+ uncategorized
812
+ };
813
+ if (source && source !== "issues") {
814
+ config.source = source;
815
+ }
816
+ if (clients.length > 0) {
817
+ config.clients = clients;
818
+ }
819
+ const yamlContent = stringifyYaml(config);
820
+ await writeFile(".releasejet.yml", yamlContent);
821
+ console.log("\n\u2713 Created .releasejet.yml");
822
+ const ciLabel = providerType === "github" ? "GitHub Actions" : "GitLab CI/CD";
823
+ const setupCi = await confirm({
824
+ message: `Set up ${ciLabel} integration?`,
825
+ default: true
826
+ });
827
+ if (setupCi) {
828
+ if (providerType === "github") {
829
+ await setupGitHubActions();
830
+ } else {
831
+ await setupGitLabCi();
832
+ }
833
+ }
834
+ const tokenMessage = providerType === "github" ? "GitHub personal access token (repo scope):" : "GitLab API token (api scope):";
835
+ const token = await input({ message: tokenMessage });
836
+ if (token) {
837
+ const credDir = join2(homedir2(), ".releasejet");
838
+ await mkdir(credDir, { recursive: true });
839
+ const credPath = join2(credDir, "credentials.yml");
840
+ let existingCreds = {};
841
+ try {
842
+ const content = await readFile3(credPath, "utf-8");
843
+ existingCreds = parseYaml3(content) ?? {};
844
+ } catch {
845
+ }
846
+ existingCreds[providerType] = token;
847
+ const yamlCreds = stringifyYaml(existingCreds);
848
+ await writeFile(credPath, yamlCreds, { mode: 384 });
849
+ console.log(`\u2713 Token stored in ${credPath}`);
850
+ }
851
+ console.log("\nSetup complete! You can now run:");
852
+ console.log(
853
+ " releasejet generate --tag <tag> # Preview release notes"
854
+ );
855
+ console.log(
856
+ " releasejet generate --tag <tag> --publish # Publish release"
857
+ );
858
+ console.log(
859
+ " releasejet validate # Check issue labels"
860
+ );
861
+ }
862
+ async function setupGitLabCi() {
863
+ const tagsInput = await input({
864
+ message: 'Runner tags (comma-separated, or Enter for "short-duration"):'
865
+ });
866
+ const ciTags = tagsInput.trim() ? tagsInput.split(",").map((t) => t.trim()).filter(Boolean) : DEFAULT_TAGS;
867
+ let existingCi = "";
868
+ try {
869
+ existingCi = await readFile3(".gitlab-ci.yml", "utf-8");
870
+ } catch (err) {
871
+ if (err.code !== "ENOENT") throw err;
872
+ }
873
+ if (hasCiBlock(existingCi)) {
874
+ console.log(" ReleaseJet CI is already configured.");
875
+ } else {
876
+ const block = generateCiBlock(ciTags);
877
+ const ciContent = appendCiBlock(existingCi, block);
878
+ await writeFile(".gitlab-ci.yml", ciContent);
879
+ console.log("\u2713 Created .gitlab-ci.yml with ReleaseJet CI configuration");
880
+ }
881
+ }
882
+ async function setupGitHubActions() {
883
+ const workflowPath = ".github/workflows/release-notes.yml";
884
+ let exists = false;
885
+ try {
886
+ await readFile3(workflowPath, "utf-8");
887
+ exists = true;
888
+ } catch {
889
+ }
890
+ if (exists) {
891
+ console.log(" GitHub Actions workflow already exists.");
892
+ return;
893
+ }
894
+ await mkdir(".github/workflows", { recursive: true });
895
+ await writeFile(workflowPath, GITHUB_ACTIONS_TEMPLATE);
896
+ console.log("\u2713 Created .github/workflows/release-notes.yml");
897
+ }
898
+
899
+ // src/cli/commands/ci.ts
900
+ import { readFile as readFile4, writeFile as writeFile2, unlink } from "fs/promises";
901
+ import { input as input2 } from "@inquirer/prompts";
902
+ var CI_FILE = ".gitlab-ci.yml";
903
+ function registerCiCommand(program2) {
904
+ const ci = program2.command("ci").description("Manage GitLab CI/CD integration");
905
+ ci.command("enable").description("Add ReleaseJet CI configuration to .gitlab-ci.yml").option("--tags <tags>", "Runner tags (comma-separated)").action(async (options) => {
906
+ await runCiEnable(options);
907
+ });
908
+ ci.command("disable").description("Remove ReleaseJet CI configuration from .gitlab-ci.yml").action(async () => {
909
+ await runCiDisable();
910
+ });
911
+ }
912
+ async function runCiEnable(options) {
913
+ let existing = "";
914
+ try {
915
+ existing = await readFile4(CI_FILE, "utf-8");
916
+ } catch (err) {
917
+ if (err.code !== "ENOENT") throw err;
918
+ }
919
+ if (hasCiBlock(existing)) {
920
+ console.log("ReleaseJet CI is already enabled.");
921
+ return;
922
+ }
923
+ let tags;
924
+ if (options.tags) {
925
+ tags = options.tags.split(",").map((t) => t.trim()).filter(Boolean);
926
+ } else {
927
+ const tagsInput = await input2({
928
+ message: 'Runner tags (comma-separated, or Enter for "short-duration"):'
929
+ });
930
+ tags = tagsInput.trim() ? tagsInput.split(",").map((t) => t.trim()).filter(Boolean) : DEFAULT_TAGS;
931
+ }
932
+ const block = generateCiBlock(tags);
933
+ const content = appendCiBlock(existing, block);
934
+ await writeFile2(CI_FILE, content);
935
+ console.log("\u2713 ReleaseJet CI configuration added to .gitlab-ci.yml");
936
+ }
937
+ async function runCiDisable() {
938
+ let existing;
939
+ try {
940
+ existing = await readFile4(CI_FILE, "utf-8");
941
+ } catch (err) {
942
+ if (err.code !== "ENOENT") throw err;
943
+ console.log("ReleaseJet CI is not configured.");
944
+ return;
945
+ }
946
+ if (!hasCiBlock(existing)) {
947
+ console.log("ReleaseJet CI is not configured.");
948
+ return;
949
+ }
950
+ const cleaned = removeCiBlock(existing);
951
+ if (cleaned.trim().length === 0) {
952
+ await unlink(CI_FILE);
953
+ console.log("\u2713 Removed .gitlab-ci.yml (no other configuration found)");
954
+ } else {
955
+ await writeFile2(CI_FILE, cleaned + "\n");
956
+ console.log("\u2713 ReleaseJet CI configuration removed from .gitlab-ci.yml");
957
+ }
958
+ }
959
+
960
+ // src/cli/index.ts
961
+ var program = new Command();
962
+ program.name("releasejet").description("Automated GitLab release notes generator").version("1.0.0");
963
+ registerGenerateCommand(program);
964
+ registerValidateCommand(program);
965
+ registerInitCommand(program);
966
+ registerCiCommand(program);
967
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@makispps/releasejet",
3
+ "version": "1.0.0",
4
+ "description": "Automated release notes generator for GitLab and GitHub",
5
+ "license": "MIT",
6
+ "author": "Mavroudis Papas",
7
+ "homepage": "https://www.releasejet.dev/",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/makisp/releasejet.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/makisp/releasejet/issues"
14
+ },
15
+ "keywords": [
16
+ "release-notes",
17
+ "changelog",
18
+ "gitlab",
19
+ "github",
20
+ "cli",
21
+ "automation",
22
+ "ci-cd"
23
+ ],
24
+ "type": "module",
25
+ "bin": {
26
+ "releasejet": "./dist/cli.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "ci",
31
+ ".releasejet.example.yml"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsup",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "dev": "tsx src/cli/index.ts",
38
+ "prepublishOnly": "npm run test && npm run build"
39
+ },
40
+ "dependencies": {
41
+ "@gitbeaker/rest": "^41.3.0",
42
+ "@inquirer/prompts": "^7.2.0",
43
+ "@octokit/rest": "^22.0.1",
44
+ "commander": "^13.1.0",
45
+ "semver": "^7.7.1",
46
+ "yaml": "^2.7.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^25.5.2",
50
+ "@types/semver": "^7.5.8",
51
+ "tsup": "^8.4.0",
52
+ "tsx": "^4.19.0",
53
+ "typescript": "^5.7.0",
54
+ "vitest": "^3.1.0"
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ }
59
+ }