diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fc26d13e..e822547e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -443,6 +443,20 @@ jobs: 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" + + # Build direct asset URLs deterministically from the release tag. + linux_appimage_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_amd64.AppImage" + linux_deb_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_amd64.deb" + macos_intel_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_x64.dmg" + macos_arm64_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_aarch64.dmg" + windows_msi_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_x64_en-US.msi" + windows_exe_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_x64-setup.exe" + checksums_url="${RELEASE_DOWNLOAD_BASE}/SHA256SUMS.txt" + # Use printf instead of heredoc because YAML's `run: |` indentation # leaks into the heredoc content — turning every heading into an # indented code block in the rendered markdown. @@ -450,22 +464,23 @@ jobs: printf '## Install\n\n' printf '### CLI + Web Console (Recommended)\n\n' printf '```bash\n' - printf 'npm install -g clawmaster@%s\n' "$NPM_TAG" + printf 'npm install -g clawmaster\n' printf 'clawmaster\n' printf '```\n\n' - printf 'Open http://localhost:16223 — the setup wizard guides you through OpenClaw engine detection and LLM provider configuration.\n\n' + printf '`clawmaster` starts the local service and opens the landing page in your browser by default.\n\n' + printf 'If the browser does not open automatically, use the backup local address: http://localhost:16223\n\n' + printf -- '- npm package: [%s](%s)\n' "clawmaster@${VERSION}" "$NPM_PACKAGE_URL" + printf -- '- all published versions: [%s](%s)\n\n' "npm versions" "$NPM_DIST_URL" printf '> To pin this exact version: `npm install -g clawmaster@%s`\n\n' "$VERSION" printf '### Desktop App (Beta)\n\n' - printf 'Download the installer for your platform from the Assets section below.\n\n' - printf '| Platform | Files |\n' + printf 'Direct downloads for this release. Checksums: [`SHA256SUMS.txt`](%s)\n\n' "$checksums_url" + printf '| Platform | Downloads |\n' printf '|---|---|\n' - printf '| Linux x64 | `.AppImage`, `.deb` |\n' - printf '| macOS Intel | `.dmg` |\n' - printf '| macOS Apple Silicon | `.dmg` |\n' - printf '| Windows x64 | `.msi`, `.exe` |\n\n' - printf '> **Note:** Desktop builds are in beta. The CLI + Web Console is the recommended install method.\n\n' - printf '## Checksums\n\n' - printf 'Verify downloads with `SHA256SUMS.txt` attached to this release.\n\n' + printf '| Linux x64 | [AppImage](%s) · [deb](%s) |\n' "$linux_appimage_url" "$linux_deb_url" + printf '| macOS Intel | [dmg](%s) |\n' "$macos_intel_url" + 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 43479837..7e1fc733 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -441,17 +441,30 @@ jobs: await skipLink.first().click(); const skipGatewayStep = page.locator('text=网关').or(page.locator('text=Gateway')); await skipGatewayStep.first().waitFor({ timeout: 5000 }); - await page.locator('text=网关已启动').or(page.locator('text=Gateway started')).first() - .waitFor({ timeout: 10000 }) - .catch(() => page.locator('text=已就绪').or(page.locator('text=Ready')).first().waitFor({ timeout: 2000 })); + const startGatewayBtn = page.locator('text=启动网关').or(page.locator('text=Start Gateway')); + const checkGatewayBtn = page.locator('text=重新检查网关').or(page.locator('text=Check Again')); const skipEnterBtn = page.locator('text=进入管理大师').or(page.locator('text=Enter ClawMaster')); - await skipEnterBtn.first().waitFor({ timeout: 5000 }); - await skipEnterBtn.first().click(); - await page.waitForTimeout(2000); - const skipMainVisible = await page.locator('.app-layout, [class*="Layout"], nav').first() - .isVisible().catch(() => false); - if (!skipMainVisible) bail('Main app not visible after skip'); - ok('Skip flow → main app rendered'); + // Skip only needs to prove the wizard lands on the mandatory gateway step + // and offers at least one valid next action from there. + let skipNextAction = await skipEnterBtn.first().isVisible().catch(() => false); + if (!skipNextAction) { + const canStartGateway = await startGatewayBtn.first().isVisible().catch(() => false); + if (canStartGateway) { + await startGatewayBtn.first().click(); + } else { + const canCheckGateway = await checkGatewayBtn.first().isVisible().catch(() => false); + if (canCheckGateway) { + await checkGatewayBtn.first().click(); + } + } + await page.waitForTimeout(2000); + const enterVisible = await skipEnterBtn.first().isVisible().catch(() => false); + const startVisible = await startGatewayBtn.first().isVisible().catch(() => false); + const checkVisible = await checkGatewayBtn.first().isVisible().catch(() => false); + skipNextAction = enterVisible || startVisible || checkVisible; + } + if (!skipNextAction) bail('Skip flow reached gateway step but exposed no valid next action'); + ok('Skip flow reached mandatory gateway step'); console.log(`\nAll ${step} wizard E2E checks passed`); await browser.close(); diff --git a/package-lock.json b/package-lock.json index 07d870bc..10a24328 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawmaster", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawmaster", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -7732,7 +7732,7 @@ }, "packages/backend": { "name": "@openclaw-manager/backend", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "@langchain/core": "^1.1.38", "cors": "^2.8.5", @@ -7750,7 +7750,7 @@ }, "packages/web": { "name": "@openclaw-manager/web", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/package.json b/package.json index d3feb7bf..39c1c4e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawmaster", - "version": "0.3.0", + "version": "0.3.1", "description": "龙虾管理大师——OpenClaw 生态的六边形战士", "bin": { "clawmaster": "bin/clawmaster.mjs" @@ -37,7 +37,7 @@ "tauri:dev": "tauri dev", "tauri:build": "tauri build", "prepack": "npm run build:backend && npm run build", - "test:cli": "node --test bin/*.test.mjs tests/desktop/harness.unit.test.mjs", + "test:cli": "node --test bin/*.test.mjs scripts/*.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/packages/backend/package.json b/packages/backend/package.json index ed84d4fb..d8d8331c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw-manager/backend", - "version": "0.3.0", + "version": "0.3.1", "private": true, "type": "module", "scripts": { diff --git a/packages/web/package.json b/packages/web/package.json index b60e56db..3a439f49 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw-manager/web", - "version": "0.3.0", + "version": "0.3.1", "private": true, "type": "module", "scripts": { diff --git a/packages/web/src/app/Layout.tsx b/packages/web/src/app/Layout.tsx index ebb373bd..5444f068 100644 --- a/packages/web/src/app/Layout.tsx +++ b/packages/web/src/app/Layout.tsx @@ -13,8 +13,8 @@ import { getCommandDescriptors } from './commandRegistry' import { getCommandShortcutLabel, isAppleClientPlatform } from './commandShortcut' import { resolveIcon } from './iconRegistry' import { NAV_SECTIONS, PAGE_META } from './navigationMeta' +import { BrandMark } from '@/shared/components/BrandMark' import { - Shell, Sun, Moon, Menu, @@ -376,9 +376,7 @@ export default function Layout({ children }: LayoutProps) { <>
-
- -
+

{t('layout.appName')}

{t('layout.appSub')}

@@ -389,18 +387,6 @@ export default function Layout({ children }: LayoutProps) {
-
-
-

{currentSection ? t(currentSection.labelKey) : t('layout.appName')}

-

{pageTitle}

-

{pageDescription}

-
- - {gwStatus?.running ? t('layout.status.gatewayRunning') : t('layout.status.gatewayStopped')} -
-
-
-
- + {t('layout.update.action')} -
- {nextItem && ( - - {t('dashboard.task.jumpToSection')} - - )} - {task.secondaryLinks.map((link) => ( - - {link.label} - - ))} +
+
+ {nextItem && ( + + {t('dashboard.task.jumpToSection')} + + )} + {visibleSecondaryLinks.map((link) => ( + + {link.label} + + ))} +
diff --git a/packages/web/src/modules/gateway/GatewayPage.tsx b/packages/web/src/modules/gateway/GatewayPage.tsx index 609ffdd6..ec2478d9 100644 --- a/packages/web/src/modules/gateway/GatewayPage.tsx +++ b/packages/web/src/modules/gateway/GatewayPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ScrollText } from 'lucide-react' +import { ExternalLink, Play, RotateCw, ScrollText, Shield, Square } from 'lucide-react' import { Link } from 'react-router-dom' import { platform } from '@/adapters' import { platformResults } from '@/shared/adapters/platformResults' @@ -120,101 +120,122 @@ export default function Gateway() { -
-
-

{t('dashboard.gatewayStatus')}

-

- {status?.running ? t('dashboard.running') : t('dashboard.stopped')} -

-

{gatewayUrl}

-
-
-

{t('gateway.port')}

-

{gatewayPort}

-

{t('gateway.bind')}: {gatewayBind}

-
-
-

{t('gateway.auth')}

-

{gatewayAuthMode}

-

- {gatewayToken ? ( - - ) : 'Token -'} -

-
-
- -
+
{loading ? ( ) : ( -
-
-
-
-

{t('dashboard.manage')}

-

{gatewayUrl}

-
-
-
- {status?.running - ? t('gateway.openInBrowser') - : t('gateway.editConfigHint')} + <> +
+
+

{t('dashboard.manage')}

+

{gatewayUrl}

-

{gatewayUrl}

-
- {operating ? ( - {operating} - ) : statusLoading ? ( - {t('common.loading')} - ) : status?.running ? ( - <> - - + + + ) : ( + + )} + - - ) : ( - - )} - - {t('gateway.openInBrowser')} - - - - {t('logs.moreDiagnostics')} - + + {t('logs.moreDiagnostics')} + +
+
-
+ +

+ {status?.running ? t('gateway.openInBrowser') : t('gateway.editConfigHint')} +

+ )}
-
+

{t('gateway.config')}

@@ -222,29 +243,32 @@ export default function Gateway() { ) : ( <> -
-
-

{t('gateway.port')}

-

{gatewayPort}

+
+
+

{t('gateway.port')}

+

{gatewayPort}

-
-

{t('gateway.bind')}

-

{gatewayBind}

+
+

{t('gateway.bind')}

+

{gatewayBind}

-
-

{t('gateway.auth')}

-

{gatewayAuthMode}

+
+

{t('gateway.auth')}

+

{gatewayAuthMode}

{gatewayToken && ( -
-

Token

- +
+

Token

+
)}
-

- {t('gateway.editConfigHint')} -

+
+ {t('gateway.editConfigHint')} + + {t('config.title')} + +
)}
diff --git a/packages/web/src/modules/settings/CapabilitiesSection.tsx b/packages/web/src/modules/settings/CapabilitiesSection.tsx index b17ee0e5..1aa2cecf 100644 --- a/packages/web/src/modules/settings/CapabilitiesSection.tsx +++ b/packages/web/src/modules/settings/CapabilitiesSection.tsx @@ -181,10 +181,86 @@ function CapabilityRow({ ) } +function NpmProxyToggle({ + checked, + busy, + registryUrl, + label, + onToggle, +}: { + checked: boolean + busy: boolean + registryUrl: string + label: string + onToggle: (nextValue: boolean) => void +}) { + const { t } = useTranslation() + + return ( + + ) +} + export function CapabilitiesSection() { const { t } = useTranslation() const { capabilities, installProgress, detecting, installing, error, detect, install } = useCapabilityManager() + const visibleCapabilities = capabilities.filter((capability) => capability.id !== 'agent') const [confirmReinstallId, setConfirmReinstallId] = useState(null) const [npmProxyEnabled, setNpmProxyEnabled] = useState(false) @@ -346,11 +422,11 @@ export function CapabilitiesSection() { }, [t]) const confirmCapability = confirmReinstallId - ? capabilities.find((c) => c.id === confirmReinstallId) + ? visibleCapabilities.find((c) => c.id === confirmReinstallId) : null return ( -
+

{t('settings.capabilities.title')}

@@ -376,28 +452,32 @@ export function CapabilitiesSection() {
)} -
-
-
-

{t('settings.capabilities.npmProxyTitle')}

-

- {t('settings.capabilities.npmProxyDesc', { - registry: effectiveNpmProxyRegistryUrl ?? DEFAULT_NPM_PROXY_REGISTRY_URL, - })} -

+
+
+
+
+

{t('settings.capabilities.npmProxyTitle')}

+

+ {t('settings.capabilities.npmProxyDesc', { + registry: effectiveNpmProxyRegistryUrl ?? DEFAULT_NPM_PROXY_REGISTRY_URL, + })} +

+
- +
+ {npmProxyError ? (

{npmProxyError} @@ -405,14 +485,14 @@ export function CapabilitiesSection() { ) : null}

-
- {capabilities.length === 0 && detecting ? ( +
+ {visibleCapabilities.length === 0 && detecting ? (
{t('settings.capabilities.statusChecking')}
) : ( - capabilities.map((capability) => ( + visibleCapabilities.map((capability) => ( (null) @@ -772,7 +786,7 @@ export default function Settings() {
{localData && ( -
+

{t('settings.localDataTitle')}

@@ -780,8 +794,8 @@ export default function Settings() {
-
-
+
+
{t(localDataStateLabelKey(localData.state))} @@ -790,74 +804,12 @@ export default function Settings() { {t(localDataEngineLabelKey(localData.engine))}
-
-
- -
-
-

- {t(localDataSummaryLabelKey(localData))} -

- {localDataEffectiveReasonLabelKey(localData) ? ( -

- {t(localDataEffectiveReasonLabelKey(localData)!)} -

- ) : ( -

- {t('settings.localDataNoPythonHint')} -

- )} -
-
- -
-
-

{t('settings.localDataRuntime')}

-

- {localData.runtimeTarget === 'wsl2' ? t('settings.runtimeWsl2') : t('settings.runtimeNative')} -

-
-
-

{t('settings.localDataProfile')}

-

- {resolvedProfileMode === 'named' - ? `${t('settings.profileNamed')} · ${resolvedProfileName}` - : resolvedProfileMode === 'dev' - ? t('settings.profileDev') - : t('settings.profileDefault')} -

-
-
-

{t('settings.localDataEmbeddedSupport')}

-

- {localData.supportsEmbedded ? t('settings.localDataSupported') : t('settings.localDataUnavailable')} -

-
-
- -
-
-

{t('settings.localDataDocuments')}

-

{localDataStats?.documentCount ?? 0}

-
-
-

{t('settings.localDataDocsModule')}

-

{localDataStats?.moduleCounts.docs ?? 0}

-
-
-

{t('settings.localDataUpdatedAt')}

-

- {localDataStats?.updatedAt ? new Date(localDataStats.updatedAt).toLocaleString() : t('common.notSet')} -

-
-
- -
+
- {isTauri() ? ( -

- {t('settings.localDataDesktopPending')} +

+ +
+
+ +
+
+

+ {t(localDataSummaryLabelKey(localData))} +

+

+ {localDataEffectiveReasonLabelKey(localData) + ? t(localDataEffectiveReasonLabelKey(localData)!) + : t('settings.localDataNoPythonHint')}

- ) : null} + {isTauri() ? ( +

+ {t('settings.localDataDesktopPending')} +

+ ) : null} +
-
-

- {t('settings.localDataResolved')} -

-
- - - - - +
+
+

{t('settings.localDataDocuments')}

+

{localDataStats?.documentCount ?? 0}

+
+
+

{t('settings.localDataDocsModule')}

+

{localDataStats?.moduleCounts.docs ?? 0}

+
+

{t('settings.localDataUpdatedAt')}

+

+ {formatLocalDataUpdatedAt(localDataStats?.updatedAt, i18n.resolvedLanguage ?? i18n.language, t('common.notSet'))} +

+
+
+ +
+ + {t('settings.localDataRuntime')}: {localData.runtimeTarget === 'wsl2' ? t('settings.runtimeWsl2') : t('settings.runtimeNative')} + + + {t('settings.localDataProfile')}: + {resolvedProfileMode === 'named' + ? `${t('settings.profileNamed')} · ${resolvedProfileName}` + : resolvedProfileMode === 'dev' + ? t('settings.profileDev') + : t('settings.profileDefault')} + + + + {t('settings.localDataEmbeddedSupport')}: + {localData.supportsEmbedded ? t('settings.localDataSupported') : t('settings.localDataUnavailable')} + +
+ +
+ + {t('settings.localDataResolved')} + {t(localDataEngineLabelKey(localData.engine))} + +
+
+
+ {t('settings.localDataEngine')} + {t(localDataEngineLabelKey(localData.engine))} +
+
+ {t('settings.localDataTarget')} + {`${localData.targetPlatform} · ${localData.targetArch}`} +
+
+ {t('settings.localDataNodeRequirement')} + {localData.nodeRequirement} +
+
+
+
+

+ {t('settings.localDataRoot')} +

+

+ {localData.dataRoot ?? t('common.notSet')} +

+
+
+

+ {t('settings.localDataEngineRoot')} +

+

+ {localData.engineRoot ?? t('common.notSet')} +

+
+
+
+
)} @@ -946,13 +979,17 @@ export default function Settings() {

{t('logs.systemCardDescription')}

-
- - - {t('logs.gotoSystemInfo')} + + {t('logs.gotoSystemInfo')} +
@@ -966,13 +1003,17 @@ export default function Settings() {

{t('logs.gatewayCardDescription')}

-
- - - {t('logs.gotoGatewayPage')} + + {t('logs.gotoGatewayPage')} +
@@ -986,13 +1027,17 @@ export default function Settings() {

{t('logs.channelsCardDescription')}

-
- - - {t('logs.gotoChannelsPage')} + + {t('logs.gotoChannelsPage')} +
@@ -1025,27 +1070,45 @@ export default function Settings() { {/* 关于 */} -
+

{t('settings.about')}

-

{t('settings.aboutName')}

-

{t('settings.aboutDesc')}

-

{t('settings.aboutCommunity')}

-
- +
+

{t('settings.aboutName')}

+

+ {t('settings.aboutDesc')} · {t('settings.aboutCommunity')} +

+
+
+ {t('settings.aboutDocs')} - + GitHub - + ClawHub
{/* Acknowledgments */} -
+

{t('settings.acknowledgments')}

{[ @@ -1060,7 +1123,7 @@ export default function Settings() { href={p.url} target="_blank" rel="noopener noreferrer" - className="rounded-xl border border-transparent px-3 py-2 transition hover:border-primary/20 hover:bg-card/70 hover:text-primary" + className="rounded-xl border border-transparent px-3 py-2 leading-5 transition hover:border-primary/20 hover:bg-card/70 hover:text-primary" title={p.desc} > {p.name} {p.desc} @@ -1127,17 +1190,6 @@ export default function Settings() { ) } -function LocalDataRow({ label, value, monospace = false }: { label: string; value: string; monospace?: boolean }) { - return ( -
- {label} - - {value} - -
- ) -} - // ─── Update Section ─── type UpdateState = 'idle' | 'checking' | 'up-to-date' | 'available' | 'error' @@ -1186,6 +1238,7 @@ function UpdateSection({ onUpdated: () => void }) { const { t } = useTranslation() + const location = useLocation() const [state, setState] = useState('idle') const [versions, setVersions] = useState(null) const [channel, setChannel] = useState('stable') @@ -1193,6 +1246,7 @@ function UpdateSection({ const [error, setError] = useState(null) const [releases, setReleases] = useState([]) const [changelogOpen, setChangelogOpen] = useState(false) + const autoCheckTriggeredRef = useRef(false) const updateTask = useInstallTask() const channelVersion = versions @@ -1244,8 +1298,19 @@ function UpdateSection({ // Recent versions for dropdown (max 20) const recentVersions = versions?.versions.slice(0, 20) ?? [] + useEffect(() => { + const shouldAutoCheck = location.hash === '#settings-update' + if (!shouldAutoCheck) { + autoCheckTriggeredRef.current = false + return + } + if (autoCheckTriggeredRef.current || state !== 'idle' || updateTask.status !== 'idle') return + autoCheckTriggeredRef.current = true + void handleCheck() + }, [handleCheck, location.hash, state, updateTask.status]) + return ( -
+

{t('settings.update')}

diff --git a/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx b/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx index 85cd6ca4..dc64746d 100644 --- a/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx +++ b/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx @@ -247,9 +247,9 @@ describe('CapabilitiesSection', () => { renderSection() - const checkbox = await screen.findByRole('checkbox') + const checkbox = await screen.findByRole('switch', { name: '使用 npm 镜像' }) await waitFor(() => { - expect(checkbox).toBeChecked() + expect(checkbox).toHaveAttribute('aria-checked', 'true') }) fireEvent.click(checkbox) @@ -295,9 +295,9 @@ describe('CapabilitiesSection', () => { renderSection() - const checkbox = await screen.findByRole('checkbox') + const checkbox = await screen.findByRole('switch', { name: '使用 npm 镜像' }) await waitFor(() => { - expect(checkbox).toBeChecked() + expect(checkbox).toHaveAttribute('aria-checked', 'true') }) fireEvent.click(checkbox) diff --git a/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx b/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx index 94b3e812..9d2e86f9 100644 --- a/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx +++ b/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx @@ -89,16 +89,16 @@ vi.mock('react-i18next', () => ({ 'settings.localDataStateReady': 'Ready', 'settings.localDataStateDegraded': 'Fallback', 'settings.localDataStateBlocked': 'Blocked', - 'settings.localDataEngineEmbedded': 'SeekDB embedded', + 'settings.localDataEngineEmbedded': 'seekdb', 'settings.localDataEngineFallback': 'Fallback store', 'settings.localDataEngineUnavailable': 'Unavailable', - 'settings.localDataReadySummary': 'SeekDB embedded can run for this runtime target.', - 'settings.localDataFallbackSummary': 'ClawMaster will keep working through a file-backed fallback until SeekDB embedded is available.', + 'settings.localDataReadySummary': 'seekdb can run for this runtime target.', + 'settings.localDataFallbackSummary': 'ClawMaster will keep working through a file-backed fallback until this target supports seekdb.', 'settings.localDataBlockedSummary': 'Local data cannot pick a safe target yet.', 'settings.localDataNoPythonHint': 'This foundation uses JavaScript/TypeScript runtime paths only; no Python package or Python-based skill is required.', - 'settings.localDataReasonNodeMissing': 'Node.js is missing in the selected runtime. SeekDB requires Node 20 or newer, so the fallback store is selected.', - 'settings.localDataReasonNodeTooOld': 'The selected runtime uses Node.js below 20. Upgrade Node.js to enable SeekDB embedded where the platform supports it.', - 'settings.localDataReasonUnsupportedPlatform': 'SeekDB embedded bindings are not available on this target yet. Use WSL2, server mode later, or fallback storage.', + 'settings.localDataReasonNodeMissing': 'Node.js is missing in the selected runtime. seekdb requires Node 20 or newer, so the fallback store is selected.', + 'settings.localDataReasonNodeTooOld': 'The selected runtime uses Node.js below 20. Upgrade Node.js to enable seekdb where the platform supports it.', + 'settings.localDataReasonUnsupportedPlatform': 'seekdb bindings are not available on this target yet. Use WSL2, server mode later, or fallback storage.', 'settings.localDataReasonWslDistroMissing': 'WSL2 mode is selected, but the saved distro is missing. Re-select the distro before Local Data can safely resolve a target.', 'settings.localDataRuntime': 'Runtime', 'settings.localDataProfile': 'Profile', @@ -112,7 +112,7 @@ vi.mock('react-i18next', () => ({ 'settings.localDataRoot': 'Data root', 'settings.localDataEngineRoot': 'Engine root', 'settings.localDataDocuments': 'Documents', - 'settings.localDataDocsModule': 'Docs indexed', + 'settings.localDataDocsModule': 'Docs index', 'settings.localDataUpdatedAt': 'Updated', 'settings.localDataRebuild': 'Rebuild Index', 'settings.localDataReset': 'Reset Store', @@ -286,9 +286,9 @@ vi.mock('@/modules/setup/adapters', () => ({ import Settings from '../SettingsPage' import { platform } from '@/adapters' -function renderSettings() { +function renderSettings(initialEntries?: string[]) { return render( - + , ) @@ -393,6 +393,23 @@ describe('UpdateSection', () => { expect(screen.getAllByText(/v2026\.3\.28/).length).toBeGreaterThanOrEqual(1) }) + it('auto-checks updates when opened from the update banner hash', async () => { + mockListVersions.mockResolvedValue({ + success: true, + data: { + distTags: { latest: '2026.4.23', beta: '2026.4.24-beta.1', dev: '2026.4.25-dev.1' }, + versions: ['2026.4.23', '2026.4.22'], + }, + error: null, + }) + + renderSettings(['/settings#settings-update']) + + expect(await screen.findByText('Version:')).toBeInTheDocument() + expect(mockListVersions).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: 'Update to 2026.4.23' })).toBeInTheDocument() + }) + it('opens recent diagnostics logs from settings and keeps the full log stream', async () => { renderSettings() await waitFor(() => { @@ -448,11 +465,11 @@ describe('UpdateSection', () => { expect(localData.getByText('Ready')).toBeInTheDocument() expect(localData.getAllByText('Fallback store').length).toBeGreaterThan(0) - expect(localData.getByText('ClawMaster will keep working through a file-backed fallback until SeekDB embedded is available.')).toBeInTheDocument() + expect(localData.getByText('ClawMaster will keep working through a file-backed fallback until this target supports seekdb.')).toBeInTheDocument() expect(localData.getByText('This foundation uses JavaScript/TypeScript runtime paths only; no Python package or Python-based skill is required.')).toBeInTheDocument() expect(localData.getByText('/home/.clawmaster/data/default/fallback')).toBeInTheDocument() expect(localData.getAllByText('12')).toHaveLength(2) - expect(localData.queryByText('SeekDB embedded can run for this runtime target.')).not.toBeInTheDocument() + expect(localData.queryByText('seekdb can run for this runtime target.')).not.toBeInTheDocument() }) it('shows fallback summary and unsupported target guidance when embedded support is unavailable', async () => { @@ -480,9 +497,9 @@ describe('UpdateSection', () => { expect(section).not.toBeNull() const localData = within(section!) - expect(localData.getByText('ClawMaster will keep working through a file-backed fallback until SeekDB embedded is available.')).toBeInTheDocument() - expect(localData.getByText('SeekDB embedded bindings are not available on this target yet. Use WSL2, server mode later, or fallback storage.')).toBeInTheDocument() - expect(localData.queryByText('SeekDB embedded can run for this runtime target.')).not.toBeInTheDocument() + expect(localData.getByText('ClawMaster will keep working through a file-backed fallback until this target supports seekdb.')).toBeInTheDocument() + expect(localData.getByText('seekdb bindings are not available on this target yet. Use WSL2, server mode later, or fallback storage.')).toBeInTheDocument() + expect(localData.queryByText('seekdb can run for this runtime target.')).not.toBeInTheDocument() }) it('clears stale local data stats when a refresh fails', async () => { diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs old mode 100755 new mode 100644 index 8fb1bb35..53fcb845 --- a/scripts/release-notes.mjs +++ b/scripts/release-notes.mjs @@ -2,96 +2,364 @@ // 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 `gh api .../releases/generate-notes` falls short because the -// tagged branch only contains release-merge commits (git-flow pattern). +// 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 { execSync } from 'node:child_process' +import { execFileSync, execSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' -const tag = process.argv[2] -if (!tag) { - console.error('usage: release-notes.mjs ') - process.exit(1) -} - -const repo = - process.env.GH_REPO || - execSync('gh repo view --json nameWithOwner --jq .nameWithOwner', { encoding: 'utf8' }).trim() +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 token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN +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+$/ -let prevTag = '' -try { - prevTag = execSync(`git describe --tags --abbrev=0 --match 'v*' ${tag}^`, { +function runGit(command, options = {}) { + return execSync(command, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], + ...options, }).trim() -} catch { - // No previous tag — this is the first release. } -const range = prevTag ? `${prevTag}..${tag}` : tag -const log = execSync(`git log --no-merges --pretty=format:%H%x09%s ${range}`, { - encoding: 'utf8', -}).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) +} -const commits = log - .split('\n') - .filter(Boolean) - .map((line) => { - const [sha, ...rest] = line.split('\t') - return { sha, subject: rest.join('\t') } - }) +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] ?? '', + } +} -const BUCKETS = [ - { id: 'features', title: '### ✨ Features & Polish', types: ['feat', 'polish'] }, - { id: 'fixes', title: '### 🐛 Fixes', types: ['fix'] }, - { id: 'misc', title: '### 📝 Misc', types: [] }, -] +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) +} -const EXCLUDED_TYPES = new Set(['ci', 'test', 'build', 'perf', 'refactor', 'chore', 'docs', 'style']) +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) +} -function bucketOf(subject) { - const m = subject.match(/^([a-z]+)(?:\([^)]+\))?:/) - if (!m) return null - const type = m[1] +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((b) => b.types.includes(type))?.id ?? 'misc' + 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 } -async function prFor(sha) { - const res = await fetch(`https://api.github.com/repos/${repo}/commits/${sha}/pulls`, { - headers: { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, +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, }) - if (!res.ok) return null - const body = await res.json() - return Array.isArray(body) && body[0]?.number ? body[0].number : 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 } -const withPr = await Promise.all( - commits.map(async (c) => ({ ...c, pr: await prFor(c.sha), bucket: bucketOf(c.subject) })), -) +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 = '' + } -const lines = [] -for (const bucket of BUCKETS) { - const items = withPr.filter((c) => c.bucket === bucket.id) - if (!items.length) continue - lines.push(bucket.title, '') - for (const c of items) { - const prLink = c.pr ? ` ([#${c.pr}](https://github.com/${repo}/pull/${c.pr}))` : '' - lines.push(`- ${c.subject}${prLink}`) + let allTags = [] + try { + const rawRemoteTags = runGit(`git ls-remote --tags origin 'v*'`) + allTags = [...new Set(parseRemoteTags(rawRemoteTags))] + } catch { + allTags = [] } - lines.push('') + + 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, + }) } -if (prevTag) { - lines.push(`**Full Changelog**: https://github.com/${repo}/compare/${prevTag}...${tag}`) +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) } -process.stdout.write(lines.join('\n').trim() + '\n') +if (process.argv[1] === fileURLToPath(import.meta.url)) { + await main() +} diff --git a/scripts/release-notes.test.mjs b/scripts/release-notes.test.mjs new file mode 100644 index 00000000..28e84b3b --- /dev/null +++ b/scripts/release-notes.test.mjs @@ -0,0 +1,259 @@ +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\)\)/) +}) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 21326f63..702899e0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.3.0" +version = "0.3.1" description = "A Tauri App" authors = ["openmaster-ai"] license = "Apache-2.0" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b7dc424d..ca879744 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "ClawMaster", - "version": "0.3.0", + "version": "0.3.1", "identifier": "ai.openclaw.manager", "build": { "frontendDist": "../packages/web/dist",