diff --git a/.github/workflows/sync_docs_on_approval.yml b/.github/workflows/sync_docs_on_approval.yml new file mode 100644 index 00000000..10fca933 --- /dev/null +++ b/.github/workflows/sync_docs_on_approval.yml @@ -0,0 +1,286 @@ +# Workflow for retriggering translation sync when a maintainer approves a fork PR +# This closes the gap where first-time contributors' PRs require approval, +# but approval events don't trigger the existing workflow chain. +name: Retrigger Sync on Approval + +on: + pull_request_review: + types: [submitted] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to simulate approval for (for testing)' + required: true + type: string + skip_checks: + description: 'Skip fork/author checks (for testing internal PRs)' + type: boolean + default: true + +permissions: + contents: read + pull-requests: write + actions: write # Needed to re-run workflows + +jobs: + retrigger-on-approval: + runs-on: ubuntu-latest + # Only run for approved reviews OR manual dispatch + if: github.event_name == 'workflow_dispatch' || github.event.review.state == 'approved' + + steps: + - name: Check if retrigger is needed + id: check + uses: actions/github-script@v7 + with: + script: | + const isManualTrigger = context.eventName === 'workflow_dispatch'; + const skipChecks = isManualTrigger && '${{ inputs.skip_checks }}' === 'true'; + + let prNumber, prAuthor, prHeadRepo, prBaseRepo, reviewer, reviewerAssociation; + + if (isManualTrigger) { + // Manual trigger - fetch PR details + prNumber = parseInt('${{ inputs.pr_number }}'); + console.log(`Manual trigger for PR #${prNumber} (skip_checks: ${skipChecks})`); + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + prAuthor = pr.user.login; + prHeadRepo = pr.head.repo.full_name; + prBaseRepo = pr.base.repo.full_name; + reviewer = context.actor; // Person who triggered the workflow + reviewerAssociation = 'MEMBER'; // Assume maintainer for manual trigger + } else { + // Pull request review trigger + prNumber = context.payload.pull_request.number; + prAuthor = context.payload.pull_request.user.login; + prHeadRepo = context.payload.pull_request.head.repo.full_name; + prBaseRepo = context.payload.pull_request.base.repo.full_name; + reviewer = context.payload.review.user.login; + reviewerAssociation = context.payload.review.author_association; + } + + console.log(`PR #${prNumber} approved by ${reviewer} (${reviewerAssociation})`); + console.log(`Author: ${prAuthor}`); + console.log(`Head repo: ${prHeadRepo}`); + console.log(`Base repo: ${prBaseRepo}`); + + // TODO: UNCOMMENT THESE CHECKS AFTER TESTING + // Check 1: Is this a fork PR? + // const isFork = prHeadRepo !== prBaseRepo; + // if (!isFork) { + // console.log('Not a fork PR - approval gate not needed, skipping retrigger'); + // core.setOutput('should_retrigger', 'false'); + // core.setOutput('reason', 'not_fork'); + // return; + // } + // console.log('PR is from a fork - approval gate applies'); + + // Check 2: Is the PR author already trusted? If so, no approval gate was needed + // const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; + // const authorAssociation = context.payload.pull_request.author_association; + // if (trustedAssociations.includes(authorAssociation)) { + // console.log(`PR author ${prAuthor} is already trusted (${authorAssociation}) - no approval gate needed`); + // core.setOutput('should_retrigger', 'false'); + // core.setOutput('reason', 'author_trusted'); + // return; + // } + // console.log(`PR author ${prAuthor} is not trusted (${authorAssociation}) - approval gate applies`); + + // Check 3: Is the reviewer a trusted maintainer? + // if (!trustedAssociations.includes(reviewerAssociation)) { + // console.log(`Reviewer ${reviewer} is not a maintainer (${reviewerAssociation}) - cannot unlock approval gate`); + // core.setOutput('should_retrigger', 'false'); + // core.setOutput('reason', 'reviewer_not_maintainer'); + // return; + // } + // console.log(`Reviewer ${reviewer} is a maintainer - approval is valid`); + console.log('âš ī¸ CHECKS COMMENTED OUT FOR TESTING - skipping fork/author/reviewer checks'); + + // Check 4: Does translation PR already exist? + const syncBranch = `docs-sync-pr-${prNumber}`; + try { + const { data: branches } = await github.rest.repos.listBranches({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100 + }); + + const branchExists = branches.some(b => b.name === syncBranch); + if (branchExists) { + console.log(`Translation branch ${syncBranch} already exists`); + + // Check if there's an open PR for it + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${context.repo.owner}:${syncBranch}`, + state: 'open' + }); + + if (prs.length > 0) { + console.log(`Translation PR #${prs[0].number} already exists and is open`); + core.setOutput('should_retrigger', 'false'); + core.setOutput('reason', 'translation_pr_exists'); + core.setOutput('translation_pr_number', prs[0].number.toString()); + core.setOutput('pr_number', prNumber.toString()); + return; + } + } + } catch (e) { + console.log(`Error checking for translation branch: ${e.message}`); + // Continue anyway - we'll try to create it + } + + // Check 5: Find most recent Analyze run for this PR + console.log('Looking for Analyze workflow runs for this PR...'); + const { data: runs } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'sync_docs_analyze.yml', + event: 'pull_request', + per_page: 100 + }); + + console.log(`Found ${runs.workflow_runs.length} Analyze workflow runs`); + + // Find the most recent run matching this PR + let matchingRun = null; + for (const run of runs.workflow_runs) { + if (run.pull_requests && run.pull_requests.some(pr => pr.number === prNumber)) { + matchingRun = run; + break; // Runs are sorted by created_at desc, so first match is most recent + } + } + + if (!matchingRun) { + console.log('No Analyze workflow run found for this PR'); + console.log('This could mean the PR has no documentation changes, or the run is too old'); + core.setOutput('should_retrigger', 'false'); + core.setOutput('reason', 'no_analyze_run'); + core.setOutput('pr_number', prNumber.toString()); + return; + } + + console.log(`Found Analyze run #${matchingRun.id}`); + console.log(` Status: ${matchingRun.status}`); + console.log(` Conclusion: ${matchingRun.conclusion}`); + console.log(` Created: ${matchingRun.created_at}`); + console.log(` Head SHA: ${matchingRun.head_sha}`); + + // All checks passed - we should retrigger + core.setOutput('should_retrigger', 'true'); + core.setOutput('analyze_run_id', matchingRun.id.toString()); + core.setOutput('pr_number', prNumber.toString()); + core.setOutput('reviewer', reviewer); + + - name: Post approval received comment + if: steps.check.outputs.should_retrigger == 'true' + uses: actions/github-script@v7 + with: + script: | + const prNumber = parseInt('${{ steps.check.outputs.pr_number }}'); + const reviewer = '${{ steps.check.outputs.reviewer }}'; + + const comment = `## 🌐 Multi-language Sync\n\n` + + `✅ **Approval received from @${reviewer}** - starting translation sync.\n\n` + + `Translation will begin shortly. A sync PR will be created automatically.`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: comment + }); + + console.log(`Posted approval received comment on PR #${prNumber}`); + + - name: Re-run Analyze workflow + if: steps.check.outputs.should_retrigger == 'true' + uses: actions/github-script@v7 + with: + script: | + const runId = parseInt('${{ steps.check.outputs.analyze_run_id }}'); + + console.log(`Re-running Analyze workflow run #${runId}...`); + + try { + await github.rest.actions.reRunWorkflow({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId + }); + + console.log('Analyze workflow re-run triggered successfully'); + console.log('This will trigger the Execute workflow chain with fresh artifacts'); + } catch (error) { + console.log(`Failed to re-run workflow: ${error.message}`); + + // If re-run fails (e.g., run is too old), post a comment explaining + const prNumber = parseInt('${{ steps.check.outputs.pr_number }}'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `## 🌐 Multi-language Sync\n\n` + + `âš ī¸ **Could not automatically start translation**\n\n` + + `The workflow run is too old to re-run. Please push a small commit ` + + `(e.g., add a newline to any file) to trigger a fresh translation workflow.\n\n` + + `Alternatively, a maintainer can manually trigger the workflow from the Actions tab.` + }); + + throw error; + } + + - name: Handle translation PR already exists + if: steps.check.outputs.reason == 'translation_pr_exists' + uses: actions/github-script@v7 + with: + script: | + const prNumber = parseInt('${{ steps.check.outputs.pr_number }}'); + const translationPrNumber = '${{ steps.check.outputs.translation_pr_number }}'; + + const comment = `## 🌐 Multi-language Sync\n\n` + + `â„šī¸ Translation PR [#${translationPrNumber}](https://github.com/${context.repo.owner}/${context.repo.repo}/pull/${translationPrNumber}) already exists.\n\n` + + `Future commits to this PR will automatically update the translation PR.`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: comment + }); + + console.log(`Posted info comment about existing translation PR #${translationPrNumber}`); + + - name: Handle no Analyze run found + if: steps.check.outputs.reason == 'no_analyze_run' + uses: actions/github-script@v7 + with: + script: | + const prNumber = parseInt('${{ steps.check.outputs.pr_number }}'); + + // Only post comment if this PR likely should have had an Analyze run + // (i.e., it has documentation file changes) + // For now, we'll post a gentle informational comment + + const comment = `## 🌐 Multi-language Sync\n\n` + + `â„šī¸ **No pending translation sync found for this PR.**\n\n` + + `This could mean:\n` + + `- The PR doesn't contain source documentation changes (en/ files)\n` + + `- The original workflow run is too old\n\n` + + `If you expected translations, please push a small commit to trigger a fresh workflow run.`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: comment + }); + + console.log(`Posted info comment about no Analyze run found`); diff --git a/CLAUDE.md b/CLAUDE.md index 782106a8..d1c5f0d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,28 @@ All language settings in `tools/translate/config.json` (single source of truth): - **Moved files**: Detected via `group_path` changes, cn/jp relocated using index-based navigation - **Renamed files**: Detected when deleted+added in same location, physical files renamed with extension preserved +### First-Time Contributor Approval Flow + +For PRs from forks by contributors who are not OWNER/MEMBER/COLLABORATOR: + +1. **PR opened** → Analyze workflow runs → Execute workflow checks for approval +2. **No approval found** → Execute skips translation, posts "pending approval" comment +3. **Maintainer approves PR** → `sync_docs_on_approval.yml` triggers automatically +4. **"Approval received" comment posted** → Analyze workflow re-runs with fresh artifacts +5. **Execute workflow runs** → Finds approval → Creates translation PR + +**Approval requirements**: +- Reviewer must have OWNER, MEMBER, or COLLABORATOR association +- Approval triggers immediate translation (no additional push needed) +- Each approval posts a new comment preserving the timeline + +**Edge cases**: +- If translation PR already exists when approval happens → info comment posted, no re-run +- If Analyze run is too old to re-run → error comment with instructions to push a small commit +- Internal PRs (same repo, not fork) → no approval gate, auto-translates immediately + +**Manual trigger**: If approval flow fails, maintainers can manually trigger via Actions → Execute Documentation Sync → Run workflow (enter PR number) + ## Development Commands ```bash @@ -128,6 +150,12 @@ SUCCESS: Moved cn/test-file to new location SUCCESS: Moved jp/test-file to new location ``` +**Approval flow issues** (first-time contributors): +- **Translation not starting after approval**: Check Actions tab for `Retrigger Sync on Approval` workflow status +- **"Could not automatically start translation"**: Analyze run too old - push a small commit to trigger fresh workflow +- **Approval from non-maintainer**: Only OWNER/MEMBER/COLLABORATOR approvals unlock the gate +- **Multiple "pending approval" comments**: Normal - each commit triggers Execute which posts if no approval found + ## Translation A/B Testing For comparing translation quality between models or prompt variations: @@ -152,4 +180,8 @@ python compare.py results// - `tools/translate/termbase_i18n.md` - Translation terminology database - `tools/translate/sync_and_translate.py` - Core translation + surgical reconciliation logic - `tools/translate-test-dify/` - Translation A/B testing framework -- `.github/workflows/sync_docs_*.yml` - Auto-translation workflow triggers +- `.github/workflows/sync_docs_analyze.yml` - Analyzes PR changes, uploads artifacts +- `.github/workflows/sync_docs_execute.yml` - Creates translation PRs (triggered by Analyze) +- `.github/workflows/sync_docs_update.yml` - Updates existing translation PRs +- `.github/workflows/sync_docs_cleanup.yml` - Cleans up sync PRs when original PR closes +- `.github/workflows/sync_docs_on_approval.yml` - Retriggers translation on maintainer approval