From 7fd31f5122095df0b0425eef8368046ce7d5b7e6 Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Tue, 21 Jul 2026 20:11:33 +0545 Subject: [PATCH 01/12] feat: auto-close issues on PR merge --- .../actions/translation-tracker/auto-close.js | 172 ++++++++++++++++++ .../translation-tracker/github-tracker.js | 147 +++++++++++++++ .github/actions/translation-tracker/utils.js | 55 ++++++ .github/workflows/translation-auto-close.yml | 42 +++++ 4 files changed, 416 insertions(+) create mode 100644 .github/actions/translation-tracker/auto-close.js create mode 100644 .github/workflows/translation-auto-close.yml diff --git a/.github/actions/translation-tracker/auto-close.js b/.github/actions/translation-tracker/auto-close.js new file mode 100644 index 0000000000..5831f1b151 --- /dev/null +++ b/.github/actions/translation-tracker/auto-close.js @@ -0,0 +1,172 @@ +const core = require('@actions/core'); +const { GitHubCommitTracker } = require('./github-tracker'); +const { parseTranslationPath } = require('./utils'); + +const RESOLVES_REGEX = /(?:resolves|closes|fixes)\s+#(\d+)/gi; + +/** + * Extract unique issue numbers from PR body (Resolves / Closes / Fixes #N). + */ +function parseReferencedIssues(prBody) { + if (!prBody) { + return []; + } + + const numbers = new Set(); + let match; + const regex = new RegExp(RESOLVES_REGEX.source, RESOLVES_REGEX.flags); + while ((match = regex.exec(prBody)) !== null) { + numbers.add(parseInt(match[1], 10)); + } + return [...numbers]; +} + +/** + * List all files changed in a merged PR (paginated). + */ +async function listPullRequestFiles(octokit, owner, repo, pullNumber) { + const files = []; + let page = 1; + const perPage = 100; + + while (true) { + const { data } = await octokit.rest.pulls.listFiles({ + owner, + repo, + pull_number: pullNumber, + per_page: perPage, + page, + }); + + files.push(...data); + + if (data.length < perPage) { + break; + } + page += 1; + } + + return files; +} + +async function main() { + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY || 'processing/p5.js-website'; + const prNumber = parseInt(process.env.PR_NUMBER || '', 10); + const prBody = process.env.PR_BODY || ''; + + if (!token) { + core.setFailed('GITHUB_TOKEN is required'); + return; + } + + if (!prNumber || Number.isNaN(prNumber)) { + core.setFailed('PR_NUMBER is required'); + return; + } + + const [owner, repo] = repository.split('/'); + const tracker = await GitHubCommitTracker.create(token, owner, repo); + + const summary = { + resolved: [], + skipped: [], + errors: [], + closedByReference: [], + }; + + // Fast path: Resolves / Closes / Fixes #N in PR body + const referencedIssues = parseReferencedIssues(prBody); + if (referencedIssues.length > 0) { + core.info(`Found ${referencedIssues.length} referenced issue(s) in PR body`); + + for (const issueNumber of referencedIssues) { + try { + const closed = await tracker.closeTrackerIssueByReference(issueNumber, prNumber); + if (closed) { + summary.closedByReference.push(issueNumber); + core.info(`Closed issue #${issueNumber} via Resolves fast path`); + } else { + summary.skipped.push({ issueNumber, reason: 'not open or not a tracker issue' }); + } + } catch (error) { + summary.errors.push({ issueNumber, error: error.message }); + core.warning(`Failed to close #${issueNumber}: ${error.message}`); + } + } + + const closedCount = summary.closedByReference.length; + core.info( + `Auto-close summary: ${closedCount} issue(s) closed via PR reference, ${summary.skipped.length} skipped, ${summary.errors.length} error(s)` + ); + return; + } + + // File-path path: match changed translation files to tracker issues + const changedFiles = await listPullRequestFiles(tracker.octokit, owner, repo, prNumber); + core.info(`Processing ${changedFiles.length} changed file(s) from PR #${prNumber}`); + + const processed = new Set(); + let closedCount = 0; + let updatedCount = 0; + + for (const file of changedFiles) { + const filePath = file.filename; + + try { + const parsed = parseTranslationPath(filePath); + if (!parsed) { + summary.skipped.push({ file: filePath, reason: 'not a translation content path' }); + continue; + } + + const dedupeKey = `${parsed.englishPath}:${parsed.language}`; + if (processed.has(dedupeKey)) { + summary.skipped.push({ file: filePath, reason: 'duplicate englishPath+language' }); + continue; + } + processed.add(dedupeKey); + + const issue = await tracker.findTrackerIssue(parsed.englishPath, parsed.language); + if (!issue) { + summary.skipped.push({ + file: filePath, + englishPath: parsed.englishPath, + language: parsed.language, + reason: 'no matching open tracker issue', + }); + continue; + } + + const result = await tracker.resolveTranslationIssue(issue, parsed.language, prNumber); + summary.resolved.push({ + issueNumber: issue.number, + file: filePath, + language: parsed.language, + closed: result.closed, + remainingLanguages: result.remainingLanguages, + }); + + if (result.closed) { + closedCount += 1; + core.info(`Closed issue #${issue.number} (${parsed.language} was last remaining language)`); + } else { + updatedCount += 1; + core.info( + `Updated issue #${issue.number}: removed lang-${parsed.language}, remaining: ${result.remainingLanguages.join(', ')}` + ); + } + } catch (error) { + summary.errors.push({ file: filePath, error: error.message }); + core.warning(`Error processing ${filePath}: ${error.message}`); + } + } + + core.info( + `Auto-close summary: ${updatedCount} issue(s) updated, ${closedCount} closed, ${summary.skipped.length} skipped, ${summary.errors.length} error(s)` + ); +} + +main().catch((error) => { + core.setFailed(error.message); +}); diff --git a/.github/actions/translation-tracker/github-tracker.js b/.github/actions/translation-tracker/github-tracker.js index 61c11944c5..de06e05bf3 100644 --- a/.github/actions/translation-tracker/github-tracker.js +++ b/.github/actions/translation-tracker/github-tracker.js @@ -325,6 +325,153 @@ ${outdatedLanguages.length > 0 || missingLanguages.length > 0 ? `**Change Type** return getLanguageDisplayName(langCode); } + /** + * Find an open translation tracker issue for an English source file and language. + * Matches on labels (needs translation + lang-) and the English path in the body. + * + * @param {string} englishPath - Repo-relative English source path + * @param {string} language - Language code (e.g. es, hi) + * @returns {Promise} Matching issue or null + */ + async findTrackerIssue(englishPath, language) { + const needle = `**File**: \`${englishPath}\``; + const labelFilter = `needs translation,lang-${language}`; + let page = 1; + const perPage = 100; + + while (true) { + const { data: issues } = await this.octokit.rest.issues.listForRepo({ + owner: this.owner, + repo: this.repo, + state: 'open', + labels: labelFilter, + per_page: perPage, + page, + }); + + const match = issues.find((issue) => issue.body && issue.body.includes(needle)); + if (match) { + return match; + } + + if (issues.length < perPage) { + break; + } + page += 1; + } + + return null; + } + + /** + * Remove a resolved language label from a tracker issue; close if no lang-* labels remain. + * + * @param {object} issue - GitHub issue object (must include number) + * @param {string} language - Language code that was updated + * @param {number} prNumber - Merged PR number (for comment link) + * @returns {Promise<{ closed: boolean, remainingLanguages: string[] }>} + */ + async resolveTranslationIssue(issue, language, prNumber) { + const issueNumber = issue.number; + const langLabel = `lang-${language}`; + + const { data: fullIssue } = await this.octokit.rest.issues.get({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + }); + + const currentLabels = fullIssue.labels.map((label) => + typeof label === 'string' ? label : label.name + ); + + const langLabelsBefore = currentLabels.filter((name) => name.startsWith('lang-')); + + if (langLabelsBefore.includes(langLabel)) { + await this.octokit.rest.issues.removeLabel({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + name: langLabel, + }); + } + + const remainingLangLabels = langLabelsBefore.filter((name) => name !== langLabel); + const remainingLanguages = remainingLangLabels.map((name) => name.replace(/^lang-/, '')); + const langDisplayName = this.getLanguageDisplayName(language); + + if (remainingLangLabels.length === 0) { + await this.octokit.rest.issues.createComment({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + body: `All translations are now up to date. Closing automatically via PR #${prNumber}.`, + }); + + await this.octokit.rest.issues.update({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + state: 'closed', + }); + + return { closed: true, remainingLanguages: [] }; + } + + const remainingDisplay = remainingLanguages + .map((code) => this.getLanguageDisplayName(code)) + .join(', '); + + await this.octokit.rest.issues.createComment({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + body: `${langDisplayName} translation updated via PR #${prNumber}. Remaining languages: ${remainingDisplay}.`, + }); + + return { closed: false, remainingLanguages }; + } + + /** + * Close a tracker issue directly when the PR body references it (Resolves #N fast path). + * + * @param {number} issueNumber + * @param {number} prNumber + * @returns {Promise} true if closed, false if skipped + */ + async closeTrackerIssueByReference(issueNumber, prNumber) { + const { data: issue } = await this.octokit.rest.issues.get({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + }); + + if (issue.state !== 'open') { + return false; + } + + const labels = issue.labels.map((label) => (typeof label === 'string' ? label : label.name)); + if (!labels.includes('needs translation')) { + return false; + } + + await this.octokit.rest.issues.createComment({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + body: `Closed automatically via PR #${prNumber} (Resolves #${issueNumber}).`, + }); + + await this.octokit.rest.issues.update({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + state: 'closed', + }); + + return true; + } + async createBranchWithFiles(branchName, commitMessage, fileChanges) { const baseBranch = this.currentBranch || 'main'; diff --git a/.github/actions/translation-tracker/utils.js b/.github/actions/translation-tracker/utils.js index dcc8d45648..850a47fd32 100644 --- a/.github/actions/translation-tracker/utils.js +++ b/.github/actions/translation-tracker/utils.js @@ -4,6 +4,8 @@ const path = require('path'); const https = require('https'); const yaml = require('js-yaml'); +const { SUPPORTED_LANGUAGES } = require('./constants'); + function getTranslationPath(englishFilePath, language) { if (!englishFilePath.includes('/en/')) { throw new Error(`Invalid English file path: ${englishFilePath}. Must contain '/en/'`); @@ -22,6 +24,58 @@ function getTranslationPath(englishFilePath, language) { return translationParts.join('/'); } +/** + * Parse a merged-PR translation file path into language, content type, and English source path. + * Returns null for English paths, non-content paths, or unsupported languages. + * + * @param {string} filePath - Repo-relative path (e.g. src/content/reference/es/p5/createCanvas.mdx) + * @returns {{ language: string, englishPath: string, contentType: string, originalPath: string } | null} + */ +function parseTranslationPath(filePath) { + if (!filePath || typeof filePath !== 'string') { + return null; + } + + const normalized = filePath.replace(/\\/g, '/'); + + if (!normalized.startsWith('src/content/')) { + return null; + } + + const parts = normalized.split('/'); + // src / content / / / ... + if (parts.length < 5) { + return null; + } + + const contentType = parts[2]; + const language = parts[3]; + + if (language === 'en') { + return null; + } + + if (!SUPPORTED_LANGUAGES.includes(language)) { + return null; + } + + const basename = parts[parts.length - 1]; + if (!basename.endsWith('.mdx') && !basename.endsWith('.yaml') && !basename.endsWith('.yml')) { + return null; + } + + const englishParts = [...parts]; + englishParts[3] = 'en'; + const englishPath = englishParts.join('/'); + + return { + language, + englishPath, + contentType, + originalPath: normalized, + }; +} + function getSlugFromEnglishPath(englishFilePath, contentType) { const prefix = `src/content/${contentType}/en/`; if (!englishFilePath.startsWith(prefix)) return null; @@ -229,6 +283,7 @@ function getLanguageDisplayName(langCode) { module.exports = { getTranslationPath, + parseTranslationPath, getSlugFromEnglishPath, parseEnvList, fileExists, diff --git a/.github/workflows/translation-auto-close.yml b/.github/workflows/translation-auto-close.yml new file mode 100644 index 0000000000..782d07bfcf --- /dev/null +++ b/.github/workflows/translation-auto-close.yml @@ -0,0 +1,42 @@ +name: Translation Auto-Close + +on: + pull_request: + types: [closed] + branches: + - v1 + paths: + - 'src/content/**/es/**' + - 'src/content/**/hi/**' + - 'src/content/**/ko/**' + - 'src/content/**/zh-Hans/**' + +permissions: + issues: write + pull-requests: read + contents: read + +jobs: + auto-close-translation-issues: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install translation tracker dependencies + run: cd .github/actions/translation-tracker && npm install + + - name: Auto-close matching translation tracker issues + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BODY: ${{ github.event.pull_request.body }} + run: node .github/actions/translation-tracker/auto-close.js From 6bab0c66ea1feb14f339e33482bc074245685399 Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Tue, 21 Jul 2026 21:52:37 +0545 Subject: [PATCH 02/12] update: keep only the auto close by reference --- .../actions/translation-tracker/auto-close.js | 117 ++---------------- .../translation-tracker/github-tracker.js | 109 +--------------- .github/actions/translation-tracker/utils.js | 55 -------- 3 files changed, 14 insertions(+), 267 deletions(-) diff --git a/.github/actions/translation-tracker/auto-close.js b/.github/actions/translation-tracker/auto-close.js index 5831f1b151..29e288c816 100644 --- a/.github/actions/translation-tracker/auto-close.js +++ b/.github/actions/translation-tracker/auto-close.js @@ -1,6 +1,5 @@ const core = require('@actions/core'); const { GitHubCommitTracker } = require('./github-tracker'); -const { parseTranslationPath } = require('./utils'); const RESOLVES_REGEX = /(?:resolves|closes|fixes)\s+#(\d+)/gi; @@ -21,34 +20,6 @@ function parseReferencedIssues(prBody) { return [...numbers]; } -/** - * List all files changed in a merged PR (paginated). - */ -async function listPullRequestFiles(octokit, owner, repo, pullNumber) { - const files = []; - let page = 1; - const perPage = 100; - - while (true) { - const { data } = await octokit.rest.pulls.listFiles({ - owner, - repo, - pull_number: pullNumber, - per_page: perPage, - page, - }); - - files.push(...data); - - if (data.length < perPage) { - break; - } - page += 1; - } - - return files; -} - async function main() { const token = process.env.GITHUB_TOKEN; const repository = process.env.GITHUB_REPOSITORY || 'processing/p5.js-website'; @@ -69,101 +40,39 @@ async function main() { const tracker = await GitHubCommitTracker.create(token, owner, repo); const summary = { - resolved: [], skipped: [], errors: [], closedByReference: [], }; - // Fast path: Resolves / Closes / Fixes #N in PR body const referencedIssues = parseReferencedIssues(prBody); - if (referencedIssues.length > 0) { - core.info(`Found ${referencedIssues.length} referenced issue(s) in PR body`); - - for (const issueNumber of referencedIssues) { - try { - const closed = await tracker.closeTrackerIssueByReference(issueNumber, prNumber); - if (closed) { - summary.closedByReference.push(issueNumber); - core.info(`Closed issue #${issueNumber} via Resolves fast path`); - } else { - summary.skipped.push({ issueNumber, reason: 'not open or not a tracker issue' }); - } - } catch (error) { - summary.errors.push({ issueNumber, error: error.message }); - core.warning(`Failed to close #${issueNumber}: ${error.message}`); - } - } - - const closedCount = summary.closedByReference.length; + if (referencedIssues.length === 0) { core.info( - `Auto-close summary: ${closedCount} issue(s) closed via PR reference, ${summary.skipped.length} skipped, ${summary.errors.length} error(s)` + `No Resolves/Closes/Fixes #N references found in PR #${prNumber} body. Nothing to close.` ); return; } - // File-path path: match changed translation files to tracker issues - const changedFiles = await listPullRequestFiles(tracker.octokit, owner, repo, prNumber); - core.info(`Processing ${changedFiles.length} changed file(s) from PR #${prNumber}`); - - const processed = new Set(); - let closedCount = 0; - let updatedCount = 0; - - for (const file of changedFiles) { - const filePath = file.filename; + core.info(`Found ${referencedIssues.length} referenced issue(s) in PR body`); + for (const issueNumber of referencedIssues) { try { - const parsed = parseTranslationPath(filePath); - if (!parsed) { - summary.skipped.push({ file: filePath, reason: 'not a translation content path' }); - continue; - } - - const dedupeKey = `${parsed.englishPath}:${parsed.language}`; - if (processed.has(dedupeKey)) { - summary.skipped.push({ file: filePath, reason: 'duplicate englishPath+language' }); - continue; - } - processed.add(dedupeKey); - - const issue = await tracker.findTrackerIssue(parsed.englishPath, parsed.language); - if (!issue) { - summary.skipped.push({ - file: filePath, - englishPath: parsed.englishPath, - language: parsed.language, - reason: 'no matching open tracker issue', - }); - continue; - } - - const result = await tracker.resolveTranslationIssue(issue, parsed.language, prNumber); - summary.resolved.push({ - issueNumber: issue.number, - file: filePath, - language: parsed.language, - closed: result.closed, - remainingLanguages: result.remainingLanguages, - }); - - if (result.closed) { - closedCount += 1; - core.info(`Closed issue #${issue.number} (${parsed.language} was last remaining language)`); + const closed = await tracker.closeTrackerIssueByReference(issueNumber, prNumber); + if (closed) { + summary.closedByReference.push(issueNumber); + core.info(`Closed issue #${issueNumber} via Resolves fast path`); } else { - updatedCount += 1; - core.info( - `Updated issue #${issue.number}: removed lang-${parsed.language}, remaining: ${result.remainingLanguages.join(', ')}` - ); + summary.skipped.push({ issueNumber, reason: 'not open or not a tracker issue' }); } } catch (error) { - summary.errors.push({ file: filePath, error: error.message }); - core.warning(`Error processing ${filePath}: ${error.message}`); + summary.errors.push({ issueNumber, error: error.message }); + core.warning(`Failed to close #${issueNumber}: ${error.message}`); } } + const closedCount = summary.closedByReference.length; core.info( - `Auto-close summary: ${updatedCount} issue(s) updated, ${closedCount} closed, ${summary.skipped.length} skipped, ${summary.errors.length} error(s)` + `Auto-close summary: ${closedCount} issue(s) closed via PR reference, ${summary.skipped.length} skipped, ${summary.errors.length} error(s)` ); } diff --git a/.github/actions/translation-tracker/github-tracker.js b/.github/actions/translation-tracker/github-tracker.js index de06e05bf3..a82ff14cbd 100644 --- a/.github/actions/translation-tracker/github-tracker.js +++ b/.github/actions/translation-tracker/github-tracker.js @@ -326,114 +326,7 @@ ${outdatedLanguages.length > 0 || missingLanguages.length > 0 ? `**Change Type** } /** - * Find an open translation tracker issue for an English source file and language. - * Matches on labels (needs translation + lang-) and the English path in the body. - * - * @param {string} englishPath - Repo-relative English source path - * @param {string} language - Language code (e.g. es, hi) - * @returns {Promise} Matching issue or null - */ - async findTrackerIssue(englishPath, language) { - const needle = `**File**: \`${englishPath}\``; - const labelFilter = `needs translation,lang-${language}`; - let page = 1; - const perPage = 100; - - while (true) { - const { data: issues } = await this.octokit.rest.issues.listForRepo({ - owner: this.owner, - repo: this.repo, - state: 'open', - labels: labelFilter, - per_page: perPage, - page, - }); - - const match = issues.find((issue) => issue.body && issue.body.includes(needle)); - if (match) { - return match; - } - - if (issues.length < perPage) { - break; - } - page += 1; - } - - return null; - } - - /** - * Remove a resolved language label from a tracker issue; close if no lang-* labels remain. - * - * @param {object} issue - GitHub issue object (must include number) - * @param {string} language - Language code that was updated - * @param {number} prNumber - Merged PR number (for comment link) - * @returns {Promise<{ closed: boolean, remainingLanguages: string[] }>} - */ - async resolveTranslationIssue(issue, language, prNumber) { - const issueNumber = issue.number; - const langLabel = `lang-${language}`; - - const { data: fullIssue } = await this.octokit.rest.issues.get({ - owner: this.owner, - repo: this.repo, - issue_number: issueNumber, - }); - - const currentLabels = fullIssue.labels.map((label) => - typeof label === 'string' ? label : label.name - ); - - const langLabelsBefore = currentLabels.filter((name) => name.startsWith('lang-')); - - if (langLabelsBefore.includes(langLabel)) { - await this.octokit.rest.issues.removeLabel({ - owner: this.owner, - repo: this.repo, - issue_number: issueNumber, - name: langLabel, - }); - } - - const remainingLangLabels = langLabelsBefore.filter((name) => name !== langLabel); - const remainingLanguages = remainingLangLabels.map((name) => name.replace(/^lang-/, '')); - const langDisplayName = this.getLanguageDisplayName(language); - - if (remainingLangLabels.length === 0) { - await this.octokit.rest.issues.createComment({ - owner: this.owner, - repo: this.repo, - issue_number: issueNumber, - body: `All translations are now up to date. Closing automatically via PR #${prNumber}.`, - }); - - await this.octokit.rest.issues.update({ - owner: this.owner, - repo: this.repo, - issue_number: issueNumber, - state: 'closed', - }); - - return { closed: true, remainingLanguages: [] }; - } - - const remainingDisplay = remainingLanguages - .map((code) => this.getLanguageDisplayName(code)) - .join(', '); - - await this.octokit.rest.issues.createComment({ - owner: this.owner, - repo: this.repo, - issue_number: issueNumber, - body: `${langDisplayName} translation updated via PR #${prNumber}. Remaining languages: ${remainingDisplay}.`, - }); - - return { closed: false, remainingLanguages }; - } - - /** - * Close a tracker issue directly when the PR body references it (Resolves #N fast path). + * Close a tracker issue when the PR body references it (Resolves / Closes / Fixes #N). * * @param {number} issueNumber * @param {number} prNumber diff --git a/.github/actions/translation-tracker/utils.js b/.github/actions/translation-tracker/utils.js index 850a47fd32..dcc8d45648 100644 --- a/.github/actions/translation-tracker/utils.js +++ b/.github/actions/translation-tracker/utils.js @@ -4,8 +4,6 @@ const path = require('path'); const https = require('https'); const yaml = require('js-yaml'); -const { SUPPORTED_LANGUAGES } = require('./constants'); - function getTranslationPath(englishFilePath, language) { if (!englishFilePath.includes('/en/')) { throw new Error(`Invalid English file path: ${englishFilePath}. Must contain '/en/'`); @@ -24,58 +22,6 @@ function getTranslationPath(englishFilePath, language) { return translationParts.join('/'); } -/** - * Parse a merged-PR translation file path into language, content type, and English source path. - * Returns null for English paths, non-content paths, or unsupported languages. - * - * @param {string} filePath - Repo-relative path (e.g. src/content/reference/es/p5/createCanvas.mdx) - * @returns {{ language: string, englishPath: string, contentType: string, originalPath: string } | null} - */ -function parseTranslationPath(filePath) { - if (!filePath || typeof filePath !== 'string') { - return null; - } - - const normalized = filePath.replace(/\\/g, '/'); - - if (!normalized.startsWith('src/content/')) { - return null; - } - - const parts = normalized.split('/'); - // src / content / / / ... - if (parts.length < 5) { - return null; - } - - const contentType = parts[2]; - const language = parts[3]; - - if (language === 'en') { - return null; - } - - if (!SUPPORTED_LANGUAGES.includes(language)) { - return null; - } - - const basename = parts[parts.length - 1]; - if (!basename.endsWith('.mdx') && !basename.endsWith('.yaml') && !basename.endsWith('.yml')) { - return null; - } - - const englishParts = [...parts]; - englishParts[3] = 'en'; - const englishPath = englishParts.join('/'); - - return { - language, - englishPath, - contentType, - originalPath: normalized, - }; -} - function getSlugFromEnglishPath(englishFilePath, contentType) { const prefix = `src/content/${contentType}/en/`; if (!englishFilePath.startsWith(prefix)) return null; @@ -283,7 +229,6 @@ function getLanguageDisplayName(langCode) { module.exports = { getTranslationPath, - parseTranslationPath, getSlugFromEnglishPath, parseEnvList, fileExists, From 3ac00c0e3e4d9fdd8d6fac0d9e9ce94f36cff04d Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Mon, 3 Aug 2026 21:56:29 +0545 Subject: [PATCH 03/12] feat: check PR to get referenced issue then strike out the translation needed --- .../actions/translation-tracker/auto-close.js | 139 ++++++++++++++++-- .../translation-tracker/github-tracker.js | 111 +++++++++++--- 2 files changed, 215 insertions(+), 35 deletions(-) diff --git a/.github/actions/translation-tracker/auto-close.js b/.github/actions/translation-tracker/auto-close.js index 29e288c816..aec9e860d9 100644 --- a/.github/actions/translation-tracker/auto-close.js +++ b/.github/actions/translation-tracker/auto-close.js @@ -1,11 +1,12 @@ const core = require('@actions/core'); const { GitHubCommitTracker } = require('./github-tracker'); +const { SUPPORTED_LANGUAGES } = require('./constants'); +const { getLanguageDisplayName } = require('./utils'); -const RESOLVES_REGEX = /(?:resolves|closes|fixes)\s+#(\d+)/gi; +// Check for linking keywords (ref: https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue) +const RESOLVES_REGEX = /(?:resolves|resolve|resolved|close|closes|closed|fixes|fix|fixed)\s+#(\d+)/gi; -/** - * Extract unique issue numbers from PR body (Resolves / Closes / Fixes #N). - */ +/// Extract unique issue numbers from PR body (Resolves / Closes / Fixes #N). function parseReferencedIssues(prBody) { if (!prBody) { return []; @@ -20,6 +21,71 @@ function parseReferencedIssues(prBody) { return [...numbers]; } +// List all files changed in a merged PR (paginated). +async function listPullRequestFiles(octokit, owner, repo, pullNumber) { + const files = []; + let page = 1; + const perPage = 100; + + while (true) { + const { data } = await octokit.rest.pulls.listFiles({ + owner, + repo, + pull_number: pullNumber, + per_page: perPage, + page, + }); + + files.push(...data); + + if (data.length < perPage) { + break; + } + + page += 1; + } + + return files; +} + +/* + Identify translation languages from changed PR file paths. + e.g. src/content/tutorials/es/**.mdx → 'es' (Spanish) + */ +function identifyLanguagesFromFiles(changedFiles) { + const languages = new Set(); + + // Sort longer codes first (standard for regex) + const langPattern = [...SUPPORTED_LANGUAGES].sort((a, b) => b.length - a.length).join('|'); // it should be like this: 'zh-Hans|es|hi|ko' + const pathRegex = new RegExp(`^src/content/[^/]+/(${langPattern})/`); + + for (const file of changedFiles) { + const filename = file.filename || file; + const match = filename.match(pathRegex); + if (match) { + languages.add(match[1]); + } + } + + return [...languages]; +} + +// Strike language required lines in the tracker issue body for resolved languages. +function strikeLanguagesInBody(body, languages) { + let result = body || ''; + + for (const language of languages) { + const displayName = getLanguageDisplayName(language); + const escaped = displayName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + // Match a list item that starts with the language display name and is not already struck. + const lineRegex = new RegExp(`^(- (?!~~)\\*\\*${escaped}\\*\\*:.*)$`, 'gm'); + result = result.replace(lineRegex, '~~$1~~'); + } + + return result; +} + async function main() { const token = process.env.GITHUB_TOKEN; const repository = process.env.GITHUB_REPOSITORY || 'processing/p5.js-website'; @@ -40,39 +106,84 @@ async function main() { const tracker = await GitHubCommitTracker.create(token, owner, repo); const summary = { + updated: [], + closed: [], skipped: [], errors: [], - closedByReference: [], }; const referencedIssues = parseReferencedIssues(prBody); if (referencedIssues.length === 0) { core.info( - `No Resolves/Closes/Fixes #N references found in PR #${prNumber} body. Nothing to close.` + `No Resolves/Closes/Fixes #N references found in PR #${prNumber} body. Nothing to close or edit.` + ); + return; + } + + const changedFiles = await listPullRequestFiles(tracker.octokit, owner, repo, prNumber); + const languages = identifyLanguagesFromFiles(changedFiles); + + if (languages.length === 0) { + core.info( + `PR #${prNumber} references issue(s) but no translation-language paths were found in changed files. Skipping...` ); return; } core.info(`Found ${referencedIssues.length} referenced issue(s) in PR body`); + core.info(`Languages from changed files: ${languages.join(', ')}`); for (const issueNumber of referencedIssues) { try { - const closed = await tracker.closeTrackerIssueByReference(issueNumber, prNumber); - if (closed) { - summary.closedByReference.push(issueNumber); - core.info(`Closed issue #${issueNumber} via Resolves fast path`); + const issue = await tracker.getIssue(issueNumber); + + if (!issue.open) { + summary.skipped.push({ issueNumber, reason: 'not open' }); + core.info(`Skipped #${issueNumber}: not open`); + continue; + } + + // check for 'needs translation' label to ensure that the issue is related to translation + if (!issue.labels.includes('needs translation')) { + summary.skipped.push({ issueNumber, reason: 'not a tracker issue' }); + core.info(`Skipped #${issueNumber}: missing 'needs translation' label`); + continue; + } + + const editedBody = strikeLanguagesInBody(issue.body, languages); + const result = await tracker.applyLanguageProgress(issue, languages, prNumber, { + body: editedBody, + }); + + if (result.closed) { + summary.closed.push(issueNumber); + core.info(`Closed issue #${issueNumber} (no remaining lang-* labels)`); + } else if (result.removedLanguages.length > 0) { + summary.updated.push({ + issueNumber, + removedLanguages: result.removedLanguages, + remainingLanguages: result.remainingLanguages, + }); + core.info( + `Updated issue #${issueNumber}: removed ${result.removedLanguages + .map((l) => `lang-${l}`) + .join(', ')}; remaining: ${result.remainingLanguages.join(', ') || 'none'}` + ); } else { - summary.skipped.push({ issueNumber, reason: 'not open or not a tracker issue' }); + summary.skipped.push({ + issueNumber, + reason: 'no matching lang-* labels for PR languages', + }); + core.info(`Skipped #${issueNumber}: no matching lang-* labels for ${languages.join(', ')}`); } } catch (error) { summary.errors.push({ issueNumber, error: error.message }); - core.warning(`Failed to close #${issueNumber}: ${error.message}`); + core.warning(`Failed to process #${issueNumber}: ${error.message}`); } } - const closedCount = summary.closedByReference.length; core.info( - `Auto-close summary: ${closedCount} issue(s) closed via PR reference, ${summary.skipped.length} skipped, ${summary.errors.length} error(s)` + `Auto-close summary: ${summary.updated.length} updated, ${summary.closed.length} closed, ${summary.skipped.length} skipped, ${summary.errors.length} error(s)` ); } diff --git a/.github/actions/translation-tracker/github-tracker.js b/.github/actions/translation-tracker/github-tracker.js index a82ff14cbd..e39391dcc4 100644 --- a/.github/actions/translation-tracker/github-tracker.js +++ b/.github/actions/translation-tracker/github-tracker.js @@ -326,43 +326,112 @@ ${outdatedLanguages.length > 0 || missingLanguages.length > 0 ? `**Change Type** } /** - * Close a tracker issue when the PR body references it (Resolves / Closes / Fixes #N). + * Fetch an issue and normalize labels for auto-close processing. * * @param {number} issueNumber - * @param {number} prNumber - * @returns {Promise} true if closed, false if skipped + * @returns {Promise<{ number: number, open: boolean, body: string, labels: string[], htmlUrl: string }>} */ - async closeTrackerIssueByReference(issueNumber, prNumber) { - const { data: issue } = await this.octokit.rest.issues.get({ + async getIssue(issueNumber) { + const { data } = await this.octokit.rest.issues.get({ owner: this.owner, repo: this.repo, issue_number: issueNumber, }); - if (issue.state !== 'open') { - return false; - } + return { + number: data.number, + open: data.state === 'open', + body: data.body || '', + labels: data.labels.map((label) => (typeof label === 'string' ? label : label.name)), + htmlUrl: data.html_url, + }; + } + + /** + * Remove lang-* labels for languages resolved by a PR. Update body, comment, and + * close the issue when no lang-* labels remain. + * + * @param {object} issue - Normalized issue from getIssue() + * @param {string[]} languages - Language codes from changed PR files + * @param {number} prNumber + * @param {{ body?: string }} [edits] + * @returns {Promise<{ closed: boolean, remainingLanguages: string[], removedLanguages: string[] }>} + */ + async applyLanguageProgress(issue, languages, prNumber, edits = {}) { + const issueNumber = issue.number; + const labels = [...issue.labels]; + const removedLanguages = []; + + for (const language of languages) { + const langLabel = `lang-${language}`; + if (!labels.includes(langLabel)) { + continue; + } - const labels = issue.labels.map((label) => (typeof label === 'string' ? label : label.name)); - if (!labels.includes('needs translation')) { - return false; + try { + await this.octokit.rest.issues.removeLabel({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + name: langLabel, + }); + removedLanguages.push(language); + } catch (error) { + // Label may already be gone (404) — treat as already removed. + if (error.status !== 404) { + throw error; + } + removedLanguages.push(language); + } } - await this.octokit.rest.issues.createComment({ - owner: this.owner, - repo: this.repo, - issue_number: issueNumber, - body: `Closed automatically via PR #${prNumber} (Resolves #${issueNumber}).`, - }); + const remainingLanguages = labels + .filter((name) => name.startsWith('lang-') && !removedLanguages.includes(name.replace(/^lang-/, ''))) + .map((name) => name.replace(/^lang-/, '')); - await this.octokit.rest.issues.update({ + const updatePayload = { owner: this.owner, repo: this.repo, issue_number: issueNumber, - state: 'closed', - }); + }; + + if (typeof edits.body === 'string' && edits.body !== issue.body) { + updatePayload.body = edits.body; + } + + if (remainingLanguages.length === 0) { + updatePayload.state = 'closed'; + } + + if (updatePayload.body || updatePayload.state) { + await this.octokit.rest.issues.update(updatePayload); + } + + const removedDisplay = removedLanguages.map((code) => this.getLanguageDisplayName(code)).join(', '); + + if (remainingLanguages.length === 0) { + await this.octokit.rest.issues.createComment({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + body: `All tracked languages are now up to date (${removedDisplay || 'via PR'}). Closing automatically via PR #${prNumber}.`, + }); + return { closed: true, remainingLanguages: [], removedLanguages }; + } + + if (removedLanguages.length > 0) { + const remainingDisplay = remainingLanguages + .map((code) => this.getLanguageDisplayName(code)) + .join(', '); + await this.octokit.rest.issues.createComment({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + body: `${removedDisplay} translation updated via PR #${prNumber}. Remaining languages: ${remainingDisplay}.`, + }); + } - return true; + return { closed: false, remainingLanguages, removedLanguages }; } async createBranchWithFiles(branchName, commitMessage, fileChanges) { From 0bd612d884513059083f9d43aa977f2b5cb320d5 Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Mon, 3 Aug 2026 22:06:20 +0545 Subject: [PATCH 04/12] update the branch --- .github/workflows/translation-auto-close.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/translation-auto-close.yml b/.github/workflows/translation-auto-close.yml index 782d07bfcf..236d7fdb69 100644 --- a/.github/workflows/translation-auto-close.yml +++ b/.github/workflows/translation-auto-close.yml @@ -4,7 +4,7 @@ on: pull_request: types: [closed] branches: - - v1 + - main paths: - 'src/content/**/es/**' - 'src/content/**/hi/**' From 724c08d93d6f4ac702945f89bcb5e1db8585d594 Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Fri, 4 Sep 2026 14:09:25 +0545 Subject: [PATCH 05/12] feat: update the structure of issue extraction/parsing We now look at the title, body, comments, cross references to pull out the issue number(s) --- .../actions/translation-tracker/auto-close.js | 100 +++++---- .../translation-tracker/github-tracker.js | 57 +++-- .../translation-tracker/pr-references.js | 208 ++++++++++++++++++ .github/workflows/translation-auto-close.yml | 1 - 4 files changed, 305 insertions(+), 61 deletions(-) create mode 100644 .github/actions/translation-tracker/pr-references.js diff --git a/.github/actions/translation-tracker/auto-close.js b/.github/actions/translation-tracker/auto-close.js index aec9e860d9..b6442520f9 100644 --- a/.github/actions/translation-tracker/auto-close.js +++ b/.github/actions/translation-tracker/auto-close.js @@ -1,26 +1,9 @@ const core = require('@actions/core'); const { GitHubCommitTracker } = require('./github-tracker'); +const { collectReferencedIssues } = require('./pr-references'); const { SUPPORTED_LANGUAGES } = require('./constants'); const { getLanguageDisplayName } = require('./utils'); -// Check for linking keywords (ref: https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue) -const RESOLVES_REGEX = /(?:resolves|resolve|resolved|close|closes|closed|fixes|fix|fixed)\s+#(\d+)/gi; - -/// Extract unique issue numbers from PR body (Resolves / Closes / Fixes #N). -function parseReferencedIssues(prBody) { - if (!prBody) { - return []; - } - - const numbers = new Set(); - let match; - const regex = new RegExp(RESOLVES_REGEX.source, RESOLVES_REGEX.flags); - while ((match = regex.exec(prBody)) !== null) { - numbers.add(parseInt(match[1], 10)); - } - return [...numbers]; -} - // List all files changed in a merged PR (paginated). async function listPullRequestFiles(octokit, owner, repo, pullNumber) { const files = []; @@ -90,7 +73,6 @@ async function main() { const token = process.env.GITHUB_TOKEN; const repository = process.env.GITHUB_REPOSITORY || 'processing/p5.js-website'; const prNumber = parseInt(process.env.PR_NUMBER || '', 10); - const prBody = process.env.PR_BODY || ''; if (!token) { core.setFailed('GITHUB_TOKEN is required'); @@ -107,16 +89,21 @@ async function main() { const summary = { updated: [], + reopened: [], closed: [], skipped: [], errors: [], }; - const referencedIssues = parseReferencedIssues(prBody); - if (referencedIssues.length === 0) { - core.info( - `No Resolves/Closes/Fixes #N references found in PR #${prNumber} body. Nothing to close or edit.` - ); + const references = await collectReferencedIssues({ + octokit: tracker.octokit, + owner, + repo, + prNumber, + }); + + if (references.size === 0) { + core.info(`No #N references found anywhere on PR #${prNumber}. Nothing to close or edit.`); return; } @@ -130,16 +117,36 @@ async function main() { return; } - core.info(`Found ${referencedIssues.length} referenced issue(s) in PR body`); + core.info(`Found ${references.size} referenced issue(s) on PR #${prNumber}:`); + for (const [issueNumber, sources] of references) { + core.info(` #${issueNumber} (from ${[...sources].join(', ')})`); + } core.info(`Languages from changed files: ${languages.join(', ')}`); - for (const issueNumber of referencedIssues) { + for (const issueNumber of references.keys()) { + if (issueNumber === prNumber) { + summary.skipped.push({ issueNumber, reason: 'self-reference' }); + core.info(`Skipped #${issueNumber}: self-reference`); + continue; + } + try { - const issue = await tracker.getIssue(issueNumber); + let issue; + try { + issue = await tracker.getIssue(issueNumber); + } catch (error) { + // A stray number (a hex color, a version string) is noise, not a failure. + if (error.status === 404) { + summary.skipped.push({ issueNumber, reason: 'not found' }); + core.info(`Skipped #${issueNumber}: no such issue`); + continue; + } + throw error; + } - if (!issue.open) { - summary.skipped.push({ issueNumber, reason: 'not open' }); - core.info(`Skipped #${issueNumber}: not open`); + if (issue.isPullRequest) { + summary.skipped.push({ issueNumber, reason: 'is a pull request' }); + core.info(`Skipped #${issueNumber}: is a pull request, not an issue`); continue; } @@ -159,16 +166,23 @@ async function main() { summary.closed.push(issueNumber); core.info(`Closed issue #${issueNumber} (no remaining lang-* labels)`); } else if (result.removedLanguages.length > 0) { + const removed = result.removedLanguages.map((l) => `lang-${l}`).join(', '); + const remaining = result.remainingLanguages.join(', ') || 'none'; + summary.updated.push({ issueNumber, removedLanguages: result.removedLanguages, remainingLanguages: result.remainingLanguages, }); - core.info( - `Updated issue #${issueNumber}: removed ${result.removedLanguages - .map((l) => `lang-${l}`) - .join(', ')}; remaining: ${result.remainingLanguages.join(', ') || 'none'}` - ); + + if (result.reopened) { + summary.reopened.push(issueNumber); + core.info( + `Reopened issue #${issueNumber}: removed ${removed}; still needs ${remaining}` + ); + } else { + core.info(`Updated issue #${issueNumber}: removed ${removed}; remaining: ${remaining}`); + } } else { summary.skipped.push({ issueNumber, @@ -183,10 +197,18 @@ async function main() { } core.info( - `Auto-close summary: ${summary.updated.length} updated, ${summary.closed.length} closed, ${summary.skipped.length} skipped, ${summary.errors.length} error(s)` + `Auto-close summary: ${summary.updated.length} updated (${summary.reopened.length} reopened), ${summary.closed.length} closed, ${summary.skipped.length} skipped, ${summary.errors.length} error(s)` ); } -main().catch((error) => { - core.setFailed(error.message); -}); +if (require.main === module) { + main().catch((error) => { + core.setFailed(error.message); + }); +} + +module.exports = { + main, + identifyLanguagesFromFiles, + strikeLanguagesInBody, +}; diff --git a/.github/actions/translation-tracker/github-tracker.js b/.github/actions/translation-tracker/github-tracker.js index e39391dcc4..a17c90f3ea 100644 --- a/.github/actions/translation-tracker/github-tracker.js +++ b/.github/actions/translation-tracker/github-tracker.js @@ -329,7 +329,7 @@ ${outdatedLanguages.length > 0 || missingLanguages.length > 0 ? `**Change Type** * Fetch an issue and normalize labels for auto-close processing. * * @param {number} issueNumber - * @returns {Promise<{ number: number, open: boolean, body: string, labels: string[], htmlUrl: string }>} + * @returns {Promise<{ number: number, open: boolean, isPullRequest: boolean, body: string, labels: string[], htmlUrl: string }>} */ async getIssue(issueNumber) { const { data } = await this.octokit.rest.issues.get({ @@ -341,6 +341,7 @@ ${outdatedLanguages.length > 0 || missingLanguages.length > 0 ? `**Change Type** return { number: data.number, open: data.state === 'open', + isPullRequest: Boolean(data.pull_request), // Issues and PRs share one number space; turns out only PRs carry this field. body: data.body || '', labels: data.labels.map((label) => (typeof label === 'string' ? label : label.name)), htmlUrl: data.html_url, @@ -348,14 +349,15 @@ ${outdatedLanguages.length > 0 || missingLanguages.length > 0 ? `**Change Type** } /** - * Remove lang-* labels for languages resolved by a PR. Update body, comment, and - * close the issue when no lang-* labels remain. + * Remove lang-* labels for languages resolved by a PR, then update the body and comment. + * Closes the issue when no lang-* labels remain, and reopens it when some do + * - GitHub's native "Fixes #N" handling may have closed it early on merge (true for /p5.js-website repo). * * @param {object} issue - Normalized issue from getIssue() * @param {string[]} languages - Language codes from changed PR files * @param {number} prNumber * @param {{ body?: string }} [edits] - * @returns {Promise<{ closed: boolean, remainingLanguages: string[], removedLanguages: string[] }>} + * @returns {Promise<{ closed: boolean, reopened: boolean, remainingLanguages: string[], removedLanguages: string[] }>} */ async applyLanguageProgress(issue, languages, prNumber, edits = {}) { const issueNumber = issue.number; @@ -389,6 +391,14 @@ ${outdatedLanguages.length > 0 || missingLanguages.length > 0 ? `**Change Type** .filter((name) => name.startsWith('lang-') && !removedLanguages.includes(name.replace(/^lang-/, ''))) .map((name) => name.replace(/^lang-/, '')); + // This PR touched nothing the issue tracks, so leave its state and body alone. + if (removedLanguages.length === 0) { + return { closed: false, reopened: false, remainingLanguages, removedLanguages }; + } + + const allLanguagesDone = remainingLanguages.length === 0; + const desiredState = allLanguagesDone ? 'closed' : 'open'; + const updatePayload = { owner: this.owner, repo: this.repo, @@ -399,39 +409,44 @@ ${outdatedLanguages.length > 0 || missingLanguages.length > 0 ? `**Change Type** updatePayload.body = edits.body; } - if (remainingLanguages.length === 0) { - updatePayload.state = 'closed'; + if (desiredState !== (issue.open ? 'open' : 'closed')) { + updatePayload.state = desiredState; } + const reopened = updatePayload.state === 'open'; + if (updatePayload.body || updatePayload.state) { await this.octokit.rest.issues.update(updatePayload); } const removedDisplay = removedLanguages.map((code) => this.getLanguageDisplayName(code)).join(', '); - if (remainingLanguages.length === 0) { + if (allLanguagesDone) { await this.octokit.rest.issues.createComment({ owner: this.owner, repo: this.repo, issue_number: issueNumber, - body: `All tracked languages are now up to date (${removedDisplay || 'via PR'}). Closing automatically via PR #${prNumber}.`, + body: `All tracked languages are now up to date (${removedDisplay}). Closing automatically via PR #${prNumber}. + *By auto-close issue workflow* + `, }); - return { closed: true, remainingLanguages: [], removedLanguages }; + return { closed: true, reopened: false, remainingLanguages: [], removedLanguages }; } - if (removedLanguages.length > 0) { - const remainingDisplay = remainingLanguages - .map((code) => this.getLanguageDisplayName(code)) - .join(', '); - await this.octokit.rest.issues.createComment({ - owner: this.owner, - repo: this.repo, - issue_number: issueNumber, - body: `${removedDisplay} translation updated via PR #${prNumber}. Remaining languages: ${remainingDisplay}.`, - }); - } + const remainingDisplay = remainingLanguages + .map((code) => this.getLanguageDisplayName(code)) + .join(', '); + + await this.octokit.rest.issues.createComment({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + body: reopened + ? `Reopening: ${removedDisplay} translation was updated via PR #${prNumber}, but this issue still tracks ${remainingDisplay}.` + : `${removedDisplay} translation updated via PR #${prNumber}. Remaining languages: ${remainingDisplay}.`, + }); - return { closed: false, remainingLanguages, removedLanguages }; + return { closed: false, reopened, remainingLanguages, removedLanguages }; } async createBranchWithFiles(branchName, commitMessage, fileChanges) { diff --git a/.github/actions/translation-tracker/pr-references.js b/.github/actions/translation-tracker/pr-references.js new file mode 100644 index 0000000000..1dfcf8a501 --- /dev/null +++ b/.github/actions/translation-tracker/pr-references.js @@ -0,0 +1,208 @@ +const core = require('@actions/core'); + +/* + Collect issue numbers referenced from a pull request. + + Problem: Contributors rarely use GitHub's linking keywords (Resolves / Closes / Fixes), + Solution: so every `#N` is treated as a candidate. Candidates are validated later by the + caller, which checks that the number is a real, translation-tracker issue. (Explained later about how an issue is validated) + */ + +// Fenced blocks (``` or ~~~) delimited on their own lines. +const FENCED_CODE_REGEX = /^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?^ {0,3}\1[^\n]*$/gm; +const INLINE_CODE_REGEX = /`[^`\n]*`/g; + +/* + `#` must not follow a word character or slash, which rejects the cross-repo + form `owner/repo#123` and URL fragments like `/issues/17#issuecomment-99`. + */ +const BARE_REF_REGEX = /(?:^|[^\w/])#(\d{1,6})\b/g; + +const MAX_ISSUE_NUMBER = 99999; // this cap should be good for now, it avoids the 6 digit hex + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Code samples and hex colors such as #336699 would otherwise look like references. So throw them away +function stripCode(text) { + return String(text || '') + .replace(FENCED_CODE_REGEX, '\n') + .replace(INLINE_CODE_REGEX, ' '); +} + +// avoid bots like github copilot PR review thing +function isBot(user) { + if (!user) { + return false; + } + return user.type === 'Bot' || /\[bot\]$/i.test(user.login || ''); +} + +/** + * Extract candidate issue numbers from a block of (refined) text. + * + * @param {string} text + * @param {{ owner?: string, repo?: string }} [repository] - Enables matching of pasted issue URLs. + * @returns {number[]} Unique issue numbers, in first-seen order. + */ +function extractIssueNumbers(text, repository = {}) { + const cleaned = stripCode(text); + + if (!cleaned.trim()) { + return []; + } + + const numbers = new Set(); + const patterns = [new RegExp(BARE_REF_REGEX.source, BARE_REF_REGEX.flags)]; + + const { owner, repo } = repository; + if (owner && repo) { + patterns.push( + new RegExp( + `https?://github\\.com/${escapeRegex(owner)}/${escapeRegex(repo)}/issues/(\\d{1,6})`, + 'gi' + ) + ); + } + + for (const pattern of patterns) { + let match; + while ((match = pattern.exec(cleaned)) !== null) { + const number = parseInt(match[1], 10); + if (number > 0 && number <= MAX_ISSUE_NUMBER) { + numbers.add(number); + } + } + } + + return [...numbers]; +} + +// Run a source collector, downgrading failures to a warning so one bad source can't fail the run. (Error handling) +async function safely(label, fn) { + try { + return await fn(); + } catch (error) { + core.warning(`Could not read ${label}: ${error.message}`); + return []; + } +} + +/** + * Gather every issue number referenced from a PR's title, body, comments, + * reviews, and the issues that cross-reference it. + * + * @param {{ octokit: object, owner: string, repo: string, prNumber: number }} options + * @returns {Promise>>} Issue number to the sources it was found in. + */ +async function collectReferencedIssues({ octokit, owner, repo, prNumber }) { + const references = new Map(); + + const record = (number, source) => { + if (!references.has(number)) { + references.set(number, new Set()); + } + references.get(number).add(source); + }; + + const scan = (text, source) => { + for (const number of extractIssueNumbers(text, { owner, repo })) { + record(number, source); + } + }; + + await safely('pull request title and body', async () => { + const { data: pullRequest } = await octokit.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + scan(pullRequest.title, 'title'); + scan(pullRequest.body, 'body'); + }); + + await safely('pull request comments', async () => { + const comments = await octokit.paginate(octokit.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + + // filter the bots comments and PR reviews, imo not helpful to include them + for (const comment of comments) { + if (isBot(comment.user)) { + continue; + } + scan(comment.body, 'comment'); + } + }); + + await safely('pull request reviews', async () => { + const reviews = await octokit.paginate(octokit.rest.pulls.listReviews, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + + for (const review of reviews) { + if (isBot(review.user)) { + continue; + } + scan(review.body, 'review'); + } + }); + + await safely('pull request review comments', async () => { + const reviewComments = await octokit.paginate(octokit.rest.pulls.listReviewComments, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + + for (const comment of reviewComments) { + if (isBot(comment.user)) { // filter out bot reviews + continue; + } + scan(comment.body, 'review-comment'); + } + }); + + // Issues that mention this PR show up as cross-referenced timeline events. + await safely('pull request timeline', async () => { + const timeline = await octokit.paginate(octokit.rest.issues.listEventsForTimeline, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + + for (const event of timeline) { + if (event.event !== 'cross-referenced' || isBot(event.actor)) { + continue; + } + + const source = event.source && event.source.issue; + if (!source || source.pull_request) { + continue; + } + + const sourceRepo = source.repository; + if (sourceRepo && sourceRepo.full_name !== `${owner}/${repo}`) { + continue; + } + + record(source.number, 'cross-reference'); + } + }); + + return references; +} + +module.exports = { + extractIssueNumbers, + collectReferencedIssues, +}; diff --git a/.github/workflows/translation-auto-close.yml b/.github/workflows/translation-auto-close.yml index 236d7fdb69..051334b7ac 100644 --- a/.github/workflows/translation-auto-close.yml +++ b/.github/workflows/translation-auto-close.yml @@ -38,5 +38,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} - PR_BODY: ${{ github.event.pull_request.body }} run: node .github/actions/translation-tracker/auto-close.js From be07f101698f39d7a97b30d1bb557ea4d09041fb Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Fri, 4 Sep 2026 20:54:43 +0545 Subject: [PATCH 06/12] fix: now checks the filePaths in issue body and compares with the files changed/added in the PR to ensure the correct translations have been added --- .../actions/translation-tracker/auto-close.js | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/.github/actions/translation-tracker/auto-close.js b/.github/actions/translation-tracker/auto-close.js index b6442520f9..e7ccfc38fe 100644 --- a/.github/actions/translation-tracker/auto-close.js +++ b/.github/actions/translation-tracker/auto-close.js @@ -53,6 +53,49 @@ function identifyLanguagesFromFiles(changedFiles) { return [...languages]; } +/** + * Parse expected translation file paths from a tracker issue body. + * + * The issue body produced by github-tracker.js contains paths in two forms: + * Outdated: `[šŸ“ View file](https://github.com/.../blob/main/src/content/...)` + * Missing: `Expected location: \`src/content/...\`` + * + * @param {string} body - The issue body text. + * @returns {Map} Language code → expected file paths. + */ +function extractExpectedPaths(body) { + const result = new Map(); + + if (!body) return result; + + const langPattern = [...SUPPORTED_LANGUAGES].sort((a, b) => b.length - a.length).join('|'); + const langFromPath = new RegExp(`^src/content/[^/]+/(${langPattern})/`); + + const addPath = (filePath) => { + const match = filePath.match(langFromPath); + if (match) { + const lang = match[1]; + if (!result.has(lang)) result.set(lang, []); + result.get(lang).push(filePath); + } + }; + + // Missing translations: Expected location: `src/content/...` + const expectedLocationRegex = /Expected location:\s*`([^`]+)`/g; + let m; + while ((m = expectedLocationRegex.exec(body)) !== null) { + addPath(m[1]); + } + + // Outdated translations: [šŸ“ View file](https://github.com/.../blob//src/content/...) + const viewFileRegex = /\[.*?View file.*?\]\(https?:\/\/github\.com\/[^)]*\/blob\/[^/]+\/([^)]+)\)/g; + while ((m = viewFileRegex.exec(body)) !== null) { + addPath(m[1]); + } + + return result; +} + // Strike language required lines in the tracker issue body for resolved languages. function strikeLanguagesInBody(body, languages) { let result = body || ''; @@ -157,8 +200,32 @@ async function main() { continue; } - const editedBody = strikeLanguagesInBody(issue.body, languages); - const result = await tracker.applyLanguageProgress(issue, languages, prNumber, { + // Narrow languages to those whose expected file actually appears in this PR. + const expectedPaths = extractExpectedPaths(issue.body); + const changedFileNames = changedFiles.map((f) => f.filename || f); + + const verifiedLanguages = languages.filter((lang) => { + const paths = expectedPaths.get(lang); + if (!paths || paths.length === 0) return true; // no path info means we fall back to label match + return paths.some((p) => changedFileNames.includes(p)); + }); + + const skippedLangs = languages.filter((lang) => !verifiedLanguages.includes(lang)); + for (const lang of skippedLangs) { + const paths = expectedPaths.get(lang) || []; + core.info( + `Skipped lang-${lang} for #${issueNumber}: expected file(s) [${paths.join(', ')}] not in PR` + ); + } + + if (verifiedLanguages.length === 0) { + summary.skipped.push({ issueNumber, reason: 'expected file(s) not in PR' }); + core.info(`Skipped #${issueNumber}: none of the expected translation files were changed`); + continue; + } + + const editedBody = strikeLanguagesInBody(issue.body, verifiedLanguages); + const result = await tracker.applyLanguageProgress(issue, verifiedLanguages, prNumber, { body: editedBody, }); @@ -211,4 +278,5 @@ module.exports = { main, identifyLanguagesFromFiles, strikeLanguagesInBody, + extractExpectedPaths, }; From f2ac8ee25c139abd1b3d1ae356c8b82d09c3d29c Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Fri, 4 Sep 2026 21:28:49 +0545 Subject: [PATCH 07/12] fix: add the strike and remove label in same condition --- .../actions/translation-tracker/auto-close.js | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/actions/translation-tracker/auto-close.js b/.github/actions/translation-tracker/auto-close.js index e7ccfc38fe..586783ddd1 100644 --- a/.github/actions/translation-tracker/auto-close.js +++ b/.github/actions/translation-tracker/auto-close.js @@ -96,20 +96,30 @@ function extractExpectedPaths(body) { return result; } -// Strike language required lines in the tracker issue body for resolved languages. +/** + * Strike language lines in the tracker issue body for languages whose translations have been added. + * + * Returns both the modified body and the list of languages that were actually + * struck, so callers can gate label removal on the same condition. + */ function strikeLanguagesInBody(body, languages) { let result = body || ''; + const struck = []; for (const language of languages) { const displayName = getLanguageDisplayName(language); const escaped = displayName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // Match a list item that starts with the language display name and is not already struck. - const lineRegex = new RegExp(`^(- (?!~~)\\*\\*${escaped}\\*\\*:.*)$`, 'gm'); + const lineRegex = new RegExp(`^(\\s*-?\\s*(?!~~)\\*\\*${escaped}\\*\\*:.*)$`, 'gm'); + const before = result; + result = result.replace(lineRegex, '~~$1~~'); + if (result !== before) { + struck.push(language); + } } - return result; + return { body: result, struck }; } async function main() { @@ -224,8 +234,17 @@ async function main() { continue; } - const editedBody = strikeLanguagesInBody(issue.body, verifiedLanguages); - const result = await tracker.applyLanguageProgress(issue, verifiedLanguages, prNumber, { + const { body: editedBody, struck } = strikeLanguagesInBody(issue.body, verifiedLanguages); + + if (struck.length === 0) { + summary.skipped.push({ issueNumber, reason: 'could not match language lines in issue body' }); + core.info( + `Skipped #${issueNumber}: verified languages [${verifiedLanguages.join(', ')}] could not be matched in body` + ); + continue; + } + + const result = await tracker.applyLanguageProgress(issue, struck, prNumber, { body: editedBody, }); From f2bba960d60de1374d26a85ecd7c84caa6c87111 Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Fri, 4 Sep 2026 22:40:23 +0545 Subject: [PATCH 08/12] fix: resolve the strike and label inconsistency --- .../actions/translation-tracker/auto-close.js | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/actions/translation-tracker/auto-close.js b/.github/actions/translation-tracker/auto-close.js index 586783ddd1..467b06cae3 100644 --- a/.github/actions/translation-tracker/auto-close.js +++ b/.github/actions/translation-tracker/auto-close.js @@ -2,7 +2,7 @@ const core = require('@actions/core'); const { GitHubCommitTracker } = require('./github-tracker'); const { collectReferencedIssues } = require('./pr-references'); const { SUPPORTED_LANGUAGES } = require('./constants'); -const { getLanguageDisplayName } = require('./utils'); +const { getLanguageDisplayName, getTranslationPath } = require('./utils'); // List all files changed in a merged PR (paginated). async function listPullRequestFiles(octokit, owner, repo, pullNumber) { @@ -76,10 +76,19 @@ function extractExpectedPaths(body) { if (match) { const lang = match[1]; if (!result.has(lang)) result.set(lang, []); - result.get(lang).push(filePath); + if (!result.get(lang).includes(filePath)) result.get(lang).push(filePath); } }; + // The English source is the canonical path in tracker-generated issues. + // Deriving translation paths avoids ambiguity when branch names contain slashes. + const englishFileMatch = body.match(/\*\*File\*\*:\s*`(src\/content\/[^`]+\/en\/[^`]+)`/); + if (englishFileMatch) { + for (const language of SUPPORTED_LANGUAGES) { + addPath(getTranslationPath(englishFileMatch[1], language)); + } + } + // Missing translations: Expected location: `src/content/...` const expectedLocationRegex = /Expected location:\s*`([^`]+)`/g; let m; @@ -87,8 +96,8 @@ function extractExpectedPaths(body) { addPath(m[1]); } - // Outdated translations: [šŸ“ View file](https://github.com/.../blob//src/content/...) - const viewFileRegex = /\[.*?View file.*?\]\(https?:\/\/github\.com\/[^)]*\/blob\/[^/]+\/([^)]+)\)/g; + // Match from src/content + const viewFileRegex = /\[.*?View file.*?\]\([^)]*?(src\/content\/[^)]+)\)/g; while ((m = viewFileRegex.exec(body)) !== null) { addPath(m[1]); } @@ -110,10 +119,10 @@ function strikeLanguagesInBody(body, languages) { const displayName = getLanguageDisplayName(language); const escaped = displayName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const lineRegex = new RegExp(`^(\\s*-?\\s*(?!~~)\\*\\*${escaped}\\*\\*:.*)$`, 'gm'); + const lineRegex = new RegExp(`^([ \\t]*)(-?[ \\t]*\\*\\*${escaped}\\*\\*:.*)$`, 'gm'); const before = result; - result = result.replace(lineRegex, '~~$1~~'); + result = result.replace(lineRegex, '$1~~$2~~'); if (result !== before) { struck.push(language); } @@ -216,7 +225,7 @@ async function main() { const verifiedLanguages = languages.filter((lang) => { const paths = expectedPaths.get(lang); - if (!paths || paths.length === 0) return true; // no path info means we fall back to label match + if (!paths || paths.length === 0) return false; return paths.some((p) => changedFileNames.includes(p)); }); From ffac127810a863416e38e9736be6c5544c5af4ee Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Mon, 19 Oct 2026 10:57:31 +0545 Subject: [PATCH 09/12] fix: removed/renamed files cannot be counted as modified/added --- .github/actions/translation-tracker/auto-close.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/actions/translation-tracker/auto-close.js b/.github/actions/translation-tracker/auto-close.js index 467b06cae3..0b75d7ec03 100644 --- a/.github/actions/translation-tracker/auto-close.js +++ b/.github/actions/translation-tracker/auto-close.js @@ -170,11 +170,14 @@ async function main() { } const changedFiles = await listPullRequestFiles(tracker.octokit, owner, repo, prNumber); - const languages = identifyLanguagesFromFiles(changedFiles); + const eligibleChangedFiles = changedFiles.filter( + (file) => file.status === 'added' || file.status === 'modified' + ); + const languages = identifyLanguagesFromFiles(eligibleChangedFiles); if (languages.length === 0) { core.info( - `PR #${prNumber} references issue(s) but no translation-language paths were found in changed files. Skipping...` + `PR #${prNumber} references issue(s) but no added or modified translation-language paths were found. Skipping...` ); return; } @@ -221,7 +224,7 @@ async function main() { // Narrow languages to those whose expected file actually appears in this PR. const expectedPaths = extractExpectedPaths(issue.body); - const changedFileNames = changedFiles.map((f) => f.filename || f); + const changedFileNames = eligibleChangedFiles.map((file) => file.filename); const verifiedLanguages = languages.filter((lang) => { const paths = expectedPaths.get(lang); From c0c71d8ef8cb33c6639db6e5e5e13cbec64d9385 Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Mon, 19 Oct 2026 11:10:19 +0545 Subject: [PATCH 10/12] fix: remove blocker from read-only to write access by changing pull_request to pull_request_target --- .github/workflows/translation-auto-close.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/translation-auto-close.yml b/.github/workflows/translation-auto-close.yml index 051334b7ac..df30e5d812 100644 --- a/.github/workflows/translation-auto-close.yml +++ b/.github/workflows/translation-auto-close.yml @@ -1,7 +1,7 @@ name: Translation Auto-Close on: - pull_request: + pull_request_target: types: [closed] branches: - main From 06c0656d06053d784e801b3e1d3352205e1c2976 Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Tue, 20 Oct 2026 10:47:25 +0545 Subject: [PATCH 11/12] feat: add translation coverage --- .../actions/translation-tracker/coverage.js | 180 ++++++++++++++++++ .github/workflows/translation-coverage.yml | 65 +++++++ 2 files changed, 245 insertions(+) create mode 100644 .github/actions/translation-tracker/coverage.js create mode 100644 .github/workflows/translation-coverage.yml diff --git a/.github/actions/translation-tracker/coverage.js b/.github/actions/translation-tracker/coverage.js new file mode 100644 index 0000000000..df83682792 --- /dev/null +++ b/.github/actions/translation-tracker/coverage.js @@ -0,0 +1,180 @@ +const fs = require('fs'); +const path = require('path'); +const { SUPPORTED_LANGUAGES, CONTENT_TYPES } = require('./constants'); +const { GitHubCommitTracker } = require('./github-tracker'); +const { checkTranslationStatus } = require('./workflows'); +const { + getAllEnglishContentFiles, + parseEnvList, + parseFrontmatter, + fileExists, +} = require('./utils'); + +function coverageOutputPath() { + return path.join(process.cwd(), 'public', 'translation-status', 'coverage.json'); +} // for it of be publicly accessible + +function contentTypeOf(englishFile) { + const normalized = englishFile.split(path.sep).join('/'); + const match = normalized.match(/^src\/content\/([^/]+)\/en\//); + return match ? match[1] : null; +} + +function isStub(translationPath) { + if (!translationPath || !fileExists(translationPath)) { + return false; + } + + try { + const raw = fs.readFileSync(translationPath, 'utf8'); + return parseFrontmatter(raw, translationPath).needsTranslation === true; + } catch (error) { + return false; + } +} + +function emptyCounts() { + return { total: 0, upToDate: 0, outdated: 0, missing: 0, stub: 0, percent: 0 }; +} + +function finalize(counts) { + counts.percent = counts.total === 0 ? 0 : Math.round((counts.upToDate / counts.total) * 100); + return counts; +} + +function bump(counts, field) { + counts[field] += 1; + counts.total += 1; +} + +/** + * Turn the per-file checker result into language and content-type totals. + * Files marked needsTranslation are stubs, not completed translations. + */ +function aggregateCoverage(status, languages) { + const stubs = []; + const keepTranslated = (items) => + items.filter((item) => { + if (!isStub(item.translationPath)) { + return true; + } + stubs.push(item); + return false; + }); + + const upToDate = keepTranslated(status.upToDate); + const outdated = keepTranslated(status.needsUpdate); + const buckets = [ + ['upToDate', upToDate], + ['outdated', outdated], + ['missing', status.missing], + ['stub', stubs], + ]; + + const byLanguage = {}; + const byContentType = {}; + + for (const language of languages) { + byLanguage[language] = emptyCounts(); + } + + for (const [field, items] of buckets) { + for (const item of items) { + if (!languages.includes(item.language)) { + continue; + } + + bump(byLanguage[item.language], field); + + const contentType = contentTypeOf(item.englishFile); + if (!contentType) { + continue; + } + + if (!byContentType[contentType]) { + byContentType[contentType] = {}; + } + if (!byContentType[contentType][item.language]) { + byContentType[contentType][item.language] = emptyCounts(); + } + bump(byContentType[contentType][item.language], field); + } + } + + for (const language of languages) { + finalize(byLanguage[language]); + } + + for (const contentType of Object.keys(byContentType)) { + for (const language of Object.keys(byContentType[contentType])) { + finalize(byContentType[contentType][language]); + } + } + + return { + generatedAt: new Date().toISOString(), + languages: byLanguage, + contentTypes: byContentType, + }; +} + +async function collectEnglishFiles(contentTypes) { + const files = []; + + for (const contentType of contentTypes) { + const found = getAllEnglishContentFiles(contentType).map((file) => file.split(path.sep).join('/')); + files.push(...found); + } + + return files; +} + +async function runCoverage() { + const repoRoot = path.join(__dirname, '../../..'); + process.chdir(repoRoot); + + /// get user passed variables or use defaults + const languages = parseEnvList(process.env.COVERAGE_LANGUAGES, SUPPORTED_LANGUAGES).filter((language) => + SUPPORTED_LANGUAGES.includes(language) + ); + const contentTypes = parseEnvList(process.env.COVERAGE_CONTENT_TYPES, CONTENT_TYPES).filter((contentType) => + CONTENT_TYPES.includes(contentType) + ); + + if (languages.length === 0 || contentTypes.length === 0) { + throw new Error('COVERAGE_LANGUAGES and COVERAGE_CONTENT_TYPES must include at least one supported value'); + } + + const englishFiles = await collectEnglishFiles(contentTypes); + console.log(`\nšŸ“Š Coverage scan: ${englishFiles.length} English file(s), languages: ${languages.join(', ')}`); + + let tracker = null; + if (process.env.GITHUB_TOKEN) { + const [owner, repo] = (process.env.GITHUB_REPOSITORY || 'processing/p5.js-website').split('/'); + tracker = await GitHubCommitTracker.create(process.env.GITHUB_TOKEN, owner, repo); + } + + const status = await checkTranslationStatus(englishFiles, tracker, false); + const report = aggregateCoverage(status, languages); + + const outputPath = coverageOutputPath(); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + console.log(`šŸ—‚ļø Wrote translation coverage: ${outputPath}`); + + return report; +} + +module.exports = { + aggregateCoverage, + contentTypeOf, + isStub, + runCoverage, +}; + +if (require.main === module) { + runCoverage().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/.github/workflows/translation-coverage.yml b/.github/workflows/translation-coverage.yml new file mode 100644 index 0000000000..bdf27e939a --- /dev/null +++ b/.github/workflows/translation-coverage.yml @@ -0,0 +1,65 @@ +name: Translation Coverage + +on: + push: + branches: + - main + paths: + - 'src/content/**/en/**' + - 'src/content/**/es/**' + - 'src/content/**/hi/**' + - 'src/content/**/ko/**' + - 'src/content/**/zh-Hans/**' + workflow_dispatch: + inputs: + languages: + description: 'Comma-separated language codes (default: all supported)' + required: false + default: 'es,hi,ko,zh-Hans' + type: string + content_types: + description: 'Comma-separated content types (default: all tracked)' + required: false + default: 'examples,reference,tutorials,text-detail,events,libraries' + type: string + +permissions: + contents: write + +jobs: + update-translation-coverage: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install translation tracker dependencies + run: cd .github/actions/translation-tracker && npm install + + - name: Generate translation coverage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + COVERAGE_LANGUAGES: ${{ github.event.inputs.languages || 'es,hi,ko,zh-Hans' }} + COVERAGE_CONTENT_TYPES: ${{ github.event.inputs.content_types || 'examples,reference,tutorials,text-detail,events,libraries' }} + run: node .github/actions/translation-tracker/coverage.js + + - name: Commit coverage report + run: | + git add public/translation-status/coverage.json + if git diff --cached --quiet; then + echo "Coverage report is unchanged." + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "chore: update translation coverage" + git push + fi From be61c515fdc3766ff37b024f8d3b487cb0362452 Mon Sep 17 00:00:00 2001 From: Aashish Panthi Date: Tue, 20 Oct 2026 11:12:33 +0545 Subject: [PATCH 12/12] fix: change the scan to reduce time w/ file-system only scan --- .../actions/translation-tracker/coverage.js | 112 +++++++----------- .github/workflows/translation-coverage.yml | 10 +- 2 files changed, 48 insertions(+), 74 deletions(-) diff --git a/.github/actions/translation-tracker/coverage.js b/.github/actions/translation-tracker/coverage.js index df83682792..f0126a79a5 100644 --- a/.github/actions/translation-tracker/coverage.js +++ b/.github/actions/translation-tracker/coverage.js @@ -1,27 +1,28 @@ const fs = require('fs'); const path = require('path'); const { SUPPORTED_LANGUAGES, CONTENT_TYPES } = require('./constants'); -const { GitHubCommitTracker } = require('./github-tracker'); -const { checkTranslationStatus } = require('./workflows'); const { getAllEnglishContentFiles, + getTranslationPath, parseEnvList, parseFrontmatter, fileExists, } = require('./utils'); +/** Same default scope as stub generation: everything except reference. */ +const DEFAULT_CONTENT_TYPES = CONTENT_TYPES.filter((type) => type !== 'reference'); + function coverageOutputPath() { return path.join(process.cwd(), 'public', 'translation-status', 'coverage.json'); -} // for it of be publicly accessible +} function contentTypeOf(englishFile) { - const normalized = englishFile.split(path.sep).join('/'); - const match = normalized.match(/^src\/content\/([^/]+)\/en\//); + const match = englishFile.split(path.sep).join('/').match(/^src\/content\/([^/]+)\/en\//); return match ? match[1] : null; } function isStub(translationPath) { - if (!translationPath || !fileExists(translationPath)) { + if (!fileExists(translationPath)) { return false; } @@ -34,43 +35,30 @@ function isStub(translationPath) { } function emptyCounts() { - return { total: 0, upToDate: 0, outdated: 0, missing: 0, stub: 0, percent: 0 }; + return { total: 0, present: 0, missing: 0, stub: 0, percent: 0 }; } function finalize(counts) { - counts.percent = counts.total === 0 ? 0 : Math.round((counts.upToDate / counts.total) * 100); + counts.percent = counts.total === 0 ? 0 : Math.round((counts.present / counts.total) * 100); return counts; } +function ensureBucket(map, key) { + if (!map[key]) { + map[key] = emptyCounts(); + } + return map[key]; +} + function bump(counts, field) { counts[field] += 1; counts.total += 1; } /** - * Turn the per-file checker result into language and content-type totals. - * Files marked needsTranslation are stubs, not completed translations. + * Filesystem coverage scan: present (translated), missing, or stub. */ -function aggregateCoverage(status, languages) { - const stubs = []; - const keepTranslated = (items) => - items.filter((item) => { - if (!isStub(item.translationPath)) { - return true; - } - stubs.push(item); - return false; - }); - - const upToDate = keepTranslated(status.upToDate); - const outdated = keepTranslated(status.needsUpdate); - const buckets = [ - ['upToDate', upToDate], - ['outdated', outdated], - ['missing', status.missing], - ['stub', stubs], - ]; - +function scanCoverage(englishFiles, languages) { const byLanguage = {}; const byContentType = {}; @@ -78,33 +66,31 @@ function aggregateCoverage(status, languages) { byLanguage[language] = emptyCounts(); } - for (const [field, items] of buckets) { - for (const item of items) { - if (!languages.includes(item.language)) { - continue; - } + for (const englishFile of englishFiles) { + const contentType = contentTypeOf(englishFile); - bump(byLanguage[item.language], field); + for (const language of languages) { + const translationPath = getTranslationPath(englishFile, language); + let field = 'missing'; - const contentType = contentTypeOf(item.englishFile); - if (!contentType) { - continue; + if (fileExists(translationPath)) { + field = isStub(translationPath) ? 'stub' : 'present'; } - if (!byContentType[contentType]) { - byContentType[contentType] = {}; - } - if (!byContentType[contentType][item.language]) { - byContentType[contentType][item.language] = emptyCounts(); + bump(byLanguage[language], field); + + if (contentType) { + if (!byContentType[contentType]) { + byContentType[contentType] = {}; + } + bump(ensureBucket(byContentType[contentType], language), field); } - bump(byContentType[contentType][item.language], field); } } for (const language of languages) { finalize(byLanguage[language]); } - for (const contentType of Object.keys(byContentType)) { for (const language of Object.keys(byContentType[contentType])) { finalize(byContentType[contentType][language]); @@ -118,44 +104,33 @@ function aggregateCoverage(status, languages) { }; } -async function collectEnglishFiles(contentTypes) { +function collectEnglishFiles(contentTypes) { const files = []; - for (const contentType of contentTypes) { - const found = getAllEnglishContentFiles(contentType).map((file) => file.split(path.sep).join('/')); - files.push(...found); + files.push(...getAllEnglishContentFiles(contentType).map((file) => file.split(path.sep).join('/'))); } - return files; } -async function runCoverage() { +function runCoverage() { const repoRoot = path.join(__dirname, '../../..'); process.chdir(repoRoot); - /// get user passed variables or use defaults const languages = parseEnvList(process.env.COVERAGE_LANGUAGES, SUPPORTED_LANGUAGES).filter((language) => SUPPORTED_LANGUAGES.includes(language) ); - const contentTypes = parseEnvList(process.env.COVERAGE_CONTENT_TYPES, CONTENT_TYPES).filter((contentType) => - CONTENT_TYPES.includes(contentType) + const contentTypes = parseEnvList(process.env.COVERAGE_CONTENT_TYPES, DEFAULT_CONTENT_TYPES).filter( + (contentType) => CONTENT_TYPES.includes(contentType) ); if (languages.length === 0 || contentTypes.length === 0) { throw new Error('COVERAGE_LANGUAGES and COVERAGE_CONTENT_TYPES must include at least one supported value'); } - const englishFiles = await collectEnglishFiles(contentTypes); + const englishFiles = collectEnglishFiles(contentTypes); console.log(`\nšŸ“Š Coverage scan: ${englishFiles.length} English file(s), languages: ${languages.join(', ')}`); - let tracker = null; - if (process.env.GITHUB_TOKEN) { - const [owner, repo] = (process.env.GITHUB_REPOSITORY || 'processing/p5.js-website').split('/'); - tracker = await GitHubCommitTracker.create(process.env.GITHUB_TOKEN, owner, repo); - } - - const status = await checkTranslationStatus(englishFiles, tracker, false); - const report = aggregateCoverage(status, languages); + const report = scanCoverage(englishFiles, languages); const outputPath = coverageOutputPath(); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); @@ -166,15 +141,18 @@ async function runCoverage() { } module.exports = { - aggregateCoverage, + scanCoverage, contentTypeOf, isStub, runCoverage, + DEFAULT_CONTENT_TYPES, }; if (require.main === module) { - runCoverage().catch((error) => { + try { + runCoverage(); + } catch (error) { console.error(error.message); process.exitCode = 1; - }); + } } diff --git a/.github/workflows/translation-coverage.yml b/.github/workflows/translation-coverage.yml index bdf27e939a..3410b64986 100644 --- a/.github/workflows/translation-coverage.yml +++ b/.github/workflows/translation-coverage.yml @@ -18,9 +18,9 @@ on: default: 'es,hi,ko,zh-Hans' type: string content_types: - description: 'Comma-separated content types (default: all tracked)' + description: 'Comma-separated content types (default: all except reference)' required: false - default: 'examples,reference,tutorials,text-detail,events,libraries' + default: 'examples,tutorials,text-detail,events,libraries' type: string permissions: @@ -33,8 +33,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 @@ -46,10 +44,8 @@ jobs: - name: Generate translation coverage env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} COVERAGE_LANGUAGES: ${{ github.event.inputs.languages || 'es,hi,ko,zh-Hans' }} - COVERAGE_CONTENT_TYPES: ${{ github.event.inputs.content_types || 'examples,reference,tutorials,text-detail,events,libraries' }} + COVERAGE_CONTENT_TYPES: ${{ github.event.inputs.content_types || 'examples,tutorials,text-detail,events,libraries' }} run: node .github/actions/translation-tracker/coverage.js - name: Commit coverage report