Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ root = true
# ---- All files ----
[*]
charset = utf-8
end_of_line = crlf
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
Expand Down
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
41 changes: 41 additions & 0 deletions .github/PR_AUTOMATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# PR Automation

This repository uses automated checks to make pull requests easier for maintainers and safer for third-party contributions.

## Required PR Body

Every PR should fill in the template and check the `SIGN` declaration. The `PR Gate` workflow verifies:

- The PR has a meaningful title and summary.
- The `SIGN` declaration is checked.
- Changes to privileged automation, packaging, script, or binary artifact paths are surfaced as warnings for maintainer review.

The gate uses `pull_request_target` but does not check out or execute contributor code.

## ChatGPT Preliminary Review

`ChatGPT PR Review` runs automatically when a PR is opened or updated. Maintainers can request another pass by commenting:

```text
@ChatGPT
```

or:

```text
/chatgpt-review
```

The workflow reads PR metadata and diff through the GitHub API, then posts or updates one bot comment with the preliminary review. It does not execute pull request code.

The bot first posts a review-in-progress status, then updates the same comment with the result or a visible failure reason. Reviews favor accepting useful open-source contributions: only concrete, high-confidence correctness, security, CI, or release problems are treated as blockers; style and optional improvements remain non-blocking suggestions.

## OpenAI Configuration

Set these repository secrets or variables:

- `OPENAI_API_KEY` secret: required for ChatGPT review. If absent, the review job skips safely.
- `OPENAI_BASE_URL` secret: optional OpenAI-compatible gateway URL, for example a relay ending in `/v1`. If absent, the official OpenAI API base URL is used.
- `OPENAI_REVIEW_MODEL` repository variable: optional model override. If absent, the workflow uses its built-in default.

The review workflow tries the Responses API first. If a gateway does not support it, the workflow falls back to an OpenAI-compatible chat completions endpoint.
12 changes: 12 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

<!-- 说明改动内容和原因 (What changed and why?). -->

## SIGN 声明 (SIGN Declaration)

- [ ] SIGN: 我确认我有权提交这些改动,并同意它们按本仓库许可证发布。
(I certify that I have the right to submit this work and agree it is provided under this repository's license.)

## 类型 (Type)

- [ ] 缺陷修复 (Bug fix)
Expand All @@ -17,6 +22,13 @@

<!-- 如有帮助,请补充实现说明、截图或 API 示例 (Add implementation notes, screenshots, or API examples if useful). -->

## 安全影响 (Security Impact)

- [ ] 未修改 GitHub Actions、发布脚本、打包脚本或二进制产物。
(No GitHub Actions, release scripts, packaging scripts, or binary artifacts were changed.)
- [ ] 如修改了上述敏感区域,我已在摘要或细节中说明原因。
(If sensitive areas were changed, I explained why in the summary or details.)

## 验证 (Verification)

- [ ] `dotnet build -c Release` 通过 (build succeeds).
Expand Down
276 changes: 276 additions & 0 deletions .github/workflows/chatgpt-pr-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
name: ChatGPT PR Review

on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
issue_comment:
types: [created]

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

jobs:
review:
name: Preliminary ChatGPT review
if: github.event_name == 'pull_request_target' || github.event_name == 'issue_comment'
runs-on: ubuntu-latest
steps:
- name: Review pull request diff
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_MODEL: ${{ vars.OPENAI_REVIEW_MODEL }}
run: |
node <<'NODE'
const fs = require('fs');

(async () => {
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
if (event.issue && !event.issue.pull_request) {
console.log('Comment is not on a pull request; skipping.');
process.exit(0);
}

if (event.comment) {
const command = (event.comment.body || '').toLowerCase();
if (!command.includes('@chatgpt') && !command.includes('/chatgpt-review')) {
console.log('Comment does not request ChatGPT review; skipping.');
process.exit(0);
}
}

const prNumber = event.pull_request?.number || event.issue?.number;
if (!prNumber) {
console.log('No pull request number found; skipping.');
process.exit(0);
}

const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
const githubHeaders = {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};

async function gh(path, options = {}) {
const response = await fetch(`https://api.github.com${path}`, {
...options,
headers: {
...githubHeaders,
...(options.headers || {}),
},
});
if (!response.ok) {
throw new Error(`GitHub API ${path} failed: ${response.status} ${await response.text()}`);
}
if (response.status === 204) {
return null;
}
return response.json();
}

async function ghText(path, accept) {
const response = await fetch(`https://api.github.com${path}`, {
headers: {
...githubHeaders,
Accept: accept,
},
});
if (!response.ok) {
throw new Error(`GitHub API ${path} failed: ${response.status} ${await response.text()}`);
}
return response.text();
}

async function listFiles() {
const files = [];
for (let page = 1; page <= 20; page += 1) {
const batch = await gh(`/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=100&page=${page}`);
files.push(...batch);
if (batch.length < 100) {
break;
}
}
return files;
}

function extractText(json) {
if (typeof json.output_text === 'string' && json.output_text.trim()) {
return json.output_text.trim();
}
if (Array.isArray(json.output)) {
return json.output
.flatMap(item => item.content || [])
.map(part => part.text || '')
.join('\n')
.trim();
}
if (Array.isArray(json.choices)) {
return json.choices
.map(choice => choice.message?.content || choice.text || '')
.join('\n')
.trim();
}
return '';
}

async function callOpenAI(prompt) {
const model = process.env.OPENAI_MODEL || 'gpt-5-mini';
const baseUrl = (process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/+$/, '');
const headers = {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
};
const system = [
'You are doing a friendly preliminary pull request review for the open-source AndroidTreeView project.',
'Favor merging useful contributions when they do not introduce a concrete correctness, security, CI, or release blocker.',
'Only call something blocking when the diff provides high-confidence evidence of a real user-facing failure, security vulnerability, data loss, broken build, or broken release.',
'Do not block on style preferences, optional refactors, speculative risks, pre-existing problems, minor documentation gaps, or missing non-critical tests.',
'Keep optional improvements under non-blocking suggestions and do not invent requirements that are not documented by the repository.',
'When no blocking issue exists, clearly conclude 建议通过 even if you have minor suggestions.',
'When reporting a blocker, cite the file and relevant code, explain the concrete impact, and state the smallest reasonable fix.',
'Write concise Simplified Chinese with exactly these sections: ## 结论, ## 阻塞问题, and ## 非阻塞建议.',
'Use 无 when a section has no findings. This remains an automated recommendation, not a formal maintainer approval.',
].join(' ');

const responsesBody = {
model,
input: [
{ role: 'system', content: system },
{ role: 'user', content: prompt },
],
max_output_tokens: 2000,
};

const responses = await fetch(`${baseUrl}/responses`, {
method: 'POST',
headers,
body: JSON.stringify(responsesBody),
});

if (responses.ok) {
const text = extractText(await responses.json());
if (text) {
return text;
}
} else if (![400, 404, 405].includes(responses.status)) {
throw new Error(`OpenAI Responses API failed: ${responses.status} ${await responses.text()}`);
}

const chat = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: prompt },
],
max_tokens: 2000,
}),
});

if (!chat.ok) {
throw new Error(`OpenAI-compatible chat completion failed: ${chat.status} ${await chat.text()}`);
}

const text = extractText(await chat.json());
if (!text) {
throw new Error('OpenAI-compatible endpoint returned an empty review.');
}
return text;
}

async function upsertComment(body) {
const marker = '<!-- android-tree-view-chatgpt-review -->';
const comments = await gh(`/repos/${owner}/${repo}/issues/${prNumber}/comments?per_page=100`);
const previous = comments.find(comment =>
comment.user?.type === 'Bot' && typeof comment.body === 'string' && comment.body.includes(marker));
const payload = JSON.stringify({ body: `${marker}\n${body}` });
if (previous) {
await gh(`/repos/${owner}/${repo}/issues/comments/${previous.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: payload,
});
} else {
await gh(`/repos/${owner}/${repo}/issues/${prNumber}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
});
}
}

const statusBody = [
'## ChatGPT PR 初审',
'',
`状态:正在审查 PR #${prNumber},请稍候。`,
'',
'完成后,本评论会自动更新为审核结论。',
].join('\n');
await upsertComment(statusBody);

try {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not configured.');
}

const pr = await gh(`/repos/${owner}/${repo}/pulls/${prNumber}`);
const files = await listFiles();
const diff = await ghText(`/repos/${owner}/${repo}/pulls/${prNumber}`, 'application/vnd.github.v3.diff');
const fileSummary = files
.map(file => `${file.status} ${file.filename} (+${file.additions}/-${file.deletions})`)
.join('\n');
const truncatedDiff = diff.length > 60000
? `${diff.slice(0, 60000)}\n\n[Diff truncated for automated review.]`
: diff;

const prompt = [
`PR #${pr.number}: ${pr.title}`,
`Author: ${pr.user?.login} (${pr.author_association})`,
`Base: ${pr.base.ref}`,
`Head: ${pr.head.label}`,
'',
'PR body:',
pr.body || '(empty)',
'',
'Changed files:',
fileSummary || '(none)',
'',
'Unified diff:',
truncatedDiff,
].join('\n');

const review = await callOpenAI(prompt);
const commentBody = [
'## ChatGPT PR 初审',
'',
review,
'',
'---',
'这是自动初审建议,不代表维护者正式批准。第三方 PR 的代码没有在此 workflow 中执行。',
].join('\n');

await upsertComment(commentBody);
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${commentBody}\n`);
} catch (error) {
const failureBody = [
'## ChatGPT PR 初审',
'',
'状态:审查失败。',
'',
'自动审核服务或 GitHub 评论接口调用失败。请检查 Actions 运行日志,修复后重新发送 `/chatgpt-review`。',
].join('\n');
await upsertComment(failureBody);
throw error;
}
})().catch(error => {
console.error(`::error::${error.message}`);
process.exit(1);
});
NODE
Loading
Loading