Repository navigation
Translation tracker: auto close issue on PR merged and closed and translation coverage #1582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
aashishpanthi
wants to merge
12
commits into
processing:v1
Choose a base branch
from
aashishpanthi:feature/translation-tracker/auto-close-issue
base: v1
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
7fd31f5
feat: auto-close issues on PR merge
aashishpanthi 6bab0c6
update: keep only the auto close by reference
aashishpanthi 3ac00c0
feat: check PR to get referenced issue then strike out the translatio…
aashishpanthi 0bd612d
update the branch
aashishpanthi 724c08d
feat: update the structure of issue extraction/parsing
aashishpanthi be07f10
fix: now checks the filePaths in issue body and compares with the fil…
aashishpanthi f2ac8ee
fix: add the strike and remove label in same condition
aashishpanthi f2bba96
fix: resolve the strike and label inconsistency
aashishpanthi ffac127
fix: removed/renamed files cannot be counted as modified/added
aashishpanthi c0c71d8
fix: remove blocker from read-only to write access by changing pull_r…
aashishpanthi 06c0656
feat: add translation coverage
aashishpanthi be61c51
fix: change the scan to reduce time w/ file-system only scan
aashishpanthi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,313 @@ | ||
| const core = require('@actions/core'); | ||
| const { GitHubCommitTracker } = require('./github-tracker'); | ||
| const { collectReferencedIssues } = require('./pr-references'); | ||
| const { SUPPORTED_LANGUAGES } = require('./constants'); | ||
| const { getLanguageDisplayName, getTranslationPath } = require('./utils'); | ||
|
|
||
| // 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]; | ||
| } | ||
|
|
||
| /** | ||
| * 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<string, string[]>} 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, []); | ||
| 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; | ||
| while ((m = expectedLocationRegex.exec(body)) !== null) { | ||
| addPath(m[1]); | ||
| } | ||
|
|
||
| // Match from src/content | ||
| const viewFileRegex = /\[.*?View file.*?\]\([^)]*?(src\/content\/[^)]+)\)/g; | ||
| while ((m = viewFileRegex.exec(body)) !== null) { | ||
| addPath(m[1]); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| /** | ||
| * 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, '\\$&'); | ||
|
|
||
| const lineRegex = new RegExp(`^([ \\t]*)(-?[ \\t]*\\*\\*${escaped}\\*\\*:.*)$`, 'gm'); | ||
| const before = result; | ||
|
|
||
| result = result.replace(lineRegex, '$1~~$2~~'); | ||
| if (result !== before) { | ||
| struck.push(language); | ||
| } | ||
| } | ||
|
|
||
| return { body: result, struck }; | ||
| } | ||
|
|
||
| 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); | ||
|
|
||
| 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 = { | ||
| updated: [], | ||
| reopened: [], | ||
| closed: [], | ||
| skipped: [], | ||
| errors: [], | ||
| }; | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| const changedFiles = await listPullRequestFiles(tracker.octokit, owner, repo, prNumber); | ||
| 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 added or modified translation-language paths were found. Skipping...` | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| 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 references.keys()) { | ||
| if (issueNumber === prNumber) { | ||
| summary.skipped.push({ issueNumber, reason: 'self-reference' }); | ||
| core.info(`Skipped #${issueNumber}: self-reference`); | ||
| continue; | ||
| } | ||
|
|
||
| try { | ||
| 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.isPullRequest) { | ||
| summary.skipped.push({ issueNumber, reason: 'is a pull request' }); | ||
| core.info(`Skipped #${issueNumber}: is a pull request, not an issue`); | ||
| 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; | ||
| } | ||
|
|
||
| // Narrow languages to those whose expected file actually appears in this PR. | ||
| const expectedPaths = extractExpectedPaths(issue.body); | ||
| const changedFileNames = eligibleChangedFiles.map((file) => file.filename); | ||
|
|
||
| const verifiedLanguages = languages.filter((lang) => { | ||
| const paths = expectedPaths.get(lang); | ||
| if (!paths || paths.length === 0) return false; | ||
| 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 { 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, | ||
| }); | ||
|
|
||
| if (result.closed) { | ||
| 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, | ||
| }); | ||
|
|
||
| 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, | ||
| 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 process #${issueNumber}: ${error.message}`); | ||
| } | ||
| } | ||
|
|
||
| core.info( | ||
| `Auto-close summary: ${summary.updated.length} updated (${summary.reopened.length} reopened), ${summary.closed.length} closed, ${summary.skipped.length} skipped, ${summary.errors.length} error(s)` | ||
| ); | ||
| } | ||
|
|
||
| if (require.main === module) { | ||
| main().catch((error) => { | ||
| core.setFailed(error.message); | ||
| }); | ||
| } | ||
|
|
||
| module.exports = { | ||
| main, | ||
| identifyLanguagesFromFiles, | ||
| strikeLanguagesInBody, | ||
| extractExpectedPaths, | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function returns every file including status: "removed"/"renamed", and neither
identifyLanguagesFromFilesnor theverifiedLanguagesfilter checks status:const verifiedLanguages = languages.filter((lang) => {
const paths = expectedPaths.get(lang);
if (!paths || paths.length === 0) return false;
return paths.some((p) => changedFileNames.includes(p)); // matches removed files too
});
A PR that deletes src/content/examples/es/foo.mdx would strike Spanish, remove lang-es, and potentially close the tracker issue - the opposite of the truth. Filter to file.status === 'added' || 'modified' (or exclude removed) before deriving languages and verifying paths.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I resolved it, now we check if the file was updated, added, or deleted.