@@ -146,6 +146,36 @@ List each conflicted file with a brief suggestion. For GENERATED files:
146146Always end suggestions with: "run: make fmt vet lint-fix"
147147</details>`
148148
149+ // Static repository guidance for AI prompts (concise, model-friendly).
150+ const projectOverview = `## Repository Guidance (Project Overview)
151+ Use this as factual context to interpret file changes. Do not invent paths or files.
152+
153+ - Type: Kubernetes Operator (Kubebuilder scaffold)
154+ - Language: Go (version pinned in go.mod)
155+ - Key libs: sigs.k8s.io/controller-runtime, k8s.io/*
156+ - Tools: sigs.k8s.io/controller-tools (controller-gen), kustomize, envtest, golangci-lint, kind (for e2e)
157+
158+ ## Project Layout
159+ - /api — CRD types and deepcopy files
160+ - internal/controllers — Reconciliation logic
161+ - internal/webhooks — Admission webhooks (defaulting and validating)
162+ - /cmd — Main entrypoint
163+ - /config — Kustomize manifests and operator configuration:
164+ - certmanager/ — Certificates and issuers
165+ - crd/ — CustomResourceDefinitions
166+ - default/ — Default overlays
167+ - manager/ — Manager deployment manifests
168+ - network-policy/ — Network policies
169+ - prometheus/ — Monitoring resources
170+ - rbac/ — Roles and bindings
171+ - samples/ — Example custom resources
172+ - webhook/ — Webhook configurations
173+ - /test — E2E tests and helpers
174+ - /dist — Distribution artifacts
175+ - Makefile — Build automation
176+ - PROJECT — Project definition file (auto-generated; avoid manual edits)
177+ `
178+
149179// isGeneratedKB returns true for Kubebuilder-generated artifacts.
150180func isGeneratedKB (path string ) bool {
151181 return strings .Contains (path , "/zz_generated." ) ||
@@ -154,20 +184,6 @@ func isGeneratedKB(path string) bool {
154184 path == "dist/install.yaml"
155185}
156186
157- // readCopilotInstructions returns the contents of .github/copilot_instructions.md
158- // from the repo root. If the file doesn't exist or can't be read, it returns "".
159- // maxBytes trims the content to avoid overly large prompts (use 0 for no trim).
160- func readCopilotInstructions () string {
161- const rel = ".github/copilot_instructions.md"
162- b , err := os .ReadFile (rel )
163- if err != nil || len (b ) == 0 {
164- return ""
165- }
166- // Normalize line endings and trim extra whitespace to keep the prompt tidy.
167- s := strings .ReplaceAll (string (b ), "\r \n " , "\n " )
168- return strings .TrimSpace (s )
169- }
170-
171187// ListConflictFiles walks the working directory and finds files that contain
172188// Git conflict markers, splitting results into SOURCE vs GENERATED buckets.
173189// This version does not rely on `git grep`.
@@ -308,36 +324,87 @@ func ListChangedFiles(base, head string) (src []string, gen []string) {
308324 return src , gen
309325}
310326
327+ // BuildAIPrompt builds the full positional prompt for `gh models run`.
328+ // Provide BOTH: changed files and conflicted files.
311329// BuildAIPrompt builds the full positional prompt for `gh models run`.
312330// Provide BOTH: changed files and conflicted files.
313331func BuildAIPrompt (
314- from , to , out , compareURL , releaseURL string ,
332+ fromVersion , toVersion , baseBranch , outBranch , compareURL , releaseURL string ,
315333 changedSrc , changedGen []string ,
316334 conflictSrc , conflictGen []string ,
317335) string {
318336 var ctx strings.Builder
337+
338+ // Header/context
319339 ctx .WriteString ("PROJECT: Created with Kubebuilder\n " )
320- fmt .Fprintf (& ctx , "Upgrade: from %s to %s\n " , from , to )
321- fmt .Fprintf (& ctx , "Output branch: %s\n " , out )
340+ fmt .Fprintf (& ctx , "Upgrade: from %s to %s\n " , fromVersion , toVersion )
341+ fmt .Fprintf (& ctx , "Output branch: %s\n " , outBranch )
322342 fmt .Fprintf (& ctx , "Compare PR URL: %s\n " , compareURL )
323343 fmt .Fprintf (& ctx , "Release notes: %s\n \n " , releaseURL )
324344
325- // Optional: project guidance for the model (teaches the AI)
326- if s := strings .TrimSpace (readCopilotInstructions ()); s != "" {
327- ctx .WriteString ("## Repository Guidance (.github/copilot_instructions.md)\n " )
328- ctx .WriteString (s )
329- ctx .WriteString ("\n \n " )
345+ // Repository guidance (concise, model-friendly)
346+ ctx .WriteString (projectOverview )
347+ ctx .WriteString ("\n \n " )
348+
349+ // Teach the model the 3-way workflow and show explicit compare range with SHAs
350+ ctx .WriteString ("## Update Method (3-way merge)\n " )
351+ ctx .WriteString ("- Ancestor: clean scaffold at FROM version\n " )
352+ ctx .WriteString ("- Original: your current project from --from-branch\n " )
353+ ctx .WriteString ("- Upgrade: clean scaffold at TO version\n " )
354+ ctx .WriteString ("- Merge: Original -> Upgrade (then squash or keep history)\n \n " )
355+
356+ base := strings .TrimSpace (baseBranch )
357+ head := strings .TrimSpace (outBranch )
358+ fmt .Fprintf (& ctx , "## Compare Range\n BASE: %s\n HEAD: %s\n " , rev (base ), rev (head ))
359+ if compareURL != "" {
360+ fmt .Fprintf (& ctx , "Compare PR URL: %s\n \n " , compareURL )
361+ } else {
362+ ctx .WriteString ("\n " )
363+ }
364+
365+ // Whole-repo diff summaries
366+ short := DiffShortStat (base , head )
367+ nameStatus := DiffNameStatus (base , head )
368+ stat := DiffStat (base , head )
369+
370+ ctx .WriteString ("## Diff Summary (whole repo)\n " )
371+ if short != "" {
372+ fmt .Fprintf (& ctx , "%s\n \n " , short )
373+ }
374+ if stat != "" {
375+ ctx .WriteString ("### Per-file stat\n " )
376+ ctx .WriteString (stat + "\n \n " )
377+ }
378+ if nameStatus != "" {
379+ ctx .WriteString ("### Name-Status (A/M/D/R)\n " )
380+ ctx .WriteString (nameStatus + "\n \n " )
330381 }
331382
332- // List changed files so the AI can build the Reviewed Changes table.
383+ // Dependency bump spotlight
384+ ctx .WriteString ("## go.mod Summary (require/replace/module)\n " )
385+ ctx .WriteString (summarizeGoModDiff (base , head ) + "\n \n " )
386+
387+ // Targeted patches for reviewer-relevant SOURCE files (exclude GENERATED)
388+ important := pickImportantFiles (changedSrc , 10 ) // include /api; keep token budget tight
389+ if len (important ) > 0 {
390+ ctx .WriteString ("## Selected Patches (SOURCE)\n " )
391+ for _ , p := range important {
392+ patch := filePatch (base , head , p , 12 << 10 ) // 12KB/file cap
393+ if patch == "" {
394+ continue
395+ }
396+ fmt .Fprintf (& ctx , "\n ----- BEGIN PATCH %s -----\n %s\n ----- END PATCH %s -----\n " , p , patch , p )
397+ }
398+ ctx .WriteString ("\n " )
399+ }
400+
401+ // File lists for the model to build tables/sections
333402 if len (changedSrc ) > 0 {
334403 fmt .Fprintf (& ctx , "\n Changed [SOURCE] files:\n %s\n " , bulletList (changedSrc ))
335404 }
336405 if len (changedGen ) > 0 {
337406 fmt .Fprintf (& ctx , "\n Changed [GENERATED] files:\n %s\n " , bulletList (changedGen ))
338407 }
339-
340- // List conflicts for extra context (will be empty if none)
341408 if len (conflictSrc ) > 0 {
342409 fmt .Fprintf (& ctx , "\n Conflicted [SOURCE] files:\n %s\n " , bulletList (conflictSrc ))
343410 }
@@ -347,3 +414,132 @@ func BuildAIPrompt(
347414
348415 return ctx .String () + "\n \n " + aiPRPrompt
349416}
417+
418+ // DiffShortStat returns "N files changed, X insertions(+), Y deletions(-)".
419+ func DiffShortStat (base , head string ) string {
420+ cmd := exec .Command ("git" , "diff" , "--shortstat" , base + ".." + head )
421+ out , _ := cmd .Output ()
422+ return strings .TrimSpace (string (out ))
423+ }
424+
425+ // DiffNameStatus returns lines like "M\tpath/file.go" (with renames).
426+ func DiffNameStatus (base , head string ) string {
427+ cmd := exec .Command ("git" , "diff" , "--name-status" , "-M" , base + ".." + head )
428+ out , _ := cmd .Output ()
429+ return strings .TrimSpace (string (out ))
430+ }
431+
432+ // DiffStat returns per-file stat (compact summary with +/- bars).
433+ func DiffStat (base , head string ) string {
434+ cmd := exec .Command ("git" , "diff" , "--stat" , "-M" , base + ".." + head )
435+ out , _ := cmd .Output ()
436+ return strings .TrimSpace (string (out ))
437+ }
438+
439+ // filePatch returns a unified diff for one file, truncated to maxBytes.
440+ func filePatch (base , head , path string , maxBytes int ) string {
441+ cmd := exec .Command ("git" , "diff" , "--no-color" , "--unified=3" , base + ".." + head , "--" , path )
442+ out , _ := cmd .Output ()
443+ if len (out ) == 0 {
444+ return ""
445+ }
446+ if maxBytes > 0 && len (out ) > maxBytes {
447+ out = append (out [:maxBytes ], []byte ("\n ... [truncated] ..." )... )
448+ }
449+ return strings .TrimSpace (string (out ))
450+ }
451+
452+ // pickImportantFiles chooses reviewer-relevant SOURCE files first.
453+ // Priority order: go.mod, Makefile, /api, internal/controllers, internal/webhooks, /cmd, then the rest.
454+ func pickImportantFiles (src []string , limit int ) []string {
455+ if len (src ) == 0 || limit <= 0 {
456+ return nil
457+ }
458+ orderFirst := []string {"go.mod" , "Makefile" }
459+ var api , ctrls , hooks , cmdPaths , rest []string
460+ seen := map [string ]bool {}
461+
462+ for _ , p := range src {
463+ switch {
464+ case p == "go.mod" || p == "Makefile" :
465+ // handled later to keep strict top order
466+ case strings .HasPrefix (p , "api/" ):
467+ api = append (api , p )
468+ seen [p ] = true
469+ case strings .HasPrefix (p , "internal/controllers/" ):
470+ ctrls = append (ctrls , p )
471+ seen [p ] = true
472+ case strings .HasPrefix (p , "internal/webhooks/" ):
473+ hooks = append (hooks , p )
474+ seen [p ] = true
475+ case strings .HasPrefix (p , "cmd/" ):
476+ cmdPaths = append (cmdPaths , p )
477+ seen [p ] = true
478+ default :
479+ // defer to the end
480+ }
481+ }
482+ for _ , p := range src {
483+ if ! seen [p ] && p != "go.mod" && p != "Makefile" {
484+ rest = append (rest , p )
485+ }
486+ }
487+
488+ var out []string
489+ // go.mod and Makefile first if present
490+ for _ , k := range orderFirst {
491+ for _ , p := range src {
492+ if p == k {
493+ out = append (out , p )
494+ break
495+ }
496+ }
497+ }
498+ // then API, controllers, webhooks, cmd/, then the rest
499+ out = append (out , api ... )
500+ out = append (out , ctrls ... )
501+ out = append (out , hooks ... )
502+ out = append (out , cmdPaths ... )
503+ out = append (out , rest ... )
504+
505+ if len (out ) > limit {
506+ out = out [:limit ]
507+ }
508+ return out
509+ }
510+
511+ // summarizeGoModDiff extracts only require/replace/module lines for brevity.
512+ func summarizeGoModDiff (base , head string ) string {
513+ cmd := exec .Command ("git" , "diff" , "--no-color" , "--unified=0" , base + ".." + head , "--" , "go.mod" )
514+ out , _ := cmd .Output ()
515+ if len (out ) == 0 {
516+ return "(no go.mod summary changes detected)"
517+ }
518+ lines := strings .Split (string (out ), "\n " )
519+ var keep []string
520+ for _ , l := range lines {
521+ t := strings .TrimSpace (l )
522+ if strings .HasPrefix (t , "+require " ) || strings .HasPrefix (t , "-require " ) ||
523+ strings .HasPrefix (t , "+replace " ) || strings .HasPrefix (t , "-replace " ) ||
524+ strings .HasPrefix (t , "+module " ) || strings .HasPrefix (t , "-module " ) {
525+ keep = append (keep , t )
526+ }
527+ }
528+ s := strings .Join (keep , "\n " )
529+ s = strings .TrimSpace (s )
530+ if s == "" {
531+ return "(no go.mod summary changes detected)"
532+ }
533+ return s
534+ }
535+
536+ // rev prints "<ref> @ <sha7>" if ref resolves, else returns the ref.
537+ func rev (ref string ) string {
538+ cmd := exec .Command ("git" , "rev-parse" , "--short=7" , ref )
539+ out , _ := cmd .Output ()
540+ sha := strings .TrimSpace (string (out ))
541+ if sha == "" {
542+ return ref
543+ }
544+ return fmt .Sprintf ("%s @ %s" , ref , sha )
545+ }
0 commit comments