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 +21 -0
- package/README.md +195 -0
- package/index.js +123 -0
- package/package.json +48 -0
- package/src/README.md +279 -0
- package/src/api/github.js +76 -0
- package/src/commands/index.js +265 -0
- package/src/presentation/formatter.js +73 -0
- package/src/presentation/table.js +34 -0
- package/src/services/releases.js +60 -0
- package/src/utils/constants.js +29 -0
- package/src/utils/date-utils.js +24 -0
- package/src/utils/shell-utils.js +27 -0
- package/src/utils/string-utils.js +53 -0
- package/src/utils/validation.js +66 -0
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
|
+
[](https://github.com/ghershko3/releaseradar/releases)
|
|
4
|
+
[](https://www.npmjs.com/package/releaseradar)
|
|
5
|
+
[](https://github.com/ghershko3/releaseradar/blob/main/LICENSE)
|
|
6
|
+
[](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
|
+
|