diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..98a1335e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Summary + + +## Verification + +- [ ] I ran the relevant local checks, or explained why they do not apply. + +## Risk + +- [ ] This PR does not touch CLI core paths, or it has maintainer approval for `allow-cli-core-change`. +- [ ] User-facing behavior changes include tests or an explanation of why tests are not practical. + +@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact. diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml new file mode 100644 index 00000000..71506c9d --- /dev/null +++ b/.github/workflows/pr-quality.yml @@ -0,0 +1,181 @@ +name: PR Quality + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: pr-quality-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + change-policy: + name: change-policy + runs-on: ubuntu-latest + outputs: + areas: ${{ steps.policy.outputs.areas }} + area_labels: ${{ steps.policy.outputs.area_labels }} + blocked: ${{ steps.policy.outputs.blocked }} + desktop_checks: ${{ steps.policy.outputs.desktop_checks }} + server_checks: ${{ steps.policy.outputs.server_checks }} + adapter_checks: ${{ steps.policy.outputs.adapter_checks }} + desktop_native_checks: ${{ steps.policy.outputs.desktop_native_checks }} + docs_checks: ${{ steps.policy.outputs.docs_checks }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Run policy tests + run: bun run check:policy + + - name: Collect changed files + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename' > changed-files.txt + jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH" > labels.txt + + - name: Evaluate change policy + id: policy + run: bun run scripts/pr/change-policy.ts --files changed-files.txt --labels-file labels.txt + + desktop-checks: + name: desktop-checks + needs: change-policy + if: needs.change-policy.outputs.desktop_checks == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install root dependencies + run: bun install + + - name: Install desktop dependencies + working-directory: desktop + run: bun install + + - name: Run desktop checks + run: bun run check:desktop + + server-checks: + name: server-checks + needs: change-policy + if: needs.change-policy.outputs.server_checks == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install root dependencies + run: bun install + + - name: Run server checks + run: bun run check:server + + adapter-checks: + name: adapter-checks + needs: change-policy + if: needs.change-policy.outputs.adapter_checks == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install adapter dependencies + working-directory: adapters + run: bun install + + - name: Run adapter checks + run: bun run check:adapters + + desktop-native-checks: + name: desktop-native-checks + needs: change-policy + if: needs.change-policy.outputs.desktop_native_checks == 'true' + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential curl wget file \ + libxdo-dev libssl-dev \ + libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev patchelf \ + libfuse2 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: 'desktop/src-tauri -> target' + + - name: Install root dependencies + run: bun install + + - name: Install desktop dependencies + working-directory: desktop + run: bun install + + - name: Install adapter dependencies + working-directory: adapters + run: bun install + + - name: Run desktop native checks + run: bun run check:native + + docs-checks: + name: docs-checks + needs: change-policy + if: needs.change-policy.outputs.docs_checks == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Run docs checks + run: npm ci && npm run docs:build diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml new file mode 100644 index 00000000..89ec8d9d --- /dev/null +++ b/.github/workflows/pr-triage.yml @@ -0,0 +1,259 @@ +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', + } + + 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/src-tauri/tauri.conf.json', + 'desktop/src-tauri/Cargo.toml', + 'desktop/src-tauri/Cargo.lock', + ]) + + 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 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/src-tauri/Cargo.lock', 'desktop/src-tauri/Cargo.toml', 'desktop/src-tauri/tauri.conf.json'].includes(file) + ) + ) requiredChecks.push('desktop-native-checks') + if (areas.has('area:docs')) requiredChecks.push('docs-checks') + + const testSignals = [] + if (desktopProduct.length > 0 && !hasTest('desktop/src/')) { + testSignals.push('Desktop product files changed without a desktop test file in the PR.') + } + if (serverProduct.length > 0 && !hasTest('src/server/')) { + testSignals.push('Server product files changed without a server test file in the PR.') + } + if (adapterProduct.length > 0 && !hasTest('adapters/')) { + testSignals.push('Adapter product files changed without an adapter test file in the PR.') + } + 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('Tauri/native code changed: inspect sidecar build and cargo check.') + } + 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') 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 = '' + 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 cliFilesText = cliCoreFiles.length + ? cliCoreFiles.slice(0, 20).map((file) => `- \`${file}\``).join('\n') + : '- none' + + const body = [ + marker, + '## PR quality triage', + '', + `**Changed areas:** ${areaText}`, + '', + `**CLI core policy:** ${approvalText}`, + '', + '**CLI core files:**', + cliFilesText, + '', + '**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'), + '', + '**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.`', + '', + 'Hard merge gates still come from GitHub Actions, not AI review.', + ].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, + }) + } diff --git a/package.json b/package.json index 575fff06..4a2b324d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,14 @@ "scripts": { "claude-haha": "bun run ./bin/claude-haha", "start": "bun run ./bin/claude-haha", + "check:pr": "bun run scripts/pr/check-pr.ts", + "check:impact": "bun run scripts/pr/impact-report.ts", + "check:policy": "bun test scripts/pr/change-policy.test.ts", + "check:server": "bun run scripts/pr/run-server-tests.ts", + "check:desktop": "cd desktop && bun run lint && bun run test -- --run && bun run build", + "check:adapters": "cd adapters && bun test", + "check:native": "cd desktop && bun run build:sidecars && cd src-tauri && cargo check", + "check:docs": "npm ci && npm run docs:build", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs" diff --git a/scripts/pr/change-policy.test.ts b/scripts/pr/change-policy.test.ts new file mode 100644 index 00000000..cd4f7e63 --- /dev/null +++ b/scripts/pr/change-policy.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' +import { evaluateChangePolicy } from './change-policy' + +describe('evaluateChangePolicy', () => { + test('blocks CLI core changes without an override label', () => { + const result = evaluateChangePolicy([ + 'src/commands/help.ts', + 'desktop/src/pages/Settings.tsx', + ]) + + expect(result.blocked).toBe(true) + expect(result.areas).toContain('cli-core') + expect(result.areas).toContain('desktop') + expect(result.areaLabels).toContain('area:cli-core') + expect(result.areaLabels).toContain('area:desktop') + expect(result.cliCoreFiles).toEqual(['src/commands/help.ts']) + }) + + test('allows CLI core changes with a maintainer override label', () => { + const result = evaluateChangePolicy( + ['src/tools/WebSearchTool/backend.ts'], + ['allow-cli-core-change'], + ) + + expect(result.blocked).toBe(false) + expect(result.areas).toEqual(['cli-core']) + expect(result.checks.server).toBe(true) + }) + + test('keeps docs-only changes on the docs lane', () => { + const result = evaluateChangePolicy([ + 'docs/index.md', + 'README.md', + ]) + + expect(result.blocked).toBe(false) + expect(result.areas).toEqual(['docs']) + expect(result.checks.docs).toBe(true) + expect(result.checks.desktop).toBe(false) + expect(result.checks.desktopNative).toBe(false) + }) + + test('routes desktop and server changes to desktop and native checks', () => { + const result = evaluateChangePolicy([ + 'desktop/src/pages/Settings.tsx', + 'src/server/ws/handler.ts', + ]) + + expect(result.areas).toEqual(['desktop', 'server']) + expect(result.checks.desktop).toBe(true) + expect(result.checks.server).toBe(true) + expect(result.checks.desktopNative).toBe(true) + }) + + test('routes adapter changes to adapter and native checks', () => { + const result = evaluateChangePolicy(['adapters/telegram/index.ts']) + + expect(result.areas).toEqual(['adapters']) + expect(result.checks.adapters).toBe(true) + expect(result.checks.desktopNative).toBe(true) + }) +}) diff --git a/scripts/pr/change-policy.ts b/scripts/pr/change-policy.ts new file mode 100644 index 00000000..21a9bd03 --- /dev/null +++ b/scripts/pr/change-policy.ts @@ -0,0 +1,287 @@ +#!/usr/bin/env bun + +import { existsSync, readFileSync, appendFileSync } from 'node:fs' + +export type ChangeArea = + | 'desktop' + | 'server' + | 'adapters' + | 'docs' + | 'release' + | 'cli-core' + +export type ChangePolicyResult = { + files: string[] + labels: string[] + areas: ChangeArea[] + areaLabels: string[] + blocked: boolean + blockingReason: string | null + cliCoreFiles: string[] + checks: { + desktop: boolean + server: boolean + adapters: boolean + desktopNative: boolean + docs: boolean + } +} + +const ALLOW_CLI_CORE_LABEL = 'allow-cli-core-change' + +const areaLabels: Record = { + desktop: 'area:desktop', + server: 'area:server', + adapters: 'area:adapters', + docs: 'area:docs', + release: 'area:release', + 'cli-core': 'area:cli-core', +} + +const cliCorePrefixes = [ + 'bin/', + 'src/entrypoints/', + 'src/screens/', + 'src/components/', + 'src/commands/', + 'src/tools/', + 'src/utils/', +] + +const desktopNativeExactPaths = new Set([ + 'bun.lock', + 'package.json', + 'desktop/bun.lock', + 'desktop/package.json', + 'desktop/package-lock.json', + 'desktop/src-tauri/Cargo.lock', + 'desktop/src-tauri/Cargo.toml', + 'desktop/src-tauri/tauri.conf.json', +]) + +const docsExactPaths = new Set([ + 'README.md', + 'README.en.md', + 'package.json', + 'package-lock.json', + '.github/workflows/deploy-docs.yml', +]) + +const releaseExactPaths = 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/src-tauri/tauri.conf.json', + 'desktop/src-tauri/Cargo.toml', + 'desktop/src-tauri/Cargo.lock', +]) + +function normalizePath(path: string) { + return path.trim().replace(/\\/g, '/').replace(/^\.\//, '') +} + +function startsWithAny(path: string, prefixes: string[]) { + return prefixes.some((prefix) => path.startsWith(prefix)) +} + +function isCliCorePath(path: string) { + return startsWithAny(path, cliCorePrefixes) +} + +function areasForPath(path: string): ChangeArea[] { + const areas = new Set() + + if (path.startsWith('desktop/')) { + areas.add('desktop') + } + + if (path.startsWith('src/server/')) { + areas.add('server') + } + + if (path.startsWith('adapters/')) { + areas.add('adapters') + } + + if ( + path.startsWith('docs/') || + path.startsWith('release-notes/') || + docsExactPaths.has(path) + ) { + areas.add('docs') + } + + if (releaseExactPaths.has(path)) { + areas.add('release') + } + + if (isCliCorePath(path)) { + areas.add('cli-core') + } + + return [...areas] +} + +export function evaluateChangePolicy( + inputFiles: string[], + inputLabels: string[] = [], +): ChangePolicyResult { + const files = [...new Set(inputFiles.map(normalizePath).filter(Boolean))].sort() + const labels = [...new Set(inputLabels.map((label) => label.trim()).filter(Boolean))].sort() + const areas = new Set() + + for (const file of files) { + for (const area of areasForPath(file)) { + areas.add(area) + } + } + + const cliCoreFiles = files.filter(isCliCorePath) + const hasCliCoreChange = cliCoreFiles.length > 0 + const hasCliCoreOverride = labels.includes(ALLOW_CLI_CORE_LABEL) + const blocked = hasCliCoreChange && !hasCliCoreOverride + + const touchesDesktopNative = files.some((file) => ( + file.startsWith('desktop/') || + file.startsWith('adapters/') || + file.startsWith('src/server/') || + desktopNativeExactPaths.has(file) + )) + + const touchesDocs = files.some((file) => ( + file.startsWith('docs/') || + file.startsWith('release-notes/') || + docsExactPaths.has(file) + )) + + const orderedAreas = [...areas].sort() + + return { + files, + labels, + areas: orderedAreas, + areaLabels: orderedAreas.map((area) => areaLabels[area]), + blocked, + blockingReason: blocked + ? `CLI core changes require the ${ALLOW_CLI_CORE_LABEL} label and maintainer approval.` + : null, + cliCoreFiles, + checks: { + desktop: areas.has('desktop') || areas.has('server'), + server: areas.has('server') || files.some((file) => file.startsWith('src/tools/') || file.startsWith('src/utils/')), + adapters: areas.has('adapters'), + desktopNative: touchesDesktopNative, + docs: touchesDocs, + }, + } +} + +function parseArgs(argv: string[]) { + const args = new Map() + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (!arg.startsWith('--')) { + continue + } + + const next = argv[index + 1] + if (next && !next.startsWith('--')) { + args.set(arg, next) + index += 1 + } else { + args.set(arg, 'true') + } + } + + return args +} + +function readListFile(path: string) { + if (!existsSync(path)) { + throw new Error(`Missing file: ${path}`) + } + + return readFileSync(path, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) +} + +function formatSummary(result: ChangePolicyResult) { + const lines = [ + 'PR change policy', + ` Areas: ${result.areas.length ? result.areas.join(', ') : 'none'}`, + ` Labels: ${result.labels.length ? result.labels.join(', ') : 'none'}`, + ` Checks: desktop=${result.checks.desktop}, server=${result.checks.server}, adapters=${result.checks.adapters}, desktopNative=${result.checks.desktopNative}, docs=${result.checks.docs}`, + ] + + if (result.cliCoreFiles.length > 0) { + lines.push(' CLI core files:') + for (const file of result.cliCoreFiles) { + lines.push(` - ${file}`) + } + } + + if (result.blockingReason) { + lines.push(` Blocked: ${result.blockingReason}`) + } + + return lines.join('\n') +} + +function writeGithubOutputs(result: ChangePolicyResult) { + const outputPath = process.env.GITHUB_OUTPUT + if (!outputPath) { + return + } + + const outputs = { + areas: result.areas.join(','), + area_labels: result.areaLabels.join(','), + blocked: String(result.blocked), + desktop_checks: String(result.checks.desktop), + server_checks: String(result.checks.server), + adapter_checks: String(result.checks.adapters), + desktop_native_checks: String(result.checks.desktopNative), + docs_checks: String(result.checks.docs), + } + + appendFileSync( + outputPath, + Object.entries(outputs) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + '\n', + ) +} + +if (import.meta.main) { + const args = parseArgs(process.argv.slice(2)) + const filesPath = args.get('--files') + const labelsPath = args.get('--labels-file') + const labelsArg = args.get('--labels') + + if (!filesPath) { + console.error('Usage: bun run scripts/pr/change-policy.ts --files [--labels-file ]') + process.exit(2) + } + + const files = readListFile(filesPath) + const labels = labelsPath + ? readListFile(labelsPath) + : labelsArg?.split(',').map((label) => label.trim()).filter(Boolean) ?? [] + + const result = evaluateChangePolicy(files, labels) + console.log(formatSummary(result)) + writeGithubOutputs(result) + + if (result.blocked) { + process.exit(1) + } +} diff --git a/scripts/pr/check-pr.ts b/scripts/pr/check-pr.ts new file mode 100644 index 00000000..20bb3c5a --- /dev/null +++ b/scripts/pr/check-pr.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env bun + +import { evaluateChangePolicy } from './change-policy' + +async function run(cmd: string[], options: { optional?: boolean } = {}) { + console.log(`\n$ ${cmd.join(' ')}`) + const proc = Bun.spawn(cmd, { + stdout: 'inherit', + stderr: 'inherit', + }) + const code = await proc.exited + + if (code !== 0 && !options.optional) { + process.exit(code) + } + + return code +} + +async function output(cmd: string[]) { + const proc = Bun.spawn(cmd, { + stdout: 'pipe', + stderr: 'pipe', + }) + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const code = await proc.exited + + if (code !== 0) { + throw new Error(stderr || stdout || `Command failed: ${cmd.join(' ')}`) + } + + return stdout.trim() +} + +async function outputOrEmpty(cmd: string[]) { + try { + return await output(cmd) + } catch { + return '' + } +} + +async function localChangedFiles() { + const staged = await outputOrEmpty(['git', 'diff', '--name-only', '--cached']) + const unstaged = await outputOrEmpty(['git', 'diff', '--name-only']) + const untracked = await outputOrEmpty(['git', 'ls-files', '--others', '--exclude-standard']) + + return [...staged.split(/\r?\n/), ...unstaged.split(/\r?\n/), ...untracked.split(/\r?\n/)] + .filter(Boolean) +} + +async function changedFiles() { + const explicit = process.argv.slice(2).filter((arg) => !arg.startsWith('--')) + if (explicit.length > 0) { + return explicit + } + + const base = process.env.PR_BASE_REF ?? 'origin/main' + const localFiles = await localChangedFiles() + try { + const diff = await output(['git', 'diff', '--name-only', `${base}...HEAD`]) + return [...new Set([...diff.split(/\r?\n/), ...localFiles].filter(Boolean))] + } catch { + try { + const diff = await output(['git', 'diff', '--name-only', 'main...HEAD']) + return [...new Set([...diff.split(/\r?\n/), ...localFiles].filter(Boolean))] + } catch { + return [...new Set(localFiles)] + } + } +} + +const files = await changedFiles() +const labels = process.env.PR_LABELS?.split(',').map((label) => label.trim()).filter(Boolean) ?? [] +if (process.env.ALLOW_CLI_CORE_CHANGE === '1' && !labels.includes('allow-cli-core-change')) { + labels.push('allow-cli-core-change') +} + +const result = evaluateChangePolicy(files, labels) + +console.log('PR local check plan') +console.log(` Files: ${files.length}`) +console.log(` Areas: ${result.areas.length ? result.areas.join(', ') : 'none'}`) + +if (result.blockingReason) { + console.error(`\nBlocked: ${result.blockingReason}`) + console.error('Set ALLOW_CLI_CORE_CHANGE=1 only after maintainer approval.') + process.exit(1) +} + +await run(['bun', 'run', 'check:policy']) + +if (result.checks.desktop) { + await run(['bun', 'run', 'check:desktop']) +} + +if (result.checks.server) { + await run(['bun', 'run', 'check:server']) +} + +if (result.checks.adapters) { + await run(['bun', 'run', 'check:adapters']) +} + +if (result.checks.desktopNative) { + await run(['bun', 'run', 'check:native']) +} + +if (result.checks.docs) { + await run(['bun', 'run', 'check:docs']) +} + +console.log('\nPR local checks completed.') diff --git a/scripts/pr/impact-report.ts b/scripts/pr/impact-report.ts new file mode 100644 index 00000000..fd0ecb2e --- /dev/null +++ b/scripts/pr/impact-report.ts @@ -0,0 +1,220 @@ +#!/usr/bin/env bun + +import { evaluateChangePolicy } from './change-policy' + +async function output(cmd: string[]) { + const proc = Bun.spawn(cmd, { + stdout: 'pipe', + stderr: 'pipe', + }) + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const code = await proc.exited + + if (code !== 0) { + throw new Error(stderr || stdout || `Command failed: ${cmd.join(' ')}`) + } + + return stdout.trim() +} + +async function outputOrEmpty(cmd: string[]) { + try { + return await output(cmd) + } catch { + return '' + } +} + +async function localChangedFiles() { + const staged = await outputOrEmpty(['git', 'diff', '--name-only', '--cached']) + const unstaged = await outputOrEmpty(['git', 'diff', '--name-only']) + const untracked = await outputOrEmpty(['git', 'ls-files', '--others', '--exclude-standard']) + + return [...staged.split(/\r?\n/), ...unstaged.split(/\r?\n/), ...untracked.split(/\r?\n/)] + .filter(Boolean) +} + +function parseListArg(name: string) { + const index = process.argv.indexOf(name) + if (index === -1) { + return [] + } + + const value = process.argv[index + 1] + if (!value || value.startsWith('--')) { + return [] + } + + return value.split(',').map((item) => item.trim()).filter(Boolean) +} + +async function changedFiles() { + const files = parseListArg('--files') + if (files.length > 0) { + return files + } + + const base = process.env.PR_BASE_REF ?? 'origin/main' + const localFiles = await localChangedFiles() + try { + const diff = await output(['git', 'diff', '--name-only', `${base}...HEAD`]) + return [...new Set([...diff.split(/\r?\n/), ...localFiles].filter(Boolean))] + } catch { + try { + const diff = await output(['git', 'diff', '--name-only', 'main...HEAD']) + return [...new Set([...diff.split(/\r?\n/), ...localFiles].filter(Boolean))] + } catch { + return [...new Set(localFiles)] + } + } +} + +function commandList(result: ReturnType) { + const commands = ['bun run check:policy'] + + if (result.checks.desktop) { + commands.push('bun run check:desktop') + } + if (result.checks.server) { + commands.push('bun run check:server') + } + if (result.checks.adapters) { + commands.push('bun run check:adapters') + } + if (result.checks.desktopNative) { + commands.push('bun run check:native') + } + if (result.checks.docs) { + commands.push('bun run check:docs') + } + + return commands +} + +function hasMatchingTest(files: string[], predicate: (file: string) => boolean) { + return files.some((file) => ( + predicate(file) && + (/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/')) + )) +} + +function changedProductionFiles(files: string[], predicate: (file: string) => boolean) { + return files.filter((file) => ( + predicate(file) && + !/\.test\.[cm]?[jt]sx?$/.test(file) && + !file.includes('/__tests__/') && + !file.includes('/fixtures/') + )) +} + +function coverageWarnings(files: string[]) { + const warnings: string[] = [] + const desktopProd = changedProductionFiles(files, (file) => file.startsWith('desktop/src/')) + const serverProd = changedProductionFiles(files, (file) => file.startsWith('src/server/')) + const adapterProd = changedProductionFiles(files, (file) => file.startsWith('adapters/')) + const agentRuntimeProd = changedProductionFiles(files, (file) => ( + file.startsWith('src/server/ws/') || + file.startsWith('src/server/services/conversation') || + file.startsWith('src/tools/') || + file.startsWith('src/utils/') + )) + + if (desktopProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('desktop/src/'))) { + warnings.push('Desktop product files changed without a desktop test file in the PR.') + } + + if (serverProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('src/server/'))) { + warnings.push('Server product files changed without a server test file in the PR.') + } + + if (adapterProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('adapters/'))) { + warnings.push('Adapter product files changed without an adapter test file in the PR.') + } + + if (agentRuntimeProd.length > 0) { + warnings.push('Agent/model runtime path changed: prefer request-shape/mock tests in PR and maintainer live-model smoke before release.') + } + + return warnings +} + +function riskNotes(files: string[]) { + const notes: string[] = [] + + if (files.some((file) => file.startsWith('desktop/src-tauri/'))) { + notes.push('Tauri/native code changed: check sidecar build and cargo check output closely.') + } + if (files.some((file) => file.startsWith('desktop/src/stores/') || file.startsWith('desktop/src/api/'))) { + notes.push('Desktop state/API layer changed: verify store persistence, WebSocket behavior, and startup errors.') + } + if (files.some((file) => file.startsWith('src/server/ws/') || file.startsWith('src/server/services/conversation'))) { + notes.push('Session runtime changed: review reconnect, startup diagnostics, provider selection, and thinking settings.') + } + if (files.some((file) => file.includes('provider') || file.includes('WebSearchTool'))) { + notes.push('Provider/search behavior changed: PR gate uses mock tests; live-provider tests should stay maintainer-only.') + } + if (files.some((file) => file.startsWith('.github/workflows/') || file.startsWith('scripts/pr/'))) { + notes.push('CI/policy changed: inspect the PR workflow behavior itself, not just application tests.') + } + + return notes +} + +const labels = [ + ...parseListArg('--labels'), + ...(process.env.PR_LABELS?.split(',').map((label) => label.trim()).filter(Boolean) ?? []), +] + +if (process.env.ALLOW_CLI_CORE_CHANGE === '1') { + labels.push('allow-cli-core-change') +} + +const files = await changedFiles() +const result = evaluateChangePolicy(files, labels) +const commands = commandList(result) +const warnings = coverageWarnings(result.files) +const notes = riskNotes(result.files) + +console.log('# PR impact report') +console.log('') +console.log(`Changed files: ${result.files.length}`) +console.log(`Areas: ${result.areas.length ? result.areas.join(', ') : 'none'}`) +console.log(`Labels: ${result.labels.length ? result.labels.join(', ') : 'none'}`) +console.log(`Blocked: ${result.blocked ? 'yes' : 'no'}`) + +if (result.blockingReason) { + console.log(`Blocking reason: ${result.blockingReason}`) +} + +console.log('') +console.log('## Required local checks') +for (const command of commands) { + console.log(`- \`${command}\``) +} + +console.log('') +console.log('## Test coverage signals') +if (warnings.length === 0) { + console.log('- No obvious missing-test signal from changed paths.') +} else { + for (const warning of warnings) { + console.log(`- ${warning}`) + } +} + +console.log('') +console.log('## Risk notes') +if (notes.length === 0) { + console.log('- No special risk notes from changed paths.') +} else { + for (const note of notes) { + console.log(`- ${note}`) + } +} + +console.log('') +console.log('## Agent/model testing policy') +console.log('- Default PR gate should not call real models or live providers.') +console.log('- Cover agent behavior with mock CLI, request-shape assertions, transcript fixtures, and provider capability tests.') +console.log('- Run live-model smoke tests only in maintainer-controlled workflows with secrets, rate limits, and explicit labels.') diff --git a/scripts/pr/run-server-tests.ts b/scripts/pr/run-server-tests.ts new file mode 100644 index 00000000..baadf80a --- /dev/null +++ b/scripts/pr/run-server-tests.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env bun + +import { readdirSync, statSync } from 'node:fs' +import { join, relative, sep } from 'node:path' + +const root = process.cwd() +const roots = ['src/server', 'src/tools', 'src/utils'] +const excludedFiles = new Set([ + // These suites are not stable enough for the default PR gate yet. Keep them + // out of CI until they are fixed or moved to a maintainer-only workflow. + 'src/server/__tests__/cron-scheduler.test.ts', + 'src/server/__tests__/providers-real.test.ts', + 'src/server/__tests__/tasks.test.ts', + 'src/server/__tests__/e2e/business-flow.test.ts', + 'src/server/__tests__/e2e/full-flow.test.ts', +]) + +function normalize(path: string) { + return relative(root, path).split(sep).join('/') +} + +function walk(path: string, files: string[]) { + const stat = statSync(path) + + if (stat.isDirectory()) { + for (const entry of readdirSync(path)) { + walk(join(path, entry), files) + } + return + } + + if (!stat.isFile()) { + return + } + + const normalized = normalize(path) + if (normalized.endsWith('.test.ts') && !excludedFiles.has(normalized)) { + files.push(normalized) + } +} + +const testFiles: string[] = [] +for (const testRoot of roots) { + walk(join(root, testRoot), testFiles) +} + +testFiles.sort() + +if (testFiles.length === 0) { + console.log('No server-side test files found.') + process.exit(0) +} + +const proc = Bun.spawn(['bun', 'test', ...testFiles], { + cwd: root, + stdout: 'inherit', + stderr: 'inherit', +}) + +process.exit(await proc.exited)