-
Notifications
You must be signed in to change notification settings - Fork 0
146 lines (131 loc) · 6.48 KB
/
Copy pathpr-guard.yml
File metadata and controls
146 lines (131 loc) · 6.48 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
name: PR Guard
on:
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled]
permissions:
contents: read
pull-requests: read
jobs:
guard:
name: PR Guard
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Enforce collaboration rules
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
ALLOWED_BASE_BRANCHES: develop
OWNER_DIRECT_BRANCH_USERS: ${{ github.repository_owner }}
TRUSTED_AUTOMATION_USERS: dependabot[bot]
with:
script: |
const pr = context.payload.pull_request;
const allowedBaseBranches = process.env.ALLOWED_BASE_BRANCHES
.split(',')
.map((branch) => branch.trim())
.filter(Boolean);
const ownerDirectBranchUsers = process.env.OWNER_DIRECT_BRANCH_USERS
.split(',')
.map((login) => login.trim())
.filter(Boolean);
const trustedAutomationUsers = (process.env.TRUSTED_AUTOMATION_USERS || '')
.split(',')
.map((login) => login.trim())
.filter(Boolean);
const isTrustedAutomation = trustedAutomationUsers.includes(pr.user.login);
const failures = [];
if (!allowedBaseBranches.includes(pr.base.ref)) {
failures.push(`PR base must be one of: ${allowedBaseBranches.join(', ')}. Got '${pr.base.ref}'.`);
}
if (!pr.head.repo) {
failures.push('PR head repository is unavailable.');
} else {
const isForkPr = pr.head.repo.fork;
const isSameRepoPr = pr.head.repo.full_name === pr.base.repo.full_name;
const isOwnerDirectBranchPr =
isSameRepoPr && (ownerDirectBranchUsers.includes(pr.user.login) || isTrustedAutomation);
if (!isForkPr && !isOwnerDirectBranchPr) {
failures.push(
'PR must come from a personal fork. ' +
`Only repository owner(s) may open same-repository branch PRs: ${ownerDirectBranchUsers.join(', ')}.`
);
}
}
if (pr.head.repo) {
try {
const basehead = `${context.repo.owner}:${pr.base.ref}...${pr.head.repo.owner.login}:${pr.head.ref}`;
const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
owner: context.repo.owner,
repo: context.repo.repo,
basehead,
});
if (!['ahead', 'identical'].includes(comparison.status)) {
failures.push(
`PR branch must include the latest '${pr.base.ref}'. ` +
`Current comparison status is '${comparison.status}'. ` +
`Run: git fetch upstream && git rebase upstream/${pr.base.ref}`
);
}
} catch (error) {
failures.push(`Could not compare PR branch with '${pr.base.ref}': ${error.message}`);
}
}
const body = pr.body || '';
const hasCjk = /[\u3400-\u9fff]/u.test(body);
const hasCjkTitle = /[\u3400-\u9fff]/u.test(pr.title || '');
const conventionalTitlePattern = /^(feat|fix|docs|style|refactor|test|chore|perf|revert)(?:\([a-z0-9._-]+\))?!?: (.+)$/u;
const closingKeywordPattern = /\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#\d+\b/iu;
const noIssueReasonPattern = /^无。原因[::]\s*\S+/u;
const placeholderPattern = /(请用中文|请保留其中一种|<issue-number>|<说明|<command>|<结果>|<原因>|<风险>|已运行必要检查)/u;
const sectionValue = (heading) => {
const pattern = new RegExp(`##\\s*${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s+|$)`, 'u');
const match = body.match(pattern);
return match ? match[1].trim() : '';
};
if (!isTrustedAutomation && hasCjkTitle) {
failures.push('PR title must use English and must not contain Chinese characters.');
}
const titleMatch = String(pr.title || '').match(conventionalTitlePattern);
if (!isTrustedAutomation && (
!titleMatch ||
/^[A-Z]/u.test(titleMatch[2]) ||
titleMatch[2].endsWith('.') ||
pr.title.length > 72
)) {
failures.push('PR title must follow Conventional Commits, use a lowercase subject, omit the final period, and stay within 72 characters.');
}
if (!isTrustedAutomation && !hasCjk) {
failures.push('PR description must be written in Chinese.');
}
for (const heading of isTrustedAutomation ? [] : ['修改内容', '关联 Issue', '验证', '已知风险']) {
const value = sectionValue(heading);
if (!value) {
failures.push(`PR description is missing the '${heading}' section content.`);
continue;
}
if (placeholderPattern.test(value)) {
failures.push(`PR description section '${heading}' still contains template placeholder text.`);
}
}
const issueSection = sectionValue('关联 Issue');
const verificationSection = sectionValue('验证');
if (!isTrustedAutomation && verificationSection && (!/已运行\s*[::]/u.test(verificationSection) || !/未运行\s*[::]/u.test(verificationSection))) {
failures.push("'验证' must explicitly list both '已运行:' and '未运行:'.");
}
const normalizedIssueSection = issueSection.replace(/^[-*]\s*/u, '').trim();
if (
!isTrustedAutomation &&
issueSection &&
!closingKeywordPattern.test(issueSection) &&
!noIssueReasonPattern.test(normalizedIssueSection)
) {
failures.push(
"'关联 Issue' must contain a GitHub closing keyword such as 'Closes #123', " +
"or a no-issue reason such as '无。原因:修正文档错别字,不对应独立任务。'."
);
}
if (failures.length > 0) {
core.setFailed(`Collaboration rules failed:\n- ${failures.join('\n- ')}`);
} else {
core.info('Collaboration rules passed.');
}