Merge pull request #980 from kaitranntt/kai/refactor/ai-review-configurable-runtime

refactor(ci): make ai-review model/endpoint configurable via repository variables
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-13 12:24:17 -04:00
committed by GitHub
7 changed files with 173 additions and 117 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
# Run quick checks before commit (typecheck + lint + format only).
# Full CI parity is enforced by .husky/pre-push via validate:ci-parity.
# Protected branches still enforce full CI parity in .husky/pre-push.
# Feature branches use a faster pre-push gate plus GitHub CI.
bun run typecheck && bun run lint:fix && bun run format:check
# Validate UI if changes detected (typecheck + lint only)
+60 -2
View File
@@ -1,3 +1,61 @@
# Enforce CI parity before pushing to remote.
#!/usr/bin/env bash
set -euo pipefail
# Protected branches keep the full CI parity gate.
# Feature branches use a faster gate and let GitHub CI run the full suite.
# Override in emergencies only: CCS_SKIP_PREPUSH_GATE=1 git push --no-verify
bun run validate:ci-parity
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
BASE_BRANCH="${CCS_PR_BASE:-}"
if [[ -z "$BASE_BRANCH" ]]; then
if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" =~ ^hotfix/ || "$CURRENT_BRANCH" =~ ^kai/hotfix- ]]; then
BASE_BRANCH="main"
else
BASE_BRANCH="dev"
fi
fi
if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "dev" || "$CURRENT_BRANCH" =~ ^hotfix/ || "$CURRENT_BRANCH" =~ ^kai/hotfix- ]]; then
echo "[i] Protected branch detected, running full CI parity gate..."
bun run validate:ci-parity
exit 0
fi
echo "[i] Feature branch detected, running fast pre-push gate..."
echo " branch: $CURRENT_BRANCH"
echo " base: $BASE_BRANCH"
bun run typecheck
bun run lint:fix
bun run format:check
git fetch origin "$BASE_BRANCH" --quiet || true
DIFF_RANGE="HEAD"
if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
DIFF_RANGE="origin/$BASE_BRANCH...HEAD"
fi
CHANGED_FILES="$(git diff --name-only "$DIFF_RANGE" || true)"
if printf '%s\n' "$CHANGED_FILES" | grep -q '^ui/'; then
echo "[i] UI changes detected, running ui:validate..."
bun run ui:validate
fi
if printf '%s\n' "$CHANGED_FILES" | grep -qE '^(\.github/workflows/|scripts/github/|tests/unit/scripts/github/)'; then
echo "[i] GitHub workflow changes detected, running workflow tests..."
bun test tests/unit/scripts/github
fi
if printf '%s\n' "$CHANGED_FILES" | grep -qE '^(src/commands/update-command.ts|tests/unit/commands/update-command-current-install.test.ts|tests/integration/update-command-install-origin.test.ts)'; then
echo "[i] Update command changes detected, running targeted update tests..."
bun test tests/unit/commands/update-command-current-install.test.ts tests/integration/update-command-install-origin.test.ts
fi
if printf '%s\n' "$CHANGED_FILES" | grep -qE '^(src/cursor/cursor-models.ts|tests/unit/cursor/cursor-models.test.ts)'; then
echo "[i] Cursor model changes detected, running targeted cursor model tests..."
bun test tests/unit/cursor/cursor-models.test.ts
fi
echo "[OK] Fast pre-push gate passed."
+4 -3
View File
@@ -183,7 +183,7 @@ Quality gates MUST pass before pushing. **Both projects have identical workflow.
bun run format # Step 1: Fix formatting
bun run lint:fix # Step 2: Fix lint issues
bun run validate # Step 3: Full gate (typecheck + lint + format + maintainability + tests)
bun run validate:ci-parity # Step 4: CI parity gate (build + validate + base branch check)
bun run validate:ci-parity # Step 4: full CI parity gate (build + validate + base branch check)
# UI project (if UI changed)
cd ui
@@ -230,7 +230,8 @@ bun run validate # Step 3: Final check (must pass)
- `prepublishOnly` / `prepack` runs `build:all` + `validate` + `sync-version.js`
- CI/CD runs `bun run validate` on every PR (maintainability is warning mode on PR events)
- husky `pre-commit` runs quick lint/type/format checks
- husky `pre-push` runs `bun run validate:ci-parity` to block CI drift before push
- husky `pre-push` runs the full `bun run validate:ci-parity` gate on `main`/`dev`/hotfix branches
- husky `pre-push` runs a faster feature-branch gate (`typecheck` + `lint:fix` + `format:check` + targeted checks based on changed files) before GitHub CI handles the full matrix
### Maintainability Baseline Gate
@@ -503,7 +504,7 @@ rm -rf ~/.ccs # Clean environment
**Quality (BLOCKERS):**
- [ ] `bun run format` — formatting fixed
- [ ] `bun run validate` — all checks pass
- [ ] `bun run validate:ci-parity` — CI parity passed (also enforced by pre-push hook)
- [ ] `bun run validate:ci-parity` — CI parity passed (required before protected-branch pushes; recommended before PRs)
- [ ] `cd ui && bun run format && bun run validate` — if UI changed
- [ ] If touching debt-sensitive code, run `bun run maintainability:check:strict` before opening/merging PR
- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json`
+82 -80
View File
@@ -31,6 +31,8 @@ type TargetTag = 'latest' | 'dev';
export interface UpdateCommandDeps {
initUI: typeof initUI;
getVersion: typeof getVersion;
log: typeof console.log;
exit: typeof process.exit;
detectCurrentInstall: typeof detectCurrentInstall;
buildPackageManagerEnv: typeof buildPackageManagerEnv;
formatManualUpdateCommand: typeof formatManualUpdateCommand;
@@ -58,6 +60,8 @@ async function loadCheckForUpdates(
const defaultDeps: UpdateCommandDeps = {
initUI,
getVersion,
log: console.log,
exit: process.exit.bind(process) as typeof process.exit,
detectCurrentInstall,
buildPackageManagerEnv,
formatManualUpdateCommand,
@@ -96,14 +100,14 @@ export async function handleUpdateCommand(
const currentInstall = deps.detectCurrentInstall();
const currentVersion = deps.getVersion();
console.log('');
console.log(header('Checking for updates...'));
console.log('');
deps.log('');
deps.log(header('Checking for updates...'));
deps.log('');
// Force reinstall - skip update check
if (force) {
console.log(info(`Force reinstall from @${targetTag} channel...`));
console.log('');
deps.log(info(`Force reinstall from @${targetTag} channel...`));
deps.log('');
const expectedVersion = await resolveTargetVersion(currentVersion, targetTag, deps);
await performNpmUpdate(currentInstall, targetTag, true, expectedVersion, deps);
return;
@@ -122,13 +126,13 @@ export async function handleUpdateCommand(
}
if (updateResult.status === 'no_update') {
handleNoUpdate(updateResult.reason, currentVersion);
handleNoUpdate(updateResult.reason, currentVersion, deps);
return;
}
// Update available
console.log(warn(`Update available: ${updateResult.current} -> ${updateResult.latest}`));
console.log('');
deps.log(warn(`Update available: ${updateResult.current} -> ${updateResult.latest}`));
deps.log('');
// Check if this is a downgrade (e.g., stable to older dev)
const isDowngrade =
@@ -138,7 +142,7 @@ export async function handleUpdateCommand(
// This happens when stable user requests @dev but @dev base is older
if (isDowngrade && beta) {
console.log(
deps.log(
warn(
'WARNING: Downgrading from ' +
(updateResult.current || 'unknown') +
@@ -146,16 +150,16 @@ export async function handleUpdateCommand(
(updateResult.latest || 'unknown')
)
);
console.log(warn('Dev channel may be behind stable.'));
console.log('');
deps.log(warn('Dev channel may be behind stable.'));
deps.log('');
}
// Show beta warning
if (beta) {
console.log(warn('Installing from @dev channel (unstable)'));
console.log(warn('Not recommended for production use'));
console.log(info('Use `ccs update` (without --beta) to return to stable'));
console.log('');
deps.log(warn('Installing from @dev channel (unstable)'));
deps.log(warn('Not recommended for production use'));
deps.log(info('Use `ccs update` (without --beta) to return to stable'));
deps.log('');
}
await performNpmUpdate(currentInstall, targetTag, false, updateResult.latest, deps);
@@ -170,40 +174,44 @@ function handleCheckFailed(
currentInstall: CurrentInstall = defaultDeps.detectCurrentInstall(),
deps: UpdateCommandDeps = defaultDeps
): void {
console.log(fail(message));
console.log('');
console.log(warn('Possible causes:'));
console.log(' - Network connection issues');
console.log(' - Firewall blocking requests');
console.log(' - GitHub/npm API temporarily unavailable');
console.log('');
console.log('Try again later or update manually:');
deps.log(fail(message));
deps.log('');
deps.log(warn('Possible causes:'));
deps.log(' - Network connection issues');
deps.log(' - Firewall blocking requests');
deps.log(' - GitHub/npm API temporarily unavailable');
deps.log('');
deps.log('Try again later or update manually:');
console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
console.log('');
process.exit(1);
deps.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
deps.log('');
deps.exit(1);
}
/**
* Handle no update available
*/
function handleNoUpdate(reason: string | undefined, version: string): void {
function handleNoUpdate(
reason: string | undefined,
version: string,
deps: UpdateCommandDeps
): void {
let message = `You are already on the latest version (${version})`;
switch (reason) {
case 'dismissed':
message = `Update dismissed. You are on version ${version}`;
console.log(warn(message));
deps.log(warn(message));
break;
case 'cached':
message = `No updates available (cached result). You are on version ${version}`;
console.log(info(message));
deps.log(info(message));
break;
default:
console.log(ok(message));
deps.log(ok(message));
}
console.log('');
process.exit(0);
deps.log('');
deps.exit(0);
}
/**
@@ -220,15 +228,13 @@ async function verifyCurrentInstallVersion(
const nextState = deps.readInstalledPackageState(currentInstall);
const installedVersion = nextState.version;
if (!installedVersion) {
console.log('');
console.log(
fail('Update finished, but CCS could not verify the current installation version.')
);
console.log('');
console.log('Current install remains ambiguous. Re-run manually:');
console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
console.log('');
process.exit(1);
deps.log('');
deps.log(fail('Update finished, but CCS could not verify the current installation version.'));
deps.log('');
deps.log('Current install remains ambiguous. Re-run manually:');
deps.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
deps.log('');
deps.exit(1);
return;
}
@@ -259,26 +265,24 @@ async function verifyCurrentInstallVersion(
const comparison = deps.compareVersionsWithPrerelease(installedVersion, expectedVersion);
if (comparison < 0 || installedVersion === previousState?.version) {
console.log('');
console.log(
deps.log('');
deps.log(
fail(
`Update completed outside the current installation. Current binary still reports ${installedVersion}; expected ${expectedVersion}.`
)
);
if (previousState?.version && previousState.version === installedVersion) {
console.log(
deps.log(
warn(
`The current install path did not change from ${previousState.version}; another package manager likely updated a different copy of CCS.`
)
);
}
console.log('');
console.log('Re-run manually against the current install:');
console.log(
color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')
);
console.log('');
process.exit(1);
deps.log('');
deps.log('Re-run manually against the current install:');
deps.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
deps.log('');
deps.exit(1);
return;
}
}
@@ -289,8 +293,8 @@ async function verifyCurrentInstallVersion(
installedVersion === previousState.version &&
!installChanged
) {
console.log('');
console.log(
deps.log('');
deps.log(
warn(
`Reinstall completed, but CCS could not prove that the current installation changed from ${previousState.version}. Verify the current binary manually if this reinstall was meant to repair a same-version install.`
)
@@ -391,8 +395,8 @@ async function performNpmUpdate(
cacheArgs = ['cache', 'clean', '--force'];
}
console.log(info(`${isReinstall ? 'Reinstalling' : 'Updating'} via ${packageManager}...`));
console.log('');
deps.log(info(`${isReinstall ? 'Reinstalling' : 'Updating'} via ${packageManager}...`));
deps.log('');
const isWindows = process.platform === 'win32';
@@ -406,17 +410,17 @@ async function performNpmUpdate(
? 'Pre-removal failed, proceeding anyway...'
: 'Cache clearing failed, proceeding anyway...';
console.log(info(stepMessage));
deps.log(info(stepMessage));
try {
const cacheCode = await runChildProcess(deps, cacheCommand, cacheArgs, {
isWindows,
env: childEnv,
});
if (cacheCode !== 0) {
console.log(warn(failMessage));
deps.log(warn(failMessage));
}
} catch {
console.log(warn(failMessage));
deps.log(warn(failMessage));
}
}
@@ -438,31 +442,29 @@ async function performNpmUpdate(
deps
);
}
console.log('');
console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`));
console.log('');
console.log(`Run ${color('ccs --version', 'command')} to verify`);
console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`));
console.log('');
deps.log('');
deps.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`));
deps.log('');
deps.log(`Run ${color('ccs --version', 'command')} to verify`);
deps.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`));
deps.log('');
} else {
console.log('');
console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`));
console.log('');
console.log('Try manually:');
console.log(
color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')
);
console.log('');
deps.log('');
deps.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`));
deps.log('');
deps.log('Try manually:');
deps.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
deps.log('');
}
process.exit(exitCode || 0);
deps.exit(exitCode || 0);
} catch {
console.log('');
console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`));
console.log('');
console.log('Try manually:');
console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
console.log('');
process.exit(1);
deps.log('');
deps.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`));
deps.log('');
deps.log('Try manually:');
deps.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
deps.log('');
deps.exit(1);
}
}
@@ -1,12 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { beforeEach, describe, expect, it } from 'bun:test';
import { handleUpdateCommand, type UpdateCommandDeps } from '../../../src/commands/update-command';
import type { UpdateResult } from '../../../src/utils/update-checker';
let logLines: string[] = [];
let spawnCalls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = [];
let exitCodes: number[] = [];
let originalConsoleLog: typeof console.log;
let originalProcessExit: typeof process.exit;
type InstalledState = {
version: string | null;
@@ -39,6 +37,12 @@ function createDeps(overrides: Partial<UpdateCommandDeps> = {}): UpdateCommandDe
return {
initUI: async () => {},
getVersion: () => '7.67.0-dev.5',
log: (...args: unknown[]) => {
logLines.push(args.map(String).join(' '));
},
exit: ((code?: number) => {
exitCodes.push(code ?? 0);
}) as typeof process.exit,
detectCurrentInstall: () => currentInstallOverride,
buildPackageManagerEnv: () => {
if (currentInstallOverride.manager === 'npm') {
@@ -110,22 +114,6 @@ beforeEach(() => {
latest: '7.67.0-dev.9',
};
currentInstallOverride = installDescriptor();
originalConsoleLog = console.log;
originalProcessExit = process.exit;
console.log = (...args: unknown[]) => {
logLines.push(args.map(String).join(' '));
};
process.exit = ((code?: number) => {
exitCodes.push(code ?? 0);
}) as typeof process.exit;
});
afterEach(() => {
console.log = originalConsoleLog;
process.exit = originalProcessExit;
});
describe('update-command current install handling', () => {
+7 -6
View File
@@ -168,20 +168,21 @@ describe('fetchModelsFromDaemon', () => {
res.end(oversizedPayload);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
try {
const models = await fetchModelsFromDaemon(address.port);
expect(models).toEqual(DEFAULT_CURSOR_MODELS);
} finally {
server.closeAllConnections?.();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
},
10000
30000
);
});
@@ -8,16 +8,21 @@ function loadWorkflow() {
}
describe('ai-review workflow', () => {
test('uses the claude-code-action reviewer path with glm-5.1 and PR-sha comment markers', () => {
test('uses the claude-code-action reviewer path with configurable review runtime and PR-sha comment markers', () => {
const workflow = loadWorkflow();
expect(workflow).toContain('timeout-minutes: 20');
expect(workflow).toContain('REVIEW_MODEL: glm-5.1');
expect(workflow).toContain('ANTHROPIC_MODEL: glm-5.1');
expect(workflow).toContain('ANTHROPIC_DEFAULT_OPUS_MODEL: glm-5.1');
expect(workflow).toContain('ANTHROPIC_DEFAULT_SONNET_MODEL: glm-5.1');
expect(workflow).toContain('ANTHROPIC_DEFAULT_HAIKU_MODEL: glm-5.1');
expect(workflow).toContain('Variables: AI_REVIEW_BASE_URL, AI_REVIEW_MODEL');
expect(workflow).toContain('Secrets: AI_REVIEW_API_KEY');
expect(workflow).toContain('ANTHROPIC_BASE_URL: ${{ vars.AI_REVIEW_BASE_URL }}');
expect(workflow).toContain('REVIEW_MODEL: ${{ vars.AI_REVIEW_MODEL }}');
expect(workflow).toContain('ANTHROPIC_AUTH_TOKEN: ${{ secrets.AI_REVIEW_API_KEY }}');
expect(workflow).toContain('ANTHROPIC_MODEL: ${{ vars.AI_REVIEW_MODEL }}');
expect(workflow).toContain('ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ vars.AI_REVIEW_MODEL }}');
expect(workflow).toContain('ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ vars.AI_REVIEW_MODEL }}');
expect(workflow).toContain('ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ vars.AI_REVIEW_MODEL }}');
expect(workflow).toContain('uses: anthropics/claude-code-action@v1');
expect(workflow).toContain('anthropic_api_key: ${{ secrets.AI_REVIEW_API_KEY }}');
expect(workflow).toContain('--model ${{ env.REVIEW_MODEL }}');
expect(workflow).toContain('--max-turns 45');
expect(workflow).toContain('--json-schema');