diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e822547..b392142 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -354,9 +354,9 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_ref || github.ref }} - # Custom release-notes script walks the full commit range between - # the previous v* tag and the current tag via `git describe` + `git - # log`, which needs complete history and tags present. + # Generated release notes need complete history and tags so the + # workflow can choose the right previous tag for stable/prerelease + # comparisons. fetch-depth: 0 fetch-tags: true @@ -399,6 +399,38 @@ jobs: exit 1 fi + - name: Resolve release notes base tag + id: release_notes_base + env: + TAG: ${{ steps.release_meta.outputs.tag }} + run: | + set -euo pipefail + + previous_ancestor_tag="" + if git rev-parse --verify "${TAG}^{tag}" >/dev/null 2>&1 || git rev-parse --verify "${TAG}^{commit}" >/dev/null 2>&1; then + previous_ancestor_tag="$(git describe --tags --abbrev=0 --match 'v*' "${TAG}^" 2>/dev/null || true)" + fi + + previous_tag="$previous_ancestor_tag" + if printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + previous_stable_tag="" + while IFS= read -r candidate; do + if [ "$candidate" = "$TAG" ]; then + break + fi + previous_stable_tag="$candidate" + done < <(git tag -l 'v*' --sort=v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true) + + first_release_tag="$(git tag -l 'v*' --sort=v:refname | head -n 1 || true)" + previous_tag="${previous_stable_tag:-$first_release_tag}" + fi + + if [ "$previous_tag" = "$TAG" ]; then + previous_tag="" + fi + + echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" + - name: Collect release assets run: | set -euo pipefail @@ -424,26 +456,15 @@ jobs: sha256sum * > SHA256SUMS.txt ) - - name: Create release notes + - name: Create release preface env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} TAG: ${{ steps.release_meta.outputs.tag }} run: | set -euo pipefail - # Custom script groups commits by conventional-commit type into - # emoji'd buckets. Beats `gh api .../generate-notes` for git-flow - # repos where the tagged branch only sees the release-merge commit. - GENERATED_BODY="$(node scripts/release-notes.mjs "$TAG")" - VERSION="${TAG#v}" - NPM_TAG="latest" - if printf '%s' "$VERSION" | grep -Eq -- '-(rc|beta|alpha)'; then - NPM_TAG="rc" - fi - RELEASE_URL="https://github.com/${GH_REPO}/releases/tag/${TAG}" RELEASE_DOWNLOAD_BASE="https://github.com/${GH_REPO}/releases/download/${TAG}" NPM_PACKAGE_URL="https://www.npmjs.com/package/clawmaster/v/${VERSION}" NPM_DIST_URL="https://www.npmjs.com/package/clawmaster?activeTab=versions" @@ -481,13 +502,11 @@ jobs: printf '| macOS Apple Silicon | [dmg](%s) |\n' "$macos_arm64_url" printf '| Windows x64 | [msi](%s) ยท [exe](%s) |\n\n' "$windows_msi_url" "$windows_exe_url" printf '> Desktop builds are in beta. The CLI + Web Console is the recommended install method.\n\n' - printf "## What's Changed\n\n" - printf '%s\n' "$GENERATED_BODY" - } > RELEASE_NOTES.md + } > RELEASE_PREFACE.md - - name: Create or update GitHub release + - name: Resolve release options + id: release_options env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.release_meta.outputs.tag }} EVENT_NAME: ${{ github.event_name }} INPUT_DRAFT: ${{ inputs.draft_release }} @@ -495,44 +514,46 @@ jobs: run: | set -euo pipefail - DRAFT_FLAG="" - PRERELEASE_FLAG="" + draft="false" + prerelease="false" if [ "$EVENT_NAME" = "workflow_dispatch" ]; then if [ "$INPUT_DRAFT" = "true" ]; then - DRAFT_FLAG="--draft" + draft="true" fi if [ "$INPUT_PRERELEASE" = "true" ]; then - PRERELEASE_FLAG="--prerelease" + prerelease="true" fi else if printf '%s' "$TAG" | grep -Eq -- '-(beta|rc)'; then - PRERELEASE_FLAG="--prerelease" + prerelease="true" fi fi - # Pre-releases must not become the "latest" release on GitHub - LATEST_FLAG="--latest" - if [ -n "$PRERELEASE_FLAG" ]; then - LATEST_FLAG="--latest=false" + make_latest="true" + if [ "$prerelease" = "true" ]; then + make_latest="false" fi - if gh release view "$TAG" >/dev/null 2>&1; then - # Force un-draft so tag re-pushes produce a publicly-visible release - gh release edit "$TAG" \ - --title "ClawMaster $TAG" \ - --notes-file RELEASE_NOTES.md \ - --draft=false \ - $LATEST_FLAG - gh release upload "$TAG" release-assets/* --clobber - else - gh release create "$TAG" release-assets/* \ - --title "ClawMaster $TAG" \ - --notes-file RELEASE_NOTES.md \ - $DRAFT_FLAG \ - $PRERELEASE_FLAG \ - $LATEST_FLAG - fi + echo "draft=$draft" >> "$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + echo "make_latest=$make_latest" >> "$GITHUB_OUTPUT" + + - name: Create or update GitHub release + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.release_meta.outputs.tag }} + name: ClawMaster ${{ steps.release_meta.outputs.tag }} + bodyFile: RELEASE_PREFACE.md + generateReleaseNotes: true + generateReleaseNotesPreviousTag: ${{ steps.release_notes_base.outputs.previous_tag }} + artifacts: release-assets/* + artifactErrorsFailBuild: true + allowUpdates: true + replacesArtifacts: true + draft: ${{ steps.release_options.outputs.draft }} + prerelease: ${{ steps.release_options.outputs.prerelease }} + makeLatest: ${{ steps.release_options.outputs.make_latest }} - name: Add workflow summary env: diff --git a/package.json b/package.json index 1519375..626ca67 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "tauri:dev": "tauri dev", "tauri:build": "tauri build", "prepack": "npm run build:backend && npm run build", - "test:cli": "node --test bin/*.test.mjs scripts/*.test.mjs tests/desktop/harness.unit.test.mjs", + "test:cli": "node --test bin/*.test.mjs tests/desktop/harness.unit.test.mjs", "test:plugins": "npx tsx --test plugins/memory-clawmaster-powermem/*.test.ts", "test:desktop": "node --test tests/desktop/smoke.test.mjs", "test:wizard-smoke": "node tests/install/wizard-install-smoke.mjs", diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs deleted file mode 100644 index 53fcb84..0000000 --- a/scripts/release-notes.mjs +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env node -// Emits a Markdown "What's Changed" section for a tag, grouping user-facing -// conventional commits into a few release-note buckets. CI / release -// housekeeping and unprefixed commits are intentionally excluded. Designed for GitHub -// releases where git-flow tags land on merge commits and the release notes should -// cover the full release window, not just the nearest preceding tag. -// -// Usage: node scripts/release-notes.mjs -// Env: GH_REPO (owner/repo) โ€” required; GH_TOKEN/GITHUB_TOKEN for PR lookup. - -import { execFileSync, execSync } from 'node:child_process' -import { fileURLToPath } from 'node:url' - -export const BUCKETS = [ - { id: 'features', title: 'Features & Polish', emoji: 'โœจ', types: ['feat', 'polish'] }, - { id: 'fixes', title: 'Fixes', emoji: '๐Ÿ›', types: ['fix'] }, - { id: 'misc', title: 'Misc', emoji: '๐Ÿ“', types: [] }, -] - -const EXCLUDED_TYPES = new Set(['ci', 'test', 'build', 'perf', 'refactor', 'chore', 'docs', 'style']) -const PRE_RELEASE_RE = /-(rc|beta|alpha)(?:\.\d+)?$/i -const STABLE_TAG_RE = /^v\d+\.\d+\.\d+$/ - -function runGit(command, options = {}) { - return execSync(command, { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - ...options, - }).trim() -} - -export function parseRemoteTags(rawRefs) { - return rawRefs - .split('\n') - .filter(Boolean) - .map((line) => { - const parts = line.trim().split(/\s+/) - return parts[1] || '' - }) - .filter((ref) => ref.startsWith('refs/tags/')) - .map((ref) => ref.replace(/^refs\/tags\//, '').replace(/\^\{\}$/, '')) - .filter(Boolean) -} - -export function parseTagVersion(tag) { - const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/) - if (!match) return null - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - prerelease: match[4] ?? '', - } -} - -function compareIdentifiers(a, b) { - const aIsNumeric = /^\d+$/.test(a) - const bIsNumeric = /^\d+$/.test(b) - if (aIsNumeric && bIsNumeric) return Number(a) - Number(b) - if (aIsNumeric) return -1 - if (bIsNumeric) return 1 - return a.localeCompare(b) -} - -export function compareTagVersions(leftTag, rightTag) { - const left = parseTagVersion(leftTag) - const right = parseTagVersion(rightTag) - if (!left || !right) return leftTag.localeCompare(rightTag) - for (const key of ['major', 'minor', 'patch']) { - if (left[key] !== right[key]) return left[key] - right[key] - } - if (!left.prerelease && !right.prerelease) return 0 - if (!left.prerelease) return 1 - if (!right.prerelease) return -1 - const leftParts = left.prerelease.split('.') - const rightParts = right.prerelease.split('.') - const max = Math.max(leftParts.length, rightParts.length) - for (let i = 0; i < max; i += 1) { - if (leftParts[i] == null) return -1 - if (rightParts[i] == null) return 1 - const cmp = compareIdentifiers(leftParts[i], rightParts[i]) - if (cmp !== 0) return cmp - } - return 0 -} - -export function isStableReleaseTag(tag) { - return STABLE_TAG_RE.test(tag) -} - -export function bucketOf(subject) { - const match = subject.match(/^([a-z]+)(?:\([^)]+\))?:/) - if (!match) return null - const type = match[1] - if (EXCLUDED_TYPES.has(type)) return null - return BUCKETS.find((bucket) => bucket.types.includes(type))?.id ?? 'misc' -} - -export function parseGitLog(rawLog) { - return rawLog - .split('\n') - .filter(Boolean) - .map((line) => { - const [sha, authorName, authorEmail, ...rest] = line.split('\t') - return { - sha, - authorName, - authorEmail, - subject: rest.join('\t'), - } - }) -} - -export function selectReleaseBaseTag({ tag, previousAncestorTag = '', allTags = [] }) { - if (!isStableReleaseTag(tag)) return previousAncestorTag - const currentVersion = parseTagVersion(tag) - if (!currentVersion) return previousAncestorTag - const matchingTags = allTags - .filter(Boolean) - .sort(compareTagVersions) - const previousStable = matchingTags - .filter((candidate) => candidate !== tag && isStableReleaseTag(candidate)) - .filter((candidate) => compareTagVersions(candidate, tag) < 0) - .at(-1) - const firstTag = matchingTags.at(0) || '' - return previousStable || firstTag || previousAncestorTag -} - -function normalizeIdentityPart(value) { - return value.trim().toLowerCase() -} - -function addIdentityKeys(target, contributor) { - if (contributor.login) target.add(`login:${normalizeIdentityPart(contributor.login)}`) - if (contributor.authorEmail) target.add(`email:${normalizeIdentityPart(contributor.authorEmail)}`) - if (contributor.authorName) target.add(`name:${normalizeIdentityPart(contributor.authorName)}`) -} - -function contributorIdentityKeys(contributor) { - const keys = new Set() - addIdentityKeys(keys, contributor) - return keys -} - -function isBotContributor({ login, authorName, authorEmail }) { - const values = [login, authorName, authorEmail].filter(Boolean).map((value) => value.toLowerCase()) - return values.some((value) => value.includes('[bot]') || value.endsWith('bot') || value.includes('bot@')) -} - -export function displayContributor(contributor) { - if (contributor.login) { - const avatar = contributor.avatarUrl ? `@${contributor.login} ` : '' - return `${avatar}[@${contributor.login}](https://github.com/${contributor.login})` - } - return contributor.authorName || contributor.authorEmail -} - -export function joinHumanList(values) { - if (values.length === 0) return '' - if (values.length === 1) return values[0] - if (values.length === 2) return `${values[0]} and ${values[1]}` - return `${values.slice(0, -1).join(', ')}, and ${values.at(-1)}` -} - -export function summarizeContributors(commits, priorContributorKeys) { - const contributors = [] - for (const commit of commits) { - if (isBotContributor(commit)) continue - const candidate = { - login: commit.login || '', - authorName: commit.authorName || '', - authorEmail: commit.authorEmail || '', - } - const candidateKeys = contributorIdentityKeys(candidate) - const existing = contributors.find((entry) => { - const entryKeys = contributorIdentityKeys(entry) - return [...candidateKeys].some((key) => entryKeys.has(key)) - }) - if (!existing) { - contributors.push(candidate) - } else { - if (!existing.login && candidate.login) existing.login = candidate.login - if (!existing.authorName && candidate.authorName) existing.authorName = candidate.authorName - if (!existing.authorEmail && candidate.authorEmail) existing.authorEmail = candidate.authorEmail - } - } - - return contributors - .map((contributor) => { - const keys = contributorIdentityKeys(contributor) - const firstTime = ![...keys].some((key) => priorContributorKeys.has(key)) - return { ...contributor, firstTime } - }) - .sort((left, right) => displayContributor(left).localeCompare(displayContributor(right))) -} - -export function buildReleaseNotesBody({ repo, tag, baseTag, commits, contributors }) { - const lines = [] - - for (const bucket of BUCKETS) { - const items = commits.filter((commit) => commit.bucket === bucket.id) - if (!items.length) continue - lines.push(`### ${bucket.emoji} ${bucket.title}`, '') - lines.push(`Summary: ${items.length} ${items.length === 1 ? 'change' : 'changes'} shipped in this area.`) - lines.push('') - lines.push('
') - lines.push(`Show ${bucket.title.toLowerCase()}`, '') - for (const commit of items) { - const hasInlinePrRef = commit.pr ? new RegExp(`(^|[^\\w])#${commit.pr}(?!\\d)`).test(commit.subject) : false - const prLink = commit.pr && !hasInlinePrRef ? ` ([#${commit.pr}](https://github.com/${repo}/pull/${commit.pr}))` : '' - const authorLink = commit.login ? ` by [@${commit.login}](https://github.com/${commit.login})` : '' - lines.push(`- ${commit.subject}${authorLink}${prLink}`) - } - lines.push('', '
', '') - } - - if (contributors.length) { - const contributorLinks = contributors.map(displayContributor) - const firstTimers = contributors.filter((contributor) => contributor.firstTime).map(displayContributor) - lines.push('### ๐Ÿ™Œ Contributors', '') - lines.push(`Thanks ${joinHumanList(contributorLinks)} for the commits that shipped in this release.`) - if (firstTimers.length === 1) { - lines.push(`Welcome to our first-time contributor ${firstTimers[0]}!`) - } else if (firstTimers.length > 1) { - lines.push(`Welcome to our first-time contributors ${joinHumanList(firstTimers)}!`) - } - lines.push('') - } - - if (baseTag) { - lines.push(`**Full Changelog**: https://github.com/${repo}/compare/${baseTag}...${tag}`) - } - - return lines.join('\n').trim() + '\n' -} - -async function fetchJson(url, token, accept = 'application/vnd.github+json') { - const pathname = new URL(url).pathname.replace(/^\/+/, '') - try { - const ghArgs = ['api', pathname, '-H', `Accept: ${accept}`, '-H', 'X-GitHub-Api-Version: 2022-11-28'] - if (token) ghArgs.push('-H', `Authorization: Bearer ${token}`) - const output = execFileSync('gh', ghArgs, { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - }).trim() - return output ? JSON.parse(output) : null - } catch { - // Fall through to fetch fallback below. - } - try { - const response = await fetch(url, { - headers: { - Accept: accept, - 'X-GitHub-Api-Version': '2022-11-28', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }) - if (response.ok) return response.json() - } catch { - // ignore - } - return null -} - -export function selectGithubIdentity({ commitAuthor = null, commitCommitter = null, pullAuthor = null }) { - const candidate = commitAuthor || commitCommitter || pullAuthor || null - return { - login: candidate?.login || '', - avatarUrl: candidate?.avatar_url || '', - } -} - -async function resolveCommitMeta({ repo, sha, token }) { - const [commitBody, pullBody] = await Promise.all([ - fetchJson(`https://api.github.com/repos/${repo}/commits/${sha}`, token), - fetchJson(`https://api.github.com/repos/${repo}/commits/${sha}/pulls`, token), - ]) - const firstPull = Array.isArray(pullBody) ? pullBody[0] ?? null : null - const identity = selectGithubIdentity({ - commitAuthor: commitBody?.author ?? null, - commitCommitter: commitBody?.committer ?? null, - pullAuthor: firstPull?.user ?? null, - }) - return { - ...identity, - pr: firstPull?.number || null, - } -} - -function loadPriorContributorKeys(baseTag) { - if (!baseTag) return new Set() - const raw = runGit(`git log --no-merges --pretty=format:%an%x09%ae ${baseTag}`) - const keys = new Set() - for (const line of raw.split('\n').filter(Boolean)) { - const [authorName = '', authorEmail = ''] = line.split('\t') - if (authorName) keys.add(`name:${normalizeIdentityPart(authorName)}`) - if (authorEmail) keys.add(`email:${normalizeIdentityPart(authorEmail)}`) - } - return keys -} - -export async function generateReleaseNotes(tag, env = process.env) { - const repo = - env.GH_REPO || - runGit('gh repo view --json nameWithOwner --jq .nameWithOwner') - const token = env.GH_TOKEN || env.GITHUB_TOKEN || '' - - let previousAncestorTag = '' - try { - previousAncestorTag = runGit(`git describe --tags --abbrev=0 --match 'v*' ${tag}^`) - } catch { - previousAncestorTag = '' - } - - let allTags = [] - try { - const rawRemoteTags = runGit(`git ls-remote --tags origin 'v*'`) - allTags = [...new Set(parseRemoteTags(rawRemoteTags))] - } catch { - allTags = [] - } - - const baseTag = selectReleaseBaseTag({ tag, previousAncestorTag, allTags }) - const range = baseTag ? `${baseTag}..${tag}` : tag - const rawLog = runGit(`git log --no-merges --pretty=format:%H%x09%an%x09%ae%x09%s ${range}`) - const commits = parseGitLog(rawLog) - - const enrichedCommits = await Promise.all( - commits.map(async (commit) => { - const meta = await resolveCommitMeta({ repo, sha: commit.sha, token }) - return { - ...commit, - ...meta, - bucket: bucketOf(commit.subject), - } - }), - ) - - const priorContributorKeys = loadPriorContributorKeys(baseTag) - const contributors = summarizeContributors(enrichedCommits, priorContributorKeys) - const changelogCommits = enrichedCommits.filter((commit) => commit.bucket) - - return buildReleaseNotesBody({ - repo, - tag, - baseTag, - commits: changelogCommits, - contributors, - }) -} - -async function main() { - const tag = process.argv[2] - if (!tag) { - console.error('usage: release-notes.mjs ') - process.exit(1) - } - - const notes = await generateReleaseNotes(tag) - process.stdout.write(notes) -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - await main() -} diff --git a/scripts/release-notes.test.mjs b/scripts/release-notes.test.mjs deleted file mode 100644 index 28e84b3..0000000 --- a/scripts/release-notes.test.mjs +++ /dev/null @@ -1,259 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - buildReleaseNotesBody, - displayContributor, - joinHumanList, - parseRemoteTags, - selectGithubIdentity, - selectReleaseBaseTag, - summarizeContributors, -} from './release-notes.mjs' - -test('parseRemoteTags uses remote tags only and strips peeled refs', () => { - const tags = parseRemoteTags(` -abc123\trefs/tags/v0.2.0 -def456\trefs/tags/v0.2.0^{} -789abc\trefs/tags/v0.3.0-rc.1 -`) - - assert.deepEqual(tags, ['v0.2.0', 'v0.2.0', 'v0.3.0-rc.1']) -}) - -test('selectReleaseBaseTag uses the previous official release for official tags', () => { - const baseTag = selectReleaseBaseTag({ - tag: 'v0.3.0', - previousAncestorTag: 'v0.3.0-rc.3', - allTags: ['v0.2.0', 'v0.3.0-rc.1', 'v0.3.0-rc.2', 'v0.3.0-rc.3', 'v0.3.0'], - }) - - assert.equal(baseTag, 'v0.2.0') -}) - -test('selectReleaseBaseTag falls back to the first tag when no prior official release exists', () => { - const baseTag = selectReleaseBaseTag({ - tag: 'v0.3.0', - previousAncestorTag: 'v0.3.0-rc.3', - allTags: ['v0.3.0-rc.1', 'v0.3.0-rc.2', 'v0.3.0-rc.3', 'v0.3.0'], - }) - - assert.equal(baseTag, 'v0.3.0-rc.1') -}) - -test('selectReleaseBaseTag keeps the nearest previous tag for prereleases', () => { - const baseTag = selectReleaseBaseTag({ - tag: 'v0.3.0-rc.3', - previousAncestorTag: 'v0.3.0-rc.2', - allTags: ['v0.2.0', 'v0.3.0-rc.1', 'v0.3.0-rc.2', 'v0.3.0-rc.3'], - }) - - assert.equal(baseTag, 'v0.3.0-rc.2') -}) - -test('summarizeContributors welcomes only first-time human contributors', () => { - const contributors = summarizeContributors( - [ - { - login: 'repeat-dev', - authorName: 'Repeat Dev', - authorEmail: 'repeat@example.com', - }, - { - login: 'new-dev', - authorName: 'New Dev', - authorEmail: 'new@example.com', - }, - { - login: 'github-actions[bot]', - authorName: 'github-actions[bot]', - authorEmail: '41898282+github-actions[bot]@users.noreply.github.com', - }, - ], - new Set(['email:repeat@example.com']), - ) - - assert.deepEqual( - contributors.map((entry) => ({ login: entry.login, firstTime: entry.firstTime })), - [ - { login: 'new-dev', firstTime: true }, - { login: 'repeat-dev', firstTime: false }, - ], - ) -}) - -test('summarizeContributors merges the same contributor across name, email, and login variants', () => { - const contributors = summarizeContributors( - [ - { - login: '', - authorName: 'Haili Zhang', - authorEmail: 'haili@example.com', - }, - { - login: 'haili', - authorName: 'Haili Zhang', - authorEmail: '', - }, - { - login: '', - authorName: '', - authorEmail: 'haili@example.com', - }, - ], - new Set(['email:haili@example.com']), - ) - - assert.equal(contributors.length, 1) - assert.deepEqual(contributors[0], { - login: 'haili', - authorName: 'Haili Zhang', - authorEmail: 'haili@example.com', - firstTime: false, - }) -}) - -test('buildReleaseNotesBody includes contributor thanks, first-time welcome, and release compare link', () => { - const body = buildReleaseNotesBody({ - repo: 'openmaster-ai/clawmaster', - tag: 'v0.3.0', - baseTag: 'v0.2.0', - commits: [ - { - subject: 'feat(models): improve default provider tiers', - login: 'alice', - pr: 101, - bucket: 'features', - }, - { - subject: 'fix(setup): wait for gateway readiness in skip flow', - login: 'bob', - pr: 102, - bucket: 'fixes', - }, - ], - contributors: [ - { - login: 'alice', - authorName: 'Alice', - authorEmail: 'alice@example.com', - avatarUrl: 'https://avatars.example/alice.png', - firstTime: false, - }, - { - login: 'bob', - authorName: 'Bob', - authorEmail: 'bob@example.com', - avatarUrl: 'https://avatars.example/bob.png', - firstTime: true, - }, - ], - }) - - assert.match(body, /### โœจ Features & Polish/) - assert.match(body, /### ๐Ÿ› Fixes/) - assert.match(body, /Summary: 1 change shipped in this area\./) - assert.match(body, /
/) - assert.match(body, /Show features & polish<\/summary>/) - assert.match(body, /feat\(models\): improve default provider tiers by \[@alice\]\(https:\/\/github\.com\/alice\) \(\[#101\]/) - assert.match(body, /fix\(setup\): wait for gateway readiness in skip flow by \[@bob\]\(https:\/\/github\.com\/bob\) \(\[#102\]/) - assert.match(body, /Thanks { - assert.equal(joinHumanList([]), '') - assert.equal(joinHumanList(['A']), 'A') - assert.equal(joinHumanList(['A', 'B']), 'A and B') - assert.equal(joinHumanList(['A', 'B', 'C']), 'A, B, and C') -}) - -test('displayContributor prefers GitHub handle and avatar when available', () => { - const rendered = displayContributor({ - login: 'alice', - authorName: 'Alice', - authorEmail: 'alice@example.com', - avatarUrl: 'https://avatars.example/alice.png', - }) - - assert.equal( - rendered, - '@alice [@alice](https://github.com/alice)', - ) -}) - -test('selectGithubIdentity falls back from author to committer to PR author', () => { - assert.deepEqual( - selectGithubIdentity({ - commitAuthor: null, - commitCommitter: { login: 'committer-user', avatar_url: 'https://avatars.example/committer.png' }, - pullAuthor: { login: 'pr-user', avatar_url: 'https://avatars.example/pr.png' }, - }), - { - login: 'committer-user', - avatarUrl: 'https://avatars.example/committer.png', - }, - ) - - assert.deepEqual( - selectGithubIdentity({ - commitAuthor: null, - commitCommitter: null, - pullAuthor: { login: 'pr-user', avatar_url: 'https://avatars.example/pr.png' }, - }), - { - login: 'pr-user', - avatarUrl: 'https://avatars.example/pr.png', - }, - ) -}) - -test('buildReleaseNotesBody keeps GitHub handles visible in commit lines and contributor thanks', () => { - const body = buildReleaseNotesBody({ - repo: 'openmaster-ai/clawmaster', - tag: 'v0.3.0', - baseTag: 'v0.2.0', - commits: [ - { - subject: 'feat(setup): require gateway check before finishing onboarding', - login: 'webup', - pr: 93, - bucket: 'features', - }, - ], - contributors: [ - { - login: 'webup', - authorName: 'Haili Zhang', - authorEmail: 'haili.zhang@outlook.com', - avatarUrl: 'https://avatars.example/webup.png', - firstTime: false, - }, - ], - }) - - assert.match(body, /by \[@webup\]\(https:\/\/github\.com\/webup\)/) - assert.match(body, /Thanks { - const body = buildReleaseNotesBody({ - repo: 'openmaster-ai/clawmaster', - tag: 'v0.3.0', - baseTag: 'v0.2.0', - commits: [ - { - subject: 'feat: add content drafts viewer (#67)', - login: 'webup', - pr: 67, - bucket: 'features', - }, - ], - contributors: [], - }) - - assert.match(body, /feat: add content drafts viewer \(#67\) by \[@webup\]/) - assert.doesNotMatch(body, /\(\[#67\]\(https:\/\/github\.com\/openmaster-ai\/clawmaster\/pull\/67\)\)/) -})