hong-review-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ const storage = require('../utils/storage');
2
+
3
+ class RequestCache {
4
+ constructor(defaultTTL = 5 * 60 * 1000) {
5
+ this.cache = new Map();
6
+ this.defaultTTL = defaultTTL;
7
+ this.maxEntries = 100;
8
+ this.cleanupIntervalTime = 5 * 60 * 1000;
9
+ this.startAutoCleanup();
10
+ }
11
+
12
+ generateKey(url, params) {
13
+ const paramsStr = params ? JSON.stringify(params) : '';
14
+ return `${url}:${paramsStr}`;
15
+ }
16
+
17
+ get(key) {
18
+ const entry = this.cache.get(key);
19
+ if (!entry) return null;
20
+
21
+ if (Date.now() - entry.timestamp > entry.ttl) {
22
+ this.cache.delete(key);
23
+ return null;
24
+ }
25
+
26
+ return entry.data;
27
+ }
28
+
29
+ set(key, data, ttl) {
30
+ if (this.cache.size >= this.maxEntries && !this.cache.has(key)) {
31
+ this.evictOldest(Math.floor(this.maxEntries * 0.2));
32
+ }
33
+
34
+ this.cache.set(key, {
35
+ data,
36
+ timestamp: Date.now(),
37
+ ttl: ttl ?? this.defaultTTL
38
+ });
39
+ }
40
+
41
+ delete(key) {
42
+ return this.cache.delete(key);
43
+ }
44
+
45
+ clear() {
46
+ this.cache.clear();
47
+ }
48
+
49
+ cleanup() {
50
+ const now = Date.now();
51
+ let cleanedCount = 0;
52
+ for (const [key, entry] of this.cache.entries()) {
53
+ if (now - entry.timestamp > entry.ttl) {
54
+ this.cache.delete(key);
55
+ cleanedCount++;
56
+ }
57
+ }
58
+ return cleanedCount;
59
+ }
60
+
61
+ evictOldest(count) {
62
+ const entries = Array.from(this.cache.entries());
63
+ entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
64
+
65
+ for (let i = 0; i < Math.min(count, entries.length); i++) {
66
+ this.cache.delete(entries[i][0]);
67
+ }
68
+ }
69
+
70
+ getStats() {
71
+ const now = Date.now();
72
+ let expired = 0;
73
+ for (const entry of this.cache.values()) {
74
+ if (now - entry.timestamp > entry.ttl) {
75
+ expired++;
76
+ }
77
+ }
78
+ return {
79
+ size: this.cache.size,
80
+ expired
81
+ };
82
+ }
83
+
84
+ startAutoCleanup() {
85
+ this.cleanupInterval = setInterval(() => {
86
+ const cleaned = this.cleanup();
87
+ }, this.cleanupIntervalTime);
88
+ // 不阻断 Node 进程退出
89
+ if (this.cleanupInterval.unref) {
90
+ this.cleanupInterval.unref();
91
+ }
92
+ }
93
+
94
+ stopAutoCleanup() {
95
+ if (this.cleanupInterval) {
96
+ clearInterval(this.cleanupInterval);
97
+ this.cleanupInterval = null;
98
+ }
99
+ }
100
+
101
+ setMaxEntries(max) {
102
+ this.maxEntries = Math.max(10, max);
103
+ while (this.cache.size > this.maxEntries) {
104
+ this.evictOldest(1);
105
+ }
106
+ }
107
+
108
+ async wrap(key, fetcher, ttl) {
109
+ const cached = this.get(key);
110
+ if (cached !== null) {
111
+ return cached;
112
+ }
113
+
114
+ const data = await fetcher();
115
+ this.set(key, data, ttl);
116
+ return data;
117
+ }
118
+
119
+ destroy() {
120
+ this.stopAutoCleanup();
121
+ this.clear();
122
+ }
123
+ }
124
+
125
+ class ReviewStorage {
126
+ constructor() {
127
+ this.STORAGE_KEY = 'reviewHistory';
128
+ this.MAX_HISTORY = 50;
129
+ }
130
+
131
+ saveReview(data) {
132
+ try {
133
+ const history = this.getAllHistory();
134
+
135
+ const newRecord = {
136
+ ...data,
137
+ id: `${data.projectId}-${data.mrIid}-${Date.now()}`,
138
+ reviewTime: Date.now()
139
+ };
140
+
141
+ history.unshift(newRecord);
142
+
143
+ if (history.length > this.MAX_HISTORY) {
144
+ history.length = this.MAX_HISTORY;
145
+ }
146
+
147
+ storage.set(this.STORAGE_KEY, history);
148
+ return newRecord.id;
149
+ } catch (e) {
150
+ console.error('Failed to save review:', e);
151
+ return '';
152
+ }
153
+ }
154
+
155
+ getAllHistory() {
156
+ try {
157
+ return storage.get(this.STORAGE_KEY) || [];
158
+ } catch (e) {
159
+ return [];
160
+ }
161
+ }
162
+
163
+ getMRHistory(projectId, mrIid) {
164
+ const history = this.getAllHistory();
165
+ return history.filter(h => h.projectId === projectId && h.mrIid === mrIid);
166
+ }
167
+
168
+ getReviewById(id) {
169
+ const history = this.getAllHistory();
170
+ return history.find(h => h.id === id) || null;
171
+ }
172
+
173
+ deleteReview(id) {
174
+ try {
175
+ const history = this.getAllHistory();
176
+ const filtered = history.filter(h => h.id !== id);
177
+ storage.set(this.STORAGE_KEY, filtered);
178
+ return true;
179
+ } catch (e) {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ clearAll() {
185
+ try {
186
+ storage.set(this.STORAGE_KEY, []);
187
+ return true;
188
+ } catch (e) {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ clearMRHistory(projectId, mrIid) {
194
+ try {
195
+ const history = this.getAllHistory();
196
+ const filtered = history.filter(h => !(h.projectId === projectId && h.mrIid === mrIid));
197
+ storage.set(this.STORAGE_KEY, filtered);
198
+ return history.length - filtered.length;
199
+ } catch (e) {
200
+ return 0;
201
+ }
202
+ }
203
+
204
+ cleanupExpired() {
205
+ try {
206
+ const history = this.getAllHistory();
207
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
208
+ const filtered = history.filter(h => h.reviewTime > sevenDaysAgo);
209
+ storage.set(this.STORAGE_KEY, filtered);
210
+ return history.length - filtered.length;
211
+ } catch (e) {
212
+ return 0;
213
+ }
214
+ }
215
+ }
216
+
217
+ module.exports = {
218
+ requestCache: new RequestCache(),
219
+ reviewStorage: new ReviewStorage(),
220
+ ReviewStorage
221
+ };
@@ -0,0 +1,164 @@
1
+ const axios = require('axios');
2
+
3
+ class GitLabClient {
4
+ constructor(config) {
5
+ this.host = config.host || 'https://gitlab.com';
6
+ this.token = config.token;
7
+ this.client = axios.create({
8
+ baseURL: this.host,
9
+ headers: {
10
+ 'PRIVATE-TOKEN': this.token
11
+ },
12
+ timeout: 30000
13
+ });
14
+ }
15
+
16
+ async request(url, options = {}) {
17
+ try {
18
+ const response = await this.client.request({
19
+ url,
20
+ method: options.method || 'GET',
21
+ data: options.data,
22
+ params: options.params
23
+ });
24
+ return response.data;
25
+ } catch (error) {
26
+ let detailMessage = '';
27
+ const status = error.response ? error.response.status : 0;
28
+ const errorData = error.response ? error.response.data : null;
29
+
30
+ if (errorData) {
31
+ if (errorData.message) detailMessage = ` - ${errorData.message}`;
32
+ else if (errorData.error) detailMessage = ` - ${errorData.error}`;
33
+ }
34
+
35
+ switch (status) {
36
+ case 401: throw new Error('GitLab Token 无效或已过期');
37
+ case 403: throw new Error(`没有权限执行此操作${detailMessage}`);
38
+ case 404: throw new Error('请求的资源不存在');
39
+ default: throw new Error(`GitLab API Error: ${error.message}${detailMessage}`);
40
+ }
41
+ }
42
+ }
43
+
44
+ async getMyReviewMergeRequests() {
45
+ // 合并待办和指派
46
+ const [todos, assignedMrs] = await Promise.all([
47
+ this.request('/api/v4/todos?state=pending'),
48
+ this.request('/api/v4/merge_requests?scope=assigned_to_me&state=opened')
49
+ ]);
50
+
51
+ const uniqueMrs = new Map();
52
+
53
+ const mrTodos = todos.filter(t => t.target_type === 'MergeRequest' && t.target && t.target.state === 'opened');
54
+ mrTodos.forEach(todo => {
55
+ const mr = todo.target;
56
+ if (!uniqueMrs.has(mr.id)) {
57
+ uniqueMrs.set(mr.id, { ...mr, source: 'todo' });
58
+ }
59
+ });
60
+
61
+ assignedMrs.forEach(mr => {
62
+ if (!uniqueMrs.has(mr.id)) {
63
+ uniqueMrs.set(mr.id, { ...mr, source: 'assigned' });
64
+ }
65
+ });
66
+
67
+ return Array.from(uniqueMrs.values()).map(mr => ({
68
+ id: mr.id,
69
+ iid: mr.iid,
70
+ project_id: mr.project_id,
71
+ title: mr.title,
72
+ description: mr.description || '',
73
+ source_branch: mr.source_branch,
74
+ target_branch: mr.target_branch,
75
+ author: {
76
+ name: mr.author?.name || 'Unknown',
77
+ avatar_url: mr.author?.avatar_url || ''
78
+ },
79
+ web_url: mr.web_url,
80
+ created_at: mr.created_at
81
+ }));
82
+ }
83
+
84
+ async getMergeRequestChanges(projectId, mrIid) {
85
+ const result = await this.request(`/api/v4/projects/${encodeURIComponent(String(projectId))}/merge_requests/${mrIid}/changes`);
86
+ if (!result.changes) return [];
87
+ return result.changes.map(c => ({
88
+ old_path: c.old_path,
89
+ new_path: c.new_path,
90
+ new_file: c.new_file,
91
+ renamed_file: c.renamed_file,
92
+ deleted_file: c.deleted_file,
93
+ diff: c.diff
94
+ }));
95
+ }
96
+
97
+ async getMergeRequestDetail(projectId, mrIid) {
98
+ return await this.request(`/api/v4/projects/${encodeURIComponent(String(projectId))}/merge_requests/${mrIid}`);
99
+ }
100
+
101
+ async getFileContent(projectId, filePath, ref) {
102
+ try {
103
+ const response = await this.client.request({
104
+ url: `/api/v4/projects/${encodeURIComponent(String(projectId))}/repository/files/${encodeURIComponent(filePath)}/raw`,
105
+ method: 'GET',
106
+ params: { ref },
107
+ responseType: 'text'
108
+ });
109
+ return response.data;
110
+ } catch (err) {
111
+ console.error(`Failed to get file ${filePath}`);
112
+ throw err;
113
+ }
114
+ }
115
+
116
+ async canMergeMR(projectId, mrIid) {
117
+ try {
118
+ const mr = await this.getMergeRequestDetail(projectId, mrIid);
119
+ if (mr.user_can_merge === false) return { canMerge: false, reason: '没有合并权限' };
120
+ if (mr.merge_status !== 'can_be_merged' && mr.merge_status !== 'unchecked') return { canMerge: false, reason: '状态不允许合并' };
121
+ if (mr.has_conflicts) return { canMerge: false, reason: '存在冲突' };
122
+ return { canMerge: true };
123
+ } catch (e) {
124
+ return { canMerge: false, reason: e.message };
125
+ }
126
+ }
127
+
128
+ async merge(projectId, mrIid) {
129
+ const check = await this.canMergeMR(projectId, mrIid);
130
+ if (!check.canMerge) throw new Error(check.reason);
131
+ return await this.request(`/api/v4/projects/${encodeURIComponent(String(projectId))}/merge_requests/${mrIid}/merge`, { method: 'PUT' });
132
+ }
133
+
134
+ async getMergeRequestNotes(projectId, mrIid) {
135
+ return await this.request(`/api/v4/projects/${encodeURIComponent(String(projectId))}/merge_requests/${mrIid}/notes?sort=asc&per_page=100`);
136
+ }
137
+
138
+ async addMergeRequestNote(projectId, mrIid, params) {
139
+ return await this.request(`/api/v4/projects/${encodeURIComponent(String(projectId))}/merge_requests/${mrIid}/notes`, {
140
+ method: 'POST',
141
+ data: params
142
+ });
143
+ }
144
+
145
+ static getFileOverview(changes) {
146
+ return changes.map(change => {
147
+ let status = 'modified';
148
+ if (change.new_file) status = 'added';
149
+ else if (change.deleted_file) status = 'deleted';
150
+ else if (change.renamed_file) status = 'renamed';
151
+
152
+ const diffLines = (change.diff || '').split('\n');
153
+ let additions = 0, deletions = 0;
154
+ for (const line of diffLines) {
155
+ if (line.startsWith('+') && !line.startsWith('+++')) additions++;
156
+ else if (line.startsWith('-') && !line.startsWith('---')) deletions++;
157
+ }
158
+
159
+ return { path: change.new_path, status, additions, deletions };
160
+ });
161
+ }
162
+ }
163
+
164
+ module.exports = GitLabClient;
@@ -0,0 +1,60 @@
1
+ const { exec } = require('child_process');
2
+ const axios = require('axios');
3
+ const storage = require('./storage');
4
+ const logger = require('./logger');
5
+
6
+ class Hooks {
7
+ constructor() {
8
+ this.hooksConfig = storage.get('hooks') || {};
9
+ }
10
+
11
+ /**
12
+ * 执行指定生命周期的 Hook
13
+ */
14
+ async emit(eventName, payload = {}) {
15
+ const hookAction = this.hooksConfig[eventName];
16
+ if (!hookAction) return;
17
+
18
+ logger.info(`触发 Hook: ${eventName}...`);
19
+
20
+ try {
21
+ if (hookAction.startsWith('http')) {
22
+ // 如果是 URL,执行 Webhook 发送
23
+ await this.executeWebhook(hookAction, payload);
24
+ } else {
25
+ // 否则认为是本地的 shell command
26
+ await this.executeShell(hookAction, payload);
27
+ }
28
+ logger.success(`Hook [${eventName}] 执行成功`);
29
+ } catch (err) {
30
+ logger.error(`Hook [${eventName}] 执行失败: ${err.message}`);
31
+ }
32
+ }
33
+
34
+ executeWebhook(url, payload) {
35
+ return axios.post(url, payload, { timeout: 5000 });
36
+ }
37
+
38
+ executeShell(command, payload) {
39
+ return new Promise((resolve, reject) => {
40
+ // 将 payload 转换为环境变量传入 shell
41
+ const env = { ...process.env };
42
+
43
+ // 将 JSON payload 添加到全局环境变量中,以便 bash 脚本获取
44
+ env['HONG_REVIEW_PAYLOAD'] = JSON.stringify(payload);
45
+ // 也可以把特定的字段展开
46
+ if (payload.mrId) env['MR_ID'] = payload.mrId;
47
+ if (payload.status) env['REVIEW_STATUS'] = payload.status;
48
+
49
+ exec(command, { env }, (error, stdout, stderr) => {
50
+ if (error) {
51
+ reject(error);
52
+ } else {
53
+ resolve(stdout);
54
+ }
55
+ });
56
+ });
57
+ }
58
+ }
59
+
60
+ module.exports = new Hooks();
@@ -0,0 +1,52 @@
1
+ const chalk = require('chalk');
2
+ const ora = require('ora');
3
+
4
+ const spinners = new Map();
5
+
6
+ class Logger {
7
+ info(msg) {
8
+ console.log(chalk.blue('ℹ ' + msg));
9
+ }
10
+
11
+ success(msg) {
12
+ console.log(chalk.green('✔ ' + msg));
13
+ }
14
+
15
+ warn(msg) {
16
+ console.log(chalk.yellow('⚠ ' + msg));
17
+ }
18
+
19
+ error(msg) {
20
+ console.log(chalk.red('✖ ' + msg));
21
+ }
22
+
23
+ startSpinner(id, text) {
24
+ if (spinners.has(id)) {
25
+ spinners.get(id).stop();
26
+ }
27
+ const spinner = ora(text).start();
28
+ spinners.set(id, spinner);
29
+ }
30
+
31
+ stopSpinner(id, success = true, text = '') {
32
+ const spinner = spinners.get(id);
33
+ if (!spinner) return;
34
+
35
+ if (success) {
36
+ spinner.succeed(text || spinner.text);
37
+ } else {
38
+ spinner.fail(text || spinner.text);
39
+ }
40
+ spinners.delete(id);
41
+ }
42
+
43
+ logRaw(msg) {
44
+ console.log(msg);
45
+ }
46
+
47
+ divider() {
48
+ console.log(chalk.gray('-'.repeat(50)));
49
+ }
50
+ }
51
+
52
+ module.exports = new Logger();
@@ -0,0 +1,67 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const CONFIG_FILE = path.join(os.homedir(), '.hong-review-config.json');
6
+
7
+ /**
8
+ * 缺省配置
9
+ */
10
+ const DEFAULT_CONFIG = {
11
+ gitlabUrl: 'https://gitlab.com',
12
+ gitlabToken: '',
13
+ aiKey: '',
14
+ aiBaseUrl: 'https://api.openai.com/v1',
15
+ aiModel: 'gpt-4o',
16
+ hooks: {
17
+ onReviewStart: '',
18
+ onReviewSuccess: '',
19
+ onReviewFailed: '',
20
+ onMerged: ''
21
+ }
22
+ };
23
+
24
+ class Storage {
25
+ constructor() {
26
+ this.config = this.loadConfig();
27
+ }
28
+
29
+ loadConfig() {
30
+ if (!fs.existsSync(CONFIG_FILE)) {
31
+ return { ...DEFAULT_CONFIG };
32
+ }
33
+ try {
34
+ const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
35
+ return { ...DEFAULT_CONFIG, ...JSON.parse(data) };
36
+ } catch (e) {
37
+ console.warn('读取配置文件失败,使用默认配置', e.message);
38
+ return { ...DEFAULT_CONFIG };
39
+ }
40
+ }
41
+
42
+ saveConfig(newConfig) {
43
+ this.config = { ...this.config, ...newConfig };
44
+ try {
45
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(this.config, null, 2), 'utf-8');
46
+ return true;
47
+ } catch (e) {
48
+ console.error('保存配置文件失败', e.message);
49
+ return false;
50
+ }
51
+ }
52
+
53
+ get(key) {
54
+ return this.config[key];
55
+ }
56
+
57
+ set(key, value) {
58
+ this.config[key] = value;
59
+ return this.saveConfig(this.config);
60
+ }
61
+
62
+ getAll() {
63
+ return this.config;
64
+ }
65
+ }
66
+
67
+ module.exports = new Storage();