Jev Git 工作流教程:提交、PR 与 CI 门禁
在 Git 提交、Pull Request 和 CI 流程中使用 Jev 完成有界语义判断,同时把 push、merge 与分支保护权限留在确定性代码和人工审批中。
1. 先划清 Jev 的职责边界
只把精确 Git 数据无法直接回答的问题交给 Jev,例如变更分类、评审领域、风险等级或是否需要人工复核。文件存在、测试结果、权限、必要评审人和受保护分支规则仍由确定性代码检查。本教程不会让模型执行 push、merge、tag、release 或绕过审批。
2. 创建一个零依赖的 PR 判断脚本
示例只使用 Node 20 内置能力和官方 POST /v1/systemone 接口。本地默认读取 staged diff;CI 通过 JEV_BASE 指向 PR 基准分支。脚本跳过超大补丁,并在发送前检查敏感路径和常见秘密模式。这不能保证发现所有凭据,真实使用前仍须检查输入。
// Node 20+. Report only: never merges, publishes, or authorizes an action.
import { appendFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
export function parsePaths(output) {
const fields = output.split('\0');
if (fields.pop() !== '') throw new Error('Invalid NUL-terminated Git output');
const paths = [];
while (fields.length) {
const status = fields.shift();
if (!/^[ACDMRTUXB][0-9]*$/.test(status)) throw new Error('Invalid Git status');
const count = /^[RC]/.test(status) ? 2 : 1;
for (let i = 0; i < count; i++) {
const path = fields.shift();
if (!path) throw new Error('Missing Git path');
paths.push(path);
}
}
return paths;
}
export function containsSensitiveInput(paths, patch) {
const sensitivePath = /(^|\/)(?:\.env(?:\.[^/]*)?|\.npmrc|\.pypirc|credentials|id_(?:rsa|ed25519|ecdsa))$|\.(?:pem|key|p12|pfx)$/i;
// Defense in depth, not a guarantee: review the input before any real upload.
const sensitiveContent = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,}|sk-[A-Za-z0-9_-]{16,}|AKIA[A-Z0-9]{16})\b|\bBearer\s+\S+|\b(?:[A-Z_]*(?:API_KEY|TOKEN|SECRET|PASSWORD))\s*[:=]\s*\S+/i;
return paths.some(path => sensitivePath.test(path)) || sensitiveContent.test(patch);
}
export const questions = {
change_type: {
type: 'choice', instructions: 'What is the primary purpose of this Git change?',
criteria: { feature: 'Adds user-visible behavior', fix: 'Corrects broken behavior',
maintenance: 'Refactor, documentation, dependencies or tooling', unknown: 'Insufficient evidence' },
},
risk: {
type: 'score', instructions: 'How risky is this change to merge?',
criteria: ['Low: isolated and reversible', 'Moderate: bounded shared behavior',
'High: data, auth, payment or deployment changes', 'Critical: credible irreversible or security impact'],
},
needs_human_review: {
type: 'noul', instructions: 'Does this change require deliberate human review before merge?',
criteria: { true: 'Ambiguous, high-impact or security-sensitive', false: 'Low-impact and reversible' },
},
};
const inRange = (value, max) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= max;
export function validAnswers(answers) {
const type = answers?.change_type;
return type?.type === 'choice' && typeof type.choice === 'string'
&& Object.hasOwn(questions.change_type.criteria, type.choice)
&& inRange(type.confidence, 1) && answers?.risk?.type === 'score'
&& inRange(answers.risk.score, 3) && answers?.needs_human_review?.type === 'noul'
&& inRange(answers.needs_human_review.noul, 1);
}
export async function reviewDiff({ names, patch, apiKey, fetchImpl = fetch }) {
let paths;
try { paths = parsePaths(names); } catch { return { status: 'invalid_git_input' }; }
if (containsSensitiveInput(paths, patch)) return { status: 'sensitive_input' };
if (!patch.trim()) return { status: 'no_changes' };
if (!apiKey) return { status: 'missing_credentials' };
// Do not silently judge an incomplete patch or send a huge file list.
if (patch.length > 14_000 || paths.length > 200) return { status: 'input_too_large' };
try {
const response = await fetchImpl('https://api.typesafe.ai/v1/systemone', {
method: 'POST',
headers: { authorization: 'Bearer ' + apiKey, 'content-type': 'application/json' },
body: JSON.stringify({ model: 'jev-latest', state: { changed_files: paths, patch }, questions }),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) return { status: 'provider_failure' };
const { answers } = await response.json();
if (!validAnswers(answers)) return { status: 'invalid_response' };
return { status: 'report_only', answers };
} catch { return { status: 'provider_failure' }; }
}
export function formatReport(result) {
const lines = ['## Jev PR report', '- Status: ' + result.status,
'- Advisory only. Keep tests and human approval; this does not authorize merging.'];
if (result.status === 'report_only') {
const a = result.answers;
lines.push('- Change type: ' + a.change_type.choice,
'- Type confidence: ' + a.change_type.confidence.toFixed(2),
'- Risk score: ' + a.risk.score.toFixed(2) + ' / 3',
'- P(human review): ' + a.needs_human_review.noul.toFixed(2));
}
// No patch, key, exception message, or provider response text in logs.
return lines.join('\n');
}
export function exitCode(result, mode = 'report') {
if (mode === 'report') return 0;
// Conservative gate: any real change or uncertainty requires existing human approval.
// No model answer grants permission to merge. Configure review in the repository rules.
if (mode === 'blocking') return result.status === 'no_changes' ? 0 : 1;
return 1;
}
export async function main(env = process.env) {
const git = (...args) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 2_000_000 });
let result;
try {
const base = env.JEV_BASE?.trim();
if (base && !/^[A-Za-z0-9][A-Za-z0-9/_.-]*$/.test(base)) throw new Error('Invalid ref');
const range = base ? [base + '...HEAD'] : ['--cached'];
result = await reviewDiff({
names: git('diff', '--name-status', '-z', '--no-ext-diff', '--no-textconv', ...range, '--'),
patch: git('diff', '--unified=1', '--no-ext-diff', '--no-textconv', ...range, '--'),
apiKey: env.TYPESAFE_API_KEY,
});
} catch { result = { status: 'git_input_failure' }; }
const report = formatReport(result);
console.log(report);
if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, report + '\n');
process.exitCode = exitCode(result, env.JEV_MODE || 'report');
return result;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
下载完整脚本(Node 20+) Node 20 内置 fetch。将下载文件保存为 scripts/jev-pr-review.mjs。默认 JEV_MODE=report,提供方失败仅记录脱敏状态;可选 JEV_MODE=blocking 对所有变更或不确定情况返回非零退出码,必须由仓库现有人工审批处理,不会根据模型分数自动放行。脚本通过离线模拟测试,尚未执行真实 API 验证。
3. 一次请求询问多个封闭问题
脚本对同一 State 同时发送三个独立问题:Choice 判断变更类型,Score 输出有序风险,Noul 判断是否需要人工复核。TypeSafe 官方说明同一请求中的问题彼此独立,因此收到结果后仍要由应用策略组合答案。
Choicechange_type只能返回 feature、fix、maintenance 或 unknown。
Scorerisk沿四级风险量表返回可落在等级之间的分数。
Noulneeds_human_review返回命题为真的概率;它没有单独的 confidence。
4. 校验响应后再执行策略
类型化结果仍需要运行时校验:Choice 必须属于原始选项,Score 必须是有限数且位于声明区间,Noul 必须在 0 到 1 之间。校验通过后再映射到清晰可见的策略,不要把权限隐藏在提示词里。
| 情况 | 只报告阶段 | 校准后的门禁 |
|---|---|---|
| API 超时或响应无效 | 记录 provider_failure,CI 继续 | 按团队策略转人工,不自动放行 |
| 风险高或复核概率高 | 在 Job Summary 中突出显示 | 要求已有的人工审批规则 |
| 风险低 | 仍然运行测试和普通评审 | 不代表允许自动合并 |
5. 先在本地以只报告模式运行
先读取暂存区,让开发者在提交前核对模型看到的证据。此阶段只输出报告、不阻止提交,用它暴露超大 diff、含糊标签、敏感路径和问题设计缺陷。
# Review exactly what is staged for the next commit.
export TYPESAFE_API_KEY='your-server-side-key'
node scripts/jev-pr-review.mjs
# Inspect the same range a pull request would use.
git fetch origin main
JEV_BASE=origin/main node scripts/jev-pr-review.mjs 6. 接入只读的 GitHub Actions
工作流拉取完整历史、获取 PR 基准分支、运行同一脚本,并把结果写入 Job Summary。权限保持只读,TYPESAFE_API_KEY 存在 Actions Secret。先报告还能积累后续校准所需的数据。
name: Jev PR report
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Fetch base branch
env:
BASE_BRANCH: "${{ github.base_ref }}"
run: git fetch origin "$BASE_BRANCH"
- name: Skip fork report
if: github.event.pull_request.head.repo.full_name != github.repository
run: echo "Fork PR — remote analysis skipped."
- name: Build Jev report
if: github.event.pull_request.head.repo.full_name == github.repository
env:
TYPESAFE_API_KEY: "${{ secrets.TYPESAFE_API_KEY }}"
JEV_BASE: "origin/${{ github.base_ref }}"
run: node scripts/jev-pr-review.mjs 7. 用历史 PR 校准后再启用门禁
回放带标注的历史 Pull Request,统计错误放行、无效复核、覆盖率、延迟和 provider 失败。阈值应来自真实错误成本,而不是直觉。后续即便改成阻断,也只应对团队已经验证过的特定高风险条件 fail closed。
- 至少准备一批人工标注的历史 PR,并保留低、中、高风险样本。
- 记录问题版本、模型版本、完整分布、阈值、最终人工结论和覆盖原因。
- 分别统计错误放行和过度拦截;两者成本通常不同。
- 先运行只报告模式,再要求人工复核;不要从第一天自动合并。
- 为 API 故障、超大 diff、敏感文件和 State 截断设置明确兜底。