-
Notifications
You must be signed in to change notification settings - Fork 58
314 lines (293 loc) · 15.6 KB
/
Copy pathpr_quality_check.yml
File metadata and controls
314 lines (293 loc) · 15.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
name: PR Quality Check (PR 合入质量看护)
# 触发时机:
# - pull_request_target.opened :PR 新建时触发
# - pull_request_target.synchronize :PR 中代码内容出现变更(新 commit 推送)时触发
# - issue_comment.created :在 PR 评论中输入 /pr_check(可附说明)手动触发重检
# - workflow_dispatch :管理员可手动触发以便回溯校验(非自动触发)
#
# 阻断合入的原理(请管理员在仓库 Settings -> Branches 中配置):
# 1. 本 workflow 中的 Job 名 "PR Quality Gate" 即为一条 status check。
# 2. 任意一项检查不通过时,本 Job 调用 core.setFailed() 失败,status check 为 failure。
# 3. 在分支保护规则中把 "PR Quality Gate" 加入 "Required status checks" 后,
# 只要该 check 未通过,GitHub 将不允许合入,从而达到"无论如何都不允许合入"的效果。
on:
pull_request_target:
types:
- opened # PR 创建
- synchronize # PR 中代码内容出现变更(push 新 commit)
issue_comment:
types: [created] # 在 PR 评论中输入 /pr_check 触发手动重检
workflow_dispatch:
permissions:
pull-requests: write
issues: write
contents: read
jobs:
pr-quality-gate:
name: PR Quality Gate
runs-on: ubuntu-latest
# 注意:本工作流的主要目的就是拦截"从 fork 仓库往主仓提的 PR",
# 因此不要用 if 把 fork PR 过滤掉;无论 PR 来自哪,都要执行检查。
# 阻断原理:检查不通过时调用 core.setFailed(),配合分支保护中的 Required status check 实现拦截。
#
# 仅 issue_comment 触发时按下面规则过滤(其它事件不受此 if 影响):
# 1) 评论必须挂在 PR 下(不是普通 Issue)
# 2) 评论正文必须为 /pr_check 或以 "/pr_check " 开头(允许附加参数/说明)
if: |
github.event_name != 'issue_comment' ||
(
github.event.issue.pull_request &&
(
github.event.comment.body == '/pr_check' ||
startsWith(github.event.comment.body, '/pr_check ')
)
)
steps:
- name: 确保失败标签存在(红色)
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
// GitHub 标准红色 d73a4a
const labels = [
{ name: 'large_pr', color: 'd73a4a', description: 'PR 新增代码超过 1000 行,建议拆分为更小的 PR' },
{ name: 'no_issue', color: 'd73a4a', description: 'PR 未关联任何 Issue,合入前必须关联 Issue' },
{ name: 'no_review', color: 'd73a4a', description: 'PR 未包含任何检视意见,合入前必须包含检视意见' },
{ name: 'review_opened', color: 'd73a4a', description: 'PR 中存在未闭环的检视意见,所有检视意见必须闭环后才能合入' },
{ name: 'no_UT', color: 'd73a4a', description: 'feature / bugfix 类型 PR 缺少 tests/UT 下的单元测试新增或修改' }
];
for (const label of labels) {
try {
await github.rest.issues.getLabel({ owner, repo, name: label.name });
console.log(`标签 '${label.name}' 已存在`);
} catch (e) {
if (e.status === 404) {
try {
await github.rest.issues.createLabel({ owner, repo, ...label });
console.log(`已创建红色标签 '${label.name}'`);
} catch (err) {
console.log(`创建标签 '${label.name}' 失败: ${err.message}`);
}
} else {
throw e;
}
}
}
- name: 执行 PR 合入质量五项检查
uses: actions/github-script@v7
with:
script: |
// 兼容三种事件来源:pull_request_target / issue_comment / workflow_dispatch
// - pull_request_target:payload 含 pull_request
// - issue_comment :payload 含 issue(PR 评论时 issue.number 即为 PR 号)
// - workflow_dispatch :payload 含 pull_request(GitHub 自动注入)
const prNumber = context.eventName === 'issue_comment'
? context.payload.issue.number
: context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
const triggerSource = context.eventName === 'issue_comment'
? 'PR 评论 /pr_check'
: (context.eventName === 'workflow_dispatch' ? '手动 workflow_dispatch' : context.eventName);
console.log(`开始对 PR #${prNumber} 进行合入质量检查...`);
console.log(`触发来源: ${triggerSource}`);
// 使用 GraphQL 一次性获取全部所需数据
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
additions
body
labels(first: 100) { nodes { name } }
closingIssuesReferences(first: 10) { totalCount }
reviews(first: 100) {
totalCount
nodes { state }
}
reviewThreads(first: 100) {
totalCount
nodes { isResolved }
}
}
}
}
`;
const result = await github.graphql(query, { owner, repo, number: prNumber });
const prData = result.repository.pullRequest;
const currentLabels = prData.labels.nodes.map(l => l.name);
// 分页拉取 PR 中所有变更的文件(用于检查 tests/UT 是否被改动)
const filesQuery = `
query($owner: String!, $repo: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
files(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { path }
}
}
}
}
`;
let changedFiles = [];
let fileCursor = null;
let fileHasNext = true;
while (fileHasNext) {
const fileResult = await github.graphql(filesQuery, {
owner, repo, number: prNumber, after: fileCursor
});
const filesConn = fileResult.repository.pullRequest.files;
changedFiles = changedFiles.concat(filesConn.nodes);
fileHasNext = filesConn.pageInfo.hasNextPage;
fileCursor = filesConn.pageInfo.endCursor;
}
console.log(`[信息] PR 共变更 ${changedFiles.length} 个文件`);
const FAILURE_LABELS = ['large_pr', 'no_issue', 'no_review', 'review_opened', 'no_UT'];
const failures = [];
const failureMessages = [];
// ===== 检查 1:超大 PR(新增代码行数 > 1000) =====
const additions = prData.additions || 0;
console.log(`[检查 1] PR 新增行数 = ${additions}`);
if (additions > 1000) {
failures.push('large_pr');
failureMessages.push(`- **超大 PR**:新增代码 ${additions} 行,超过 1000 行上限,建议拆分。`);
} else {
console.log('[检查 1] 通过');
}
// ===== 检查 2:必须关联 Issue =====
// 两种方式同时检测:
// a) GraphQL 的 closingIssuesReferences(Fixes/Closes 等关键字 + 侧边栏 Development 链接)
// b) PR body 中是否包含形如 #123 / issues/123 的引用
const prBody = prData.body || '';
const closingCount = prData.closingIssuesReferences.totalCount;
const bodyIssueRef = /(?:fixes|closes|resolves|relates to|ref|references)?\s*#\d+|(?:issues\/)\d+/i.test(prBody);
const hasIssueLink = closingCount > 0 || bodyIssueRef;
console.log(`[检查 2] closingIssuesReferences=${closingCount}, body 中含 #N=${bodyIssueRef}`);
if (!hasIssueLink) {
failures.push('no_issue');
failureMessages.push('- **未关联 Issue**:请在 PR 描述中关联 Issue,例如 `Fixes #123` 或 `Relates to #123`。');
} else {
console.log('[检查 2] 通过');
}
// ===== 检查 3:必须至少含一条已提交的检视意见 =====
const reviews = prData.reviews.nodes;
const submittedReviews = reviews.filter(r =>
r.state === 'APPROVED' || r.state === 'CHANGES_REQUESTED' || r.state === 'COMMENTED'
);
console.log(`[检查 3] 总 review 数 = ${reviews.length}, 已提交 review 数 = ${submittedReviews.length}`);
if (submittedReviews.length === 0) {
failures.push('no_review');
failureMessages.push('- **无检视意见**:请至少邀请一位 reviewer 提交检视意见(Approve / Request changes / Comment 均可)。');
} else {
console.log('[检查 3] 通过');
}
// ===== 检查 4:所有行内检视意见 thread 必须已闭环 =====
// reviewThread 是 GitHub 的"行内检视意见对话",每条 thread 带有 isResolved 字段;
// 当 thread 数量为 0 时视为通过(不存在需要闭环的对象)。
const threads = prData.reviewThreads.nodes;
const unresolvedThreads = threads.filter(t => !t.isResolved);
console.log(`[检查 4] reviewThread 总数 = ${threads.length}, 未闭环 = ${unresolvedThreads.length}`);
if (threads.length > 0 && unresolvedThreads.length > 0) {
failures.push('review_opened');
failureMessages.push(`- **检视意见未闭环**:还存在 ${unresolvedThreads.length} 条未 Resolve 的检视意见,请全部 Resolve 后再合入。`);
} else {
console.log('[检查 4] 通过');
}
// ===== 检查 5:feature / bugfix 类型 PR 是否包含 UT 变更 =====
// 规则:
// - 带 feature 标签:必须 tests/UT 路径下有文件新增或修改
// - 带 bugfix 标签 且 总新增 > 50 行:必须 tests/UT 路径下有文件新增或修改
// - 其余标签或 bugfix 但新增 ≤ 50:不强制要求 UT
const labelLower = currentLabels.map(l => l.toLowerCase());
const hasFeature = labelLower.includes('feature');
const hasBugfix = labelLower.includes('bugfix');
const UT_DIR_PREFIX = 'tests/UT/';
const hasUTChange = changedFiles.some(f => f.path && f.path.startsWith(UT_DIR_PREFIX));
let needUT = false;
let utReason = '';
if (hasFeature) {
needUT = true;
utReason = 'feature';
} else if (hasBugfix && additions > 50) {
needUT = true;
utReason = `bugfix 且新增 ${additions} 行 > 50 行`;
}
console.log(`[检查 5] hasFeature=${hasFeature}, hasBugfix=${hasBugfix}, additions=${additions}, hasUTChange=${hasUTChange}, needUT=${needUT}`);
if (needUT && !hasUTChange) {
failures.push('no_UT');
failureMessages.push(`- **缺少 UT**:PR 含 \`${utReason}\` 标签,但 \`${UT_DIR_PREFIX}\` 路径下没有任何文件新增或修改,请补充相应单元测试。`);
} else if (needUT && hasUTChange) {
console.log(`[检查 5] 通过(含 ${utReason} 标签且 ${UT_DIR_PREFIX} 下有变更)`);
} else {
console.log('[检查 5] 跳过(无需强制 UT)');
}
// ===== 标签维护:失败则添加对应红色标签,通过则移除 =====
const labelsToAdd = failures.filter(label => !currentLabels.includes(label));
const labelsToRemove = currentLabels.filter(label =>
FAILURE_LABELS.includes(label) && !failures.includes(label)
);
for (const label of labelsToRemove) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label
});
console.log(`已移除标签: ${label}`);
} catch (e) {
// 404 = 标签本来就不存在,可忽略;403 = fork PR 写权限受限,也可忽略;
// 这两种情况都不应让 Job 失败(status check 是否失败只取决于检查项本身)。
if (e.status === 404) {
console.log(`标签 ${label} 不存在,跳过移除`);
} else if (e.status === 403) {
console.log(`移除标签 ${label} 失败(fork PR 权限受限,可忽略): ${e.message}`);
} else {
throw e;
}
}
}
if (labelsToAdd.length > 0) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: labelsToAdd
});
console.log(`已添加标签: ${labelsToAdd.join(', ')}`);
} catch (e) {
// fork PR 的 GITHUB_TOKEN 通常为只读,写标签会 403;
// 这种情况下静默失败即可——status check 本身仍会按规则失败,照样能阻断合入。
console.log(`添加标签失败(可能为 fork PR 权限受限,可忽略): ${e.message}`);
}
}
// ===== 输出汇总 + 决定是否失败 =====
console.log('\n===== PR 合入质量检查结果 =====');
console.log(`失败项数: ${failures.length}`);
console.log(`失败项列表: ${failures.length === 0 ? '无' : failures.join(', ')}`);
if (failures.length > 0) {
const summary = [
'🚫 **PR 合入质量检查未通过,PR 已被禁止合入。**',
'',
'未通过项:' + failures.map(f => `\`${f}\``).join('、'),
'',
...failureMessages,
'',
'请按上述提示整改后,重新推送(push)或评论本 PR 即可触发再次检查。'
].join('\n');
// 在 PR 上贴一条可见评论,方便 PR 作者定位问题
try {
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body: summary
});
} catch (e) {
console.log(`创建评论失败(可忽略): ${e.message}`);
}
// 让 status check 失败 -> 分支保护若将本 Job 设为 Required,则合入被阻断
core.setFailed(summary);
} else {
try {
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: '✅ PR 合入质量五项检查全部通过,允许合入。'
});
} catch (e) {
console.log(`创建评论失败(可忽略): ${e.message}`);
}
console.log('✅ 所有检查通过,PR 可合入。');
}