mirror of
https://github.com/langgenius/dify-docs.git
synced 2026-03-27 13:28:32 +07:00
- Add bypass logic for manual workflow_dispatch triggers on revamp branch - Prevents workflow from skipping when manually triggered for testing - Maintains security checks for workflow_run events
579 lines
24 KiB
YAML
579 lines
24 KiB
YAML
name: Execute Documentation Sync
|
|
|
|
on:
|
|
workflow_run:
|
|
workflows: ["Analyze Documentation Changes"]
|
|
types:
|
|
- completed
|
|
branches: [main, revamp]
|
|
workflow_dispatch:
|
|
inputs:
|
|
pr_number:
|
|
description: 'PR number to process'
|
|
required: true
|
|
type: string
|
|
|
|
permissions:
|
|
contents: write
|
|
pull-requests: write
|
|
actions: read
|
|
|
|
jobs:
|
|
execute-sync:
|
|
runs-on: ubuntu-latest
|
|
if: |
|
|
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') ||
|
|
github.event_name == 'workflow_dispatch'
|
|
steps:
|
|
- name: Check workflow source
|
|
id: check-source
|
|
run: |
|
|
echo "Checking workflow source..."
|
|
echo "Event name: ${{ github.event_name }}"
|
|
|
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
|
# Manual trigger - bypass source checks for revamp branch
|
|
if [[ "${{ github.ref }}" == "refs/heads/revamp" ]]; then
|
|
echo "Manual workflow_dispatch on revamp branch - proceeding"
|
|
echo "is_fork=false" >> $GITHUB_OUTPUT
|
|
echo "should_process=true" >> $GITHUB_OUTPUT
|
|
else
|
|
echo "Manual workflow_dispatch not on revamp branch - skipping"
|
|
echo "should_process=false" >> $GITHUB_OUTPUT
|
|
fi
|
|
else
|
|
# workflow_run event - use existing logic
|
|
echo "Event: ${{ github.event.workflow_run.event }}"
|
|
echo "Repository: ${{ github.event.workflow_run.repository.full_name }}"
|
|
echo "Head Repository: ${{ github.event.workflow_run.head_repository.full_name }}"
|
|
echo "Head Branch: ${{ github.event.workflow_run.head_branch }}"
|
|
|
|
# Security check: Only process PRs from the same repository or trusted forks
|
|
if [[ "${{ github.event.workflow_run.event }}" != "pull_request" ]]; then
|
|
echo "Not a pull request event, skipping"
|
|
echo "should_process=false" >> $GITHUB_OUTPUT
|
|
exit 0
|
|
fi
|
|
|
|
# Check if this is from a fork
|
|
IS_FORK="false"
|
|
if [[ "${{ github.event.workflow_run.repository.full_name }}" != "${{ github.event.workflow_run.head_repository.full_name }}" ]]; then
|
|
IS_FORK="true"
|
|
fi
|
|
|
|
echo "is_fork=$IS_FORK" >> $GITHUB_OUTPUT
|
|
echo "should_process=true" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Download analysis artifacts
|
|
if: steps.check-source.outputs.should_process == 'true'
|
|
uses: actions/github-script@v7
|
|
id: download-artifacts
|
|
with:
|
|
script: |
|
|
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
run_id: ${{ github.event.workflow_run.id }}
|
|
});
|
|
|
|
const matchArtifact = artifacts.data.artifacts.find(artifact => {
|
|
return artifact.name.startsWith('docs-sync-analysis-');
|
|
});
|
|
|
|
if (!matchArtifact) {
|
|
console.log('No analysis artifacts found');
|
|
return false;
|
|
}
|
|
|
|
const download = await github.rest.actions.downloadArtifact({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
artifact_id: matchArtifact.id,
|
|
archive_format: 'zip'
|
|
});
|
|
|
|
const fs = require('fs');
|
|
fs.writeFileSync('/tmp/artifacts.zip', Buffer.from(download.data));
|
|
|
|
// Extract PR number from artifact name
|
|
const prNumber = matchArtifact.name.split('-').pop();
|
|
core.setOutput('pr_number', prNumber);
|
|
core.setOutput('artifact_found', 'true');
|
|
|
|
return true;
|
|
|
|
- name: Extract and validate artifacts
|
|
if: steps.download-artifacts.outputs.artifact_found == 'true'
|
|
id: extract-artifacts
|
|
run: |
|
|
echo "Extracting artifacts..."
|
|
|
|
# Create secure temporary directory
|
|
WORK_DIR=$(mktemp -d /tmp/sync-XXXXXX)
|
|
echo "work_dir=$WORK_DIR" >> $GITHUB_OUTPUT
|
|
|
|
# Extract to temporary directory
|
|
cd "$WORK_DIR"
|
|
unzip /tmp/artifacts.zip
|
|
|
|
# Validate extracted files
|
|
REQUIRED_FILES="analysis.json sync_plan.json changed_files.txt"
|
|
for file in $REQUIRED_FILES; do
|
|
if [ ! -f "$file" ]; then
|
|
echo "Error: Required file $file not found"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# Validate JSON structure
|
|
python3 -c "
|
|
import json
|
|
import sys
|
|
|
|
try:
|
|
with open('analysis.json') as f:
|
|
analysis = json.load(f)
|
|
with open('sync_plan.json') as f:
|
|
sync_plan = json.load(f)
|
|
|
|
# Validate required fields
|
|
assert 'pr_number' in analysis
|
|
assert 'files_to_sync' in sync_plan
|
|
assert 'target_languages' in sync_plan
|
|
|
|
print('Artifacts validated successfully')
|
|
except Exception as e:
|
|
print(f'Validation error: {e}')
|
|
sys.exit(1)
|
|
"
|
|
|
|
# Extract PR number and other metadata
|
|
PR_NUMBER=$(python3 -c "import json; print(json.load(open('analysis.json'))['pr_number'])")
|
|
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
|
|
|
|
# Check if sync is required
|
|
SYNC_REQUIRED=$(python3 -c "import json; print(str(json.load(open('sync_plan.json'))['sync_required']).lower())")
|
|
echo "sync_required=$SYNC_REQUIRED" >> $GITHUB_OUTPUT
|
|
|
|
- name: Checkout base repository
|
|
if: steps.extract-artifacts.outputs.sync_required == 'true'
|
|
uses: actions/checkout@v4
|
|
with:
|
|
token: ${{ secrets.GITHUB_TOKEN }}
|
|
fetch-depth: 0
|
|
|
|
- name: Set up Python
|
|
if: steps.extract-artifacts.outputs.sync_required == 'true'
|
|
uses: actions/setup-python@v4
|
|
with:
|
|
python-version: '3.9'
|
|
|
|
- name: Install dependencies
|
|
if: steps.extract-artifacts.outputs.sync_required == 'true'
|
|
run: |
|
|
cd tools/translate
|
|
pip install httpx aiofiles python-dotenv
|
|
|
|
- name: Check for manual approval requirement
|
|
if: steps.extract-artifacts.outputs.sync_required == 'true' && steps.check-source.outputs.is_fork == 'true'
|
|
id: check-approval
|
|
uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
const prNumber = ${{ steps.extract-artifacts.outputs.pr_number }};
|
|
|
|
// Get PR details
|
|
const pr = await github.rest.pulls.get({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: prNumber
|
|
});
|
|
|
|
const author = pr.data.user.login;
|
|
const authorAssociation = pr.data.author_association;
|
|
|
|
// Check if author is trusted
|
|
const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
|
const trustedContributors = process.env.TRUSTED_CONTRIBUTORS?.split(',') || [];
|
|
|
|
const isTrusted = trustedAssociations.includes(authorAssociation) ||
|
|
trustedContributors.includes(author);
|
|
|
|
if (!isTrusted) {
|
|
// Check for approval from maintainer
|
|
const reviews = await github.rest.pulls.listReviews({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: prNumber
|
|
});
|
|
|
|
const hasApproval = reviews.data.some(review =>
|
|
review.state === 'APPROVED' &&
|
|
trustedAssociations.includes(review.author_association)
|
|
);
|
|
|
|
if (!hasApproval) {
|
|
console.log('PR requires manual approval from a maintainer');
|
|
core.setOutput('needs_approval', 'true');
|
|
|
|
// Comment on PR
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
body: '⏸️ **Documentation sync is pending approval**\n\n' +
|
|
'This PR requires approval from a maintainer before automatic synchronization can proceed.\n\n' +
|
|
'Once approved, the documentation will be automatically translated and synchronized.'
|
|
});
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
core.setOutput('needs_approval', 'false');
|
|
|
|
- name: Execute safe synchronization
|
|
if: steps.extract-artifacts.outputs.sync_required == 'true' && steps.check-approval.outputs.needs_approval != 'true'
|
|
id: sync
|
|
env:
|
|
DIFY_API_KEY: ${{ secrets.DIFY_API_KEY }}
|
|
run: |
|
|
echo "Executing documentation synchronization..."
|
|
|
|
WORK_DIR="${{ steps.extract-artifacts.outputs.work_dir }}"
|
|
PR_NUMBER="${{ steps.extract-artifacts.outputs.pr_number }}"
|
|
|
|
# Create a new branch for the sync results
|
|
SYNC_BRANCH="docs-sync-pr-${PR_NUMBER}"
|
|
git checkout -b "$SYNC_BRANCH"
|
|
|
|
# Run synchronization with security constraints
|
|
cd tools/translate
|
|
|
|
# Create a secure sync script
|
|
cat > secure_sync.py <<'EOF'
|
|
import json
|
|
import sys
|
|
import os
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
# Add parent directory to path
|
|
sys.path.append(os.path.dirname(__file__))
|
|
from sync_and_translate import DocsSynchronizer
|
|
|
|
async def secure_sync():
|
|
work_dir = sys.argv[1]
|
|
|
|
# Load sync plan
|
|
with open(f"{work_dir}/sync_plan.json") as f:
|
|
sync_plan = json.load(f)
|
|
|
|
# Security: Only sync files from the approved list
|
|
files_to_sync = sync_plan.get("files_to_sync", [])
|
|
|
|
# Validate file paths again
|
|
for file_info in files_to_sync:
|
|
file_path = file_info["path"]
|
|
|
|
# Security checks
|
|
if ".." in file_path or file_path.startswith("/"):
|
|
print(f"Security error: Invalid path {file_path}")
|
|
return False
|
|
|
|
if not file_path.startswith("en/"):
|
|
print(f"Security error: File outside en/ directory: {file_path}")
|
|
return False
|
|
|
|
# Initialize synchronizer
|
|
api_key = os.environ.get("DIFY_API_KEY")
|
|
if not api_key:
|
|
print("Error: DIFY_API_KEY not set")
|
|
return False
|
|
|
|
synchronizer = DocsSynchronizer(api_key)
|
|
|
|
# Perform limited sync
|
|
results = {
|
|
"translated": [],
|
|
"failed": [],
|
|
"skipped": []
|
|
}
|
|
|
|
for file_info in files_to_sync[:10]: # Limit to 10 files
|
|
file_path = file_info["path"]
|
|
print(f"Processing: {file_path}")
|
|
|
|
try:
|
|
# Only translate if file exists and is safe
|
|
if os.path.exists(f"../../{file_path}"):
|
|
for target_lang in ["zh-hans", "ja-jp"]:
|
|
target_path = file_path.replace("en/", f"{target_lang}/")
|
|
success = await synchronizer.translate_file_with_notice(
|
|
file_path,
|
|
target_path,
|
|
target_lang
|
|
)
|
|
if success:
|
|
results["translated"].append(target_path)
|
|
else:
|
|
results["failed"].append(target_path)
|
|
else:
|
|
results["skipped"].append(file_path)
|
|
except Exception as e:
|
|
print(f"Error processing {file_path}: {e}")
|
|
results["failed"].append(file_path)
|
|
|
|
# Handle docs.json structure sync if needed
|
|
if sync_plan.get("structure_changes", {}).get("structure_changed"):
|
|
print("Syncing docs.json structure...")
|
|
try:
|
|
sync_log = synchronizer.sync_docs_json_structure()
|
|
print("\n".join(sync_log))
|
|
except Exception as e:
|
|
print(f"Error syncing structure: {e}")
|
|
|
|
# Save results
|
|
with open("/tmp/sync_results.json", "w") as f:
|
|
json.dump(results, f, indent=2)
|
|
|
|
return len(results["failed"]) == 0
|
|
|
|
if __name__ == "__main__":
|
|
success = asyncio.run(secure_sync())
|
|
sys.exit(0 if success else 1)
|
|
EOF
|
|
|
|
# Run the secure sync
|
|
python secure_sync.py "$WORK_DIR"
|
|
SYNC_EXIT_CODE=$?
|
|
|
|
echo "sync_exit_code=$SYNC_EXIT_CODE" >> $GITHUB_OUTPUT
|
|
|
|
# Check for changes
|
|
if [[ -n $(git status --porcelain) ]]; then
|
|
echo "has_changes=true" >> $GITHUB_OUTPUT
|
|
else
|
|
echo "has_changes=false" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Commit and create translation PR
|
|
if: steps.sync.outputs.has_changes == 'true'
|
|
id: create-translation-pr
|
|
run: |
|
|
PR_NUMBER="${{ steps.extract-artifacts.outputs.pr_number }}"
|
|
SYNC_BRANCH="docs-sync-pr-${PR_NUMBER}"
|
|
|
|
git config user.name 'github-actions[bot]'
|
|
git config user.email 'github-actions[bot]@users.noreply.github.com'
|
|
|
|
# Commit translation changes
|
|
git add .
|
|
git commit -m "🌐 Auto-translate documentation for PR #${PR_NUMBER}
|
|
|
|
Auto-generated translations for documentation changes in PR #${PR_NUMBER}.
|
|
|
|
Original PR: #${PR_NUMBER}
|
|
Languages: Chinese (zh-hans), Japanese (ja-jp)
|
|
|
|
🤖 Generated with GitHub Actions"
|
|
|
|
# Push the translation branch to main repo
|
|
git push origin "$SYNC_BRANCH" --force
|
|
|
|
# Get original PR details for translation PR
|
|
ORIGINAL_PR_TITLE=$(gh pr view ${PR_NUMBER} --json title --jq '.title' 2>/dev/null || echo "Documentation changes")
|
|
|
|
# Create translation PR body
|
|
cat > /tmp/translation_pr_body.md <<EOF
|
|
# 🌐 Auto-generated Translations
|
|
|
|
This PR contains automatically generated translations for the documentation changes in PR #${PR_NUMBER}.
|
|
|
|
## Original PR
|
|
**Title:** ${ORIGINAL_PR_TITLE}
|
|
**Link:** #${PR_NUMBER}
|
|
|
|
## What's included
|
|
- 🇨🇳 Chinese (zh-hans) translations
|
|
- 🇯🇵 Japanese (ja-jp) translations
|
|
- 📋 Updated navigation structure in docs.json
|
|
|
|
## Review Process
|
|
1. Review the generated translations for accuracy
|
|
2. Make any necessary adjustments
|
|
3. Merge this PR to apply the translations
|
|
|
|
## Links
|
|
- **Original English PR:** #${PR_NUMBER}
|
|
- **Translation branch:** \`${SYNC_BRANCH}\`
|
|
|
|
---
|
|
🤖 This PR was created automatically by the documentation translation workflow.
|
|
EOF
|
|
|
|
# Create the translation PR
|
|
TRANSLATION_PR_URL=$(gh pr create \
|
|
--base main \
|
|
--head "$SYNC_BRANCH" \
|
|
--title "🌐 Auto-translations for PR #${PR_NUMBER}: ${ORIGINAL_PR_TITLE}" \
|
|
--body-file /tmp/translation_pr_body.md \
|
|
--label "🌐 translation" \
|
|
--label "🤖 automated" 2>/dev/null || echo "")
|
|
|
|
if [ -n "$TRANSLATION_PR_URL" ]; then
|
|
# Extract PR number from URL
|
|
TRANSLATION_PR_NUMBER=$(echo "$TRANSLATION_PR_URL" | grep -o '[0-9]\+$')
|
|
echo "translation_pr_number=$TRANSLATION_PR_NUMBER" >> $GITHUB_OUTPUT
|
|
echo "translation_pr_url=$TRANSLATION_PR_URL" >> $GITHUB_OUTPUT
|
|
echo "branch_name=$SYNC_BRANCH" >> $GITHUB_OUTPUT
|
|
echo "creation_successful=true" >> $GITHUB_OUTPUT
|
|
|
|
echo "✅ Translation PR created successfully: #${TRANSLATION_PR_NUMBER}"
|
|
else
|
|
echo "❌ Failed to create translation PR"
|
|
echo "creation_successful=false" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Comment on original PR with translation PR link
|
|
if: steps.extract-artifacts.outputs.sync_required == 'true' && steps.check-approval.outputs.needs_approval != 'true'
|
|
uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
const fs = require('fs');
|
|
const prNumber = ${{ steps.extract-artifacts.outputs.pr_number }};
|
|
const hasChanges = '${{ steps.sync.outputs.has_changes }}' === 'true';
|
|
const translationPrNumber = '${{ steps.create-translation-pr.outputs.translation_pr_number }}';
|
|
const translationPrUrl = '${{ steps.create-translation-pr.outputs.translation_pr_url }}';
|
|
const creationSuccessful = '${{ steps.create-translation-pr.outputs.creation_successful }}' === 'true';
|
|
|
|
let comment = '## 🤖 Automatic Translation Status\n\n';
|
|
|
|
if (hasChanges && creationSuccessful && translationPrNumber) {
|
|
// Load sync results if available
|
|
let results = { translated: [], failed: [], skipped: [] };
|
|
try {
|
|
results = JSON.parse(fs.readFileSync('/tmp/sync_results.json', 'utf8'));
|
|
} catch (e) {
|
|
console.log('Could not load sync results');
|
|
results = { translated: [], failed: [], skipped: [] };
|
|
}
|
|
|
|
comment += `🎉 **Translation PR Created Successfully!**\n\n`;
|
|
comment += `Your English documentation changes have been automatically translated and a separate PR has been created.\n\n`;
|
|
comment += `### 🔗 Translation PR: [#${translationPrNumber}](${translationPrUrl})\n\n`;
|
|
|
|
if (results.translated && results.translated.length > 0) {
|
|
comment += `### ✅ Successfully Translated (${results.translated.length} files):\n`;
|
|
results.translated.slice(0, 8).forEach(file => {
|
|
comment += `- \`${file}\`\n`;
|
|
});
|
|
if (results.translated.length > 8) {
|
|
comment += `- ... and ${results.translated.length - 8} more files\n`;
|
|
}
|
|
comment += '\n';
|
|
}
|
|
|
|
if (results.failed && results.failed.length > 0) {
|
|
comment += `### ⚠️ Translation Issues (${results.failed.length}):\n`;
|
|
results.failed.slice(0, 5).forEach(file => {
|
|
comment += `- \`${file}\`\n`;
|
|
});
|
|
if (results.failed.length > 5) {
|
|
comment += `- ... and ${results.failed.length - 5} more\n`;
|
|
}
|
|
comment += '\n';
|
|
}
|
|
|
|
comment += '### 🔄 What Happens Next:\n';
|
|
comment += `1. **Review**: The translation PR [#${translationPrNumber}](${translationPrUrl}) is ready for review\n`;
|
|
comment += '2. **Independent**: Both PRs can be reviewed and merged independently\n';
|
|
comment += '3. **Automatic**: Future updates to this PR will automatically update the translation PR\n\n';
|
|
|
|
comment += '### 📋 Languages Included:\n';
|
|
comment += '- 🇨🇳 **Chinese (zh-hans)**: Simplified Chinese translations\n';
|
|
comment += '- 🇯🇵 **Japanese (ja-jp)**: Japanese translations\n';
|
|
comment += '- 📋 **Navigation**: Updated docs.json structure for both languages\n\n';
|
|
|
|
comment += '---\n';
|
|
comment += '_🤖 This is an automated translation workflow. The translation PR was created automatically and is ready for review._';
|
|
|
|
} else if (hasChanges && !creationSuccessful) {
|
|
comment += '⚠️ **Translation PR Creation Failed**\n\n';
|
|
comment += 'The automatic translation process completed, but there was an issue creating the translation PR.\n\n';
|
|
comment += '**What you can do:**\n';
|
|
comment += '1. Check the workflow logs for detailed error information\n';
|
|
comment += '2. Contact a maintainer if the issue persists\n';
|
|
comment += '3. The translations may have been generated but need manual PR creation\n\n';
|
|
comment += '_🤖 This is an automated notification from the translation workflow._';
|
|
|
|
} else {
|
|
comment += '✅ **No Translation Changes Needed**\n\n';
|
|
comment += 'Your changes did not require new translations, or all translations are already up to date.\n\n';
|
|
comment += '_🤖 This is an automated check from the translation workflow._';
|
|
}
|
|
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
body: comment
|
|
});
|
|
|
|
- name: Comment on translation PR with original PR link
|
|
if: steps.create-translation-pr.outputs.creation_successful == 'true' && steps.create-translation-pr.outputs.translation_pr_number
|
|
uses: actions/github-script@v7
|
|
continue-on-error: true
|
|
with:
|
|
script: |
|
|
const prNumber = ${{ steps.extract-artifacts.outputs.pr_number }};
|
|
const translationPrNumber = ${{ steps.create-translation-pr.outputs.translation_pr_number }};
|
|
|
|
const backLinkComment = [
|
|
'## 🔗 Linked to Original PR',
|
|
'',
|
|
`This translation PR was automatically created for the English documentation changes in **PR #${prNumber}**.`,
|
|
'',
|
|
'### 📝 Original Changes',
|
|
`- **Original PR**: #${prNumber}`,
|
|
'- **Type**: English documentation updates',
|
|
'- **Auto-translation**: This PR contains the corresponding translations',
|
|
'',
|
|
'### 🔄 Synchronization',
|
|
'- **Automatic Updates**: This PR will be automatically updated if the original PR changes',
|
|
'- **Independent Review**: This translation PR can be reviewed and merged independently',
|
|
'- **Quality Check**: Please review translations for accuracy and cultural appropriateness',
|
|
'',
|
|
'---',
|
|
`🤖 _This PR is part of the automated translation workflow. Any updates to PR #${prNumber} will automatically update this translation PR._`
|
|
].join('\\n');
|
|
|
|
try {
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: translationPrNumber,
|
|
body: backLinkComment
|
|
});
|
|
console.log(`Successfully linked translation PR #${translationPrNumber} to original PR #${prNumber}`);
|
|
} catch (error) {
|
|
console.log(`Could not comment on translation PR #${translationPrNumber}:`, error.message);
|
|
}
|
|
|
|
handle-failure:
|
|
runs-on: ubuntu-latest
|
|
if: github.event.workflow_run.conclusion == 'failure'
|
|
steps:
|
|
- name: Report analysis failure
|
|
uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
// Try to extract PR number from workflow run
|
|
const workflowRun = context.payload.workflow_run;
|
|
|
|
console.log('Analysis workflow failed');
|
|
console.log('Attempting to notify PR if possible...');
|
|
|
|
// This is a best-effort attempt to notify
|
|
// In practice, you might want to store PR number differently |