diff --git a/.github/dot-review/README.md b/.github/dot-review/README.md new file mode 100644 index 0000000..656c99a --- /dev/null +++ b/.github/dot-review/README.md @@ -0,0 +1,81 @@ +# Dot review submission and collection + +Follow [AGENTS.md](../../AGENTS.md). These workflows request read-only advice; +they grant no code changes, deployment, trading or approval authority. + +`dot-review-submit.yml` runs trusted default-branch code after the exact +`PR risk gate` workflow completes, including a failed gate. It independently +reads GitHub and classifies changed paths. Only open, non-draft R2 PRs are +submitted automatically. Missing associations require manual dispatch. +No PR checkout, cache, script, upstream output or artifact is executed. + +The classification, diff and discussion helpers are adapted from QuantAIService +PR #450. `policy.json` defines this repository's paths. The policy digest binds +the local helper, classifier, policy and all three related workflows. This +does not install #450's evaluator or change the existing risk gate. In particular, +these drafts cannot satisfy the old gate or be relabeled as accepted #450 evidence. +A future publisher must compute the then-current gate binding independently. + +## Enablement + +All switches default off. Before enabling `DOT_REVIEW_READY`, an authorized +operator must read back the deployed service version, exact audience, six +per-workflow/event grants, project mappings/policies, Dot client project access, +OAuth scopes and each project's active `task.requested` subscription. This +variable records operator readiness; it is not itself subscription evidence. +If that readback is unavailable or a connection/subscription change is needed, +keep submission disabled. Recheck immediately before first dispatch. The service +also refuses work without an admitted project and fails undispatched Dot tasks +when no authorized active subscription exists. + +Configure existing repository variables `AI_SERVICE_URL` (HTTPS origin), +`AI_SERVICE_AUDIENCE` (the exact approved audience), `DOT_REVIEW_PROFILE` and +`DOT_REVIEW_MODEL` from protected configuration. No service token, GitHub App, +PAT, environment subject or new secret is used. Requests must fit both local +limits in `policy.json` and the service's project policy. Never increase the +service policy or select another model merely to make a request pass. + +Manual submission on `main` takes `pr_number`, a full `target_sha`, +`review_kind` and optional `expected_base_sha`. Premerge binds current head/base; +posthoc requires the actual merged commit and its sole parent. The original PR +head is retained separately. Perform the approved first posthoc review completely, +then the second, before separately enabling `DOT_REVIEW_AUTO_ENABLED`. +No scheduled collector is installed. + +## Recovery and results + +The complete frozen request is uploaded before POST. A bot intent comment stores +its SHA-256, stable idempotency key and artifact locator before network submission. +An existing intent, a lost POST response or any ambiguous outcome leads only to +lookup with the original request hash. Preparation first finds the existing intent +for the same review kind and target SHA, then reads the original task to restore +its frozen request and key. Changed CI records or model settings cannot authorize +a new POST. An unavailable lookup, mismatched request or changed binding stops +recovery; it never creates a replacement intent. A lookup miss remains unknown. Do not +delete the comment, change the key, change the request, or dispatch again as a +way to recover an unknown task. Use the collector with the original locator +comment ID; it supports lookup even when the POST returned no task ID. + +Collect manually on `main` with `locator_comment_id` and optionally the expected +`task_id`. Collection never submits, appends, cancels, claims or reports a task. +It validates the project, frozen request, binding, provider, attempt, revision, +execution provenance and review completeness. It then rechecks the GitHub snapshot. +Changed head, base, policy or discussion produces stale evidence. Nonterminal and +failed results remain such. Only one result comment per task is updated. + +`posthoc-ai-review/v1` is an archive contract for closed PRs, not premerge approval. +Both premerge and posthoc output is `bot-draft-only`, with `approval: false`. +`technical_result_valid` describes the checked technical output, not gate approval. +The reported model/run reference comes from the authenticated worker result; +it is not independent provider attestation. A publisher must inspect that real +execution before issuing any separate User evidence. No owner approval record, +override, label, native approval review or commit status is created here. + +Incomplete pages, capped API results, missing or binary source, symlinks, +truncated patches and oversized requests fail closed without silent truncation. +Source includes changed files at base and target, applicable AGENTS files and +engineering instructions. Additional dependencies remain a reviewer's explicit +coverage responsibility; missing context must be reported as a limitation. + +Run the offline checks with `node --test .github/dot-review/test-*.cjs` and +`actionlint .github/workflows/dot-review-{submit,collect}.yml`. diff --git a/.github/dot-review/binding.cjs b/.github/dot-review/binding.cjs new file mode 100644 index 0000000..eaf2664 --- /dev/null +++ b/.github/dot-review/binding.cjs @@ -0,0 +1,132 @@ +// Pure classification and binding helpers adapted from QuantAIService PR #450. +// This module neither evaluates approval nor publishes a commit status. +'use strict'; + +const { createHash } = require('node:crypto'); + +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`; + } + return JSON.stringify(value); +} +const digest = value => createHash('sha256').update(canonical(value)).digest('hex'); +const sha = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value); +const assert = (condition, message) => { if (!condition) throw new Error(message); }; +const matches = (path, rule) => rule.endsWith('/') ? path.startsWith(rule) : path === rule; + +function pathTier(path, policy) { + assert(typeof path === 'string' && path.length > 0 && !path.startsWith('/') + && !path.includes('\\') && !path.split('/').some(p => p === '..' || p === '.' || p === ''), 'Unknown path'); + const p = path.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); + // Protected paths take precedence over prose, tests, examples and fixtures. + if (p.startsWith('.github/') || /(^|\/)(agents|claude)\.md$/.test(p) + || /(^|\/)(codeowners|\.coderabbit\.ya?ml|\.env(?:[._-].*)?)$/.test(p) + || /(^|[\/_.-])(auth(?:entication|orization)?|oauth|login|session|tokens?|credentials?|secrets?|iam|permissions?|privacy|storage|trad(?:e|es|ing)|execut(?:ion|or)s?|orders?|brokers?|risk|deploy(?:ment)?|runtime|strateg(?:y|ies)|governance)([\/_.-]|$)/.test(p) + || /(^|\/)(dockerfile(?:\..*)?|docker-compose[^/]*|compose\.ya?ml|vercel\.json|wrangler\.(?:toml|jsonc?)|firebase\.json|package(?:-lock)?\.json|requirements[^/]*|pyproject\.toml|setup\.(?:py|cfg)|uv\.lock|poetry\.lock|yarn\.lock|pnpm-lock\.yaml|makefile|.*\.config\.[^/]+)$/.test(p) + || /\.(?:tf|tfvars|sh|bash|ps1)$/.test(p) + || policy.r2_paths.some(rule => matches(path, rule))) return 'R2'; + return policy.r0_paths.some(rule => matches(path, rule)) && /\.(?:md|txt)$/.test(p) ? 'R0' : 'R1'; +} + +function classify(files, expected, policy) { + assert(Array.isArray(files) && files.length > 0 && files.length === expected && files.length < 3000, 'Incomplete file list'); + assert(new Set(files.map(f => f.filename)).size === files.length, 'Duplicate file'); + let tier = 'R0'; + const rules = []; + for (const file of files) { + assert(['added', 'modified', 'removed', 'renamed', 'copied', 'changed'].includes(file.status), 'Unknown file status'); + assert(sha(file.sha), 'Missing file object SHA'); + assert(Number.isSafeInteger(file.additions) && file.additions >= 0 && Number.isSafeInteger(file.deletions) && file.deletions >= 0, 'Unknown diff size'); + if (file.status === 'renamed') assert(file.previous_filename, 'Missing previous path'); + const paths = [file.filename, file.previous_filename].filter(Boolean); + let fileTier = paths.map(p => pathTier(p, policy)).sort().at(-1); + // Count actual hunk lines: missing/truncated/binary patches cannot attest coverage. + const patch = file.patch || ''; + const lines = patch.split('\n'); + assert(lines.filter(l => l.startsWith('+')).length === file.additions + && lines.filter(l => l.startsWith('-')).length === file.deletions, 'Incomplete patch'); + assert(patch || (file.additions === 0 && file.deletions === 0 && file.status === 'renamed'), 'Missing patch'); + const changed = lines.filter(l => /^[+-]/.test(l)).map(l => l.slice(1)); + // R0 is deliberately narrow: even inline code, links and HTML need a reviewer. + if (fileTier === 'R0' && (file.status !== 'modified' || changed.some(l => /[`~<>\[\]{}\\$]|^\s{4}|(?:https?:|javascript:|\b(?:curl|wget|npm|python|sudo|bash)\b)/i.test(l)))) fileTier = 'R1'; + if (paths.some(p => /\.(?:md|txt)$/.test(p)) && changed.some(l => /\b(?:auth|iam|secret|credential|permission|trading|orders?|strategy|deploy|workflow|owner-approved)\b|认证|授权|凭据|交易|下单|部署/i.test(l))) fileTier = 'R2'; + if (fileTier > tier) tier = fileTier; + rules.push({ paths, tier: fileTier }); + } + return { tier, rules }; +} + +function receipt(body, schema) { + // A whole review/comment body must be a JSON object, optionally in one JSON fence. + try { + const text = (body || '').trim().replace(/^```json\s*\n([\s\S]*?)\n```$/, '$1'); + const value = JSON.parse(text); + return value?.schema_version === schema ? value : null; + } catch { return null; } +} + +function binding(snapshot, policySha) { + const pr = snapshot.pr; + assert(sha(pr.head.sha) && sha(pr.base.sha) && sha(snapshot.policyCommit), 'Unknown commit'); + return { + repository: snapshot.repository, repository_id: pr.base.repo.id, pr_number: pr.number, + head_sha: pr.head.sha, base_sha: pr.base.sha, policy_sha256: policySha, + diff_sha256: digest(snapshot.files.map(f => ({ filename: f.filename, previous_filename: f.previous_filename || null, + status: f.status, sha: f.sha, additions: f.additions, deletions: f.deletions, patch: f.patch || '' })) + .sort((a, b) => a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0)), + }; +} + +function stableComment(comment) { + // Reactions and profile decoration are not approval or review evidence. + const { id, body, created_at, updated_at, path, line, original_line, side, + start_line, start_side, commit_id, original_commit_id, in_reply_to_id, + pull_request_review_id } = comment; + return { id, body, created_at, updated_at, path, line, original_line, side, + start_line, start_side, commit_id, original_commit_id, in_reply_to_id, + pull_request_review_id, + user: { id: comment.user?.id, login: comment.user?.login, type: comment.user?.type } }; +} + +function stableReview(review) { + return { ...stableComment(review), state: review.state, submitted_at: review.submitted_at }; +} + +function discussion(snapshot) { + // Review receipts are excluded to avoid a circular digest. Their exact bodies + // are separately bound into the owner approval. QAS shadow output is advisory. + const reviews = snapshot.reviews.filter(r => r.state === 'CHANGES_REQUESTED' + || (!receipt(r.body, 'pr-review/v1') && !receipt(r.body, 'owner-approval/v1'))).map(stableReview); + const secondOpinions = snapshot.issueComments.filter(c => c.user?.type === 'Bot' && c.user?.login === 'coderabbitai[bot]').map(stableComment); + return { reviews, comments: snapshot.reviewComments.map(stableComment), threads: snapshot.threads, secondOpinions }; +} + + +module.exports = { canonical, digest, classify, pathTier, binding, discussion, stableComment, stableReview }; + +async function readThreads(github, repo, number) { + const threads = []; + let cursor = null; + do { + const data = await github.graphql(`query($owner:String!,$repo:String!,$number:Int!,$cursor:String) { + repository(owner:$owner,name:$repo) { pullRequest(number:$number) { + reviewThreads(first:100,after:$cursor) { + nodes { id isResolved isOutdated } + pageInfo { hasNextPage endCursor } + } + } } + }`, { ...repo, number, cursor }); + const page = data.repository?.pullRequest?.reviewThreads; + if (!page || !Array.isArray(page.nodes) || !page.pageInfo) throw new Error('Incomplete threads'); + threads.push(...page.nodes); + const next = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; + if (page.nodes.some(t => !t.id || typeof t.isResolved !== 'boolean') || (page.pageInfo.hasNextPage && (!next || next === cursor))) throw new Error('Invalid thread page'); + cursor = next; + } while (cursor); + return threads; +} + + +module.exports.readThreads = readThreads; diff --git a/.github/dot-review/policy.json b/.github/dot-review/policy.json new file mode 100644 index 0000000..c503178 --- /dev/null +++ b/.github/dot-review/policy.json @@ -0,0 +1,8 @@ +{ + "version": "dot-review-policy/v1", + "r0_paths": ["README.md", "docs/prose/"], + "r2_paths": ["application/", "entrypoints/", "main.py", "strategy/", "notifications/", "config/", "scripts/"], + "max_request_bytes": 500000, + "max_source_bytes": 200000, + "timeout_seconds": 1800 +} diff --git a/.github/dot-review/review.cjs b/.github/dot-review/review.cjs new file mode 100644 index 0000000..c2d19ad --- /dev/null +++ b/.github/dot-review/review.cjs @@ -0,0 +1,391 @@ +'use strict'; +// Follow AGENTS.md. PR text and source are data, never executable instructions. +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const b = require('./binding.cjs'); +const policy = require('./policy.json'); +const assert = (ok, code) => { if (!ok) throw new Error(code); }; +const sha = x => typeof x === 'string' && /^[a-f0-9]{40}$/.test(x); +const hash = text => createHash('sha256').update(text).digest('hex'); +const canonical = value => b.canonical(JSON.parse(JSON.stringify(value))); +const digest = value => hash(canonical(value)); +const marker = ''; +const resultMarker = '`; + const comments = fresh.issueComments.filter(c => bot(c) && c.body?.startsWith(mark)); + assert(comments.length <= 1, 'duplicate_result_comments'); + assert((mark+'\n'+canonical(record)).length <= 59000, 'result_comment_too_large'); + let resultId = comments[0]?.id; + if (!resultId) { + const {data} = await github.rest.issues.createComment({...context.repo,issue_number:locator.pr_number, + body:mark+'\n'+canonical({publication:'bot-draft-only',task_id:task.id,status:'publication_pending',approval:false})}); + resultId = data.id; + } + const finalTrust = await trusted(github,context); + const after = await snapshot(github,context,locator.pr_number,binding.review_kind, + binding.target_sha || binding.head_sha,binding.parent_sha || binding.base_sha,finalTrust,false); + stale = stale || digest(after.binding) !== digest(binding); + record.stale = stale;record.technical_result_valid = validation.eligible && !stale; + record.comment_id = resultId; + const body = mark+'\n'+canonical(record); + // No model text is executed, no labels, reviews, approvals or statuses are written. + await github.rest.issues.updateComment({...context.repo,comment_id:resultId,body}); + save('result',record); + await core.summary.addRaw(`Task ${task.id}: ${task.status}; stale=${stale}; bot draft only.\n`).write(); +} + +module.exports = {classify:b.classify,targetBinding,outputSchema,intent,submitOnce,validateResult,select,prepare,submit,collect}; diff --git a/.github/dot-review/test-review.cjs b/.github/dot-review/test-review.cjs new file mode 100644 index 0000000..017688b --- /dev/null +++ b/.github/dot-review/test-review.cjs @@ -0,0 +1,170 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const r = require('./review.cjs'); +const policy = require('./policy.json'); +const sha = 'a'.repeat(40), base = 'b'.repeat(40); +const file = {filename: 'application/orders.py', status: 'modified', sha, + additions: 1, deletions: 1, patch: '@@ -1 +1 @@\n-old\n+new'}; + +test('trusted classifier rejects missing patches and protects renamed old paths', () => { + assert.throws(() => r.classify([{...file, patch: ''}], 1, policy)); + assert.equal(r.classify([{...file, filename:'README.md', previous_filename:file.filename, + status:'renamed'}], 1, policy).tier, 'R2'); + assert.equal(r.classify([{...file,filename:'README.md'}], 1, policy).tier, 'R0'); + assert.equal(r.classify([{...file,filename:'ordinary.py'}], 1, policy).tier, 'R1'); +}); +test('posthoc is the exact merged commit and single parent, never original head', () => { + const pr = {merged:true, state:'closed', merge_commit_sha:sha, head:{sha:'c'.repeat(40)}}; + assert.equal(r.targetBinding(pr,'posthoc',sha,base,{sha,parents:[{sha:base}]}).base,base); + assert.throws(() => r.targetBinding(pr,'posthoc',pr.head.sha,base,{sha,parents:[{sha:base}]})); + assert.throws(() => r.targetBinding(pr,'posthoc',sha,base,{sha,parents:[{sha:base},{sha}]})); + assert.throws(() => r.targetBinding(pr,'posthoc',sha,sha,{sha,parents:[{sha:base}]})); +}); +test('premerge requires current open non-draft head and base', () => { + const pr={state:'open',draft:false,head:{sha},base:{sha:base}}; + assert.equal(r.targetBinding(pr,'premerge',sha,base).base,base); + for(const changed of [{state:'closed'},{draft:true},{head:{sha:base}}]) + assert.throws(() => r.targetBinding({...pr,...changed},'premerge',sha,base)); +}); +test('frozen intent is deterministic and all material changes change the key', () => { + const request={mode:'agent',materials:[{binding:{base_sha:base,head_sha:sha}}]}; + const a=r.intent('example/repo',12,7,request); + assert.deepEqual(a,r.intent('example/repo',12,7,JSON.parse(JSON.stringify(request)))); + assert.notEqual(a.key,r.intent('example/repo',12,7,{...request,model:'different'}).key); + assert.match(a.key,/^dot-review:v1:12:7:[a-f0-9]{64}$/); +}); +test('ambiguous POST only looks up the original key and never posts twice', async () => { + let posts=0,lookups=0; + const i=r.intent('example/repo',12,7,{mode:'agent'}); + const api=async(method,path)=>{if(method==='POST'){posts++;throw Error('lost');} + lookups++;assert.equal(path,'/v1/tasks/lookup');return {id:'task-one'};}; + assert.equal((await r.submitOnce(api,i,false)).id,'task-one'); + assert.equal(posts,1);assert.equal(lookups,1); + await r.submitOnce(api,i,true);assert.equal(posts,1);assert.equal(lookups,2); + await assert.rejects(r.submitOnce(async()=>{throw Error('missing');},i,true)); +}); +test('completed result must bind real attempt, revision, source and complete review', () => { + const binding={repository:'example/repo',target_sha:sha}; + const request={materials:[{binding}],output_schema:r.outputSchema(binding,1)}; + const i=r.intent('example/repo',12,7,request); + const output={binding,ai_review:{provider:'dot',model:'actual-model',run_ref:'https://example.test/run/1',mode:'independent'}, + execution_status:'completed',verdict:'pass',coverage:{complete:true,reviewed_files:1},blocking_findings:[],dispositions:[],limitations:[]}; + const task={id:'task-one',owner:'example/repo',request,provider:'dot',revision:1,attempt:'attempt-one',worker:'worker-one',status:'completed',output}; + assert.equal(r.validateResult(task,i).eligible,true); + for(const change of [{provider:'fake'},{revision:2},{attempt:null},{owner:'other/repo'}, + {output:{...output,ai_review:{...output.ai_review,run_ref:''}}}]) assert.throws(()=>r.validateResult({...task,...change},i)); + assert.equal(r.validateResult({...task,output:{...output,limitations:['missing source']}},i).eligible,false); + assert.equal(r.validateResult({...task,output:{...output,blocking_findings:[{severity:'P1'}]}},i).eligible,false); + assert.equal(r.validateResult({...task,status:'running',output:null},i).eligible,false); +}); + +test('failed upstream gate can select one PR; wrong workflows and ambiguous associations stop', async () => { + const run={workflow_id:12,status:'completed',conclusion:'failure',event:'pull_request_target', + repository:{full_name:'example/repo'},pull_requests:[{number:7}]}; + const github={rest:{actions:{getWorkflowRun:async()=>({data:run}), + getWorkflow:async()=>({data:{id:12,path:'.github/workflows/risk-gate.yml',name:'PR risk gate'}})}, + pulls:{list:async()=>({data:[]})}}}; + const context={repo:{owner:'example',repo:'repo'},eventName:'workflow_run',payload:{workflow_run:{id:5}}}; + assert.equal(await r.select(github,context),7); + run.workflow_id=13;await assert.rejects(r.select(github,context));run.workflow_id=12; + run.pull_requests=[{number:7},{number:8}];await assert.rejects(r.select(github,context)); + run.pull_requests=[];await assert.rejects(r.select(github,context)); + run.event='schedule';assert.equal(await r.select(github,context),null); +}); + +for (const kind of ['premerge','posthoc']) for (const uncertain of [false,true]) test(kind + (uncertain ? ' uncertain' : '') + ': offline submit/collect keeps one task and never grants approval', async () => { + const fs=require('node:fs'),os=require('node:os'),path=require('node:path'); + const cwd=process.cwd(), directory=fs.mkdtempSync(path.join(os.tmpdir(),'dot-review-test-')); + const env={...process.env}, originalFetch=global.fetch; + const comments=[], calls=[], outputs={};let task=null, posts=0, firstFrozen; + const content='synthetic source\n', sourceSHA='d'.repeat(40); + const pr={number:7,changed_files:1,commits:1,title:'Synthetic change',body:'Synthetic body', + state:'open',draft:false,head:{sha},base:{sha:base,ref:'main',repo:{id:12}}}; + const methods={files:()=>[file],reviews:()=>[],reviewComments:()=>[],comments:()=>comments,commits:()=>[{}]}; + const github={rest:{checks:{listForRef:()=>[{id:context.runId,head_sha:sha,name:'test',status:'completed',conclusion:'success'}]},repos:{listCommitStatusesForRef:()=>[{id:context.runId,context:'test',state:'success'}],get:async()=>({data:{id:12,full_name:'example/repo',default_branch:'main'}}), + getBranch:async()=>({data:{commit:{sha:base}}}), + getCommit:async()=>({data:{sha,parents:[{sha:base}],files:[file]},headers:{}})},pulls:{get:async()=>({data:pr}), + listFiles:methods.files,listReviews:methods.reviews,listReviewComments:methods.reviewComments,listCommits:methods.commits}, + issues:{listComments:methods.comments, + createComment:async args=>{const c={id:comments.length+1,body:args.body,user:{id:41898282,type:'Bot',login:'github-actions[bot]'}, + issue_url:'https://api.github.com/repos/example/repo/issues/7'};comments.push(c);return {data:c};}, + updateComment:async args=>{const c=comments.find(c=>c.id===args.comment_id);c.body=args.body;return {data:c};}, + getComment:async args=>({data:comments.find(c=>c.id===args.comment_id)})}, + git:{getTree:async()=>({data:{truncated:false,tree:[{path:file.filename,type:'blob',mode:'100644',sha:sourceSHA,size:content.length}]}}), + getBlob:async()=>({data:{sha:sourceSHA,encoding:'base64',content:Buffer.from(content).toString('base64'),size:content.length}})}}, + paginate:async fn=>JSON.parse(JSON.stringify(fn())), + graphql:async()=>({repository:{pullRequest:{reviewThreads:{nodes:[],pageInfo:{hasNextPage:false,endCursor:null}}}}})}; + const summary={addRaw(){return this;},async write(){}}; + const core={setOutput:(k,v)=>outputs[k]=v,getIDToken:async audience=>{ + assert.equal(audience,'https://audience.example.test');return 'synthetic-only';},summary}; + const context={repo:{owner:'example',repo:'repo'},ref:'refs/heads/main',eventName:'workflow_dispatch',runId:1, + serverUrl:'https://github.com',payload:{inputs:{pr_number:'7',review_kind:kind,target_sha:sha,expected_base_sha:base}}}; + try { + if(kind === 'posthoc') Object.assign(pr,{state:'closed',merged:true,merge_commit_sha:sha,head:{sha:'c'.repeat(40)}}); + process.chdir(directory); + Object.assign(process.env,{TRUSTED_SHA:base,DOT_REVIEW_READY:'true',DOT_REVIEW_PROFILE:'review',DOT_REVIEW_MODEL:'fixture-model', + AI_SERVICE_URL:'https://tasks.example.test',AI_SERVICE_AUDIENCE:'https://audience.example.test'}); + global.fetch=async(url,options)=>{ + calls.push([options.method,url]); + if(options.method==='POST') {posts++;const request=JSON.parse(options.body); + task={id:'synthetic-task',owner:'example/repo',provider:'dot',revision:1,status:'running',request,output:null,artifacts:[]}; + if (uncertain) throw Error('lost POST response'); + return Response.json(task,{status:202});} + if(url.endsWith('/lookup')) { + assert.equal(options.headers['Idempotency-Key'],firstFrozen.key); + assert.equal(options.headers['X-Request-SHA256'],firstFrozen.request_sha256); + if (uncertain && context.runId <= 2) throw Error('lookup unavailable'); + return Response.json({id:task.id}); + } + return Response.json(task); + }; + await r.prepare({github,context,core});assert.equal(outputs.prepared,'true'); + firstFrozen=JSON.parse(fs.readFileSync('dot-review/frozen.json')); + if (uncertain) await assert.rejects(r.submit({github,context,core})); + else await r.submit({github,context,core}); + assert.equal(posts,1);assert.equal(comments.length,1); + const recoveryStart=calls.length; + // A new workflow run has neither the previous runner's files nor stable CI IDs. + fs.rmSync('dot-review',{recursive:true});context.runId=2; + if (uncertain) { + await assert.rejects(async()=>{await r.prepare({github,context,core});await r.submit({github,context,core});}); + assert.equal(posts,1);assert.equal(comments.length,1); + } + context.runId=3;process.env.DOT_REVIEW_MODEL='changed-model'; + await r.prepare({github,context,core}); + const recovered=JSON.parse(fs.readFileSync('dot-review/frozen.json')); + assert.deepEqual(recovered.request,firstFrozen.request); + assert.equal(recovered.key,firstFrozen.key); + assert.equal(recovered.request_sha256,firstFrozen.request_sha256); + await r.submit({github,context,core});assert.equal(posts,1);assert.equal(comments.length,1); + assert.ok(calls.slice(recoveryStart).every(([method])=>method==='GET')); + // The lookup response must not substitute a different request. + const originalRequest=task.request; + task.request={...originalRequest,model:'mismatched-request'}; + await assert.rejects(r.prepare({github,context,core}),/intent_conflict/); + assert.equal(posts,1);assert.equal(comments.length,1); + task.request=originalRequest; + task={...task,status:'completed',attempt:'attempt-one',worker:'worker-one',output:{binding:task.request.materials[0].binding, + ai_review:{provider:'dot',model:'fixture-model',run_ref:'https://example.test/actual/1',mode:'independent'}, + execution_status:'completed',verdict:'pass',coverage:{complete:true,reviewed_files:1},blocking_findings:[],dispositions:[],limitations:[]}}; + context.payload.inputs={locator_comment_id:'1',task_id:task.id}; + const before=calls.length; + await r.collect({github,context,core});await r.collect({github,context,core}); + assert.equal(comments.length,2);assert.equal(posts,1); + assert.ok(calls.slice(before).every(([method])=>method==='GET')); + const result=JSON.parse(fs.readFileSync('dot-review/result.json')); + assert.equal(result.technical_result_valid,true);assert.equal(result.approval,false); + assert.equal(result.publication,'bot-draft-only');assert.equal(result.comment_id,2); + comments.push({id:3,body:'A new unresolved concern',user:{id:99,type:'User',login:'reviewer'}}); + await r.collect({github,context,core}); + assert.equal(JSON.parse(fs.readFileSync('dot-review/result.json')).stale,true); + context.payload.inputs={pr_number:'7',review_kind:kind,target_sha:sha,expected_base_sha:base}; + await assert.rejects(r.prepare({github,context,core}),/intent_conflict/); + assert.equal(posts,1); + } finally { + global.fetch=originalFetch;process.chdir(cwd); + for(const key of Object.keys(process.env)) if(!(key in env)) delete process.env[key]; + Object.assign(process.env,env);fs.rmSync(directory,{recursive:true,force:true}); + } +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7db4e5b..b857382 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,9 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Test Dot review safety contracts offline + run: node --test .github/dot-review/test-*.cjs + - name: Reject public runtime configuration bindings run: | set -euo pipefail diff --git a/.github/workflows/dot-review-collect.yml b/.github/workflows/dot-review-collect.yml new file mode 100644 index 0000000..2e72358 --- /dev/null +++ b/.github/workflows/dot-review-collect.yml @@ -0,0 +1,53 @@ +name: Dot review collect + +on: + workflow_dispatch: + inputs: + locator_comment_id: + description: Original bot intent comment ID (also supports unknown POST recovery) + required: true + type: string + task_id: + description: Expected task ID, if known + type: string + +permissions: {} +concurrency: + group: dot-review-collect + cancel-in-progress: false + +jobs: + collect: + if: github.ref == 'refs/heads/main' && vars.DOT_REVIEW_READY == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + issues: write + id-token: write + env: + TRUSTED_SHA: ${{ github.workflow_sha }} + AI_SERVICE_URL: ${{ vars.AI_SERVICE_URL }} + AI_SERVICE_AUDIENCE: ${{ vars.AI_SERVICE_AUDIENCE }} + steps: + - name: Load trusted default-branch code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - name: Read the original task and update its draft record + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + retries: 0 + script: | + try { await require('./.github/dot-review/review.cjs').collect({github,context,core}); } + catch { core.setFailed('Collection failed closed; no approval or passing evidence published.'); } + - name: Save result record + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dot-review-result-${{ github.run_id }}-${{ github.run_attempt }} + path: dot-review/result.json + retention-days: 30 + if-no-files-found: ignore diff --git a/.github/workflows/dot-review-submit.yml b/.github/workflows/dot-review-submit.yml new file mode 100644 index 0000000..59daca9 --- /dev/null +++ b/.github/workflows/dot-review-submit.yml @@ -0,0 +1,90 @@ +name: Dot review submit + +on: + workflow_run: + workflows: [PR risk gate] + types: [completed] + workflow_dispatch: + inputs: + pr_number: + description: Pull request in this repository + required: true + type: string + target_sha: + description: Full 40-character head or posthoc merge SHA (never execution ref) + required: true + type: string + review_kind: + description: Review contract + required: true + type: choice + options: [premerge, posthoc] + expected_base_sha: + description: Optional full expected base or merge parent SHA + type: string + +permissions: {} +concurrency: + group: dot-review-submit + cancel-in-progress: false + +jobs: + submit: + if: >- + github.ref == 'refs/heads/main' && vars.DOT_REVIEW_READY == 'true' && + (github.event_name == 'workflow_dispatch' || vars.DOT_REVIEW_AUTO_ENABLED == 'true') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + issues: write + actions: read + checks: read + statuses: read + id-token: write + env: + TRUSTED_SHA: ${{ github.workflow_sha }} + DOT_REVIEW_READY: ${{ vars.DOT_REVIEW_READY }} + DOT_REVIEW_PROFILE: ${{ vars.DOT_REVIEW_PROFILE }} + DOT_REVIEW_MODEL: ${{ vars.DOT_REVIEW_MODEL }} + AI_SERVICE_URL: ${{ vars.AI_SERVICE_URL }} + AI_SERVICE_AUDIENCE: ${{ vars.AI_SERVICE_AUDIENCE }} + steps: + - name: Load only the immutable trusted workflow commit + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - name: Freeze complete SHA-bound materials + id: prepare + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + retries: 0 + script: | + try { await require('./.github/dot-review/review.cjs').prepare({github,context,core}); } + catch { core.setFailed('Review preparation failed closed; no task submitted.'); } + - name: Persist frozen intent before any task submission + if: steps.prepare.outputs.prepared == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dot-review-frozen-${{ github.run_id }}-${{ github.run_attempt }} + path: dot-review/frozen.json + retention-days: 30 + if-no-files-found: error + - name: Submit once or recover the original task + if: steps.prepare.outputs.prepared == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + retries: 0 + script: | + try { await require('./.github/dot-review/review.cjs').submit({github,context,core}); } + catch { core.setFailed('Submission unconfirmed or rejected. Preserve the intent; use lookup only.'); } + - name: Save task locator + if: always() && steps.prepare.outputs.prepared == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dot-review-locator-${{ github.run_id }}-${{ github.run_attempt }} + path: dot-review/locator.json + retention-days: 30 + if-no-files-found: ignore