Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/ISSUE_TEMPLATE/reward-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,20 @@ labels:
body:
- type: markdown
attributes:
value: This form is created from https://github.com/idea2app/GitHub-reward
value: |
This form is created from https://github.com/idea2app/GitHub-reward

Please discuss the task in GitHub Discussions first. A maintainer should
confirm the scope before a reward issue is opened and assigned.

- type: input
id: discussion
attributes:
label: Approved discussion
description: URL of the GitHub Discussion where a maintainer confirmed the task
placeholder: https://github.com/orgs/Open-Source-Bazaar/discussions/123
validations:
required: true

- type: textarea
id: description
Expand Down
36 changes: 25 additions & 11 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
Checklist(清单):

<!-- Please follow this checklist and put an x in each of the boxes, like this: [x].(请遵循此清单,并在每个 [ ] 中输入 x,如下所示:[x]。) -->

- [ ] Labels
- [ ] Assignees
- [ ] Reviewers

<!--If your pull request closes a GitHub issue, replace the XXXXX below with the issue number.(如果 pull request 关闭一个 GitHub issue,请用 GitHub issue 编号替换下面的 XXXXX)-->

Closes #XXXXX
## 变更说明

<!-- 请说明改了什么、为什么这样改,以及主要影响范围。不要只粘贴 Issue 标题。 -->

## 关联任务

<!-- 使用 Closes #123、Fixes #123 或 Resolves #123 关联任务。 -->

Closes #XXXXX

## 验证方式

<!-- 请填写实际运行的命令、测试场景和结果。未运行测试时请解释原因。 -->

```text
命令:
结果:
```

## 贡献声明

- [ ] 我已阅读并理解本次改动,能够回答维护者的问题
- [ ] 我已实际运行上方验证步骤,并如实记录结果

AI 使用情况:<!-- 填写“未使用”,或说明使用了什么工具以及人工复核内容 -->
98 changes: 98 additions & 0 deletions .github/scripts/contributor-trust/rules.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const trustedAssociations = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);

function cleanText(value = '') {
return value
.replace(/<!--[^]*?-->/g, '')
.replace(/```[^]*?```/g, match => match.replace(/```\w*/g, ''))
.trim();
}

function section(body, names) {
const lines = body.split(/\r?\n/);
const heading = new RegExp(`^##\\s+(?:${names.join('|')})\\s*$`, 'i');
const start = lines.findIndex(line => heading.test(line.trim()));
if (start < 0) return '';

const content = [];
for (let index = start + 1; index < lines.length; index += 1) {
if (/^##\s+/.test(lines[index].trim())) break;
content.push(lines[index]);
}
return cleanText(content.join('\n'));
}

function hasChecked(body, phrase) {
const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`-\\s*\\[[xX]\\]\\s*${escaped}`, 'u').test(body);
}

export function evaluatePullRequest({
body = '',
draft = false,
authorAssociation = 'NONE',
authorType = 'User',
changedFiles = 0,
additions = 0,
rewardAuthorization,
}) {
if (draft) {
return { passed: true, skipped: true, missing: [], reviewReasons: [] };
}

const trusted = trustedAssociations.has(authorAssociation);
const summary = section(body, ['变更说明', 'Summary']);
const verification = section(body, ['验证方式', 'Verification']);
const missing = [];

if (!trusted) {
if (summary.length < 20) missing.push('补充不少于 20 字的变更说明');
if (
verification.length < 12 ||
/^(?:未运行|未测试|none|not run|n\/?a|无)[。.!\s]*$/iu.test(verification)
) {
missing.push('填写实际运行的验证命令、场景和结果');
}
if (!/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#\d+/iu.test(body)) {
missing.push('使用 Closes/Fixes/Resolves #编号 关联任务');
}
if (rewardAuthorization?.required && !rewardAuthorization.authorized) {
missing.push(
`奖励任务 #${rewardAuthorization.issueNumbers.join(', #')} 需要维护者先指派给提交者或添加 implementation-approved 标签`,
);
}
if (!hasChecked(body, '我已阅读并理解本次改动,能够回答维护者的问题')) {
missing.push('勾选“已阅读并理解本次改动”责任声明');
}
if (!hasChecked(body, '我已实际运行上方验证步骤,并如实记录结果')) {
missing.push('勾选“已实际运行验证步骤”责任声明');
}

const disclosure = body.match(/^AI 使用情况:\s*(.+)$/imu)?.[1]?.trim() ?? '';
if (!disclosure || /^(?:未填写|待填写|todo|n\/?a)$/iu.test(disclosure)) {
missing.push('如实填写 AI 使用情况及人工复核内容');
}
}

const reviewReasons = [];
if (authorType === 'Bot') reviewReasons.push('机器人账号提交');
if (changedFiles > 50) reviewReasons.push(`改动文件较多(${changedFiles})`);
if (additions > 1500) reviewReasons.push(`新增代码量较大(${additions} 行)`);

return {
passed: missing.length === 0,
skipped: false,
trusted,
missing,
reviewReasons,
};
}

export function linkedIssueNumbers(body = '') {
return [
...new Set(
[...body.matchAll(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/giu)].map(match =>
Number(match[1]),
),
),
];
}
87 changes: 87 additions & 0 deletions .github/scripts/contributor-trust/rules.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { evaluatePullRequest, linkedIssueNumbers } from './rules.mjs';

const completeBody = `## 变更说明

修复奖励任务页面的状态同步问题,并补充回归覆盖以避免同类问题再次出现。

## 关联任务

Closes #89

## 验证方式

运行 pnpm test,全部测试通过;随后执行 pnpm build,构建成功。

## 贡献声明

- [x] 我已阅读并理解本次改动,能够回答维护者的问题
- [x] 我已实际运行上方验证步骤,并如实记录结果

AI 使用情况:使用了代码补全,已逐行复核并运行测试。`;

test('passes a complete external contribution', () => {
const result = evaluatePullRequest({ body: completeBody });
assert.equal(result.passed, true);
assert.deepEqual(result.missing, []);
});

test('lists every missing proof for an empty template', () => {
const result = evaluatePullRequest({ body: 'Closes #XXXXX' });
assert.equal(result.passed, false);
assert.equal(result.missing.length, 6);
});

test('trusted maintainers bypass contributor proof fields', () => {
const result = evaluatePullRequest({ body: '', authorAssociation: 'MEMBER' });
assert.equal(result.passed, true);
assert.equal(result.trusted, true);
});

test('draft pull requests are skipped until ready', () => {
const result = evaluatePullRequest({ body: '', draft: true });
assert.equal(result.passed, true);
assert.equal(result.skipped, true);
});

test('flags bots and unusually large changes for manual review', () => {
const result = evaluatePullRequest({
body: completeBody,
authorType: 'Bot',
changedFiles: 51,
additions: 1501,
});
assert.equal(result.passed, true);
assert.equal(result.reviewReasons.length, 3);
});

test('requires maintainer authorization for reward work', () => {
const result = evaluatePullRequest({
body: completeBody,
rewardAuthorization: {
required: true,
authorized: false,
issueNumbers: [89],
},
});
assert.equal(result.passed, false);
assert.match(result.missing.join('\n'), /implementation-approved/);
});

test('accepts reward work assigned or approved by a maintainer', () => {
const result = evaluatePullRequest({
body: completeBody,
rewardAuthorization: {
required: true,
authorized: true,
issueNumbers: [89],
},
});
assert.equal(result.passed, true);
});

test('extracts unique closing issue references', () => {
assert.deepEqual(linkedIssueNumbers('Closes #89, fixes #90 and resolves #89'), [89, 90]);
});
134 changes: 134 additions & 0 deletions .github/scripts/contributor-trust/run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { appendFile } from 'node:fs/promises';

import { evaluatePullRequest, linkedIssueNumbers } from './rules.mjs';

const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? '').split('/');
const pullNumber = Number(process.env.TRUST_PR_NUMBER);
const token = process.env.GITHUB_TOKEN;
const marker = '<!-- contributor-trust-gate -->';

if (!owner || !repo || !Number.isInteger(pullNumber) || !token) {
throw new Error('GITHUB_REPOSITORY, TRUST_PR_NUMBER and GITHUB_TOKEN are required');
}

async function api(path, options = {}, allowNotFound = false) {
const response = await fetch(`https://api.github.com${path}`, {
...options,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
...options.headers,
},
});
if (allowNotFound && response.status === 404) return null;
if (!response.ok) throw new Error(`${options.method ?? 'GET'} ${path}: ${response.status}`);
return response.status === 204 ? null : response.json();
}

async function ensureLabel(name, color, description) {
const encoded = encodeURIComponent(name);
const existing = await api(`/repos/${owner}/${repo}/labels/${encoded}`, {}, true);
if (existing) return;
await api(`/repos/${owner}/${repo}/labels`, {
method: 'POST',
body: JSON.stringify({ name, color, description }),
});
}

async function setLabel(name, enabled) {
const encoded = encodeURIComponent(name);
if (enabled) {
await api(`/repos/${owner}/${repo}/issues/${pullNumber}/labels`, {
method: 'POST',
body: JSON.stringify({ labels: [name] }),
});
} else {
await api(
`/repos/${owner}/${repo}/issues/${pullNumber}/labels/${encoded}`,
{
method: 'DELETE',
},
true,
);
}
}

async function syncComment(body, createWhenMissing) {
const comments = await api(`/repos/${owner}/${repo}/issues/${pullNumber}/comments?per_page=100`);
const existing = comments.find(comment => comment.body?.includes(marker));
if (existing) {
await api(`/repos/${owner}/${repo}/issues/comments/${existing.id}`, {
method: 'PATCH',
body: JSON.stringify({ body }),
});
} else if (createWhenMissing) {
await api(`/repos/${owner}/${repo}/issues/${pullNumber}/comments`, {
method: 'POST',
body: JSON.stringify({ body }),
});
}
}

const pull = await api(`/repos/${owner}/${repo}/pulls/${pullNumber}`);
const linkedIssues = await Promise.all(
linkedIssueNumbers(pull.body ?? '').map(number =>
api(`/repos/${owner}/${repo}/issues/${number}`, {}, true),
),
);
const rewardIssues = linkedIssues.filter(issue =>
issue?.labels?.some(label => label.name === 'reward'),
);
const rewardAuthorization = rewardIssues.length
? {
required: true,
issueNumbers: rewardIssues.map(issue => issue.number),
authorized: rewardIssues.every(
issue =>
issue.assignees?.some(assignee => assignee.login === pull.user?.login) ||
issue.labels?.some(label => label.name === 'implementation-approved'),
),
}
: undefined;
const result = evaluatePullRequest({
body: pull.body ?? '',
draft: pull.draft,
authorAssociation: pull.author_association,
authorType: pull.user?.type,
changedFiles: pull.changed_files,
additions: pull.additions,
rewardAuthorization,
});

await ensureLabel('contributor-check:passed', '1f883d', '贡献信息门禁已通过');
await ensureLabel('needs-contributor-info', 'd1242f', '需要补充可验证的贡献信息');
await ensureLabel('needs-maintainer-review', 'bf8700', '需要维护者人工复核');
await ensureLabel('implementation-approved', '8250df', '维护者已批准贡献者实现该奖励任务');

await setLabel('contributor-check:passed', result.passed && !result.skipped);
await setLabel('needs-contributor-info', !result.passed);
await setLabel('needs-maintainer-review', result.reviewReasons.length > 0);

const missingLines = result.missing.map(item => `- [ ] ${item}`).join('\n');
const reviewLines = result.reviewReasons.map(item => `- ${item}`).join('\n');
const comment = `${marker}
## 贡献信息检查

${result.passed ? '已通过自动检查。维护者仍会审阅实现质量和实际行为。' : `请补充以下信息后再次更新 PR:\n\n${missingLines}`}
${reviewLines ? `\n### 人工复核提示\n\n${reviewLines}` : ''}

本检查不禁止使用 AI,但提交者必须理解改动、披露使用情况并提供真实验证证据。`;

await syncComment(comment, !result.passed || result.reviewReasons.length > 0);

if (process.env.GITHUB_STEP_SUMMARY) {
await appendFile(
process.env.GITHUB_STEP_SUMMARY,
`## Contributor trust gate\n\n- PR: #${pullNumber}\n- Result: ${result.skipped ? 'skipped (draft)' : result.passed ? 'passed' : 'failed'}\n- Missing: ${result.missing.length}\n- Manual review reasons: ${result.reviewReasons.length}\n`,
);
}

if (!result.passed) {
process.exitCode = 1;
console.error(`Contributor information is incomplete: ${result.missing.join('; ')}`);
}
Loading