返回 CodeWhale
approve-contributor.yml
根目录 / .github / workflows / approve-contributor.yml
1 name: Approve gated contributor
2
3 on:
4 issue_comment:
5 types: [created]
6
7 permissions:
8 contents: write
9 issues: write
10 pull-requests: write
11
12 concurrency:
13 group: contribution-gate-approval
14 cancel-in-progress: false
15
16 jobs:
17 approve:
18 runs-on: ubuntu-latest
19 steps:
20 - name: Open allowlist update PR
21 uses: actions/github-script@v9
22 with:
23 script: |
24 const comment = context.payload.comment;
25 const issue = context.payload.issue;
26 const owner = context.repo.owner;
27 const repo = context.repo.repo;
28 const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
29 const command = (comment.body || '').trim().toLowerCase();
30 const scopeByCommand = new Map([
31 ['/lgtm', 'pr'],
32 ['lgtm', 'pr'],
33 ['/lgtmi', 'issue'],
34 ['lgtmi', 'issue'],
35 ]);
36 const scope = scopeByCommand.get(command);
37
38 if (!scope) return;
39 if (!privileged.has(comment.author_association)) return;
40 if (scope === 'pr' && !issue.pull_request) {
41 await github.rest.issues.createComment({
42 owner,
43 repo,
44 issue_number: issue.number,
45 body: '`/lgtm` grants PR access and must be used on a pull request. Use `/lgtmi` to grant issue access.',
46 });
47 return;
48 }
49 if (scope === 'issue' && issue.pull_request) {
50 await github.rest.issues.createComment({
51 owner,
52 repo,
53 issue_number: issue.number,
54 body: '`/lgtmi` grants issue access and must be used on an issue. Use `/lgtm` to grant PR access.',
55 });
56 return;
57 }
58
59 const path = '.github/APPROVED_CONTRIBUTORS';
60 const targetLogin = issue.user.login;
61 const normalizedLogin = targetLogin.toLowerCase();
62 const entry = `${scope}:${normalizedLogin}`;
63 const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor';
64
65 const defaultContent = [
66 '# Scoped contribution-gate allowlist.',
67 '#',
68 '# Maintainers and collaborators bypass the gate automatically. Use this file',
69 '# for external contributors who are allowed through the automated front door.',
70 '# Seed active contributors here before switching the gate workflows to enforce mode.',
71 '#',
72 '# Supported entries:',
73 '# pr:username',
74 '# issue:username',
75 '# all:username',
76 '',
77 ].join('\n');
78
79 function parseAllowlist(content) {
80 return new Set(
81 content
82 .split(/\r?\n/)
83 .map(line => line.replace(/#.*/, '').trim().toLowerCase())
84 .filter(Boolean)
85 );
86 }
87
88 const { data: repoData } = await github.rest.repos.get({ owner, repo });
89 const defaultBranch = repoData.default_branch;
90 const { data: baseRef } = await github.rest.git.getRef({
91 owner,
92 repo,
93 ref: `heads/${defaultBranch}`,
94 });
95 const baseSha = baseRef.object.sha;
96 const { data: baseCommit } = await github.rest.git.getCommit({
97 owner,
98 repo,
99 commit_sha: baseSha,
100 });
101
102 let content = defaultContent;
103 try {
104 const { data } = await github.rest.repos.getContent({
105 owner,
106 repo,
107 path,
108 ref: defaultBranch,
109 });
110 if (!Array.isArray(data) && data.type === 'file') {
111 content = Buffer.from(data.content, data.encoding || 'base64').toString('utf8');
112 }
113 } catch (error) {
114 if (error.status !== 404) throw error;
115 }
116
117 const existing = parseAllowlist(content);
118 if (existing.has(entry) || existing.has(`all:${normalizedLogin}`)) {
119 await github.rest.issues.createComment({
120 owner,
121 repo,
122 issue_number: issue.number,
123 body: `@${targetLogin} is already approved for ${scope} contributions in \`${path}\`.`,
124 });
125 return;
126 }
127
128 const openPrs = [];
129 for (let page = 1; ; page++) {
130 const { data: pagePrs } = await github.rest.pulls.list({
131 owner,
132 repo,
133 state: 'open',
134 per_page: 100,
135 page,
136 });
137 openPrs.push(...pagePrs);
138 if (pagePrs.length < 100) break;
139 }
140 const repoFullName = `${owner}/${repo}`.toLowerCase();
141 const pendingPr = openPrs.find(openPr => {
142 const sameRepo = (openPr.head?.repo?.full_name || '').toLowerCase() === repoFullName;
143 const body = openPr.body || '';
144 return sameRepo && body.includes(`Adds \`${entry}\` to \`${path}\`.`);
145 });
146
147 if (pendingPr) {
148 await github.rest.issues.createComment({
149 owner,
150 repo,
151 issue_number: issue.number,
152 body: `@${targetLogin} already has a pending allowlist update PR for ${scope} contributions: ${pendingPr.html_url}`,
153 });
154 return;
155 }
156
157 const nextContent = `${content.trimEnd()}\n${entry}\n`;
158 const { data: blob } = await github.rest.git.createBlob({
159 owner,
160 repo,
161 content: nextContent,
162 encoding: 'utf-8',
163 });
164 const { data: tree } = await github.rest.git.createTree({
165 owner,
166 repo,
167 base_tree: baseCommit.tree.sha,
168 tree: [
169 {
170 path,
171 mode: '100644',
172 type: 'blob',
173 sha: blob.sha,
174 },
175 ],
176 });
177
178 const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`;
179 await github.rest.git.createRef({
180 owner,
181 repo,
182 ref: `refs/heads/${branchName}`,
183 sha: baseSha,
184 });
185
186 const { data: commit } = await github.rest.git.createCommit({
187 owner,
188 repo,
189 message: `chore: approve @${targetLogin} for ${scope} contributions`,
190 tree: tree.sha,
191 parents: [baseSha],
192 });
193 await github.rest.git.updateRef({
194 owner,
195 repo,
196 ref: `heads/${branchName}`,
197 sha: commit.sha,
198 });
199
200 const { data: pr } = await github.rest.pulls.create({
201 owner,
202 repo,
203 title: `chore: approve @${targetLogin} for ${scope} contributions`,
204 head: branchName,
205 base: defaultBranch,
206 body: [
207 `Adds \`${entry}\` to \`${path}\`.`,
208 '',
209 `Requested by @${comment.user.login} in #${issue.number}.`,
210 ].join('\n'),
211 });
212
213 await github.rest.issues.createComment({
214 owner,
215 repo,
216 issue_number: issue.number,
217 body: `Created allowlist update PR: ${pr.html_url}`,
218 });
219
219 lines YAML