mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 16:17:25 +00:00
* feat: add ai review to deployment requests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: bound ai review fetches and surface unreadable items Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: add fix in ai session and model picker to deployment ai review Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: add workspace instructions for deployment ai review Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: read review instructions from the parent and bind ai review to the fork Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: pin the fix-all session preview to the fork's compare page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: show the protection-rule bypass toggle inline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: make the bypass toggle small and red Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: drop workspace-settings instructions from deployment ai review Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: folder-scoped review instructions for the deployment ai review Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: review the selected items with ai on compare and deploy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep reviewing when review instructions cannot be listed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: say the review instruction floor is a prompt request, not enforcement Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mark an ai review out of date when its items change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to 77703cf32e58964906d87ee83ddda44b5b8d1b7e This commit updates the EE repository reference after PR #926 was merged in windmill-ee-private. Previous ee-repo-ref: e30ab9e69d1854fcaed5620c934ace32acb0a729 New ee-repo-ref: 77703cf32e58964906d87ee83ddda44b5b8d1b7e Automated by sync-ee-ref workflow. * feat: review git sync pull requests with wmill review * fix: withhold retyped resources and encryption keys, fall back from the responses route * fix: withhold fileset children, workspace-specific resources and pathspec-named files * feat: include the ready-to-post review in wmill review --json * feat: review a workspace synced into a repository subfolder * docs: say what wmill review takes from flags rather than wmill.yaml --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
194 lines
9.0 KiB
TypeScript
194 lines
9.0 KiB
TypeScript
// What the review asks of the model and how its answer is read, for every place it runs: the
|
|
// deployment request panel here, and `wmill review` on a git sync pull request, which carries
|
|
// this module through cli/generate-ai-review.ts. Keep it free of `$lib` imports; the
|
|
// staleness test in cli/test/ai_review_bundle_unit.test.ts fails when the CLI's copy drifts.
|
|
import {
|
|
formatFolderInstructions,
|
|
type FolderInstruction
|
|
} from '../copilot/chat/folderInstructionScopes'
|
|
|
|
export {
|
|
folderInstructionScope,
|
|
instructionsCovering,
|
|
type FolderInstruction
|
|
} from '../copilot/chat/folderInstructionScopes'
|
|
|
|
/**
|
|
* Review instructions are resources of this type: a markdown file resource whose
|
|
* `value.content` applies to every changed item under the directory it lives in.
|
|
*/
|
|
export const REVIEW_INSTRUCTIONS_RESOURCE_TYPE = 'ai_review_instruction'
|
|
|
|
export type AiReviewSeverity = 'high' | 'medium' | 'low'
|
|
|
|
export type AiReviewFinding = {
|
|
id: string
|
|
/** Empty when the finding names no changed item. */
|
|
kind: string
|
|
path: string
|
|
severity: AiReviewSeverity
|
|
comment: string
|
|
/** Pull request reviews only: the changed file and line the finding is anchored to. */
|
|
file?: string
|
|
line?: number
|
|
side?: 'LEFT' | 'RIGHT'
|
|
}
|
|
|
|
// A variable's value is read decrypted and a resource's value holds credentials, so neither may
|
|
// reach the model: they are reviewed by name and change type only.
|
|
export const VALUE_WITHHELD_KINDS = new Set(['variable', 'resource'])
|
|
|
|
export const PER_SIDE_CHAR_CAP = 20_000
|
|
export const TOTAL_CHAR_BUDGET = 160_000
|
|
// Under the Anthropic SDK's non-streaming pre-flight max_tokens limit.
|
|
export const MAX_OUTPUT_TOKENS = 8_000
|
|
|
|
/** What is being reviewed: a fork's deployment request, or a pull request on a git sync repo. */
|
|
export type ReviewSubject = 'deployment_request' | 'pull_request'
|
|
|
|
const INTRO: Record<ReviewSubject, string> = {
|
|
deployment_request:
|
|
'You review a Windmill deployment request: a set of changes made in a fork workspace that will be merged into its parent workspace. Each changed item is given with its parent version (what is deployed now) and its fork version (what will be deployed).',
|
|
pull_request:
|
|
"You review a pull request on a git repository that Windmill's git sync keeps in step with a workspace: merging it deploys its changes. Each changed item is given with the files that make it up in the repository, each as a unified diff of the base branch (what is deployed now) against the pull request (what will be deployed). Every diff line starts with its line number: R<n> for line n of the pull request's version of the file, L<n> for line n of the base version, then + for an added line, - for a removed one and a space for unchanged context."
|
|
}
|
|
|
|
const ANSWER_SHAPE: Record<ReviewSubject, string> = {
|
|
deployment_request:
|
|
'{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}',
|
|
pull_request:
|
|
'{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "file": "<the file the issue is in, as given>", "line": "<the R or L line number the issue is on, as given, such as R12, or null for the file as a whole>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}'
|
|
}
|
|
|
|
const SOURCE: Record<ReviewSubject, string> = {
|
|
deployment_request: 'The parent workspace',
|
|
pull_request: "The repository's base branch"
|
|
}
|
|
|
|
export function reviewSystemPrompt(subject: ReviewSubject): string {
|
|
return `${INTRO[subject]} An item can be a script, flow, app, schedule, trigger, folder, resource type, datatable migration, variable or resource.
|
|
|
|
Report only issues a reviewer should act on before approving the merge:
|
|
- bugs and logic errors introduced by the change
|
|
- breaking changes: removed or renamed inputs, changed input types, changed outputs that callers may rely on
|
|
- risky operational changes: schedules retimed, triggers pointed at a different script or flow, folder owners or permissions, app policies, destructive migrations
|
|
- hardcoded secrets or credentials
|
|
- deletions that look unintended
|
|
|
|
Do not comment on style, naming, formatting or lockfiles, and do not praise. Do not repeat a finding for every item it touches. An item with no issue gets no finding.
|
|
|
|
Answer with JSON only, no prose around it, in this shape:
|
|
${ANSWER_SHAPE[subject]}`
|
|
}
|
|
|
|
/** The system prompt addition carrying the review instructions that apply, or '' for none. */
|
|
export function reviewInstructionsSection(
|
|
subject: ReviewSubject,
|
|
blocks: readonly { instruction: FolderInstruction; body: string }[]
|
|
): string {
|
|
if (blocks.length === 0) return ''
|
|
// Hardcoded secrets and unintended deletions stay a floor: a folder's writers steer the
|
|
// review of their own changes, so they must not be able to switch those checks off.
|
|
return `\n\n${SOURCE[subject]} gives the review instructions below, each for the changed items under the folder in its scope. For those items they take precedence over the instructions above, and a nested folder's over its parent folder's. They may ask you to check more, check less or weigh severity differently, except that hardcoded secrets or credentials and deletions that look unintended are always reported. The answer is still the JSON above.\n\n${formatFolderInstructions(blocks)}`
|
|
}
|
|
|
|
/** One side of a changed item as the model reads it, lockfiles dropped and capped. */
|
|
export function renderItemValue(value: unknown): string {
|
|
if (value == null) return '(none)'
|
|
// Flows and apps nest their inline scripts' lockfiles, so they are dropped at any depth.
|
|
const text =
|
|
typeof value === 'string'
|
|
? value
|
|
: JSON.stringify(value, (key, v) => (key === 'lock' ? undefined : v), 2)
|
|
return capText(text, PER_SIDE_CHAR_CAP)
|
|
}
|
|
|
|
export function capText(text: string, cap: number): string {
|
|
return text.length > cap
|
|
? `${text.slice(0, cap)}\n… (truncated, ${text.length - cap} more characters)`
|
|
: text
|
|
}
|
|
|
|
/**
|
|
* Packs item sections into the prompt in order. The first section that does not fit spends
|
|
* the budget, and every item after it is listed by name only: `used` alone could stay under
|
|
* the cap forever, and a caller that reads items lazily stops reading once `spent` is set.
|
|
*/
|
|
export class ReviewBudget {
|
|
included: string[] = []
|
|
/** `kind:path` of the changed items reviewed by name only. */
|
|
omitted: string[] = []
|
|
spent = false
|
|
private used = 0
|
|
|
|
add(key: string, section: string): void {
|
|
if (!this.spent && this.used + section.length <= TOTAL_CHAR_BUDGET) {
|
|
this.included.push(section)
|
|
this.used += section.length
|
|
} else {
|
|
this.spent = true
|
|
this.omitted.push(key)
|
|
}
|
|
}
|
|
|
|
userPrompt(header: string): string {
|
|
let user = `${header}\n\n${this.included.join('\n\n')}`
|
|
if (this.omitted.length > 0) {
|
|
user += `\n\nAlso changed, contents not shown for size:\n${this.omitted.map((o) => `- ${o}`).join('\n')}`
|
|
}
|
|
return user
|
|
}
|
|
}
|
|
|
|
/** What a finding may be anchored to. */
|
|
export type ReviewAnchors = {
|
|
/** `kind:path` of every changed item. */
|
|
items: ReadonlySet<string>
|
|
/** Pull request reviews: for each changed file, the line numbers a comment can be placed on. */
|
|
lines?: ReadonlyMap<string, { LEFT: ReadonlySet<number>; RIGHT: ReadonlySet<number> }>
|
|
}
|
|
|
|
export function parseReviewAnswer(
|
|
text: string,
|
|
anchors: ReviewAnchors
|
|
): { summary: string; findings: AiReviewFinding[] } {
|
|
const start = text.indexOf('{')
|
|
const end = text.lastIndexOf('}')
|
|
if (start === -1 || end <= start) {
|
|
throw new Error('The AI answer was not in the expected format')
|
|
}
|
|
const parsed = JSON.parse(text.slice(start, end + 1)) as {
|
|
summary?: unknown
|
|
findings?: unknown
|
|
}
|
|
// Anchoring a comment needs an item, file and line the change actually has, so a finding
|
|
// naming anything else keeps its comment but loses the anchor rather than being posted
|
|
// against a phantom.
|
|
const findings: AiReviewFinding[] = []
|
|
for (const [i, raw] of (Array.isArray(parsed.findings) ? parsed.findings : []).entries()) {
|
|
if (!raw || typeof raw.comment !== 'string' || !raw.comment.trim()) continue
|
|
const anchored = anchors.items.has(`${raw.kind}:${raw.path}`)
|
|
const finding: AiReviewFinding = {
|
|
id: String(i),
|
|
kind: anchored ? raw.kind : '',
|
|
path: anchored ? raw.path : '',
|
|
severity: ['high', 'medium', 'low'].includes(raw.severity) ? raw.severity : 'medium',
|
|
comment: raw.comment.trim()
|
|
}
|
|
const fileLines = typeof raw.file === 'string' ? anchors.lines?.get(raw.file) : undefined
|
|
if (fileLines) {
|
|
finding.file = raw.file
|
|
const line = /^([LR])(\d+)$/.exec(String(raw.line ?? '').trim())
|
|
const side = line?.[1] === 'L' ? 'LEFT' : 'RIGHT'
|
|
if (line && fileLines[side].has(Number(line[2]))) {
|
|
finding.side = side
|
|
finding.line = Number(line[2])
|
|
}
|
|
}
|
|
findings.push(finding)
|
|
}
|
|
const rank: Record<AiReviewSeverity, number> = { high: 0, medium: 1, low: 2 }
|
|
findings.sort((a, b) => rank[a.severity] - rank[b.severity])
|
|
return { summary: typeof parsed.summary === 'string' ? parsed.summary : '', findings }
|
|
}
|