Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
20 changes: 20 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Test

on:
push:
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- uses: actions/checkout@v6
- uses: actions/checkout@v7

每个 action 都升级到最新版。

with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: 24
- run: npm test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ out
# Nuxt.js build / generate output
.nuxt
dist
!dist/
!dist/index.mjs
.output

# Gatsby files
Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,45 @@
# contributor-trust-action
# Contributor Trust Action

Creates an evidence-first report for the author of a pull request, issue, or issue comment. It reads public GitHub profile and activity data, optionally asks GitHub Models for a second opinion, and updates a single review comment and label.

The action does not infer identity from writing style, does not treat AI disclosure as misconduct, and never blocks an account automatically. Maintainers keep the final decision.

## Usage

```yaml
name: Contributor trust

on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
issues:
types: [opened]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
types: [opened]
types:
- opened

尽量不用 JSON 兼容语法,而用 YAML 原生风格,阅读起来更清晰。

issue_comment:
types: [created]

permissions:
contents: read
issues: write
pull-requests: write
models: read

jobs:
report:
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
steps:
- uses: Open-Source-Bazaar/contributor-trust-action@v1
with:
github-token: ${{ github.token }}
```

The action never checks out or executes code from an external pull request. GitHub Models is best-effort: if Models is disabled, the public-evidence report still completes.

## Outputs

- `author`
- `risk-level`: `low`, `medium`, or `high`
- `risk-score`: `0` to `100`
- `report-json`

Set `fail-on-high-risk: 'true'` only after reviewing the action against your community's contribution patterns.
42 changes: 42 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Contributor Trust Report
description: Review public GitHub account and contribution signals before a maintainer acts.
author: Open-Source-Bazaar contributors

inputs:
github-token:
description: GitHub token with read access and permission to comment or label.
required: true
ai-review:
description: Use GitHub Models for a second, evidence-grounded review.
required: false
default: 'true'
model:
description: GitHub Models model identifier.
required: false
default: openai/gpt-4.1
comment:
description: Create or update the contributor report comment.
required: false
default: 'true'
fail-on-high-risk:
description: Fail the workflow when the report is high risk.
required: false
default: 'false'
Comment on lines +28 to +31

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

高风险不是应该报告吗?为什么要失败?


outputs:
author:
description: Contributor login that was inspected.
risk-level:
description: low, medium, or high.
risk-score:
description: Numeric risk score from 0 to 100.
report-json:
description: Machine-readable report.

runs:
using: node24
main: dist/index.mjs

branding:
icon: shield
color: blue
262 changes: 262 additions & 0 deletions dist/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { appendFile, readFile } from 'node:fs/promises';

import { analyzeContributor, mergeAiReview, shouldRetainReviewLabel } from '../src/analyze.mjs';

const token = input('github-token');
const useAi = input('ai-review', 'true') === 'true';
const model = input('model', 'openai/gpt-4.1');
const shouldComment = input('comment', 'true') === 'true';
const failOnHighRisk = input('fail-on-high-risk', 'false') === 'true';
const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? '').split('/');

if (!token || !owner || !repo || !process.env.GITHUB_EVENT_PATH) {
throw new Error('github-token, GITHUB_REPOSITORY and GITHUB_EVENT_PATH are required');
}

const payload = JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, 'utf8'));
Comment on lines +19 to +23

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

环境变量提前在前面解构成常量。

const target = resolveTarget(payload);
const encodedLogin = encodeURIComponent(target.login);

const [profile, events, pullSearch, issueSearch, organizationSearch] = await Promise.all([
github(`/users/${encodedLogin}`),
github(`/users/${encodedLogin}/events/public?per_page=100`),
github(`/search/issues?q=${encodeURIComponent(`type:pr author:${target.login}`)}&per_page=1`),
github(`/search/issues?q=${encodeURIComponent(`type:issue author:${target.login}`)}&per_page=1`),
github(`/search/issues?q=${encodeURIComponent(`type:pr org:${owner} author:${target.login}`)}&per_page=1`),
Comment on lines +30 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

直接用 URLSearchParams 的对象传参形式,不要用这么老的方法了。

]);

let report = analyzeContributor({
profile,
events,
authoredPullRequests: pullSearch.total_count,
authoredIssues: issueSearch.total_count,
organizationPullRequests: organizationSearch.total_count,
association: target.association,
content: target.content,
});

let aiError = '';
if (useAi && !report.trusted && profile.type !== 'Bot') {
try {
report = mergeAiReview(report, await reviewWithGitHubModels({ profile, report, target }));
} catch (error) {
aiError = error.message;
console.warning?.(`GitHub Models review unavailable: ${aiError}`);
console.log(`GitHub Models review unavailable: ${aiError}`);
}
}
Comment on lines +45 to +54

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let aiError = '';
if (useAi && !report.trusted && profile.type !== 'Bot') {
try {
report = mergeAiReview(report, await reviewWithGitHubModels({ profile, report, target }));
} catch (error) {
aiError = error.message;
console.warning?.(`GitHub Models review unavailable: ${aiError}`);
console.log(`GitHub Models review unavailable: ${aiError}`);
}
}
let aiError = '';
if (useAi && !report.trusted && profile.type !== 'Bot')
try {
report = mergeAiReview(report, await reviewWithGitHubModels({ profile, report, target }));
} catch (error) {
aiError = error.message;
console.warning?.(`GitHub Models review unavailable: ${aiError}`);
console.log(`GitHub Models review unavailable: ${aiError}`);
}

变量声明和具体逻辑代码之间留有必要空格。


const finalReport = {
author: target.login,
subject: target.kind,
number: target.number,
...report,
aiError: aiError || undefined,
};

if (shouldComment) await syncRepositoryState(finalReport);
await writeSummary(finalReport);
await output('author', target.login);
await output('risk-level', report.level);
await output('risk-score', String(report.score));
await output('report-json', JSON.stringify(finalReport));

if (failOnHighRisk && report.level === 'high') {
process.exitCode = 1;
console.error(`Contributor report for @${target.login} is high risk (${report.score}/100).`);
}
Comment on lines +74 to +77

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (failOnHighRisk && report.level === 'high') {
process.exitCode = 1;
console.error(`Contributor detection for @${target.login} is high risk (${report.score}/100).`);
}
if (failOnHighRisk && report.level === 'high')
throw new Error(`Contributor detection for @${target.login} is high risk (${report.score}/100).`);


function input(name, fallback = '') {
return process.env[`INPUT_${name.toUpperCase()}`] ?? fallback;
}

function resolveTarget(event) {
if (event.pull_request) {
return {
kind: 'pull request',
number: event.pull_request.number,
login: event.pull_request.user.login,
association: event.pull_request.author_association ?? 'NONE',
content: `${event.pull_request.title ?? ''}\n${event.pull_request.body ?? ''}`,
};
}
if (event.comment && event.issue) {
return {
kind: 'issue comment',
number: event.issue.number,
login: event.comment.user.login,
association: event.comment.author_association ?? 'NONE',
content: event.comment.body ?? '',
};
}
if (event.issue) {
return {
kind: 'issue',
number: event.issue.number,
login: event.issue.user.login,
association: event.issue.author_association ?? 'NONE',
content: `${event.issue.title ?? ''}\n${event.issue.body ?? ''}`,
};
}
throw new Error(`Unsupported event payload: ${process.env.GITHUB_EVENT_NAME ?? 'unknown'}`);
}

async function github(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': '2026-03-10',
...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 reviewWithGitHubModels({ profile, report, target }) {
const response = await fetch('https://models.github.ai/inference/chat/completions', {
method: 'POST',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-GitHub-Api-Version': '2026-03-10',
},
body: JSON.stringify({
model,
temperature: 0,
max_tokens: 400,
response_format: { type: 'json_object' },
messages: [
{
role: 'system',
content: [
'Assess whether a public GitHub contribution warrants maintainer review for likely automation or bounty spam.',
'Use only supplied facts. Never infer identity from writing style, language, nationality, or AI-tool disclosure.',
'A new or sparse account is not proof. Prefer inconclusive when evidence is weak.',
'Return JSON: {"classification":"likely-human|inconclusive|likely-automated","confidence":0..1,"reasons":[...],"recommendation":"..."}.',
].join(' '),
},
{
role: 'user',
content: JSON.stringify({
profile: {
login: profile.login,
type: profile.type,
created_at: profile.created_at,
public_repos: profile.public_repos,
followers: profile.followers,
profile_complete: Boolean(profile.name || profile.bio || profile.company || profile.blog),
},
facts: report.facts,
heuristicReasons: report.reasons,
contributionKind: target.kind,
contributionText: target.content.slice(0, 4000),
}),
},
],
}),
});
if (!response.ok) throw new Error(`GitHub Models returned ${response.status}`);
const data = await response.json();
const text = data.choices?.[0]?.message?.content ?? '';
const parsed = JSON.parse(text.replace(/^```json\s*|\s*```$/g, ''));
const classifications = new Set(['likely-human', 'inconclusive', 'likely-automated']);
if (!classifications.has(parsed.classification)) throw new Error('GitHub Models returned an invalid classification');
return {
classification: parsed.classification,
confidence: Math.min(1, Math.max(0, Number(parsed.confidence) || 0)),
reasons: Array.isArray(parsed.reasons) ? parsed.reasons.slice(0, 5).map(String) : [],
recommendation: String(parsed.recommendation ?? ''),
};
}

async function syncRepositoryState(report) {
const label = 'needs-contributor-review';
const marker = `<!-- contributor-trust:${report.author} -->`;
const comments = await github(`/repos/${owner}/${repo}/issues/${report.number}/comments?per_page=100`);
const existingLabel = await github(`/repos/${owner}/${repo}/labels/${encodeURIComponent(label)}`, {}, true);
if (!existingLabel) {
await github(`/repos/${owner}/${repo}/labels`, {
method: 'POST',
body: JSON.stringify({
name: label,
color: 'bf8700',
description: 'Public account signals need human review',
}),
});
}

const needsReview = shouldRetainReviewLabel(report, comments);
if (needsReview) {
await github(`/repos/${owner}/${repo}/issues/${report.number}/labels`, {
method: 'POST',
body: JSON.stringify({ labels: [label] }),
});
} else {
await github(
`/repos/${owner}/${repo}/issues/${report.number}/labels/${encodeURIComponent(label)}`,
{ method: 'DELETE' },
true,
);
}

const existing = comments.find(comment => comment.body?.includes(marker));
const body = renderComment(report, marker);
if (existing) {
await github(`/repos/${owner}/${repo}/issues/comments/${existing.id}`, {
method: 'PATCH',
body: JSON.stringify({ body }),
});
} else {
await github(`/repos/${owner}/${repo}/issues/${report.number}/comments`, {
method: 'POST',
body: JSON.stringify({ body }),
});
}
}

function renderComment(report, marker) {
const facts = report.facts;
const reasons = report.reasons.length ? report.reasons.map(reason => `- ${reason}`).join('\n') : '- No heuristic warnings.';
const ai = report.aiReview
? `\n### AI review\n\n- Classification: **${report.aiReview.classification}** (${Math.round(report.aiReview.confidence * 100)}% confidence)\n- Recommendation: ${report.aiReview.recommendation || 'No recommendation.'}\n${report.aiReview.reasons.map(reason => `- ${reason}`).join('\n')}`
: report.aiError
? '\n### AI review\n\nUnavailable; the evidence-only report remains valid.'
: '';
return `${marker}
## Contributor trust report

**@${report.author}: ${report.level.toUpperCase()} (${report.score}/100)**

| Public signal | Value |
| --- | ---: |
| Account age | ${facts.accountAgeDays} days |
| Public repositories | ${facts.publicRepositories} |
| Recent public events | ${facts.recentPublicEvents} |
| Public pull requests | ${facts.authoredPullRequests} |
| Earlier PRs in this organization | ${facts.organizationPullRequests} |

### Evidence

${reasons}${ai}

This report uses public signals to prioritize human review. It is not proof that a contributor used automation, and it never blocks an account automatically.`;
}

async function writeSummary(report) {
if (!process.env.GITHUB_STEP_SUMMARY) return;
await appendFile(
process.env.GITHUB_STEP_SUMMARY,
`## Contributor trust report\n\n- Author: @${report.author}\n- Risk: ${report.level} (${report.score}/100)\n- Subject: ${report.subject} #${report.number}\n`,
);
}

async function output(name, value) {
if (!process.env.GITHUB_OUTPUT) return;
const delimiter = `EOF_${Date.now()}_${Math.random().toString(16).slice(2)}`;
await appendFile(process.env.GITHUB_OUTPUT, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
}
Comment on lines +79 to +298

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这些函数都放到 src/utility.ts 模块再引用回来,然后我再做审核。

同时,整个项目更改为 TypeScript 编写,本文件的源码也移到 src 文件夹。

13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "contributor-trust-action",
"version": "1.0.0",
"private": true,
"type": "module",
"license": "LGPL-2.1-only",
"scripts": {
"test": "node --test"
},
"engines": {
"node": ">=24"
}
Comment on lines +5 to +12

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"type": "module",
"license": "LGPL-2.1-only",
"scripts": {
"test": "node --test"
},
"engines": {
"node": ">=24"
}
"license": "LGPL-2.1-only",
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"test": "node --test"
}

}
Loading