cc-haha/.github/workflows/pr-triage.yml
程序员阿江(Relakkes) 386a41e606 feat(desktop): enable Electron migration path
Introduce the Electron desktop shell alongside the existing React renderer and local Bun server boundary. The migration keeps the DesktopHost contract explicit across Tauri, Electron, and browser runtimes while adding Electron main/preload services for dialogs, shell, notifications, updates, tray/window lifecycle, terminal, preview WebContentsView, app mode, and release/package validation.

The commit also carries the latest local main desktop command updates, including agent slash entries and hidden-by-default markdown thinking details, so the packaged Electron build matches the current main UX surface.

Constraint: React renderer, local Bun server, REST/WebSocket, and sidecar boundaries must remain reusable during the migration
Constraint: macOS dev packages are ad-hoc signed and cannot prove Developer ID notarization or Gatekeeper release launch
Rejected: Browser-only smoke validation | it cannot exercise native dialogs, keychain prompts, notification behavior, or packaged app startup
Confidence: medium
Scope-risk: broad
Directive: Do not remove Tauri host support until signed Electron release artifacts pass native OS smoke on macOS, Windows, and Linux
Tested: bun run check:desktop
Tested: cd desktop && bun run check:electron
Tested: CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron📦dir
Tested: bun run test:package-smoke --platform macos --package-kind dir --artifacts-dir desktop/build-artifacts/electron
Tested: Computer Use read packaged Electron app window at desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app
Not-tested: Developer ID signed/notarized Gatekeeper launch
Not-tested: Real OS notification click-to-session action
Not-tested: Windows and Linux packaged app smoke on real hosts
2026-06-01 22:43:16 +08:00

308 lines
14 KiB
YAML

name: PR Triage
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: pr-triage-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
triage:
name: label-and-summarize
runs-on: ubuntu-latest
steps:
- name: Apply deterministic PR labels and summary
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner
const repo = context.repo.repo
const issue_number = context.payload.pull_request.number
const managedLabels = {
'area:desktop': '1d76db',
'area:server': '0e8a16',
'area:adapters': '5319e7',
'area:docs': '0075ca',
'area:release': 'fbca04',
'area:cli-core': 'b60205',
'needs-maintainer-approval': 'b60205',
'allow-cli-core-change': 'c2e0c6',
'allow-missing-tests': 'c2e0c6',
'allow-coverage-baseline-change': 'c2e0c6',
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: issue_number,
per_page: 100,
})
const filenames = files.map((file) => file.filename)
const currentLabels = new Set((context.payload.pull_request.labels || []).map((label) => label.name))
const areas = new Set()
const cliCoreFiles = []
const cliPrefixes = [
'bin/',
'src/entrypoints/',
'src/screens/',
'src/components/',
'src/commands/',
'src/tools/',
'src/utils/',
]
const docsExact = new Set(['README.md', 'README.en.md', 'package.json', 'package-lock.json', '.github/workflows/deploy-docs.yml'])
const releaseExact = new Set([
'.github/workflows/pr-quality.yml',
'.github/workflows/pr-triage.yml',
'.github/workflows/release-desktop.yml',
'.github/workflows/build-desktop-dev.yml',
'scripts/pr/change-policy.ts',
'scripts/pr/change-policy.test.ts',
'scripts/pr/check-pr.ts',
'scripts/pr/run-server-tests.ts',
'scripts/release.ts',
'desktop/electron/tsconfig.json',
'desktop/scripts/build-linux.sh',
'desktop/scripts/build-macos-arm64.sh',
'desktop/scripts/build-windows-x64.ps1',
])
for (const filename of filenames) {
if (filename.startsWith('desktop/')) areas.add('area:desktop')
if (filename.startsWith('src/server/')) areas.add('area:server')
if (filename.startsWith('adapters/')) areas.add('area:adapters')
if (filename.startsWith('docs/') || filename.startsWith('release-notes/') || docsExact.has(filename)) areas.add('area:docs')
if (releaseExact.has(filename)) areas.add('area:release')
if (cliPrefixes.some((prefix) => filename.startsWith(prefix))) {
areas.add('area:cli-core')
cliCoreFiles.push(filename)
}
}
if (cliCoreFiles.length > 0 && !currentLabels.has('allow-cli-core-change')) {
areas.add('needs-maintainer-approval')
}
const hasTest = (prefix) => filenames.some((file) =>
file.startsWith(prefix) && (/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/'))
)
const changedProduct = (predicate) => filenames.filter((file) =>
predicate(file) &&
!/\.test\.[cm]?[jt]sx?$/.test(file) &&
!file.includes('/__tests__/') &&
!file.includes('/fixtures/')
)
const desktopProduct = changedProduct((file) => file.startsWith('desktop/src/'))
const serverProduct = changedProduct((file) => file.startsWith('src/server/'))
const adapterProduct = changedProduct((file) => file.startsWith('adapters/'))
const agentRuntimeProduct = changedProduct((file) =>
file.startsWith('src/server/ws/') ||
file.startsWith('src/server/services/conversation') ||
file.startsWith('src/tools/') ||
file.startsWith('src/utils/')
)
const missingTestSignals = []
if (desktopProduct.length > 0 && !hasTest('desktop/src/')) {
missingTestSignals.push('Desktop product files changed without a desktop test file in the PR.')
}
if (serverProduct.length > 0 && !hasTest('src/server/')) {
missingTestSignals.push('Server product files changed without a server test file in the PR.')
}
if (adapterProduct.length > 0 && !hasTest('adapters/')) {
missingTestSignals.push('Adapter product files changed without an adapter test file in the PR.')
}
if (agentRuntimeProduct.length > 0 && !filenames.some((file) =>
(file.startsWith('src/tools/') || file.startsWith('src/utils/')) &&
(/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/'))
)) {
missingTestSignals.push('Agent/runtime product files changed without a tools/utils test file in the PR.')
}
if (missingTestSignals.length > 0 && !currentLabels.has('allow-missing-tests')) {
areas.add('needs-maintainer-approval')
}
const coveragePolicyFiles = filenames.filter((file) =>
file === 'scripts/quality-gate/coverage-baseline.json' ||
file === 'scripts/quality-gate/coverage-thresholds.json'
)
if (coveragePolicyFiles.length > 0 && !currentLabels.has('allow-coverage-baseline-change')) {
areas.add('needs-maintainer-approval')
}
const requiredChecks = ['change-policy']
if (areas.has('area:desktop') || areas.has('area:server')) requiredChecks.push('desktop-checks')
if (areas.has('area:server') || filenames.some((file) => file.startsWith('src/tools/') || file.startsWith('src/utils/'))) requiredChecks.push('server-checks')
if (areas.has('area:adapters')) requiredChecks.push('adapter-checks')
if (
filenames.some((file) =>
file.startsWith('desktop/') ||
file.startsWith('adapters/') ||
file.startsWith('src/server/') ||
['bun.lock', 'package.json', 'desktop/bun.lock', 'desktop/package.json', 'desktop/package-lock.json', 'desktop/electron/tsconfig.json', 'desktop/scripts/build-linux.sh', 'desktop/scripts/build-macos-arm64.sh', 'desktop/scripts/build-windows-x64.ps1'].includes(file)
)
) requiredChecks.push('desktop-native-checks')
if (areas.has('area:docs')) requiredChecks.push('docs-checks')
if (
filenames.some((file) =>
file.startsWith('desktop/src/') ||
file.startsWith('src/server/') ||
file.startsWith('src/tools/') ||
file.startsWith('src/utils/') ||
file.startsWith('adapters/') ||
file.startsWith('scripts/quality-gate/') ||
['package.json', 'desktop/package.json', 'desktop/bun.lock'].includes(file)
)
) requiredChecks.push('coverage-checks')
const testSignals = []
testSignals.push(...missingTestSignals.map((signal) => `BLOCKING unless \`allow-missing-tests\` is applied: ${signal}`))
if (agentRuntimeProduct.length > 0) {
testSignals.push('Agent/model runtime path changed: use mock/request-shape tests in PR and maintainer live-model smoke before release.')
}
if (testSignals.length === 0) {
testSignals.push('No obvious missing-test signal from changed paths.')
}
const riskNotes = []
if (filenames.some((file) => file.startsWith('desktop/src-tauri/'))) {
riskNotes.push('Legacy native resources changed: inspect Electron sidecar/package output and package-smoke results.')
}
if (filenames.some((file) => file.startsWith('desktop/src/stores/') || file.startsWith('desktop/src/api/'))) {
riskNotes.push('Desktop state/API layer changed: verify store persistence, WebSocket behavior, and startup errors.')
}
if (filenames.some((file) => file.startsWith('src/server/ws/') || file.startsWith('src/server/services/conversation'))) {
riskNotes.push('Session runtime changed: review reconnect, startup diagnostics, provider selection, and thinking settings.')
}
if (filenames.some((file) => file.includes('provider') || file.includes('WebSearchTool'))) {
riskNotes.push('Provider/search behavior changed: PR gate uses mock tests; live-provider tests stay maintainer-only.')
}
if (filenames.some((file) => file.startsWith('.github/workflows/') || file.startsWith('scripts/pr/'))) {
riskNotes.push('CI/policy changed: inspect workflow behavior itself, not just application tests.')
}
if (riskNotes.length === 0) {
riskNotes.push('No special risk notes from changed paths.')
}
for (const [name, color] of Object.entries(managedLabels)) {
try {
await github.rest.issues.createLabel({
owner,
repo,
name,
color,
})
} catch (error) {
if (error.status !== 422) throw error
}
}
const desiredLabels = [...areas]
if (desiredLabels.length > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: desiredLabels,
})
}
for (const label of Object.keys(managedLabels)) {
if (label === 'allow-cli-core-change' || label === 'allow-missing-tests' || label === 'allow-coverage-baseline-change') continue
if (!areas.has(label) && currentLabels.has(label)) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
})
} catch (error) {
if (error.status !== 404) throw error
}
}
}
const marker = '<!-- pr-quality-triage -->'
const areaText = [...areas].filter((label) => label.startsWith('area:')).sort().join(', ') || 'none'
const approvalText = cliCoreFiles.length > 0 && !currentLabels.has('allow-cli-core-change')
? 'Blocked by policy until a maintainer applies `allow-cli-core-change` and approves the PR.'
: 'No CLI-core policy block detected.'
const missingTestText = missingTestSignals.length > 0 && !currentLabels.has('allow-missing-tests')
? 'Blocked by policy until a maintainer applies `allow-missing-tests` or matching tests are added.'
: 'No missing-test policy block detected.'
const coveragePolicyText = coveragePolicyFiles.length > 0 && !currentLabels.has('allow-coverage-baseline-change')
? 'Blocked by policy until a maintainer applies `allow-coverage-baseline-change` after reviewing the baseline or threshold change.'
: 'No coverage-baseline policy block detected.'
const cliFilesText = cliCoreFiles.length
? cliCoreFiles.slice(0, 20).map((file) => `- \`${file}\``).join('\n')
: '- none'
const coveragePolicyFilesText = coveragePolicyFiles.length
? coveragePolicyFiles.slice(0, 20).map((file) => `- \`${file}\``).join('\n')
: '- none'
const body = [
marker,
'## PR quality triage',
'',
`**Changed areas:** ${areaText}`,
'',
`**CLI core policy:** ${approvalText}`,
'',
`**Missing-test policy:** ${missingTestText}`,
'',
`**Coverage baseline policy:** ${coveragePolicyText}`,
'',
'**CLI core files:**',
cliFilesText,
'',
'**Coverage policy files:**',
coveragePolicyFilesText,
'',
'**Expected checks:**',
requiredChecks.map((check) => `- \`${check}\``).join('\n'),
'',
'**Test coverage signals:**',
testSignals.map((signal) => `- ${signal}`).join('\n'),
'',
'**Risk notes:**',
riskNotes.map((note) => `- ${note}`).join('\n'),
'',
'Hard merge gates still come from GitHub Actions, not AI review.',
'',
'**Dosu handoff:** Dosu can be used as the AI reviewer for risk explanation, missing-test prompts, and maintainer Q&A. If it does not comment automatically from the PR template, ask:',
'',
'@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact.',
].join('\n')
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
})
const existing = comments.find((comment) => comment.body && comment.body.includes(marker))
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
})
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
})
}