ai-git-tools 2.0.8 → 2.0.9
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/package.json +1 -1
- package/src/commands/pr.js +5 -5
- package/src/pr-modules/ai/code-analyzer.js +387 -0
- package/src/pr-modules/ai/label-analyzer.js +113 -0
- package/src/pr-modules/core/config-loader.js +132 -0
- package/src/pr-modules/core/git-operations.js +211 -0
- package/src/pr-modules/core/github-api.js +376 -0
- package/src/pr-modules/core/workflow.js +371 -0
- package/src/pr-modules/reviewers/reviewer-selector.js +232 -0
- package/src/{utils → pr-modules/ui}/interactive-select.js +4 -27
- package/src/pr-modules/ui/logger.js +40 -0
- package/src/pr-modules/utils/constants.js +115 -0
- package/src/pr-modules/utils/helpers.js +75 -0
- package/src/core/github-api.js +0 -161
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { CONSTANTS } from '../utils/constants.js';
|
|
3
|
+
import { PRError } from '../utils/helpers.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Git 操作封裝
|
|
7
|
+
*/
|
|
8
|
+
export class GitOperations {
|
|
9
|
+
/**
|
|
10
|
+
* 偵測可用的 release 分支
|
|
11
|
+
*/
|
|
12
|
+
detectReleaseBranches() {
|
|
13
|
+
try {
|
|
14
|
+
execSync('git fetch origin', { stdio: 'ignore' });
|
|
15
|
+
const branches = execSync('git branch -r')
|
|
16
|
+
.toString()
|
|
17
|
+
.split('\n')
|
|
18
|
+
.map((b) => b.trim())
|
|
19
|
+
.filter((b) => b.startsWith('origin/release-'))
|
|
20
|
+
.map((b) => b.replace('origin/', ''));
|
|
21
|
+
return branches;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 找到最新的 release 分支
|
|
29
|
+
*/
|
|
30
|
+
findLatestReleaseBranch() {
|
|
31
|
+
const branches = this.detectReleaseBranches();
|
|
32
|
+
if (branches.length === 0) return null;
|
|
33
|
+
|
|
34
|
+
const monthlyBranches = branches.filter((b) => b.includes('-m'));
|
|
35
|
+
const weeklyBranches = branches.filter((b) => b.includes('-w'));
|
|
36
|
+
const priorityBranches = monthlyBranches.length > 0 ? monthlyBranches : weeklyBranches;
|
|
37
|
+
|
|
38
|
+
priorityBranches.sort().reverse();
|
|
39
|
+
return priorityBranches[0];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 獲取當前分支
|
|
44
|
+
*/
|
|
45
|
+
getCurrentBranch() {
|
|
46
|
+
return execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 獲取變更統計
|
|
51
|
+
*/
|
|
52
|
+
getChangeStats(baseBranch, headBranch) {
|
|
53
|
+
try {
|
|
54
|
+
const stats = execSync(`git diff --shortstat origin/${baseBranch}...${headBranch}`, {
|
|
55
|
+
encoding: 'utf-8',
|
|
56
|
+
}).trim();
|
|
57
|
+
|
|
58
|
+
const filesChanged = execSync(
|
|
59
|
+
`git diff --name-only origin/${baseBranch}...${headBranch} | wc -l`,
|
|
60
|
+
{ encoding: 'utf-8' }
|
|
61
|
+
).trim();
|
|
62
|
+
|
|
63
|
+
return { stats, filesChanged: parseInt(filesChanged, 10) };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return { stats: '無法獲取統計', filesChanged: 0 };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 獲取變更的檔案列表
|
|
71
|
+
*/
|
|
72
|
+
getChangedFiles(baseBranch, headBranch) {
|
|
73
|
+
try {
|
|
74
|
+
const files = execSync(`git diff --name-only origin/${baseBranch}...${headBranch}`, {
|
|
75
|
+
encoding: 'utf-8',
|
|
76
|
+
})
|
|
77
|
+
.split('\n')
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
return files;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 獲取 commit 列表
|
|
87
|
+
*/
|
|
88
|
+
getCommits(baseBranch, headBranch, options = {}) {
|
|
89
|
+
const { oneline = true, noDecorate = true } = options;
|
|
90
|
+
try {
|
|
91
|
+
let cmd = `git log origin/${baseBranch}..origin/${headBranch}`;
|
|
92
|
+
if (oneline) cmd += ' --oneline';
|
|
93
|
+
if (noDecorate) cmd += ' --no-decorate';
|
|
94
|
+
|
|
95
|
+
return execSync(cmd, { encoding: 'utf-8' });
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new PRError(
|
|
98
|
+
'無法比較分支差異',
|
|
99
|
+
'GIT_COMPARE_FAILED',
|
|
100
|
+
['檢查遠端分支是否存在: git branch -r', '執行診斷: npm run diagnose:pr'],
|
|
101
|
+
`git log origin/${baseBranch}..origin/${headBranch}`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 獲取 diff
|
|
108
|
+
*/
|
|
109
|
+
getDiff(baseBranch, headBranch, maxBuffer = CONSTANTS.MAX_BUFFER_SIZE) {
|
|
110
|
+
try {
|
|
111
|
+
return execSync(`git diff origin/${baseBranch}...${headBranch}`, {
|
|
112
|
+
encoding: 'utf-8',
|
|
113
|
+
maxBuffer,
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// 嘗試替代方案
|
|
117
|
+
return execSync(`git diff origin/${baseBranch}..${headBranch}`, {
|
|
118
|
+
encoding: 'utf-8',
|
|
119
|
+
maxBuffer,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 推送到遠端
|
|
126
|
+
*/
|
|
127
|
+
async push(branch) {
|
|
128
|
+
try {
|
|
129
|
+
execSync(`git push -u origin ${branch}`, { stdio: 'inherit' });
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throw new PRError(
|
|
133
|
+
'推送失敗',
|
|
134
|
+
'GIT_PUSH_FAILED',
|
|
135
|
+
['檢查是否有推送權限', '檢查遠端分支是否有衝突', '檢查網路連接'],
|
|
136
|
+
`git push -u origin ${branch}`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 同步遠端資訊
|
|
143
|
+
*/
|
|
144
|
+
async fetch() {
|
|
145
|
+
try {
|
|
146
|
+
execSync('git fetch origin', { stdio: 'ignore' });
|
|
147
|
+
return true;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 智能截斷 diff
|
|
155
|
+
*/
|
|
156
|
+
truncateDiff(diff, maxLength = CONSTANTS.MAX_DIFF_LENGTH) {
|
|
157
|
+
if (diff.length <= maxLength) return diff;
|
|
158
|
+
|
|
159
|
+
const lines = diff.split('\n');
|
|
160
|
+
const header = lines.slice(0, CONSTANTS.DIFF_CONTEXT_LINES).join('\n');
|
|
161
|
+
const footer = lines.slice(-CONSTANTS.DIFF_CONTEXT_LINES).join('\n');
|
|
162
|
+
|
|
163
|
+
const middle = `\n\n... [已省略 ${lines.length - 100} 行變更] ...\n\n`;
|
|
164
|
+
|
|
165
|
+
return header + middle + footer;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 獲取當前用戶資訊
|
|
170
|
+
*/
|
|
171
|
+
getCurrentUser() {
|
|
172
|
+
try {
|
|
173
|
+
const email = execSync('git config user.email', { encoding: 'utf-8' }).trim();
|
|
174
|
+
const name = execSync('git config user.name', { encoding: 'utf-8' }).trim();
|
|
175
|
+
let githubUser = null;
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
githubUser = execSync('gh api user --jq .login', {
|
|
179
|
+
encoding: 'utf-8',
|
|
180
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
181
|
+
}).trim();
|
|
182
|
+
} catch {
|
|
183
|
+
// GitHub CLI 未認證或未安裝
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { email, name, githubUser };
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 獲取 repository owner
|
|
194
|
+
*/
|
|
195
|
+
getRepoOwner() {
|
|
196
|
+
try {
|
|
197
|
+
const remoteUrl = execSync('git config --get remote.origin.url', {
|
|
198
|
+
encoding: 'utf-8',
|
|
199
|
+
}).trim();
|
|
200
|
+
|
|
201
|
+
// 解析 GitHub URL
|
|
202
|
+
const httpsMatch = remoteUrl.match(/github\.com[/:]([^/]+)\//);
|
|
203
|
+
const sshMatch = remoteUrl.match(/github\.com:([^/]+)\//);
|
|
204
|
+
|
|
205
|
+
const owner = httpsMatch?.[1] || sshMatch?.[1];
|
|
206
|
+
return owner || null;
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
3
|
+
import { log } from '../utils/helpers.js';
|
|
4
|
+
import { colors } from '../utils/constants.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GitHub API 操作封裝
|
|
8
|
+
*/
|
|
9
|
+
export class GitHubAPI {
|
|
10
|
+
constructor(config = {}) {
|
|
11
|
+
this.orgName = config.orgName || 'kingsinfo-project';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 安全刪除檔案(如果存在)
|
|
16
|
+
*/
|
|
17
|
+
safeUnlink(filePath) {
|
|
18
|
+
try {
|
|
19
|
+
if (existsSync(filePath)) {
|
|
20
|
+
unlinkSync(filePath);
|
|
21
|
+
}
|
|
22
|
+
} catch (error) {
|
|
23
|
+
// 忽略刪除錯誤
|
|
24
|
+
log.warning(`⚠️ 無法刪除臨時檔案: ${filePath}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 檢查 GitHub CLI 認證狀態
|
|
30
|
+
*/
|
|
31
|
+
checkAuth() {
|
|
32
|
+
try {
|
|
33
|
+
const authStatus = execSync('gh auth status 2>&1', {
|
|
34
|
+
encoding: 'utf-8',
|
|
35
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
authenticated: authStatus.includes('Logged in'),
|
|
40
|
+
details: authStatus,
|
|
41
|
+
};
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return {
|
|
44
|
+
authenticated: false,
|
|
45
|
+
details: error.message,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 從 GitHub 抓取組織的成員列表
|
|
52
|
+
*/
|
|
53
|
+
async fetchOrgMembers(orgName = this.orgName) {
|
|
54
|
+
try {
|
|
55
|
+
log.step(`正在嘗試抓取 ${orgName} 組織的成員列表...`);
|
|
56
|
+
|
|
57
|
+
const membersJson = execSync(`gh api orgs/${orgName}/members --jq '.'`, {
|
|
58
|
+
encoding: 'utf-8',
|
|
59
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const membersData = JSON.parse(membersJson);
|
|
63
|
+
const members = membersData.map((member) => ({
|
|
64
|
+
login: member.login,
|
|
65
|
+
name: member.name || member.login,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
if (members.length > 0) {
|
|
69
|
+
log.success(`成功抓取 ${members.length} 位組織成員\n`);
|
|
70
|
+
return members;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [];
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 從 GitHub 抓取組織的團隊列表
|
|
81
|
+
*/
|
|
82
|
+
async fetchTeams(orgName = this.orgName) {
|
|
83
|
+
try {
|
|
84
|
+
// 先檢查認證狀態
|
|
85
|
+
const authStatus = this.checkAuth();
|
|
86
|
+
if (!authStatus.authenticated) {
|
|
87
|
+
log.warning('GitHub CLI 未認證');
|
|
88
|
+
log.info('請執行: gh auth login\n');
|
|
89
|
+
return { teams: {}, members: [] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
log.step(`正在從 GitHub 抓取 ${orgName} 的團隊資訊...`);
|
|
93
|
+
|
|
94
|
+
let teamsJson;
|
|
95
|
+
try {
|
|
96
|
+
teamsJson = execSync(`gh api orgs/${orgName}/teams --jq '.'`, {
|
|
97
|
+
encoding: 'utf-8',
|
|
98
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// 無法存取團隊 API,使用替代方案
|
|
102
|
+
log.warning('無法存取組織團隊資訊(可能是權限問題)');
|
|
103
|
+
log.info('嘗試使用替代方案:直接抓取組織成員...\n');
|
|
104
|
+
|
|
105
|
+
const members = await this.fetchOrgMembers(orgName);
|
|
106
|
+
if (members.length > 0) {
|
|
107
|
+
return { teams: {}, members };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const teams = JSON.parse(teamsJson);
|
|
114
|
+
|
|
115
|
+
if (!teams || teams.length === 0) {
|
|
116
|
+
log.warning('未找到任何團隊,嘗試直接抓取組織成員...');
|
|
117
|
+
const members = await this.fetchOrgMembers(orgName);
|
|
118
|
+
return { teams: {}, members };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const teamData = {};
|
|
122
|
+
|
|
123
|
+
for (const team of teams) {
|
|
124
|
+
try {
|
|
125
|
+
const membersJson = execSync(
|
|
126
|
+
`gh api orgs/${orgName}/teams/${team.slug}/members --jq '.'`,
|
|
127
|
+
{
|
|
128
|
+
encoding: 'utf-8',
|
|
129
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const members = JSON.parse(membersJson);
|
|
134
|
+
teamData[team.slug] = {
|
|
135
|
+
name: team.name,
|
|
136
|
+
slug: team.slug,
|
|
137
|
+
description: team.description || '',
|
|
138
|
+
members: members.map((m) => ({
|
|
139
|
+
login: m.login,
|
|
140
|
+
name: m.name || m.login,
|
|
141
|
+
})),
|
|
142
|
+
};
|
|
143
|
+
} catch (error) {
|
|
144
|
+
log.warning(`無法抓取團隊 ${team.slug} 的成員: ${error.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 同時取得所有成員作為備選
|
|
149
|
+
const allMembers = await this.fetchOrgMembers(orgName);
|
|
150
|
+
|
|
151
|
+
log.success(
|
|
152
|
+
`成功抓取 ${Object.keys(teamData).length} 個團隊和 ${allMembers.length} 位成員\n`
|
|
153
|
+
);
|
|
154
|
+
return { teams: teamData, members: allMembers };
|
|
155
|
+
} catch (error) {
|
|
156
|
+
log.warning('無法從 GitHub 抓取資訊');
|
|
157
|
+
log.info('請確認:');
|
|
158
|
+
console.log(' 1. 已安裝 GitHub CLI: brew install gh');
|
|
159
|
+
console.log(' 2. 已執行認證: gh auth login');
|
|
160
|
+
console.log(' 3. 選擇正確的認證範圍(需要 read:org 權限)');
|
|
161
|
+
console.log(` 4. 有權限存取 ${orgName} 組織`);
|
|
162
|
+
console.log(' 5. 組織名稱正確(可用 --org 參數指定)\n');
|
|
163
|
+
|
|
164
|
+
log.info('提示: 你仍可以手動輸入 reviewer 的 GitHub username\n');
|
|
165
|
+
|
|
166
|
+
return { teams: {}, members: [] };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 創建或更新 PR
|
|
172
|
+
*/
|
|
173
|
+
async createOrUpdatePR(params) {
|
|
174
|
+
const { title, body, baseBranch, headBranch, reviewers, config } = params;
|
|
175
|
+
const bodyFile = '/tmp/pr-body.md';
|
|
176
|
+
writeFileSync(bodyFile, body);
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
// 檢查 PR 是否已存在
|
|
180
|
+
let existingPRUrl = null;
|
|
181
|
+
try {
|
|
182
|
+
existingPRUrl = execSync(
|
|
183
|
+
`gh pr list --head "${headBranch}" --base "${baseBranch}" --json url --jq '.[0].url'`,
|
|
184
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
185
|
+
).trim();
|
|
186
|
+
} catch (error) {
|
|
187
|
+
// PR 不存在
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const draftFlag = config.draft ? '--draft' : '';
|
|
191
|
+
const escapedTitle = title.replace(/"/g, '\\"');
|
|
192
|
+
|
|
193
|
+
if (existingPRUrl) {
|
|
194
|
+
// 更新現有 PR
|
|
195
|
+
log.step('偵測到現有 PR,正在更新內容...');
|
|
196
|
+
|
|
197
|
+
const updateCmd = `gh pr edit "${existingPRUrl}" --title "${escapedTitle}" --body-file "${bodyFile}"`;
|
|
198
|
+
execSync(updateCmd, { stdio: 'inherit' });
|
|
199
|
+
|
|
200
|
+
// 更新 reviewers
|
|
201
|
+
if (reviewers) {
|
|
202
|
+
await this.addReviewers(existingPRUrl, reviewers);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
log.success('Pull Request 已更新!');
|
|
206
|
+
console.log(`🔗 ${existingPRUrl}\n`);
|
|
207
|
+
|
|
208
|
+
this.safeUnlink(bodyFile);
|
|
209
|
+
return existingPRUrl;
|
|
210
|
+
} else {
|
|
211
|
+
// 創建新 PR
|
|
212
|
+
log.step('正在創建 Pull Request...');
|
|
213
|
+
|
|
214
|
+
// 使用完整的分支格式(包含組織名稱)以避免 GitHub API 錯誤
|
|
215
|
+
const fullHeadBranch = headBranch.includes(':')
|
|
216
|
+
? headBranch
|
|
217
|
+
: `${this.orgName}:${headBranch}`;
|
|
218
|
+
let createCmd = `gh pr create --base "${baseBranch}" --head "${fullHeadBranch}" --title "${escapedTitle}" --body-file "${bodyFile}"`;
|
|
219
|
+
|
|
220
|
+
if (draftFlag) {
|
|
221
|
+
createCmd += ` ${draftFlag}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const result = execSync(createCmd, {
|
|
226
|
+
encoding: 'utf-8',
|
|
227
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
228
|
+
});
|
|
229
|
+
log.success(`Pull Request 創建成功!${config.draft ? ' (草稿模式)' : ''}`);
|
|
230
|
+
|
|
231
|
+
// 提取 PR URL
|
|
232
|
+
const prUrl = result
|
|
233
|
+
.trim()
|
|
234
|
+
.split('\n')
|
|
235
|
+
.find((line) => line.includes('https://'));
|
|
236
|
+
|
|
237
|
+
if (prUrl) {
|
|
238
|
+
console.log(`🔗 ${prUrl}`);
|
|
239
|
+
|
|
240
|
+
// 添加 reviewers
|
|
241
|
+
if (reviewers) {
|
|
242
|
+
const prNumber = prUrl.split('/').pop();
|
|
243
|
+
await this.addReviewersByAPI(prNumber, reviewers);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this.safeUnlink(bodyFile);
|
|
248
|
+
return prUrl;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
this.safeUnlink(bodyFile);
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch (error) {
|
|
255
|
+
this.safeUnlink(bodyFile);
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 添加 Reviewers (給已存在的 PR)
|
|
262
|
+
*/
|
|
263
|
+
async addReviewers(prUrl, reviewers) {
|
|
264
|
+
if (!reviewers) return;
|
|
265
|
+
|
|
266
|
+
const prNumber = prUrl.split('/').pop();
|
|
267
|
+
|
|
268
|
+
if (reviewers.individuals && reviewers.individuals.length > 0) {
|
|
269
|
+
await this.addReviewersByAPI(prNumber, { individuals: reviewers.individuals });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (reviewers.teams && reviewers.teams.length > 0) {
|
|
273
|
+
await this.addTeamReviewers(prNumber, reviewers.teams);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 使用 API 添加個人 Reviewers
|
|
279
|
+
*/
|
|
280
|
+
async addReviewersByAPI(prNumber, reviewers) {
|
|
281
|
+
if (!reviewers.individuals || reviewers.individuals.length === 0) return;
|
|
282
|
+
|
|
283
|
+
log.info(`正在添加個人 Reviewers (共 ${reviewers.individuals.length} 位)...`);
|
|
284
|
+
console.log(
|
|
285
|
+
`${colors.blue}準備添加: ${reviewers.individuals.map((r) => `@${r}`).join(', ')}${
|
|
286
|
+
colors.reset
|
|
287
|
+
}\n`
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const successfulReviewers = [];
|
|
291
|
+
const failedReviewers = [];
|
|
292
|
+
|
|
293
|
+
for (const username of reviewers.individuals) {
|
|
294
|
+
try {
|
|
295
|
+
console.log(`${colors.cyan}正在添加 @${username}...${colors.reset}`);
|
|
296
|
+
|
|
297
|
+
const requestBody = JSON.stringify({ reviewers: [username] });
|
|
298
|
+
|
|
299
|
+
execSync(
|
|
300
|
+
`printf '%s' '${requestBody.replace(
|
|
301
|
+
/'/g,
|
|
302
|
+
"'\\''"
|
|
303
|
+
)}' | gh api repos/:owner/:repo/pulls/${prNumber}/requested_reviewers --input - -X POST`,
|
|
304
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// 等待 GitHub 處理
|
|
308
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
309
|
+
|
|
310
|
+
// 驗證是否成功
|
|
311
|
+
const verifyResponse = execSync(
|
|
312
|
+
`gh api repos/:owner/:repo/pulls/${prNumber} --jq '.requested_reviewers[].login'`,
|
|
313
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
314
|
+
).trim();
|
|
315
|
+
|
|
316
|
+
const currentReviewers = verifyResponse.split('\n').filter(Boolean);
|
|
317
|
+
|
|
318
|
+
if (currentReviewers.includes(username)) {
|
|
319
|
+
console.log(` ${colors.green}✓ 成功添加 @${username}${colors.reset}\n`);
|
|
320
|
+
successfulReviewers.push(username);
|
|
321
|
+
} else {
|
|
322
|
+
console.log(` ${colors.yellow}⚠ 無法添加 @${username}${colors.reset}\n`);
|
|
323
|
+
failedReviewers.push({ username, reason: 'API 調用成功但未添加' });
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.log(` ${colors.red}✗ 失敗: ${error.message}${colors.reset}\n`);
|
|
327
|
+
failedReviewers.push({ username, reason: error.message });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 顯示結果
|
|
332
|
+
if (successfulReviewers.length > 0) {
|
|
333
|
+
log.success(`成功添加 ${successfulReviewers.length} 位 Reviewers`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (failedReviewers.length > 0) {
|
|
337
|
+
log.warning(`失敗 ${failedReviewers.length} 位,請手動添加`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 添加團隊 Reviewers
|
|
343
|
+
*/
|
|
344
|
+
async addTeamReviewers(prNumber, teams) {
|
|
345
|
+
if (!teams || teams.length === 0) return;
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
log.info('正在添加團隊 Reviewers...');
|
|
349
|
+
const requestBody = JSON.stringify({ team_reviewers: teams });
|
|
350
|
+
|
|
351
|
+
execSync(
|
|
352
|
+
`printf '%s' '${requestBody.replace(
|
|
353
|
+
/'/g,
|
|
354
|
+
"'\\''"
|
|
355
|
+
)}' | gh api repos/:owner/:repo/pulls/${prNumber}/requested_reviewers --input - -X POST`,
|
|
356
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
360
|
+
|
|
361
|
+
// 驗證
|
|
362
|
+
const verifyResponse = execSync(
|
|
363
|
+
`gh api repos/:owner/:repo/pulls/${prNumber} --jq '.requested_teams[].slug'`,
|
|
364
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
365
|
+
).trim();
|
|
366
|
+
|
|
367
|
+
const actualTeams = verifyResponse.split('\n').filter(Boolean);
|
|
368
|
+
|
|
369
|
+
if (actualTeams.length > 0) {
|
|
370
|
+
log.success(`成功添加團隊: ${actualTeams.join(', ')}`);
|
|
371
|
+
}
|
|
372
|
+
} catch (error) {
|
|
373
|
+
log.error('無法添加團隊 reviewers,請手動操作');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|