Files
CherryHQ-cherry-studio/.github/workflows/github-content-translator.yml
2026-06-26 15:22:31 +08:00

277 lines
11 KiB
YAML

name: GitHub Content Translator
run-name: "Translate: ${{ github.event.issue.title || github.event.pull_request.title || github.event.comment.body || github.event.review.body || 'GitHub content' }}"
concurrency:
group: translator-${{ github.event.comment.id || github.event.issue.number || github.event.review.id }}
cancel-in-progress: true
on:
issues:
types: [opened]
issue_comment:
types: [created, edited]
pull_request_review:
types: [submitted, edited]
pull_request_review_comment:
types: [created, edited]
jobs:
translate:
if: |
(github.event_name == 'issues')
|| (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot')
|| (
(github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment')
&& github.event.sender.type != 'Bot'
&& github.event.pull_request.head.repo.fork == false
)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
pull-requests: write
steps:
- name: Translate and update event content
uses: actions/github-script@v8
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }}
ANTHROPIC_MODEL: ${{ vars.ANTHROPIC_MODEL }}
with:
github-token: ${{ github.token }}
script: |
const ORIGINAL_SUMMARY = '<summary>Original Content</summary>'
const stripCodeFence = (value) =>
value
.trim()
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/i, '')
.trim()
const parseTranslatorResult = (text, responseMetadata) => {
const strippedText = stripCodeFence(text)
try {
return JSON.parse(strippedText)
} catch (error) {
core.startGroup('Translator response metadata')
core.info(JSON.stringify(responseMetadata, null, 2))
core.endGroup()
core.startGroup('Translator raw model response')
core.info(text)
core.endGroup()
if (strippedText !== text) {
core.startGroup('Translator response after code-fence stripping')
core.info(strippedText)
core.endGroup()
}
throw new Error(`Translator API returned invalid JSON: ${error.message}`)
}
}
const getTranslatedMarker = (kind) => {
switch (kind) {
case 'issue':
return 'This issue was translated automatically.'
case 'issue_comment':
return 'This comment was translated automatically.'
case 'pull_request_review':
return 'This review was translated automatically.'
case 'pull_request_review_comment':
return 'This review comment was translated automatically.'
default:
return 'This content was translated automatically.'
}
}
const buildBody = (kind, translatedBody, originalBody) =>
[
`> ${getTranslatedMarker(kind)}`,
'',
translatedBody.trim(),
'',
'---',
'<details>',
ORIGINAL_SUMMARY,
'',
originalBody.trim(),
'</details>'
].join('\n')
const getTarget = () => {
const payload = context.payload
switch (context.eventName) {
case 'issues':
return {
kind: 'issue',
issueNumber: payload.issue.number,
title: payload.issue.title || '',
body: payload.issue.body || ''
}
case 'issue_comment':
return {
kind: 'issue_comment',
issueNumber: payload.issue.number,
commentId: payload.comment.id,
title: '',
body: payload.comment.body || ''
}
case 'pull_request_review':
return {
kind: 'pull_request_review',
pullNumber: payload.pull_request.number,
reviewId: payload.review.id,
title: '',
body: payload.review.body || ''
}
case 'pull_request_review_comment':
return {
kind: 'pull_request_review_comment',
pullNumber: payload.pull_request.number,
commentId: payload.comment.id,
title: '',
body: payload.comment.body || ''
}
default:
throw new Error(`Unsupported event: ${context.eventName}`)
}
}
const updateTarget = async (target, translatedTitle, translatedBody, originalBody) => {
const body = buildBody(target.kind, translatedBody, originalBody)
const { owner, repo } = context.repo
switch (target.kind) {
case 'issue':
await github.rest.issues.update({
owner,
repo,
issue_number: target.issueNumber,
title: translatedTitle || target.title,
body
})
return
case 'issue_comment':
await github.rest.issues.updateComment({
owner,
repo,
comment_id: target.commentId,
body
})
return
case 'pull_request_review':
await github.request('PUT /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}', {
owner,
repo,
pull_number: target.pullNumber,
review_id: target.reviewId,
body
})
return
case 'pull_request_review_comment':
await github.rest.pulls.updateReviewComment({
owner,
repo,
comment_id: target.commentId,
body
})
return
}
}
const callTranslator = async (target) => {
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) {
throw new Error('Missing ANTHROPIC_API_KEY')
}
const baseUrl = (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '')
const model = process.env.ANTHROPIC_MODEL
if (!model) {
throw new Error('Missing ANTHROPIC_MODEL')
}
const response = await fetch(`${baseUrl}/v1/messages`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model,
max_tokens: 4096,
temperature: 0,
system: `You translate GitHub issue, issue comment, pull request review, and pull request review comment content into English.
Return only compact JSON with this exact shape:
{
"should_update": boolean,
"translated_title": string | null,
"translated_body": string | null,
"original_body": string | null
}
Rules:
- If the content is already fully English, return should_update=false.
- If the content already follows the automatic translation format and its Original Content matches the current source, return should_update=false.
- If the content is translated but the original and translated content do not match, return should_update=true and fix it.
- Translate non-English or partially non-English content to natural English.
- For issues, translate the title when it is non-English. For all other event kinds, translated_title must be null.
- Preserve Markdown structure, code blocks, task lists, links, and mentions.
- If the content quotes an earlier automatic translation, keep only the English quoted content and remove translation markers and Original Content sections from the quote.
- If the content quotes non-translated content, keep that quote unchanged.
- If the content is an email reply, remove quoted email history from both translated_body and original_body.
- original_body must be the source text that corresponds to translated_body, after any required email quote cleanup.`,
messages: [
{
role: 'user',
content: JSON.stringify({
event: context.eventName,
target
})
}
]
})
})
if (!response.ok) {
throw new Error(`Translator API failed with ${response.status}: ${await response.text()}`)
}
const data = await response.json()
const text = data.content?.find((part) => part.type === 'text')?.text
if (!text) {
throw new Error('Translator API returned no text content')
}
return parseTranslatorResult(text, {
id: data.id,
model: data.model,
stop_reason: data.stop_reason,
stop_sequence: data.stop_sequence,
usage: data.usage
})
}
const target = getTarget()
const result = await callTranslator(target)
if (!result.should_update) {
core.info('Translator reported that no translation update is needed.')
return
}
if (typeof result.translated_body !== 'string' || typeof result.original_body !== 'string') {
throw new Error('Translator requested an update without translated_body and original_body')
}
await updateTarget(target, result.translated_title, result.translated_body, result.original_body)
core.info(`Updated ${target.kind} with English translation.`)