Translate: fix(shell-env): expire the cached shell environment so PATH changes reach subprocesses #15553
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 isWorkflowTranslationEdit = (target) => { | |
| const previousBody = context.payload.changes?.body?.from | |
| const translatedMarker = `> ${getTranslatedMarker(target.kind)}` | |
| return ( | |
| context.payload.action === 'edited' && | |
| typeof previousBody === 'string' && | |
| !previousBody.includes(translatedMarker) && | |
| target.body.startsWith(translatedMarker) && | |
| target.body.includes(ORIGINAL_SUMMARY) | |
| ) | |
| } | |
| 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() | |
| if (isWorkflowTranslationEdit(target)) { | |
| core.info('Skipping the edit event emitted by this workflow translation.') | |
| return | |
| } | |
| 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.`) |