glab-tool 1.0.4
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/.glabrc.example +11 -0
- package/README.md +193 -0
- package/index.js +39 -0
- package/package.json +35 -0
- package/src/cleanup.js +445 -0
- package/src/index.js +27 -0
- package/src/list.js +208 -0
package/.glabrc.example
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# GitLab configuration file
|
|
2
|
+
# Place this file in your current working directory as .glabrc
|
|
3
|
+
|
|
4
|
+
# GitLab API URL
|
|
5
|
+
GITLAB_URL=https://gitlab.example.com/api/v4
|
|
6
|
+
|
|
7
|
+
# GitLab Personal Access Token
|
|
8
|
+
GITLAB_TOKEN=your_personal_access_token_here
|
|
9
|
+
|
|
10
|
+
# Project ID or path
|
|
11
|
+
PROJECT_ID=my-group/my-project
|
package/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# glab-tool - GitLab Extended CLI Tool
|
|
2
|
+
|
|
3
|
+
一个多功能的 GitLab 命令行工具集。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
- **列出分支** - 显示项目所有分支及其详细信息(创建日期、作者、保护状态)
|
|
8
|
+
- **智能清理** - 清理已合并或已关闭的合并请求对应的源分支
|
|
9
|
+
- **交互式选择** - 使用 inquirer 进行交互式分支选择
|
|
10
|
+
- **按状态分组** - 分支按 "已合并" 和 "已关闭" 分组展示
|
|
11
|
+
- **干运行模式** - 预览将删除的分支而不实际执行
|
|
12
|
+
- **强制删除** - 支持强制删除受保护分支
|
|
13
|
+
- **通配符支持** - 目标分支支持通配符匹配(如 `release/*`)
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g glab-tool
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 快速开始
|
|
22
|
+
|
|
23
|
+
1. 创建配置文件 `.glabrc`:
|
|
24
|
+
```ini
|
|
25
|
+
gitlab_url=https://gitlab.example.com/api/v4
|
|
26
|
+
gitlab_token=your_access_token
|
|
27
|
+
project_id=your_project_id
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
2. 列出所有分支:
|
|
31
|
+
```bash
|
|
32
|
+
glabx list
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
3. 清理分支(交互式选择):
|
|
36
|
+
```bash
|
|
37
|
+
glabx clean
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 使用说明
|
|
41
|
+
|
|
42
|
+
### 全局选项
|
|
43
|
+
|
|
44
|
+
- `--config <file>`: 配置文件路径(默认: `.glabrc`)
|
|
45
|
+
|
|
46
|
+
### 命令
|
|
47
|
+
|
|
48
|
+
#### `glabx list`
|
|
49
|
+
|
|
50
|
+
列出 GitLab 项目的所有分支。
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
glabx list
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
输出示例:
|
|
57
|
+
```
|
|
58
|
+
Found 15 branches:
|
|
59
|
+
+-------------------+--------------+------------------+-----------+
|
|
60
|
+
| Branch | Created Date | Author | Protected |
|
|
61
|
+
+-------------------+--------------+------------------+-----------+
|
|
62
|
+
| main | 2024-01-15 | John Doe | Yes |
|
|
63
|
+
| feature/user-auth | 2024-02-20 | Jane Smith | No |
|
|
64
|
+
| bugfix/login | 2024-02-22 | Bob Wilson | No |
|
|
65
|
+
+-------------------+--------------+------------------+-----------+
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
#### `glabx clean`
|
|
69
|
+
|
|
70
|
+
清理目标分支的已合并/已关闭合并请求对应的源分支。
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
glabx clean [options]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**选项**:
|
|
77
|
+
- `--target <branch>`: 目标分支(默认: `main`),支持通配符如 `release/*`
|
|
78
|
+
- `--force`: 强制删除受保护分支
|
|
79
|
+
- `-d, --dry-run`: 预览模式,显示将删除的分支但不实际删除
|
|
80
|
+
|
|
81
|
+
**使用示例**:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# 清理 targeting main 分支的分支
|
|
85
|
+
glabx clean --target main
|
|
86
|
+
|
|
87
|
+
# 预览将删除的分支
|
|
88
|
+
glabx clean --target main --dry-run
|
|
89
|
+
|
|
90
|
+
# 强制删除(包括受保护分支)
|
|
91
|
+
glabx clean --target main --force
|
|
92
|
+
|
|
93
|
+
# 使用通配符匹配多个目标分支
|
|
94
|
+
glabx clean --target release/*
|
|
95
|
+
|
|
96
|
+
# 交互式选择(无参数)
|
|
97
|
+
glabx clean
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## 配置
|
|
101
|
+
|
|
102
|
+
支持三种配置方式(优先级从高到低):
|
|
103
|
+
|
|
104
|
+
### 1. 配置文件 `.glabrc`
|
|
105
|
+
|
|
106
|
+
```ini
|
|
107
|
+
# GitLab API 地址
|
|
108
|
+
gitlab_url=https://gitlab.example.com/api/v4
|
|
109
|
+
|
|
110
|
+
# GitLab 访问令牌
|
|
111
|
+
gitlab_token=glpat-xxxxxxxxxxxx
|
|
112
|
+
|
|
113
|
+
# 项目 ID 或路径(如 group/project)
|
|
114
|
+
project_id=123
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 2. 环境变量
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
export GITLAB_URL=https://gitlab.example.com/api/v4
|
|
121
|
+
export GITLAB_TOKEN=glpat-xxxxxxxxxxxx
|
|
122
|
+
export PROJECT_ID=123
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 3. 命令行参数
|
|
126
|
+
|
|
127
|
+
(当前版本通过配置文件或环境变量配置)
|
|
128
|
+
|
|
129
|
+
## 技术栈
|
|
130
|
+
|
|
131
|
+
- **Node.js** >= 18.0.0
|
|
132
|
+
- **Commander** ^13.1.0 - CLI 命令解析
|
|
133
|
+
- **Axios** ^1.6.0 - GitLab API 客户端
|
|
134
|
+
- **Inquirer** ^9.2.12 - 交互式提示
|
|
135
|
+
|
|
136
|
+
## 项目结构
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
git-clean/
|
|
140
|
+
├── index.js # 主入口,CLI 命令定义
|
|
141
|
+
├── package.json
|
|
142
|
+
├── src/
|
|
143
|
+
│ ├── list.js # 列出分支功能
|
|
144
|
+
│ └── cleanup.js # 清理分支功能
|
|
145
|
+
└── README.md
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## 架构说明
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
CLI 层 (index.js)
|
|
152
|
+
↓
|
|
153
|
+
业务层 (list.js / cleanup.js)
|
|
154
|
+
↓
|
|
155
|
+
工具层 (配置读取、API 调用)
|
|
156
|
+
↓
|
|
157
|
+
外部层 (GitLab API、配置文件)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## 数据流向
|
|
161
|
+
|
|
162
|
+
### 列出分支流程
|
|
163
|
+
1. 读取配置(文件 → 环境变量)
|
|
164
|
+
2. 创建 GitLab API 客户端
|
|
165
|
+
3. 分页获取所有分支
|
|
166
|
+
4. 对每个分支获取最后提交信息
|
|
167
|
+
5. 格式化输出表格
|
|
168
|
+
|
|
169
|
+
### 清理分支流程
|
|
170
|
+
1. 读取配置
|
|
171
|
+
2. 获取目标分支的已合并/已关闭 MR
|
|
172
|
+
3. 获取当前存在的分支
|
|
173
|
+
4. 过滤出可清理的分支
|
|
174
|
+
5. 交互式选择 → 确认 → 删除
|
|
175
|
+
|
|
176
|
+
## 开发
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
# 克隆项目
|
|
180
|
+
git clone <repo>
|
|
181
|
+
cd git-clean
|
|
182
|
+
|
|
183
|
+
# 安装依赖
|
|
184
|
+
npm install
|
|
185
|
+
|
|
186
|
+
# 本地运行
|
|
187
|
+
node index.js list
|
|
188
|
+
node index.js clean
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## 许可证
|
|
192
|
+
|
|
193
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { cleanup } from './src/cleanup.js';
|
|
5
|
+
import { list } from './src/list.js';
|
|
6
|
+
|
|
7
|
+
const program = new Command();
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name('glabx')
|
|
11
|
+
.description('A CLI tool to clean up invalid GitLab branches')
|
|
12
|
+
.version('1.0.0');
|
|
13
|
+
|
|
14
|
+
// 添加 clean 命令
|
|
15
|
+
program
|
|
16
|
+
.command('clean')
|
|
17
|
+
.description('Clean up GitLab branches from merge requests targeting main branch')
|
|
18
|
+
.option('--target <branch>', 'Target branch to filter merge requests (default: main)', 'main')
|
|
19
|
+
.option('--force', 'Force delete protected branches')
|
|
20
|
+
.option('-d, --dry-run', 'Show what would be deleted without actually deleting')
|
|
21
|
+
.action(async (options) => {
|
|
22
|
+
await cleanup(options);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// 添加 list 命令
|
|
26
|
+
program
|
|
27
|
+
.command('list')
|
|
28
|
+
.description('List all GitLab branches')
|
|
29
|
+
.action(async (options) => {
|
|
30
|
+
await list(options);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// 解析命令行参数
|
|
34
|
+
program.parse();
|
|
35
|
+
|
|
36
|
+
// 如果没有提供任何命令,则显示帮助
|
|
37
|
+
if (!process.argv.slice(2).length) {
|
|
38
|
+
program.help();
|
|
39
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "glab-tool",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "GitLab Extended CLI Tool - A multi-purpose GitLab utility CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"glabx": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/*",
|
|
12
|
+
".glabrc.example",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node index.js",
|
|
17
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"gitlab",
|
|
21
|
+
"branch",
|
|
22
|
+
"cleanup",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"author": "GitLab Branch Cleaner",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"axios": "^1.6.0",
|
|
29
|
+
"commander": "^13.1.0",
|
|
30
|
+
"inquirer": "^9.2.12"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/cleanup.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
|
|
5
|
+
// 从配置文件读取配置
|
|
6
|
+
function readConfig(configPath) {
|
|
7
|
+
const config = {};
|
|
8
|
+
|
|
9
|
+
if (fs.existsSync(configPath)) {
|
|
10
|
+
try {
|
|
11
|
+
const configContent = fs.readFileSync(configPath, 'utf8');
|
|
12
|
+
const lines = configContent.split('\n');
|
|
13
|
+
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
const trimmedLine = line.trim();
|
|
16
|
+
// 跳过空行和注释
|
|
17
|
+
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 解析键值对格式 (key=value)
|
|
22
|
+
const [key, value] = trimmedLine.split('=').map(part => part.trim());
|
|
23
|
+
if (key && value) {
|
|
24
|
+
config[key.toLowerCase()] = value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.warn('Warning: Could not read config file:', error.message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return config;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 获取配置值
|
|
36
|
+
function getConfigValue(config, key) {
|
|
37
|
+
// 优先级:配置文件 > 环境变量 > 默认值
|
|
38
|
+
return config[key] ||
|
|
39
|
+
process.env[key.toUpperCase()] ||
|
|
40
|
+
null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 获取指定目标分支的合并请求(获取已合并和已关闭的)
|
|
44
|
+
async function getMergeRequestsByTargetBranch(apiClient, projectId, targetBranch) {
|
|
45
|
+
const mergeRequests = [];
|
|
46
|
+
let page = 1;
|
|
47
|
+
let hasNextPage = true;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// 先获取已合并的
|
|
51
|
+
while (hasNextPage) {
|
|
52
|
+
const response = await apiClient.get(`/projects/${projectId}/merge_requests`, {
|
|
53
|
+
params: {
|
|
54
|
+
page: page,
|
|
55
|
+
per_page: 100,
|
|
56
|
+
state: 'merged',
|
|
57
|
+
target_branch: targetBranch
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
mergeRequests.push(...response.data);
|
|
62
|
+
|
|
63
|
+
// 检查是否有下一页
|
|
64
|
+
const linkHeader = response.headers.link;
|
|
65
|
+
hasNextPage = linkHeader && linkHeader.includes('rel="next"');
|
|
66
|
+
page++;
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error('Error fetching merged merge requests:', error);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 重置页码获取已关闭的
|
|
74
|
+
page = 1;
|
|
75
|
+
hasNextPage = true;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
while (hasNextPage) {
|
|
79
|
+
const response = await apiClient.get(`/projects/${projectId}/merge_requests`, {
|
|
80
|
+
params: {
|
|
81
|
+
page: page,
|
|
82
|
+
per_page: 100,
|
|
83
|
+
state: 'closed',
|
|
84
|
+
target_branch: targetBranch
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
mergeRequests.push(...response.data);
|
|
89
|
+
|
|
90
|
+
// 检查是否有下一页
|
|
91
|
+
const linkHeader = response.headers.link;
|
|
92
|
+
hasNextPage = linkHeader && linkHeader.includes('rel="next"');
|
|
93
|
+
page++;
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('Error fetching closed merge requests:', error);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return mergeRequests;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 检查分支名称是否匹配模式(支持通配符 *)
|
|
104
|
+
function matchesPattern(branchName, pattern) {
|
|
105
|
+
if (!pattern.includes('*')) {
|
|
106
|
+
// 不包含通配符,直接比较
|
|
107
|
+
return branchName === pattern;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 将通配符转换为正则表达式
|
|
111
|
+
const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
112
|
+
const regex = new RegExp(`^${escapedPattern}$`);
|
|
113
|
+
return regex.test(branchName);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 删除分支
|
|
117
|
+
async function deleteBranch(apiClient, projectId, branchName, force = false) {
|
|
118
|
+
try {
|
|
119
|
+
const deleteUrl = `/projects/${projectId}/repository/branches/${encodeURIComponent(branchName)}`;
|
|
120
|
+
|
|
121
|
+
// 如果是强制删除,需要使用不同的API端点
|
|
122
|
+
if (force) {
|
|
123
|
+
// 使用强制删除的API端点
|
|
124
|
+
await apiClient.delete(deleteUrl, {
|
|
125
|
+
params: {
|
|
126
|
+
force: true
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
// 正常删除
|
|
131
|
+
await apiClient.delete(deleteUrl);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(`Successfully deleted branch: ${branchName}`);
|
|
135
|
+
return true;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error.response?.status === 403) {
|
|
138
|
+
console.error(`Error: Cannot delete protected branch "${branchName}". Use --force to override.`);
|
|
139
|
+
} else if (error.response?.status === 404) {
|
|
140
|
+
console.error(`Error: Branch "${branchName}" not found.`);
|
|
141
|
+
} else {
|
|
142
|
+
console.error(`Error deleting branch "${branchName}":`, error.message);
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 获取项目中的所有分支名称
|
|
149
|
+
async function getAllBranches(apiClient, projectId) {
|
|
150
|
+
const branches = [];
|
|
151
|
+
let page = 1;
|
|
152
|
+
let hasNextPage = true;
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
while (hasNextPage) {
|
|
156
|
+
const response = await apiClient.get(`/projects/${projectId}/repository/branches`, {
|
|
157
|
+
params: {
|
|
158
|
+
page: page,
|
|
159
|
+
per_page: 100
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
branches.push(...response.data.map(branch => branch.name));
|
|
164
|
+
|
|
165
|
+
// 检查是否有下一页
|
|
166
|
+
const linkHeader = response.headers.link;
|
|
167
|
+
hasNextPage = linkHeader && linkHeader.includes('rel="next"');
|
|
168
|
+
page++;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return branches;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error('Error fetching branches:', error);
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 清理分支
|
|
179
|
+
async function cleanup(options) {
|
|
180
|
+
// 读取配置
|
|
181
|
+
const configPath = options.config || '.glabrc';
|
|
182
|
+
const config = readConfig(configPath);
|
|
183
|
+
|
|
184
|
+
// 获取配置值
|
|
185
|
+
const gitlabUrl = getConfigValue(config, 'gitlab_url');
|
|
186
|
+
const token = getConfigValue(config, 'gitlab_token');
|
|
187
|
+
const projectId = getConfigValue(config, 'project_id');
|
|
188
|
+
|
|
189
|
+
// 验证必需选项
|
|
190
|
+
if (!gitlabUrl) {
|
|
191
|
+
console.error('Error: GitLab API URL is required');
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!token) {
|
|
196
|
+
console.error('Error: GitLab token is required');
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!projectId) {
|
|
201
|
+
console.error('Error: Project ID or path is required');
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 构建 GitLab API 基础 URL
|
|
206
|
+
const gitlabBaseUrl = gitlabUrl.replace(/\/$/, ''); // 移除末尾的斜杠
|
|
207
|
+
|
|
208
|
+
// 创建 axios 实例
|
|
209
|
+
const apiClient = axios.create({
|
|
210
|
+
baseURL: gitlabBaseUrl,
|
|
211
|
+
headers: {
|
|
212
|
+
'PRIVATE-TOKEN': token,
|
|
213
|
+
'Content-Type': 'application/json'
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// 对项目ID进行URL编码(如果包含特殊字符)
|
|
218
|
+
const encodedProjectId = encodeURIComponent(projectId);
|
|
219
|
+
|
|
220
|
+
// 获取指定目标分支的合并请求
|
|
221
|
+
try {
|
|
222
|
+
const targetBranch = options.target || 'main';
|
|
223
|
+
let targetMergeRequests = [];
|
|
224
|
+
|
|
225
|
+
// 如果目标分支包含通配符,则获取所有合并请求并在客户端过滤
|
|
226
|
+
if (targetBranch.includes('*')) {
|
|
227
|
+
// 获取所有合并请求,不指定目标分支(包括已合并和已关闭的)
|
|
228
|
+
const allMergeRequests = [];
|
|
229
|
+
let page = 1;
|
|
230
|
+
let hasNextPage = true;
|
|
231
|
+
|
|
232
|
+
// 先获取已合并的
|
|
233
|
+
while (hasNextPage) {
|
|
234
|
+
const response = await apiClient.get(`/projects/${encodedProjectId}/merge_requests`, {
|
|
235
|
+
params: {
|
|
236
|
+
page: page,
|
|
237
|
+
per_page: 100,
|
|
238
|
+
state: 'merged'
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
allMergeRequests.push(...response.data);
|
|
243
|
+
|
|
244
|
+
// 检查是否有下一页
|
|
245
|
+
const linkHeader = response.headers.link;
|
|
246
|
+
hasNextPage = linkHeader && linkHeader.includes('rel="next"');
|
|
247
|
+
page++;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 重置页码获取已关闭的
|
|
251
|
+
page = 1;
|
|
252
|
+
hasNextPage = true;
|
|
253
|
+
|
|
254
|
+
while (hasNextPage) {
|
|
255
|
+
const response = await apiClient.get(`/projects/${encodedProjectId}/merge_requests`, {
|
|
256
|
+
params: {
|
|
257
|
+
page: page,
|
|
258
|
+
per_page: 100,
|
|
259
|
+
state: 'closed'
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
allMergeRequests.push(...response.data);
|
|
264
|
+
|
|
265
|
+
// 检查是否有下一页
|
|
266
|
+
const linkHeader = response.headers.link;
|
|
267
|
+
hasNextPage = linkHeader && linkHeader.includes('rel="next"');
|
|
268
|
+
page++;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 根据目标分支模式过滤合并请求
|
|
272
|
+
targetMergeRequests = allMergeRequests.filter(mr => matchesPattern(mr.target_branch, targetBranch));
|
|
273
|
+
} else {
|
|
274
|
+
// 如果不包含通配符,使用原来的 API 查询方式
|
|
275
|
+
targetMergeRequests = await getMergeRequestsByTargetBranch(apiClient, encodedProjectId, targetBranch);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (targetMergeRequests.length === 0) {
|
|
279
|
+
console.log(`No merge requests targeting the "${targetBranch}" branch found.`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 获取项目中所有实际存在的分支
|
|
284
|
+
const allBranches = await getAllBranches(apiClient, encodedProjectId);
|
|
285
|
+
|
|
286
|
+
// 过滤出实际存在的分支,并按状态分组
|
|
287
|
+
const mergedBranches = [];
|
|
288
|
+
const closedBranches = [];
|
|
289
|
+
|
|
290
|
+
for (const mr of targetMergeRequests) {
|
|
291
|
+
if (allBranches.includes(mr.source_branch)) {
|
|
292
|
+
if (mr.state === 'merged') {
|
|
293
|
+
mergedBranches.push(mr);
|
|
294
|
+
} else if (mr.state === 'closed') {
|
|
295
|
+
closedBranches.push(mr);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 如果两个分组都为空
|
|
301
|
+
if (mergedBranches.length === 0 && closedBranches.length === 0) {
|
|
302
|
+
console.log(`No existing merge requests targeting the "${targetBranch}" branch found.`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 询问用户是否确认删除
|
|
307
|
+
if (options.dryRun) {
|
|
308
|
+
console.log('Dry run mode: No branches will be deleted.');
|
|
309
|
+
// 显示分组统计信息
|
|
310
|
+
if (mergedBranches.length > 0) {
|
|
311
|
+
console.log(`\nMerged branches (${mergedBranches.length}):`);
|
|
312
|
+
mergedBranches.forEach(mr => console.log(` - MR !${mr.iid}: ${mr.source_branch}`));
|
|
313
|
+
}
|
|
314
|
+
if (closedBranches.length > 0) {
|
|
315
|
+
console.log(`\nClosed branches (${closedBranches.length}):`);
|
|
316
|
+
closedBranches.forEach(mr => console.log(` - MR !${mr.iid}: ${mr.source_branch}`));
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 如果有 --force 参数,直接删除所有分支
|
|
322
|
+
if (options.force) {
|
|
323
|
+
console.log('Force mode enabled: Deleting all branches without confirmation...');
|
|
324
|
+
const allBranchesToDelete = [...mergedBranches, ...closedBranches];
|
|
325
|
+
for (const mr of allBranchesToDelete) {
|
|
326
|
+
await deleteBranch(apiClient, encodedProjectId, mr.source_branch, true);
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 创建选择项数组,按状态分组显示
|
|
332
|
+
const choices = [];
|
|
333
|
+
|
|
334
|
+
// 添加已合并分组
|
|
335
|
+
if (mergedBranches.length > 0) {
|
|
336
|
+
choices.push(new inquirer.Separator(' 📦 Merged branches (' + mergedBranches.length + ')'));
|
|
337
|
+
mergedBranches.forEach((mr, index) => {
|
|
338
|
+
choices.push({
|
|
339
|
+
name: `${index + 1}. MR !${mr.iid}: ${mr.title} (${mr.source_branch})`,
|
|
340
|
+
value: {
|
|
341
|
+
id: index + 1,
|
|
342
|
+
mrId: mr.iid,
|
|
343
|
+
sourceBranch: mr.source_branch,
|
|
344
|
+
title: mr.title,
|
|
345
|
+
state: 'merged'
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 添加已关闭分组
|
|
352
|
+
if (closedBranches.length > 0) {
|
|
353
|
+
choices.push(new inquirer.Separator(' ❌ Closed branches (' + closedBranches.length + ')'));
|
|
354
|
+
closedBranches.forEach((mr, index) => {
|
|
355
|
+
choices.push({
|
|
356
|
+
name: `${mergedBranches.length + index + 1}. MR !${mr.iid}: ${mr.title} (${mr.source_branch})`,
|
|
357
|
+
value: {
|
|
358
|
+
id: mergedBranches.length + index + 1,
|
|
359
|
+
mrId: mr.iid,
|
|
360
|
+
sourceBranch: mr.source_branch,
|
|
361
|
+
title: mr.title,
|
|
362
|
+
state: 'closed'
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 使用 inquirer 进行交互式选择
|
|
369
|
+
const questions = [
|
|
370
|
+
{
|
|
371
|
+
type: 'checkbox',
|
|
372
|
+
name: 'selectedBranches',
|
|
373
|
+
message: 'Select branches to delete:',
|
|
374
|
+
choices: choices,
|
|
375
|
+
validate: function (answer) {
|
|
376
|
+
if (answer.length < 1) {
|
|
377
|
+
return 'You must select at least one branch.';
|
|
378
|
+
}
|
|
379
|
+
return true;
|
|
380
|
+
},
|
|
381
|
+
pageSize: 100, // 设置较大的值以显示更多选项
|
|
382
|
+
loop: false, // 禁止循环选择,防止从最后一个回到第一个
|
|
383
|
+
// 添加额外的安全配置
|
|
384
|
+
searchable: false
|
|
385
|
+
}
|
|
386
|
+
];
|
|
387
|
+
|
|
388
|
+
const answers = await inquirer.prompt(questions);
|
|
389
|
+
|
|
390
|
+
// 获取用户选择的分支信息
|
|
391
|
+
const selectedBranches = answers.selectedBranches;
|
|
392
|
+
|
|
393
|
+
if (selectedBranches.length === 0) {
|
|
394
|
+
console.log('No branches selected for deletion.');
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 显示用户选择的分支(按状态分组显示)
|
|
399
|
+
console.log('\nSelected branches to delete:');
|
|
400
|
+
|
|
401
|
+
const selectedMerged = selectedBranches.filter(b => b.state === 'merged');
|
|
402
|
+
const selectedClosed = selectedBranches.filter(b => b.state === 'closed');
|
|
403
|
+
|
|
404
|
+
if (selectedMerged.length > 0) {
|
|
405
|
+
console.log(`\n📦 Merged branches (${selectedMerged.length}):`);
|
|
406
|
+
selectedMerged.forEach(branch => {
|
|
407
|
+
console.log(` - MR !${branch.mrId}: ${branch.sourceBranch}`);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (selectedClosed.length > 0) {
|
|
412
|
+
console.log(`\n❌ Closed branches (${selectedClosed.length}):`);
|
|
413
|
+
selectedClosed.forEach(branch => {
|
|
414
|
+
console.log(` - MR !${branch.mrId}: ${branch.sourceBranch}`);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// 确认删除
|
|
419
|
+
const confirmQuestions = [
|
|
420
|
+
{
|
|
421
|
+
type: 'confirm',
|
|
422
|
+
name: 'confirmDelete',
|
|
423
|
+
message: '\nDo you want to delete these branches?',
|
|
424
|
+
default: false
|
|
425
|
+
}
|
|
426
|
+
];
|
|
427
|
+
|
|
428
|
+
const confirmAnswers = await inquirer.prompt(confirmQuestions);
|
|
429
|
+
|
|
430
|
+
if (confirmAnswers.confirmDelete) {
|
|
431
|
+
console.log('\nDeleting branches...');
|
|
432
|
+
for (const branch of selectedBranches) {
|
|
433
|
+
await deleteBranch(apiClient, encodedProjectId, branch.sourceBranch, options.force);
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
console.log('\nOperation cancelled by user.');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
} catch (error) {
|
|
440
|
+
console.error('Error cleaning up branches:', error.message);
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export { cleanup };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { list } from "./list.js";
|
|
5
|
+
|
|
6
|
+
const program = new Command();
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name("glbc")
|
|
10
|
+
.description("A CLI tool to clean up invalid GitLab branches")
|
|
11
|
+
.version("1.0.0");
|
|
12
|
+
|
|
13
|
+
// 添加 list 命令
|
|
14
|
+
program
|
|
15
|
+
.command("list")
|
|
16
|
+
.description("List all GitLab branches")
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
await list(options);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// 解析命令行参数
|
|
22
|
+
program.parse();
|
|
23
|
+
|
|
24
|
+
// 如果没有提供任何命令,则显示帮助
|
|
25
|
+
if (!process.argv.slice(2).length) {
|
|
26
|
+
program.help();
|
|
27
|
+
}
|
package/src/list.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
|
|
4
|
+
// 从配置文件读取配置
|
|
5
|
+
function readConfig(configPath) {
|
|
6
|
+
const config = {};
|
|
7
|
+
|
|
8
|
+
if (fs.existsSync(configPath)) {
|
|
9
|
+
try {
|
|
10
|
+
const configContent = fs.readFileSync(configPath, 'utf8');
|
|
11
|
+
const lines = configContent.split('\n');
|
|
12
|
+
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
const trimmedLine = line.trim();
|
|
15
|
+
// 跳过空行和注释
|
|
16
|
+
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 解析键值对格式 (key=value)
|
|
21
|
+
const [key, value] = trimmedLine.split('=').map(part => part.trim());
|
|
22
|
+
if (key && value) {
|
|
23
|
+
config[key.toLowerCase()] = value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.warn('Warning: Could not read config file:', error.message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return config;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 获取配置值
|
|
35
|
+
function getConfigValue(config, key) {
|
|
36
|
+
// 优先级:配置文件 > 环境变量 > 默认值
|
|
37
|
+
return config[key] ||
|
|
38
|
+
process.env[key.toUpperCase()] ||
|
|
39
|
+
null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 获取所有分支
|
|
43
|
+
async function getAllBranches(apiClient, projectId) {
|
|
44
|
+
const branches = [];
|
|
45
|
+
let page = 1;
|
|
46
|
+
let hasNextPage = true;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
while (hasNextPage) {
|
|
50
|
+
const response = await apiClient.get(`/projects/${projectId}/repository/branches`, {
|
|
51
|
+
params: {
|
|
52
|
+
page: page,
|
|
53
|
+
per_page: 100
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
branches.push(...response.data);
|
|
58
|
+
|
|
59
|
+
// 检查是否有下一页
|
|
60
|
+
const linkHeader = response.headers.link;
|
|
61
|
+
hasNextPage = linkHeader && linkHeader.includes('rel="next"');
|
|
62
|
+
page++;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return branches;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('Error fetching branches:', error.message);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 获取分支最后提交信息(包括提交者信息)
|
|
73
|
+
async function getLastCommitInfo(apiClient, projectId, branchName) {
|
|
74
|
+
try {
|
|
75
|
+
const response = await apiClient.get(`/projects/${projectId}/repository/commits`, {
|
|
76
|
+
params: {
|
|
77
|
+
ref_name: branchName,
|
|
78
|
+
per_page: 1
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (response.data.length > 0) {
|
|
83
|
+
const commit = response.data[0];
|
|
84
|
+
return {
|
|
85
|
+
date: new Date(commit.committed_date),
|
|
86
|
+
author: commit.author_name || 'Unknown'
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error(`Error fetching commit info for branch ${branchName}:`, error.message);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 列出所有分支
|
|
98
|
+
async function list(options) {
|
|
99
|
+
// 读取配置
|
|
100
|
+
const configPath = options.config || '.glabrc';
|
|
101
|
+
const config = readConfig(configPath);
|
|
102
|
+
|
|
103
|
+
// 获取配置值
|
|
104
|
+
const gitlabUrl = getConfigValue(config, 'gitlab_url');
|
|
105
|
+
const token = getConfigValue(config, 'gitlab_token');
|
|
106
|
+
const projectId = getConfigValue(config, 'project_id');
|
|
107
|
+
|
|
108
|
+
// 验证必需选项
|
|
109
|
+
if (!gitlabUrl) {
|
|
110
|
+
console.error('Error: GitLab API URL is required');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!token) {
|
|
115
|
+
console.error('Error: GitLab token is required');
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!projectId) {
|
|
120
|
+
console.error('Error: Project ID or path is required');
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 构建 GitLab API 基础 URL
|
|
125
|
+
const gitlabBaseUrl = gitlabUrl.replace(/\/$/, ''); // 移除末尾的斜杠
|
|
126
|
+
|
|
127
|
+
// 创建 axios 实例
|
|
128
|
+
const apiClient = axios.create({
|
|
129
|
+
baseURL: gitlabBaseUrl,
|
|
130
|
+
headers: {
|
|
131
|
+
'PRIVATE-TOKEN': token,
|
|
132
|
+
'Content-Type': 'application/json'
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// 对项目ID进行URL编码(如果包含特殊字符)
|
|
137
|
+
const encodedProjectId = encodeURIComponent(projectId);
|
|
138
|
+
|
|
139
|
+
// 列出所有分支
|
|
140
|
+
try {
|
|
141
|
+
const branches = await getAllBranches(apiClient, encodedProjectId);
|
|
142
|
+
|
|
143
|
+
// 计算每列的最大宽度
|
|
144
|
+
let maxBranchWidth = 6; // "Branch" header length
|
|
145
|
+
let maxDateWidth = 12; // "Created Date" header length
|
|
146
|
+
let maxAuthorWidth = 6; // "Author" header length
|
|
147
|
+
let maxProtectedWidth = 8; // "Protected" header length
|
|
148
|
+
|
|
149
|
+
// 遍历所有分支计算最大宽度
|
|
150
|
+
for (const branch of branches) {
|
|
151
|
+
maxBranchWidth = Math.max(maxBranchWidth, branch.name.length);
|
|
152
|
+
// 日期格式通常是 YYYY-MM-DD,固定长度为 10
|
|
153
|
+
maxDateWidth = Math.max(maxDateWidth, 10);
|
|
154
|
+
// 作者名称可能较长,但通常不会太长
|
|
155
|
+
maxAuthorWidth = Math.max(maxAuthorWidth, 16);
|
|
156
|
+
maxProtectedWidth = Math.max(maxProtectedWidth, 8);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 确保最小宽度
|
|
160
|
+
maxBranchWidth = Math.max(maxBranchWidth, 6);
|
|
161
|
+
maxDateWidth = Math.max(maxDateWidth, 12);
|
|
162
|
+
maxAuthorWidth = Math.max(maxAuthorWidth, 6);
|
|
163
|
+
maxProtectedWidth = Math.max(maxProtectedWidth, 8);
|
|
164
|
+
|
|
165
|
+
// 添加一些额外空间
|
|
166
|
+
maxBranchWidth += 2;
|
|
167
|
+
maxDateWidth += 2;
|
|
168
|
+
maxAuthorWidth += 2;
|
|
169
|
+
maxProtectedWidth += 2;
|
|
170
|
+
|
|
171
|
+
// 构建分隔线
|
|
172
|
+
const separator = '+' + '-'.repeat(maxBranchWidth) + '+' + '-'.repeat(maxDateWidth) + '+' + '-'.repeat(maxAuthorWidth) + '+' + '-'.repeat(maxProtectedWidth) + '+';
|
|
173
|
+
|
|
174
|
+
// 输出表格头部
|
|
175
|
+
console.log(`\nFound ${branches.length} branches:`);
|
|
176
|
+
console.log(separator);
|
|
177
|
+
console.log(`| ${'Branch'.padEnd(maxBranchWidth - 2)} | ${'Created Date'.padEnd(maxDateWidth - 2)} | ${'Author'.padEnd(maxAuthorWidth - 2)} | ${'Protected'.padEnd(maxProtectedWidth - 2)} |`);
|
|
178
|
+
console.log(separator);
|
|
179
|
+
|
|
180
|
+
// 输出每个分支的信息
|
|
181
|
+
for (const branch of branches) {
|
|
182
|
+
// 获取最后提交信息
|
|
183
|
+
const commitInfo = await getLastCommitInfo(apiClient, encodedProjectId, branch.name);
|
|
184
|
+
|
|
185
|
+
// 格式化分支名称(不截断)
|
|
186
|
+
const branchName = branch.name;
|
|
187
|
+
|
|
188
|
+
// 格式化日期
|
|
189
|
+
const commitDate = commitInfo ? commitInfo.date.toISOString().split('T')[0] : 'Unknown';
|
|
190
|
+
|
|
191
|
+
// 格式化作者名称
|
|
192
|
+
const author = commitInfo ? (commitInfo.author.length > maxAuthorWidth - 2 ? commitInfo.author.substring(0, maxAuthorWidth - 5) + '...' : commitInfo.author) : 'Unknown';
|
|
193
|
+
|
|
194
|
+
// 格式化保护状态
|
|
195
|
+
const protectedStatus = branch.protected ? 'Yes' : 'No';
|
|
196
|
+
|
|
197
|
+
// 输出表格行
|
|
198
|
+
console.log(`| ${branchName.padEnd(maxBranchWidth - 2)} | ${commitDate.padEnd(maxDateWidth - 2)} | ${author.padEnd(maxAuthorWidth - 2)} | ${protectedStatus.padEnd(maxProtectedWidth - 2)} |`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(separator);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.error('Error listing branches:', error.message);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export { list };
|