diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8213bffe..829483ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -191,11 +191,59 @@ jobs: cli.js cli/ lib/ plugins/ web-ui.html web-ui/ \ node_modules/ package.json LICENSE README.md README.zh.md echo "STANDALONE_TGZ=$name" >> "$GITHUB_ENV" + - name: Fetch contributors from GitHub API + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} + LATEST_TAG: ${{ steps.resolve.outputs.latest_tag }} + CONTRIBUTORS_FILE: release-contributors.txt + run: | + if [ -z "${LATEST_TAG}" ]; then + echo "::notice title=No previous tag::Skipping contributors fetch for initial release." + echo "" > "${CONTRIBUTORS_FILE}" + exit 0 + fi + + if ! command -v gh >/dev/null 2>&1; then + echo "::error title=gh CLI not found::GitHub CLI is required." + exit 1 + fi + + tmp_logins=$(mktemp) + trap 'rm -f "${tmp_logins}"' EXIT + + # Fetch PR authors in range using base...head comparison + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --limit 500 \ + --json author \ + --jq '.[].author.login' 2>/dev/null | sort -u > "${tmp_logins}" || true + + # Fetch merged PRs in range using commits + tmp_merged=$(mktemp) + git log "${LATEST_TAG}...${RELEASE_TAG}" --pretty=format:%s \ + | grep -oE '#[0-9]+' \ + | sed 's/^#//' \ + | sort -u \ + | while read -r pr_number; do + gh pr view "${pr_number}" --repo "${GITHUB_REPOSITORY}" --json author --jq '.author.login' 2>/dev/null || true + done \ + | sort -u > "${tmp_merged}" || true + + if [ -s "${tmp_merged}" ]; then + cat "${tmp_merged}" > "${CONTRIBUTORS_FILE}" + elif [ -s "${tmp_logins}" ]; then + cat "${tmp_logins}" > "${CONTRIBUTORS_FILE}" + else + echo "::notice title=No contributors found::No contributors in this range." + echo "" > "${CONTRIBUTORS_FILE}" + fi - name: Generate release notes from actual commit range env: RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} TAG_EXISTS: ${{ steps.resolve.outputs.tag_exists }} RELEASE_CHANGELOG_FILE: release-changelog.md + CONTRIBUTORS_FILE: release-contributors.txt run: | if [ "${TAG_EXISTS}" = "true" ] && [ ! -f tools/release/changelog.js ]; then echo "::notice title=Release changelog skipped::tools/release/changelog.js is not present in existing tag ${RELEASE_TAG}." diff --git a/tests/unit/release-changelog.test.mjs b/tests/unit/release-changelog.test.mjs index 7f589b25..1d3c1993 100644 --- a/tests/unit/release-changelog.test.mjs +++ b/tests/unit/release-changelog.test.mjs @@ -37,7 +37,7 @@ test('release changelog groups PR commits and direct commits for action logs', ( const grouped = groupCommits(commits); assert.deepStrictEqual(grouped.directCommits.map((commit) => commit.hash), ['f5700cf', 'abc1234']); - assert.deepStrictEqual(listContributors(commits), ['awsl233777']); + assert.deepStrictEqual(listContributors(commits), [{ login: 'awsl233777', displayName: 'Awsl' }]); const changelog = formatChangelog({ repository: 'SakuraByteCore/codexmate', diff --git a/tools/release/changelog.js b/tools/release/changelog.js index 69d1fc32..1431c4c9 100644 --- a/tools/release/changelog.js +++ b/tools/release/changelog.js @@ -117,8 +117,13 @@ function contributorProfile(author) { return { login: displayName, displayName }; } -function formatContributorCard(author) { - const { login, displayName } = contributorProfile(author); +function formatContributorCard(authorOrObj) { + let login, displayName; + if (typeof authorOrObj === 'object' && authorOrObj !== null) { + ({ login, displayName } = authorOrObj); + } else { + ({ login, displayName } = contributorProfile(authorOrObj)); + } const safeLogin = encodeURIComponent(login); const safeDisplayName = escapeHtml(displayName); const githubAvatarUrl = `https://github.com/${safeLogin}.png?size=96`; @@ -130,16 +135,26 @@ function formatContributorCard(author) { ].join('\n'); } -function listContributors(commits) { +function listContributors(commits, externalLogins = []) { const seen = new Set(); const contributors = []; + + // Use external logins from GitHub API if available + for (const login of externalLogins) { + const safeLogin = String(login || '').trim(); + if (!safeLogin || seen.has(safeLogin)) continue; + seen.add(safeLogin); + contributors.push({ login: safeLogin, displayName: safeLogin }); + } + + // Fallback to commit authors for missing entries for (const commit of commits) { - const contributor = formatContributorName(commit.author); - const key = contributor.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - contributors.push(contributor); + const { login, displayName } = contributorProfile(commit.author); + if (!login || seen.has(login)) continue; + seen.add(login); + contributors.push({ login, displayName }); } + return contributors; } @@ -179,7 +194,7 @@ function formatChangeSummary(commits) { return lines; } -function formatChangelog({ repository = '', previousTag = '', currentTag = '', currentRef = 'HEAD', commits = [] }) { +function formatChangelog({ repository = '', previousTag = '', currentTag = '', currentRef = 'HEAD', commits = [], externalLogins = [] }) { const lines = []; if (!previousTag) { @@ -217,7 +232,7 @@ function formatChangelog({ repository = '', previousTag = '', currentTag = '', c } lines.push('### Contributors'); - const contributors = listContributors(commits); + const contributors = listContributors(commits, externalLogins); if (!contributors.length) { lines.push('- Unknown contributor'); } else { @@ -248,12 +263,26 @@ function main(env = process.env) { const previousTag = selectPreviousSemverTag(tags, currentTag); const currentRef = resolveCurrentRef(currentTag); const commits = previousTag ? readCommits(previousTag, currentRef) : []; + + // Load external logins from GitHub API if available + let externalLogins = []; + const contributorsFile = env.CONTRIBUTORS_FILE; + if (contributorsFile && fs.existsSync(contributorsFile)) { + try { + const content = fs.readFileSync(contributorsFile, 'utf8'); + externalLogins = content.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean); + } catch (e) { + console.warn(`Failed to read contributors file: ${e.message}`); + } + } + const changelog = formatChangelog({ repository: env.GITHUB_REPOSITORY || '', previousTag, currentTag, currentRef, - commits + commits, + externalLogins }); console.log(changelog.trimEnd()); diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs index 765f0090..1f68eead 100644 --- a/web-ui/modules/i18n/locales/vi.mjs +++ b/web-ui/modules/i18n/locales/vi.mjs @@ -1180,10 +1180,6 @@ const vi = Object.freeze({ 'settings.tab.data': 'Dữ liệu', 'settings.tabs.aria': 'Danh mục cài đặt', 'settings.quickSettings.title': 'Cài đặt nhanh', - 'settings.sharePrefix.title': 'Tiền tố lệnh chia sẻ', - 'settings.sharePrefix.meta': 'Dùng làm tiền tố cho "Sao chép lệnh chia sẻ" trong Web UI', - 'settings.sharePrefix.label': 'Tiền tố', - 'settings.sharePrefix.hint': 'Mặc định là npm start (local). Bạn có thể chuyển sang codexmate toàn cục. Cài đặt này được lưu trong trình duyệt.', 'settings.claude.title': 'Cấu hình Claude', 'settings.claude.meta': 'Sao lưu / nhập ~/.claude', 'settings.codex.title': 'Cấu hình Codex', @@ -1219,8 +1215,6 @@ const vi = Object.freeze({ 'settings.trash.retentionMeta': 'Mục trong thùng rác cũ hơn số ngày lưu giữ sẽ tự động bị xóa', 'settings.trash.retentionLabel': 'Số ngày lưu giữ', 'settings.trash.retentionHint': 'Phạm vi 1-365 ngày, mặc định 30. Mục hết hạn bị xóa khi tải thùng rác.', - 'settings.trashConfig.title': 'Cấu hình thùng rác', - 'settings.trashConfig.meta': 'Bật/tắt thùng rác và số ngày tự động dọn dẹp', 'settings.templateConfirm.title': 'Xác nhận áp dụng template', 'settings.templateConfirm.meta': 'Giảm ghi nhầm', 'settings.templateConfirm.toggle': 'Xem trước diff trước khi áp dụng (Xác nhận → Áp dụng)',