releaseradar 1.0.17

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Guy Hershko
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,195 @@
1
+ # releaseradar
2
+
3
+ [![GitHub release](https://img.shields.io/github/v/release/ghershko3/releaseradar)](https://github.com/ghershko3/releaseradar/releases)
4
+ [![npm version](https://img.shields.io/npm/v/releaseradar.svg)](https://www.npmjs.com/package/releaseradar)
5
+ [![license](https://img.shields.io/github/license/ghershko3/releaseradar.svg)](https://github.com/ghershko3/releaseradar/blob/main/LICENSE)
6
+ [![node](https://img.shields.io/badge/node-%3E%3D14.0.0-brightgreen)](https://www.npmjs.com/package/releaseradar)
7
+
8
+ > Built on `gh`. A faster way to answer "what shipped since the version in prod?"
9
+
10
+ releaseradar wraps the GitHub CLI with opinionated shortcuts for teams that release often — especially monorepos with prefixed tags like `api-*` or `worker-*`. Set your org and repo once, then skip the flags and the date math.
11
+
12
+ **Common workflows**
13
+
14
+ - `rr releases api-2.1.0` — everything released since prod for one service
15
+ - `rr releases api-2.1.0 api-2.1.5` — diff between two deployed versions
16
+ - `rr list api --last 7d` — this week's releases for one service
17
+ - `rr search "CVE"` — find releases mentioning a keyword
18
+
19
+ Set `RR_ORG` and `RR_REPO` once; no flags on every command.
20
+
21
+ ## Demo
22
+
23
+ ```bash
24
+ $ rr list --last 7d
25
+
26
+ TAG DATE AUTHOR
27
+ ────────────────────────────────────────
28
+ v2.1.5 2025-12-20 09:23 alice
29
+ v2.1.4 2025-12-19 16:42 bob
30
+
31
+ Total: 2 release(s)
32
+
33
+ $ rr releases v2.1.0 v2.1.5
34
+
35
+ TAG DATE AUTHOR CHANGES
36
+ ──────────────────────────────────────────────────
37
+ v2.1.5 2025-12-20 09:23 alice Rate limiting fix
38
+ v2.1.4 2025-12-19 16:42 bob Auth token refresh
39
+ v2.1.3 2025-12-19 14:15 charlie DB connection pool
40
+
41
+ Total: 3 release(s)
42
+ ```
43
+
44
+ ## Quick Start
45
+
46
+ **Install**
47
+
48
+ ```bash
49
+ npm i -g releaseradar
50
+ # or
51
+ pnpm i -g releaseradar
52
+ # or
53
+ yarn global add releaseradar
54
+ ```
55
+
56
+ > **Requirements:** [GitHub CLI](https://cli.github.com/) (`gh`) must be installed and authenticated. releaseradar uses `gh` under the hood — no separate GitHub token setup needed.
57
+ >
58
+ > ```bash
59
+ > brew install gh && gh auth login
60
+ > ```
61
+
62
+ **Configure** (add to `~/.zshrc` or `~/.bashrc`)
63
+
64
+ ```bash
65
+ export RR_ORG=myorg
66
+ export RR_REPO=myrepo
67
+ ```
68
+
69
+ **Use**
70
+
71
+ ```bash
72
+ rr list # View all releases
73
+ rr releases v2.1.0 # Compare since version
74
+ rr search "fix" # Search releases
75
+ rr info v2.1.5 # Release details
76
+ ```
77
+
78
+ ## Commands
79
+
80
+ ```bash
81
+ rr releases <from> [to] # Show releases since a tag, or between two tags
82
+ rr list [prefix] # List all releases (optional: filter by prefix)
83
+ rr search <term> # Search release notes
84
+ rr info <tag> # Get detailed release info
85
+ ```
86
+
87
+ **Flags**
88
+
89
+ - `--org <name>` - Override GitHub organization
90
+ - `--repo <name>` - Override repository name
91
+ - `--last <N>m|h|d` - Filter `list` to releases in the last N minutes/hours/days (e.g. `24h`, `7d`)
92
+
93
+ ## Features
94
+
95
+ **Version Comparison**
96
+
97
+ ```bash
98
+ $ rr releases v2.1.0
99
+
100
+ TAG DATE AUTHOR CHANGES
101
+ ──────────────────────────────────────────────────
102
+ v2.1.5 2025-12-20 09:23 alice Rate limiting fix
103
+ v2.1.4 2025-12-19 16:42 bob Auth token refresh
104
+ v2.1.3 2025-12-19 14:15 charlie DB connection pool
105
+
106
+ Total: 3 release(s)
107
+ ```
108
+
109
+ **Version Range** - Compare between two specific versions (not up to latest)
110
+
111
+ ```bash
112
+ $ rr releases v2.1.0 v2.1.5
113
+
114
+ TAG DATE AUTHOR CHANGES
115
+ ──────────────────────────────────────────────────
116
+ v2.1.5 2025-12-20 09:23 alice Rate limiting fix
117
+ v2.1.4 2025-12-19 16:42 bob Auth token refresh
118
+ v2.1.3 2025-12-19 14:15 charlie DB connection pool
119
+
120
+ Total: 3 release(s)
121
+ ```
122
+
123
+ **Monorepo Support** - Filter by service prefix
124
+
125
+ ```bash
126
+ rr list api # only api-* releases
127
+ rr list --last 24h # releases from the last 24 hours
128
+ rr list api --last 7d # api-* releases from the last 7 days
129
+ rr releases api-1.0.0 # compare API versions
130
+ ```
131
+
132
+ **Real Author Detection** - Shows actual developers, not CI bots
133
+
134
+ **Multi-Repo** - Switch repos with flags
135
+
136
+ ```bash
137
+ rr --org myorg --repo backend list
138
+ ```
139
+
140
+ ## Configuration
141
+
142
+ **Environment Variables** (recommended)
143
+
144
+ ```bash
145
+ export RR_ORG=myorg
146
+ export RR_REPO=myrepo
147
+ export RR_LIMIT=500 # optional, default: 300
148
+ ```
149
+
150
+ **Command Flags** (override env vars)
151
+
152
+ ```bash
153
+ rr --org company --repo backend releases v1.0
154
+ ```
155
+
156
+ ## Troubleshooting
157
+
158
+ **Command not found**
159
+
160
+ ```bash
161
+ npm i -g releaseradar
162
+ # or
163
+ pnpm i -g releaseradar
164
+ # or
165
+ yarn global add releaseradar
166
+ ```
167
+
168
+ **GitHub CLI missing**
169
+
170
+ ```bash
171
+ brew install gh && gh auth login
172
+ ```
173
+
174
+ **No releases showing**
175
+
176
+ - Verify: `rr --org myorg --repo myrepo list`
177
+ - Increase limit: `export RR_LIMIT=500`
178
+
179
+ ## Migration from @ghershko/releaseradar
180
+
181
+ The package was renamed from the scoped `@ghershko/releaseradar` to unscoped `releaseradar`. Install the new package:
182
+
183
+ ```bash
184
+ npm i -g releaseradar
185
+ ```
186
+
187
+ The `rr` command and all configuration (`RR_ORG`, `RR_REPO`, etc.) remain the same.
188
+
189
+ ## License
190
+
191
+ MIT
192
+
193
+ ---
194
+
195
+ [Report Bug](https://github.com/ghershko3/releaseradar/issues) · [Request Feature](https://github.com/ghershko3/releaseradar/issues)
package/index.js ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+
3
+ import chalk from 'chalk';
4
+ import { checkGitHubCli } from './src/api/github.js';
5
+ import {
6
+ commandReleases,
7
+ commandInfo,
8
+ commandSearch,
9
+ commandList,
10
+ showHelp
11
+ } from './src/commands/index.js';
12
+ import { validateConfig, RrError } from './src/utils/validation.js';
13
+ import { COMMANDS_REQUIRING_GH, ENV_VAR_KEYS, DEFAULT_RELEASE_LIMIT } from './src/utils/constants.js';
14
+
15
+ const resolveConfig = (configOverrides) => {
16
+ const merged = {
17
+ org: configOverrides.org || process.env[ENV_VAR_KEYS.ORG],
18
+ repo: configOverrides.repo || process.env[ENV_VAR_KEYS.REPO],
19
+ releaseLimit: process.env[ENV_VAR_KEYS.LIMIT]
20
+ ? parseInt(process.env[ENV_VAR_KEYS.LIMIT], 10)
21
+ : DEFAULT_RELEASE_LIMIT
22
+ };
23
+
24
+ if (!merged.org || !merged.repo) {
25
+ console.error(chalk.red('\nMissing required configuration\n'));
26
+ console.log('Set environment variables (recommended):');
27
+ console.log(chalk.dim(' export RR_ORG=myorg'));
28
+ console.log(chalk.dim(' export RR_REPO=myrepo\n'));
29
+ console.log('Or use flags:');
30
+ console.log(chalk.dim(' rr --org myorg --repo myrepo list\n'));
31
+ process.exit(1);
32
+ }
33
+
34
+ const validated = validateConfig(merged);
35
+ return { ...validated, last: configOverrides.last };
36
+ };
37
+
38
+ const parseArguments = (args) => {
39
+ const flags = args.filter(arg => arg.startsWith('--'));
40
+ const nonFlags = args.filter(arg => !arg.startsWith('--'));
41
+
42
+ const getFlagValue = (flagName) => {
43
+ const flag = flags.find(f => f.startsWith(`--${flagName}=`) || f === `--${flagName}`);
44
+ if (!flag) return null;
45
+
46
+ const equalIndex = flag.indexOf('=');
47
+ if (equalIndex === -1) {
48
+ const flagIndex = args.indexOf(flag);
49
+ const nextArg = args[flagIndex + 1];
50
+ return nextArg && !nextArg.startsWith('--') ? nextArg : null;
51
+ }
52
+ return flag.substring(equalIndex + 1);
53
+ };
54
+
55
+ return {
56
+ command: nonFlags[0],
57
+ parameter: nonFlags[1],
58
+ parameter2: nonFlags[2],
59
+ configOverrides: {
60
+ org: getFlagValue('org'),
61
+ repo: getFlagValue('repo'),
62
+ last: getFlagValue('last')
63
+ }
64
+ };
65
+ };
66
+
67
+ const COMMAND_HANDLERS = {
68
+ releases: commandReleases,
69
+ info: commandInfo,
70
+ search: commandSearch,
71
+ list: commandList,
72
+ help: showHelp,
73
+ '--help': showHelp,
74
+ '-h': showHelp
75
+ };
76
+
77
+ const COMMANDS_NOT_REQUIRING_CONFIG = ['help', '--help', '-h'];
78
+
79
+ const executeCommand = (command, parameter, parameter2, config) => {
80
+ const handler = COMMAND_HANDLERS[command];
81
+
82
+ if (!handler) {
83
+ throw new RrError(`Unknown command: ${command}`);
84
+ }
85
+
86
+ if (COMMANDS_REQUIRING_GH.includes(command)) {
87
+ checkGitHubCli();
88
+ }
89
+
90
+ handler(parameter, config, parameter2);
91
+ };
92
+
93
+ const main = () => {
94
+ try {
95
+ const args = process.argv.slice(2);
96
+
97
+ if (args.length === 0) {
98
+ showHelp();
99
+ process.exit(0);
100
+ }
101
+
102
+ const { command, parameter, parameter2, configOverrides } = parseArguments(args);
103
+
104
+ if (COMMANDS_NOT_REQUIRING_CONFIG.includes(command)) {
105
+ executeCommand(command, parameter, parameter2, null);
106
+ } else {
107
+ const config = resolveConfig(configOverrides);
108
+ executeCommand(command, parameter, parameter2, config);
109
+ }
110
+
111
+ } catch (error) {
112
+ if (error instanceof RrError) {
113
+ console.error(chalk.red(`\n${error.message}\n`));
114
+ process.exit(1);
115
+ }
116
+
117
+ console.error(chalk.red('\nUnexpected error:'), error.message);
118
+ console.error(chalk.dim(error.stack));
119
+ process.exit(1);
120
+ }
121
+ };
122
+
123
+ main();
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "releaseradar",
3
+ "version": "1.0.17",
4
+ "description": "CLI to list, compare, search, and diff GitHub releases from your terminal",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "rr": "index.js"
9
+ },
10
+ "preferGlobal": true,
11
+ "scripts": {
12
+ "start": "node index.js",
13
+ "version:patch": "npm version patch -m 'chore: bump version to %s'",
14
+ "version:minor": "npm version minor -m 'chore: bump version to %s'",
15
+ "version:major": "npm version major -m 'chore: bump version to %s'"
16
+ },
17
+ "keywords": [
18
+ "github-releases",
19
+ "release-notes",
20
+ "changelog",
21
+ "gh-cli",
22
+ "cli",
23
+ "devtools",
24
+ "semver",
25
+ "monorepo"
26
+ ],
27
+ "author": "Guy Hershko <ghershko3@gmail.com>",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/ghershko3/releaseradar.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/ghershko3/releaseradar/issues"
35
+ },
36
+ "homepage": "https://github.com/ghershko3/releaseradar#readme",
37
+ "files": [
38
+ "index.js",
39
+ "src/",
40
+ "README.md"
41
+ ],
42
+ "engines": {
43
+ "node": ">=14.0.0"
44
+ },
45
+ "dependencies": {
46
+ "chalk": "^5.6.2"
47
+ }
48
+ }
package/src/README.md ADDED
@@ -0,0 +1,279 @@
1
+ # Source Code Architecture
2
+
3
+ Clean, modular organization following separation of concerns principles.
4
+
5
+ ## Directory Structure
6
+
7
+ ```
8
+ src/
9
+ ├── utils/ # Utility functions (reusable across layers)
10
+ │ ├── constants.js # Application-wide constants
11
+ │ ├── string-utils.js # String manipulation utilities
12
+ │ ├── date-utils.js # Date formatting and operations
13
+ │ ├── validation.js # Input validation and error classes
14
+ │ └── shell-utils.js # Shell command execution wrapper
15
+
16
+ ├── api/ # External API communication
17
+ │ └── github.js # GitHub CLI wrapper
18
+
19
+ ├── services/ # Business logic layer
20
+ │ └── releases.js # Release data processing
21
+
22
+ ├── presentation/ # Display formatting layer
23
+ │ ├── formatter.js # Console output formatting
24
+ │ └── table.js # Table rendering utilities
25
+
26
+ └── commands/ # CLI command orchestration
27
+ └── index.js # Command handlers
28
+ ```
29
+
30
+ ## Layer Responsibilities
31
+
32
+ ### `utils/` - Utility Functions
33
+
34
+ **Purpose**: Reusable, pure functions used across all layers
35
+
36
+ **Modules**:
37
+
38
+ - **`constants.js`** - Application constants (limits, patterns, widths)
39
+ - **`string-utils.js`** - String operations (prefix extraction, text cleaning)
40
+ - **`date-utils.js`** - Date formatting and comparisons
41
+ - **`validation.js`** - Input validation, config validation, custom error classes
42
+ - **`shell-utils.js`** - Safe shell command execution
43
+
44
+ **Characteristics**:
45
+
46
+ - Pure functions (no side effects)
47
+ - Highly testable
48
+ - Zero external dependencies within utils
49
+
50
+ ### `api/` - External Communication
51
+
52
+ **Purpose**: Isolates all external API calls
53
+
54
+ **Responsibilities**:
55
+
56
+ - Wraps GitHub CLI (`gh`) commands
57
+ - Handles authentication checks
58
+ - Manages pagination for large datasets
59
+ - Error handling for API failures
60
+
61
+ **Key Functions**:
62
+
63
+ - `checkGitHubCli()` - Verifies CLI installation and auth
64
+ - `fetchReleasesFromGitHub()` - Fetches paginated releases
65
+
66
+ ### `services/` - Business Logic
67
+
68
+ **Purpose**: Core data processing and transformation
69
+
70
+ **Responsibilities**:
71
+
72
+ - Transform raw GitHub data to internal format
73
+ - Extract real authors from CI/CD bot releases
74
+ - Sort and filter release data
75
+ - Version prefix extraction
76
+
77
+ **Key Functions**:
78
+
79
+ - `fetchAndProcessReleases()` - Main orchestration
80
+ - `extractRealAuthorFromReleaseNotes()` - Parse release notes for real author
81
+ - `transformReleaseData()` - Convert GitHub format to internal format
82
+
83
+ **Data Flow**:
84
+
85
+ ```
86
+ Raw GitHub JSON → Transform → Validate → Sort → Internal Format
87
+ ```
88
+
89
+ ### `presentation/` - Display Formatting
90
+
91
+ **Purpose**: Handles all console output formatting
92
+
93
+ **Responsibilities**:
94
+
95
+ - Format release data for display
96
+ - Create table layouts
97
+ - Handle detailed vs. compact views
98
+ - Text truncation and padding
99
+
100
+ **Key Functions**:
101
+
102
+ - `formatRelease()` - Main formatting dispatcher
103
+ - `formatReleaseDetailed()` - Detailed single release view
104
+ - `formatReleaseCompact()` - Table row formatting
105
+ - `createReleasesTableHeader()` - Table header creation
106
+
107
+ **Separation**: Knows how to display, not what to fetch or process
108
+
109
+ ### `commands/` - CLI Orchestration
110
+
111
+ **Purpose**: Coordinates between services and presentation
112
+
113
+ **Responsibilities**:
114
+
115
+ - Handle command routing
116
+ - Validate command parameters
117
+ - Coordinate data fetching (services) and display (presentation)
118
+ - Implement command-specific logic
119
+
120
+ **Key Functions**:
121
+
122
+ - `commandReleases()` - Show releases since version
123
+ - `commandInfo()` - Show detailed release info
124
+ - `commandSearch()` - Search across releases
125
+ - `commandList()` - List releases with filtering
126
+ - `showHelp()` - Display help text
127
+
128
+ **Pattern**: Each command follows:
129
+
130
+ 1. Validate input
131
+ 2. Fetch/process data (via services)
132
+ 3. Format output (via presentation)
133
+
134
+ ## Design Principles
135
+
136
+ ### 1. Separation of Concerns
137
+
138
+ Each layer has a single, well-defined responsibility:
139
+
140
+ - **Utils** = reusable tools
141
+ - **API** = external communication
142
+ - **Services** = business logic
143
+ - **Presentation** = formatting
144
+ - **Commands** = orchestration
145
+
146
+ ### 2. Dependency Direction
147
+
148
+ ```
149
+ Commands → Services → API
150
+ Commands → Presentation
151
+ All layers → Utils
152
+ ```
153
+
154
+ No circular dependencies. Lower layers never depend on higher layers.
155
+
156
+ ### 3. Pure Functions Where Possible
157
+
158
+ Most functions in `utils/` and parts of `services/` are pure:
159
+
160
+ - Same input = same output
161
+ - No side effects
162
+ - Easy to test
163
+
164
+ ### 4. Error Handling
165
+
166
+ Custom error classes in `utils/validation.js`:
167
+
168
+ - `RrError` - Base error class
169
+ - `ValidationError` - Input/config validation errors
170
+ - `GitHubApiError` - API communication errors
171
+
172
+ Errors bubble up to `index.js` for centralized handling.
173
+
174
+ ### 5. Modern JavaScript Patterns
175
+
176
+ - Destructuring for cleaner code
177
+ - Optional chaining (`?.`) for safe property access
178
+ - Nullish coalescing (`??`) for default values
179
+ - Arrow functions for concise syntax
180
+ - ES6 modules for clear imports/exports
181
+
182
+ ## Data Models
183
+
184
+ ### Release Object (Internal Format)
185
+
186
+ ```javascript
187
+ {
188
+ tag: string, // e.g., "app-25.12.107"
189
+ name: string, // Release name
190
+ repo: string, // Repository name
191
+ published: string, // ISO date string
192
+ author: string, // Real author (not bot)
193
+ releaseNotes: string, // Full release notes
194
+ url: string, // GitHub release URL
195
+ commitSha: string // Commit SHA
196
+ }
197
+ ```
198
+
199
+ ### Config Object
200
+
201
+ ```javascript
202
+ {
203
+ org: string, // GitHub organization
204
+ repo: string, // Repository name
205
+ releaseLimit: number // Max releases to fetch (default: 300)
206
+ }
207
+ ```
208
+
209
+ ## Testing Strategy
210
+
211
+ Tests are organized by functionality:
212
+
213
+ - **`formatting.test.js`** - String/date utilities
214
+ - **`github.test.js`** - Author extraction logic
215
+ - **`run-all.js`** - Test runner
216
+
217
+ ### Test Coverage
218
+
219
+ - ✅ Prefix extraction with edge cases
220
+ - ✅ Date formatting
221
+ - ✅ Change text extraction and cleaning
222
+ - ✅ Real author detection patterns
223
+ - ✅ Null/empty input handling
224
+
225
+ ## Adding New Features
226
+
227
+ ### Adding a New Utility
228
+
229
+ 1. Add to appropriate file in `utils/` or create new util file
230
+ 2. Export function
231
+ 3. Add tests
232
+ 4. Document in this README
233
+
234
+ ### Adding a New Command
235
+
236
+ 1. Add handler to `commands/index.js`
237
+ 2. Register in `COMMAND_HANDLERS` in `index.js`
238
+ 3. Add to help text in `showHelp()`
239
+ 4. Update main README
240
+
241
+ ### Adding a New Data Source
242
+
243
+ 1. Create new file in `api/` (e.g., `gitlab.js`)
244
+ 2. Implement same interface as `github.js`
245
+ 3. Update services to handle new format
246
+ 4. Add configuration options
247
+
248
+ ## Code Style Guidelines
249
+
250
+ - Use `const` by default, `let` when needed
251
+ - Prefer arrow functions for callbacks
252
+ - Use descriptive variable names (e.g., `releaseNotes` not `body`)
253
+ - Extract magic numbers/strings to constants
254
+ - Keep functions small (< 30 lines ideally)
255
+ - One responsibility per function
256
+ - Document complex logic with clear comments (when needed)
257
+
258
+ ## Performance Considerations
259
+
260
+ - Releases are fetched once per command invocation
261
+ - Sorting/filtering happen in memory (fast for < 1000 releases)
262
+ - Table formatting is optimized for console output
263
+ - Pagination prevents fetching unnecessary data
264
+
265
+ ## Future Enhancements
266
+
267
+ Potential improvements:
268
+
269
+ - Caching releases to disk for faster subsequent runs
270
+ - Compare two versions side-by-side
271
+ - Export to JSON/CSV formats
272
+ - Interactive mode with prompts
273
+ - Multiple repository support in single command
274
+ - Release notes templates validation
275
+ - Custom output formatters (JSON, Markdown)
276
+
277
+ ---
278
+
279
+ **Architecture Goal**: Simple, maintainable, and extensible code that's easy to understand and modify.
@@ -0,0 +1,76 @@
1
+ import chalk from 'chalk';
2
+ import { executeCommand, checkCommandExists } from '../utils/shell-utils.js';
3
+ import { GitHubApiError } from '../utils/validation.js';
4
+ import { RELEASES_PER_PAGE, MAX_BUFFER_SIZE } from '../utils/constants.js';
5
+
6
+ export const checkGitHubCliInstalled = () => {
7
+ if (!checkCommandExists('gh')) {
8
+ console.error(chalk.red('\nGitHub CLI (gh) is not installed or not in PATH'));
9
+ console.log('\nInstall GitHub CLI:');
10
+ console.log(chalk.dim(' macOS: brew install gh'));
11
+ console.log(chalk.dim(' Linux: https://github.com/cli/cli/blob/trunk/docs/install_linux.md'));
12
+ console.log(chalk.dim(' Windows: https://github.com/cli/cli/releases\n'));
13
+
14
+ throw new GitHubApiError('GitHub CLI not found');
15
+ }
16
+ };
17
+
18
+ export const checkGitHubAuthentication = () => {
19
+ try {
20
+ const result = executeCommand('gh auth status 2>&1');
21
+
22
+ if (!result?.includes('Logged in to github.com')) {
23
+ throw new Error('Not authenticated');
24
+ }
25
+ } catch {
26
+ console.error(chalk.red('\nGitHub CLI is not authenticated'));
27
+ console.log('\nAuthenticate with GitHub:');
28
+ console.log(chalk.dim(' Run: gh auth login\n'));
29
+
30
+ throw new GitHubApiError('GitHub CLI not authenticated');
31
+ }
32
+ };
33
+
34
+ export const checkGitHubCli = () => {
35
+ checkGitHubCliInstalled();
36
+ checkGitHubAuthentication();
37
+ };
38
+
39
+ export const fetchReleasesFromGitHub = (org, repo, limit = 300) => {
40
+ process.stdout.write(chalk.dim(`Fetching releases from ${chalk.cyan(org + '/' + repo)}...`));
41
+
42
+ const totalPages = Math.ceil(limit / RELEASES_PER_PAGE);
43
+ const allReleases = [];
44
+
45
+ try {
46
+ for (let page = 1; page <= totalPages; page++) {
47
+ const apiEndpoint = `/repos/${org}/${repo}/releases`;
48
+ const params = `per_page=${RELEASES_PER_PAGE}&page=${page}`;
49
+ const command = `gh api "${apiEndpoint}?${params}"`;
50
+
51
+ const result = executeCommand(command, {
52
+ maxBuffer: MAX_BUFFER_SIZE
53
+ });
54
+
55
+ const pageReleases = JSON.parse(result);
56
+
57
+ if (!Array.isArray(pageReleases) || pageReleases.length === 0) {
58
+ break;
59
+ }
60
+
61
+ allReleases.push(...pageReleases);
62
+
63
+ if (pageReleases.length < RELEASES_PER_PAGE || allReleases.length >= limit) {
64
+ break;
65
+ }
66
+ }
67
+
68
+ const limitedReleases = allReleases.slice(0, limit);
69
+ process.stdout.write('\r' + ' '.repeat(80) + '\r');
70
+
71
+ return limitedReleases;
72
+ } catch (error) {
73
+ process.stdout.write('\r' + ' '.repeat(80) + '\r');
74
+ throw error;
75
+ }
76
+ };
@@ -0,0 +1,265 @@
1
+ import chalk from 'chalk';
2
+ import { fetchAndProcessReleases, extractVersionPrefix } from '../services/releases.js';
3
+ import { formatRelease } from '../presentation/formatter.js';
4
+ import { printTable, createReleasesTableHeader } from '../presentation/table.js';
5
+ import { requireParameter, ValidationError, validateConfig } from '../utils/validation.js';
6
+ import { isNewerThan, parseDuration, isWithinLast } from '../utils/date-utils.js';
7
+
8
+ const findReleaseByVersion = (releases, version) => {
9
+ return releases.find(release => release.tag === version);
10
+ };
11
+
12
+ const filterReleasesByPrefix = (releases, prefix) => {
13
+ return releases.filter(release => extractVersionPrefix(release.tag) === prefix);
14
+ };
15
+
16
+ const filterReleasesNewerThan = (releases, targetRelease) => {
17
+ return releases.filter(release =>
18
+ isNewerThan(release.published, targetRelease.published)
19
+ );
20
+ };
21
+
22
+ export const filterReleasesInRange = (releases, fromRelease, toRelease) =>
23
+ releases.filter(release =>
24
+ isNewerThan(release.published, fromRelease.published) &&
25
+ !isNewerThan(release.published, toRelease.published)
26
+ );
27
+
28
+ const filterReleasesByLast = (releases, last) => {
29
+ if (!last) return releases;
30
+ const durationMs = parseDuration(last);
31
+ if (!durationMs) {
32
+ throw new ValidationError(
33
+ `Invalid --last value: ${last}. Use formats like 30m, 24h, 7d`
34
+ );
35
+ }
36
+ return releases.filter(release => isWithinLast(release.published, durationMs));
37
+ };
38
+
39
+ const matchesSearchQuery = (release, query) => {
40
+ const searchableText = [
41
+ release.tag,
42
+ release.name,
43
+ release.releaseNotes
44
+ ].join(' ').toLowerCase();
45
+
46
+ return searchableText.includes(query.toLowerCase());
47
+ };
48
+
49
+ export const commandReleases = (sinceVersion, config, toVersion) => {
50
+ requireParameter(sinceVersion, 'releases');
51
+
52
+ const releases = fetchAndProcessReleases(config);
53
+ const prefix = extractVersionPrefix(sinceVersion);
54
+
55
+ if (!prefix) {
56
+ throw new ValidationError(`Could not extract prefix from version: ${sinceVersion}`);
57
+ }
58
+
59
+ const releasesWithPrefix = filterReleasesByPrefix(releases, prefix);
60
+ const targetRelease = findReleaseByVersion(releasesWithPrefix, sinceVersion);
61
+
62
+ if (!targetRelease) {
63
+ console.log(chalk.yellow(`\nVersion ${sinceVersion} not found.`) + ` Showing all releases with prefix "${prefix}":\n`);
64
+ const tableConfig = createReleasesTableHeader();
65
+ printTable(tableConfig);
66
+ releasesWithPrefix.forEach(release =>
67
+ formatRelease(release, { showChanges: true })
68
+ );
69
+ console.log(chalk.dim(`\nTotal: ${releasesWithPrefix.length} release(s)`));
70
+ return;
71
+ }
72
+
73
+ if (toVersion) {
74
+ const toPrefix = extractVersionPrefix(toVersion);
75
+ if (toPrefix !== prefix) {
76
+ throw new ValidationError(
77
+ `Version prefixes must match: ${sinceVersion} (${prefix}) vs ${toVersion} (${toPrefix})`
78
+ );
79
+ }
80
+
81
+ const toRelease = findReleaseByVersion(releasesWithPrefix, toVersion);
82
+ if (!toRelease) {
83
+ console.log(chalk.yellow(`\nVersion ${toVersion} not found.`) + ` Showing all releases with prefix "${prefix}":\n`);
84
+ const tableConfig = createReleasesTableHeader();
85
+ printTable(tableConfig);
86
+ releasesWithPrefix.forEach(release =>
87
+ formatRelease(release, { showChanges: true })
88
+ );
89
+ console.log(chalk.dim(`\nTotal: ${releasesWithPrefix.length} release(s)`));
90
+ return;
91
+ }
92
+
93
+ if (!isNewerThan(toRelease.published, targetRelease.published)) {
94
+ throw new ValidationError('To version must be newer than from version');
95
+ }
96
+
97
+ const rangeReleases = filterReleasesByLast(
98
+ filterReleasesInRange(releasesWithPrefix, targetRelease, toRelease),
99
+ config.last
100
+ );
101
+
102
+ console.log(`\nReleases from ${chalk.cyan(sinceVersion)} to ${chalk.cyan(toVersion)} ${chalk.dim(`(prefix: ${prefix}):`)}\n`);
103
+ const tableConfig = createReleasesTableHeader();
104
+ printTable(tableConfig);
105
+
106
+ if (rangeReleases.length === 0) {
107
+ console.log(chalk.dim('No releases found in range.'));
108
+ } else {
109
+ rangeReleases.forEach(release =>
110
+ formatRelease(release, { showChanges: true })
111
+ );
112
+ console.log(chalk.dim(`\nTotal: ${rangeReleases.length} release(s)`));
113
+ }
114
+ return;
115
+ }
116
+
117
+ const newerReleases = filterReleasesByLast(
118
+ filterReleasesNewerThan(releasesWithPrefix, targetRelease),
119
+ config.last
120
+ );
121
+
122
+ console.log(`\nReleases since ${chalk.cyan(sinceVersion)} ${chalk.dim(`(prefix: ${prefix}):`)}\n`);
123
+ const tableConfig = createReleasesTableHeader();
124
+ printTable(tableConfig);
125
+
126
+ if (newerReleases.length === 0) {
127
+ console.log(chalk.dim('No newer releases found.'));
128
+ } else {
129
+ newerReleases.forEach(release =>
130
+ formatRelease(release, { showChanges: true })
131
+ );
132
+ console.log(chalk.dim(`\nTotal: ${newerReleases.length} release(s)`));
133
+ }
134
+ };
135
+
136
+ export const commandInfo = (version, config) => {
137
+ requireParameter(version, 'info');
138
+
139
+ const releases = fetchAndProcessReleases(config);
140
+ const release = findReleaseByVersion(releases, version);
141
+
142
+ if (!release) {
143
+ throw new ValidationError(`Release not found: ${version}`);
144
+ }
145
+
146
+ formatRelease(release, { detailed: true });
147
+ };
148
+
149
+ export const commandSearch = (query, config) => {
150
+ requireParameter(query, 'search');
151
+
152
+ const releases = fetchAndProcessReleases(config);
153
+ const matchingReleases = releases.filter(release =>
154
+ matchesSearchQuery(release, query)
155
+ );
156
+
157
+ console.log(`\nSearch results for "${query}":\n`);
158
+ const tableConfig = createReleasesTableHeader();
159
+ printTable(tableConfig);
160
+
161
+ if (matchingReleases.length === 0) {
162
+ console.log(chalk.dim('No matches found.'));
163
+ } else {
164
+ matchingReleases.forEach(release =>
165
+ formatRelease(release, { showChanges: true })
166
+ );
167
+ console.log(chalk.dim(`\nTotal: ${matchingReleases.length} match(es)`));
168
+ }
169
+ };
170
+
171
+ export const commandList = (prefix, config) => {
172
+ const releases = fetchAndProcessReleases(config);
173
+
174
+ let filteredReleases = prefix
175
+ ? filterReleasesByPrefix(releases, prefix)
176
+ : releases;
177
+
178
+ if (config.last) {
179
+ const durationMs = parseDuration(config.last);
180
+ if (!durationMs) {
181
+ throw new ValidationError(
182
+ `Invalid --last value: ${config.last}. Use formats like 30m, 24h, 7d`
183
+ );
184
+ }
185
+ filteredReleases = filteredReleases.filter(release =>
186
+ isWithinLast(release.published, durationMs)
187
+ );
188
+ }
189
+
190
+ const lastSuffix = config.last ? ` (last ${config.last})` : '';
191
+ const title = prefix
192
+ ? `\nReleases with prefix "${prefix}"${lastSuffix}:`
193
+ : `\nAll releases${lastSuffix}:`;
194
+
195
+ console.log(`${title}\n`);
196
+
197
+ const tableConfig = createReleasesTableHeader();
198
+ printTable(tableConfig);
199
+
200
+ filteredReleases.forEach(release =>
201
+ formatRelease(release, { showChanges: true })
202
+ );
203
+
204
+ console.log(chalk.dim(`\nTotal: ${filteredReleases.length} release(s)`));
205
+ };
206
+
207
+ export const showHelp = () => {
208
+ console.log(`
209
+ ReleaseRadar (rr) 🚀
210
+
211
+ QUICK START:
212
+ # Set once in ~/.zshrc or ~/.bashrc
213
+ export RR_ORG=myorg RR_REPO=myrepo
214
+
215
+ # Start using immediately!
216
+ rr list
217
+
218
+ COMMANDS:
219
+ rr releases <from> [to] Compare: What's new since a version, or between two versions
220
+ Example: rr releases app-25.12.100
221
+ Example: rr releases app-25.12.100 app-25.12.105
222
+
223
+ rr search "<query>" Search: Find releases by keyword
224
+ Example: rr search "authentication fix"
225
+
226
+ rr list [prefix] Browse: All releases (optionally filtered)
227
+ Example: rr list api
228
+ Example: rr list --last 24h
229
+
230
+ rr info <tag> Details: Full info about a release
231
+ Example: rr info app-25.12.107
232
+
233
+ CONFIGURATION:
234
+ Environment Variables (recommended):
235
+ RR_ORG=myorg Your GitHub organization
236
+ RR_REPO=myrepo Your repository name
237
+ RR_LIMIT=500 Max releases to fetch (default: 300)
238
+
239
+ Override with flags:
240
+ --org <name> One-time org override
241
+ --repo <name> One-time repo override
242
+ --last <N>m|h|d Filter list to releases in the last N minutes/hours/days
243
+
244
+ EXAMPLES:
245
+ # Setup once
246
+ echo 'export RR_ORG=acme RR_REPO=backend' >> ~/.zshrc
247
+
248
+ # Find what changed since production
249
+ rr releases api-2.1.0
250
+
251
+ # Compare between two deployed versions
252
+ rr releases api-2.1.0 api-2.1.5
253
+
254
+ # Search for security patches
255
+ rr search "CVE"
256
+
257
+ # Check a specific release
258
+ rr info api-2.1.5
259
+
260
+ # Override for different repo
261
+ rr --org acme --repo frontend list
262
+
263
+ Need help? Visit: https://github.com/ghershko3/releaseradar
264
+ `);
265
+ };
@@ -0,0 +1,73 @@
1
+ import chalk from 'chalk';
2
+ import { formatIsoDate } from '../utils/date-utils.js';
3
+ import { extractFirstChangeFromNotes, truncateText, padText } from '../utils/string-utils.js';
4
+ import { createSeparator } from './table.js';
5
+ import { TABLE_WIDTHS } from '../utils/constants.js';
6
+
7
+ export const formatReleaseDetailed = (release) => {
8
+ if (!release?.tag) return;
9
+
10
+ const maxWidth = 80;
11
+ const border = '═'.repeat(maxWidth);
12
+ const light = '─'.repeat(maxWidth);
13
+
14
+ console.log(chalk.dim(border));
15
+ console.log(chalk.cyan.bold(` ${release.tag}`));
16
+ console.log(chalk.dim(light));
17
+
18
+ const formatRow = (label, value, dimValue = false) => {
19
+ const padding = ' '.repeat(2);
20
+ const labelFormatted = chalk.dim(label.padEnd(12));
21
+ const valueFormatted = dimValue ? chalk.dim(value) : value;
22
+ console.log(`${padding}${labelFormatted}${valueFormatted}`);
23
+ };
24
+
25
+ formatRow('Published:', formatIsoDate(release.published), true);
26
+ formatRow('Author:', release.author);
27
+ formatRow('Repository:', release.repo, true);
28
+
29
+ if (release.commitSha) {
30
+ formatRow('Commit:', release.commitSha.substring(0, 12), true);
31
+ }
32
+
33
+ formatRow('URL:', release.url, true);
34
+
35
+ if (release.releaseNotes && release.releaseNotes.trim()) {
36
+ console.log(chalk.dim(light));
37
+ console.log(chalk.dim(' Release Notes:'));
38
+ console.log();
39
+ const notes = release.releaseNotes.split('\n').map(line => ` ${line}`).join('\n');
40
+ console.log(notes);
41
+ }
42
+
43
+ console.log(chalk.dim(border));
44
+ };
45
+
46
+ export const formatReleaseCompact = (release, options = {}) => {
47
+ if (!release?.tag) return;
48
+
49
+ const { showChanges = false } = options;
50
+ const tag = chalk.cyan(padText(release.tag, TABLE_WIDTHS.TAG));
51
+ const date = chalk.dim(padText(formatIsoDate(release.published).substring(0, 16), TABLE_WIDTHS.DATE));
52
+ const author = padText(release.author, TABLE_WIDTHS.AUTHOR);
53
+
54
+ if (showChanges) {
55
+ const changes = extractFirstChangeFromNotes(release.releaseNotes);
56
+ const truncatedChanges = truncateText(changes, TABLE_WIDTHS.CHANGES);
57
+ const changesPadded = padText(truncatedChanges, TABLE_WIDTHS.CHANGES);
58
+ console.log(`${tag} ${date} ${author} ${changesPadded}`);
59
+ } else {
60
+ const repo = padText(release.repo, TABLE_WIDTHS.REPO);
61
+ console.log(`${tag} ${repo} ${date} ${author}`);
62
+ }
63
+ };
64
+
65
+ export const formatRelease = (release, options = {}) => {
66
+ const { detailed = false, showChanges = false } = options;
67
+
68
+ if (detailed) {
69
+ formatReleaseDetailed(release);
70
+ } else {
71
+ formatReleaseCompact(release, { showChanges });
72
+ }
73
+ };
@@ -0,0 +1,34 @@
1
+ import chalk from 'chalk';
2
+ import { TABLE_WIDTHS } from '../utils/constants.js';
3
+
4
+ export const createSeparator = (length) => {
5
+ return chalk.dim('─'.repeat(length));
6
+ };
7
+
8
+ export const createTableHeader = (columns) => {
9
+ return columns.map(col => col.text.padEnd(col.width)).join(' ');
10
+ };
11
+
12
+ export const createTableRow = (values, widths) => {
13
+ return values.map((value, index) =>
14
+ (value || '').padEnd(widths[index])
15
+ ).join(' ');
16
+ };
17
+
18
+ export const printTable = ({ headers, separator }) => {
19
+ console.log(headers);
20
+ console.log(createSeparator(separator));
21
+ };
22
+
23
+ export const createReleasesTableHeader = () => {
24
+ return {
25
+ headers: createTableHeader([
26
+ { text: 'TAG', width: TABLE_WIDTHS.TAG },
27
+ { text: 'DATE', width: TABLE_WIDTHS.DATE },
28
+ { text: 'AUTHOR', width: TABLE_WIDTHS.AUTHOR },
29
+ { text: 'CHANGES', width: TABLE_WIDTHS.CHANGES }
30
+ ]),
31
+ separator: TABLE_WIDTHS.SEPARATOR_FULL
32
+ };
33
+ };
34
+
@@ -0,0 +1,60 @@
1
+ import { fetchReleasesFromGitHub } from '../api/github.js';
2
+ import { extractVersionPrefix } from '../utils/string-utils.js';
3
+ import { validateReleases } from '../utils/validation.js';
4
+ import { RELEASE_AUTHOR_PATTERN, PR_AUTHOR_PATTERN } from '../utils/constants.js';
5
+
6
+ export const extractRealAuthorFromReleaseNotes = (releaseNotes, fallbackAuthor) => {
7
+ if (!releaseNotes) return fallbackAuthor;
8
+
9
+ const createdByMatch = releaseNotes.match(RELEASE_AUTHOR_PATTERN);
10
+ if (createdByMatch) {
11
+ return createdByMatch[1];
12
+ }
13
+
14
+ const prAuthorMatch = releaseNotes.match(PR_AUTHOR_PATTERN);
15
+ if (prAuthorMatch) {
16
+ return prAuthorMatch[1];
17
+ }
18
+
19
+ return fallbackAuthor;
20
+ };
21
+
22
+ export const transformReleaseData = (release, repoName) => {
23
+ const botAuthor = release?.author?.login ?? 'unknown';
24
+ const releaseNotes = release.body ?? '';
25
+ const realAuthor = extractRealAuthorFromReleaseNotes(releaseNotes, botAuthor);
26
+
27
+ return {
28
+ tag: release.tag_name,
29
+ name: release.name || release.tag_name,
30
+ repo: repoName,
31
+ published: release.published_at,
32
+ author: realAuthor,
33
+ releaseNotes,
34
+ url: release.html_url,
35
+ commitSha: release.target_commitish
36
+ };
37
+ };
38
+
39
+ export const sortReleasesByDate = (releases) => {
40
+ return releases.sort((a, b) =>
41
+ new Date(b.published).getTime() - new Date(a.published).getTime()
42
+ );
43
+ };
44
+
45
+ export const fetchAndProcessReleases = (config) => {
46
+ const { org, repo, releaseLimit } = config;
47
+
48
+ const rawReleases = fetchReleasesFromGitHub(org, repo, releaseLimit);
49
+ validateReleases(rawReleases);
50
+
51
+ const processedReleases = rawReleases
52
+ .filter(release => release?.tag_name)
53
+ .map(release => transformReleaseData(release, repo));
54
+
55
+ const sortedReleases = sortReleasesByDate(processedReleases);
56
+
57
+ return sortedReleases;
58
+ };
59
+
60
+ export { extractVersionPrefix };
@@ -0,0 +1,29 @@
1
+ export const DEFAULT_RELEASE_LIMIT = 300;
2
+ export const RELEASES_PER_PAGE = 100;
3
+ export const MAX_BUFFER_SIZE = 10 * 1024 * 1024;
4
+
5
+ export const ENV_VAR_KEYS = {
6
+ ORG: 'RR_ORG',
7
+ REPO: 'RR_REPO',
8
+ LIMIT: 'RR_LIMIT'
9
+ };
10
+
11
+ export const TABLE_WIDTHS = {
12
+ TAG: 30,
13
+ REPO: 20,
14
+ DATE: 17,
15
+ AUTHOR: 16,
16
+ CHANGES: 50,
17
+ SEPARATOR_FULL: 115,
18
+ SEPARATOR_STANDARD: 80
19
+ };
20
+
21
+ export const COMMANDS_REQUIRING_GH = ['releases', 'info', 'search', 'list'];
22
+
23
+ export const VERSION_PREFIX_PATTERN = /^([a-zA-Z]+-?[a-zA-Z]*-|\d+\.)/;
24
+ export const RELEASE_AUTHOR_PATTERN = /Release created by (\S+)/;
25
+ export const PR_AUTHOR_PATTERN = /by @(\S+) in/;
26
+ export const BULLET_POINT_PATTERN = /^[*-]\s+(.+)/;
27
+
28
+ export const EXCLUDED_CHANGELOG_PHRASES = ['Full Changelog', 'Services tagged'];
29
+
@@ -0,0 +1,24 @@
1
+ export const formatIsoDate = (isoDateString) => {
2
+ const date = new Date(isoDateString);
3
+ return date.toISOString().replace('T', ' ').substring(0, 19);
4
+ };
5
+
6
+ export const getTimestamp = (dateString) => {
7
+ return new Date(dateString).getTime();
8
+ };
9
+
10
+ export const isNewerThan = (date1, date2) => {
11
+ return getTimestamp(date1) > getTimestamp(date2);
12
+ };
13
+
14
+ export const parseDuration = (input) => {
15
+ const match = /^(\d+)([mhd])$/.exec((input || '').trim());
16
+ if (!match) return null;
17
+ const value = parseInt(match[1], 10);
18
+ const multipliers = { m: 60000, h: 3600000, d: 86400000 };
19
+ return value * multipliers[match[2]];
20
+ };
21
+
22
+ export const isWithinLast = (dateString, durationMs, now = Date.now()) =>
23
+ now - getTimestamp(dateString) <= durationMs;
24
+
@@ -0,0 +1,27 @@
1
+ import { execSync } from 'child_process';
2
+ import { GitHubApiError } from './validation.js';
3
+
4
+ export const executeCommand = (command, options = {}) => {
5
+ try {
6
+ return execSync(command, {
7
+ encoding: 'utf-8',
8
+ stdio: options.silent ? 'ignore' : 'pipe',
9
+ ...options
10
+ });
11
+ } catch (error) {
12
+ if (options.throwOnError !== false) {
13
+ throw new GitHubApiError(`Command failed: ${command}\n${error.message}`);
14
+ }
15
+ return null;
16
+ }
17
+ };
18
+
19
+ export const checkCommandExists = (command) => {
20
+ try {
21
+ executeCommand(`${command} --version`, { silent: true });
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ };
27
+
@@ -0,0 +1,53 @@
1
+ import {
2
+ VERSION_PREFIX_PATTERN,
3
+ BULLET_POINT_PATTERN,
4
+ EXCLUDED_CHANGELOG_PHRASES
5
+ } from './constants.js';
6
+
7
+ export const extractVersionPrefix = (version) => {
8
+ if (!version) return null;
9
+ const match = version.match(VERSION_PREFIX_PATTERN);
10
+ return match ? match[1] : null;
11
+ };
12
+
13
+ export const cleanAuthorMention = (text) => {
14
+ if (!text) return text;
15
+ return text
16
+ .replace(/\s+by @\S+/g, '')
17
+ .replace(/\s+in https?:\/\/\S+/g, '');
18
+ };
19
+
20
+ export const extractFirstChangeFromNotes = (releaseNotes) => {
21
+ if (!releaseNotes) return '';
22
+
23
+ const lines = releaseNotes.split('\n');
24
+ const changes = [];
25
+
26
+ for (const line of lines) {
27
+ const trimmedLine = line.trim();
28
+ const match = trimmedLine.match(BULLET_POINT_PATTERN);
29
+
30
+ if (match) {
31
+ const hasExcludedPhrase = EXCLUDED_CHANGELOG_PHRASES.some(
32
+ phrase => trimmedLine.includes(phrase)
33
+ );
34
+
35
+ if (!hasExcludedPhrase) {
36
+ const cleanedText = cleanAuthorMention(match[1]);
37
+ changes.push(cleanedText);
38
+ }
39
+ }
40
+ }
41
+
42
+ return changes[0] || '';
43
+ };
44
+
45
+ export const truncateText = (text, maxLength) => {
46
+ if (!text) return '';
47
+ return text.length > maxLength ? text.substring(0, maxLength) : text;
48
+ };
49
+
50
+ export const padText = (text, width) => {
51
+ return (text || '').padEnd(width);
52
+ };
53
+
@@ -0,0 +1,66 @@
1
+ import { DEFAULT_RELEASE_LIMIT } from './constants.js';
2
+
3
+ import chalk from 'chalk';
4
+
5
+ export class RrError extends Error {
6
+ constructor(message, code = 'RR_ERROR') {
7
+ super(message);
8
+ this.name = 'RrError';
9
+ this.code = code;
10
+ }
11
+ }
12
+
13
+ export class ValidationError extends RrError {
14
+ constructor(message) {
15
+ super(message, 'VALIDATION_ERROR');
16
+ this.name = 'ValidationError';
17
+ }
18
+ }
19
+
20
+ export class GitHubApiError extends RrError {
21
+ constructor(message) {
22
+ super(message, 'GITHUB_API_ERROR');
23
+ this.name = 'GitHubApiError';
24
+ }
25
+ }
26
+
27
+ export const validateConfig = (config) => {
28
+ if (!config) {
29
+ throw new ValidationError('Configuration is required');
30
+ }
31
+
32
+ const required = ['org', 'repo'];
33
+ const missing = required.filter(key => !config[key]);
34
+
35
+ if (missing.length > 0) {
36
+ throw new ValidationError(`Missing required config fields: ${missing.join(', ')}`);
37
+ }
38
+
39
+ return {
40
+ org: config.org,
41
+ repo: config.repo,
42
+ releaseLimit: config.releaseLimit ?? DEFAULT_RELEASE_LIMIT
43
+ };
44
+ };
45
+
46
+ export const requireParameter = (param, commandName) => {
47
+ if (!param) {
48
+ throw new ValidationError(
49
+ `Missing required parameter for '${commandName}' command\n\n` +
50
+ `Usage: rr ${commandName} <parameter>`
51
+ );
52
+ }
53
+ };
54
+
55
+ export const validateReleases = (releases) => {
56
+ if (!releases || releases.length === 0) {
57
+ console.log(chalk.yellow('\nNo releases found'));
58
+ console.log(chalk.dim('Tips:'));
59
+ console.log(chalk.dim(' • Check your org/repo configuration'));
60
+ console.log(chalk.dim(' • Increase limit: export RR_LIMIT=500'));
61
+ console.log(chalk.dim(' • Verify the repository has releases\n'));
62
+ throw new GitHubApiError('No releases found');
63
+ }
64
+ return releases;
65
+ };
66
+