@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.
- package/.releasejet.example.yml +28 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/ci/release-notes-github.yml +19 -0
- package/ci/release-notes-gitlab.yml +22 -0
- package/dist/cli.js +967 -0
- package/package.json +59 -0
|
@@ -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
|
+
}
|