diff --git a/lefthook.yml b/lefthook.yml index dad9fc43f70..41acb531d48 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -107,3 +107,7 @@ pre-push: - "go.mod" - "go.sum" run: tools/githooks/.bin/githooks test {push_files} + + pr-size-guard: + tags: [guard, diff] + run: tools/githooks/.bin/githooks pr-size --fail-on-large diff --git a/tools/githooks/README.md b/tools/githooks/README.md index 69975e6c513..2db7661789c 100644 --- a/tools/githooks/README.md +++ b/tools/githooks/README.md @@ -4,13 +4,14 @@ ## Features -- **End-of-File Normalization:** Ensures eligible text and code files (`.go`, `.py`, `.md`, `.yaml`, `.json`, etc.) end with exactly one newline (`\n`), leaving empty files 0 bytes. +- **End-of-File Normalization:** Ensures eligible text and code files end with exactly one newline. - **Trailing Whitespace Fixing:** Fixes erroneous trailing whitespace across eligible text and document files (preserving Markdown 2-space hard line breaks). - **Module & Package Resolution:** Automatically maps changed or staged Go files to their enclosing `go.mod` module roots and specific package paths (e.g. `./core/logger`). - **Targeted Code Generation:** Runs `go generate` only for packages where `.proto` or generate files changed, updates config schema docs and `go.md` when relevant files change, and regenerates mocks via `mockery` when affected packages are listed in a `.mockery.yaml`. - **Parallel Module Tidy:** Runs `go mod tidy` in parallel across all affected modules. - **Targeted Linting:** Runs `golangci-lint` only against the exact changed packages within affected modules instead of scanning whole modules or the entire repository. - **Targeted Unit Testing:** Discovers changed test packages and executes `tools/test` with `-short` directly on those packages (aligned with CI unit test scope). +- **PR Diff Size Guard:** Calculates branch diff against the default branch merge-base, classifies size (`small`, `medium`, `large`), warns or fails on large diffs, and prompts developers to break PRs into smaller chunks. - **Dependency Changes:** Automatically runs on all packages (`./...`) if a module's `go.mod` or `go.sum` is modified. - **Lefthook Integration:** Seamlessly works with Lefthook staged/push file filters and `stage_fixed`. @@ -115,6 +116,34 @@ go -C tools/githooks run . test core/logger/logger_test.go tools/ci-testshard/ma go -C tools/githooks run . test --short=false ``` +### `pr-size` (aliases: `big-pr-guard`, `pr-guard`, `diff-guard`, `diff-size`) + +Calculates the git diff of the current branch against the default branch (the PR diff), classifies it into size categories (`small`, `medium`, `large`), and prompts the developer with recommendations to split large PRs. + +```bash +# Check PR diff size with default settings +go -C tools/githooks run . pr-size + +# Check with custom thresholds +go -C tools/githooks run . pr-size --small-limit=100 --medium-limit=300 + +# Fail the command on large PRs (useful in CI or strict pre-push mode) +go -C tools/githooks run . pr-size --fail-on-large + +# Bypass large PR failure via flag or env var +go -C tools/githooks run . pr-size --fail-on-large --allow-large-pr +ALLOW_LARGE_PR=true go -C tools/githooks run . pr-size --fail-on-large + +# Select calculation strategy: per-file-max (default), sum, max, or weighted +go -C tools/githooks run . pr-size --strategy=per-file-max + +# Diff against a specific base branch or ref +go -C tools/githooks run . pr-size --base=origin/develop + +# Automatically ignores generated files defined in .gitattributes (linguist-generated=true) +go -C tools/githooks run . pr-size --ignore-generated=true +``` + ## Running Tests & Benchmarks ```bash diff --git a/tools/githooks/cmd/pr_size.go b/tools/githooks/cmd/pr_size.go new file mode 100644 index 00000000000..5b57c967ff2 --- /dev/null +++ b/tools/githooks/cmd/pr_size.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink/v2/tools/githooks/internal/prsize" +) + +func newPRSizeCmd() *cobra.Command { + var ( + strategy string + smallLimit int + mediumLimit int + failOnLarge bool + allowLargePR bool + baseRef string + ignoreLockfiles bool + ignoreGenerated bool + includeUncommitted bool + ) + + cmd := &cobra.Command{ + Use: "pr-size", + Aliases: []string{"big-pr-guard", "pr-guard", "diff-guard", "diff-size"}, + Short: "Check PR diff size against default branch and guard against oversized PRs", + Long: "Calculates the git diff of the current branch against the default branch (the PR diff), " + + "classifies the diff as small, medium, or large, and prompts developers with recommendations " + + "to split large PRs into smaller, reviewable chunks.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + repoRoot, err := findRepoRoot(ctx) + if err != nil { + return fmt.Errorf("could not find repo root: %w", err) + } + + cfg := prsize.Config{ + RepoRoot: repoRoot, + BaseRef: baseRef, + Strategy: prsize.Strategy(strategy), + SmallLimit: smallLimit, + MediumLimit: mediumLimit, + FailOnLarge: failOnLarge, + AllowLargePR: allowLargePR, + IgnoreLockfiles: ignoreLockfiles, + IgnoreGenerated: ignoreGenerated, + IncludeUncommitted: includeUncommitted, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + UI: uiForCmd(cmd), + } + + if err := prsize.Run(ctx, cfg); err != nil { + if errors.Is(err, prsize.ErrLargePR) { + return SilentError{Err: err} + } + return err + } + return nil + }, + } + + cmd.Flags().StringVar(&strategy, "strategy", string(prsize.StrategyPerFileMax), "Diff size strategy: per-file-max, sum, max, weighted") + cmd.Flags().IntVar(&smallLimit, "small-limit", prsize.DefaultSmallLimit, "Maximum effective lines for small classification") + cmd.Flags().IntVar(&smallLimit, "max-small", prsize.DefaultSmallLimit, "Alias for --small-limit") + cmd.Flags().IntVar(&mediumLimit, "medium-limit", prsize.DefaultMediumLimit, "Maximum effective lines for medium classification") + cmd.Flags().IntVar(&mediumLimit, "max-medium", prsize.DefaultMediumLimit, "Alias for --medium-limit") + cmd.Flags().BoolVar(&failOnLarge, "fail-on-large", false, "Exit with error code if PR is classified as large") + cmd.Flags().BoolVar(&allowLargePR, "allow-large-pr", false, "Allow large PRs without failing (or set ALLOW_LARGE_PR=true)") + cmd.Flags().StringVar(&baseRef, "base", "", "Base branch or ref to diff against (default: origin default branch merge-base)") + cmd.Flags().StringVar(&baseRef, "rev", "", "Alias for --base") + cmd.Flags().BoolVar(&ignoreLockfiles, "ignore-lockfiles", true, "Ignore lockfiles (go.sum, package-lock.json, etc.) from diff line count") + cmd.Flags().BoolVar(&ignoreGenerated, "ignore-generated", true, "Ignore generated changes marked in .gitattributes (linguist-generated)") + cmd.Flags().BoolVar(&includeUncommitted, "include-uncommitted", false, "Include uncommitted staged and unstaged working-tree changes") + + return cmd +} diff --git a/tools/githooks/cmd/pr_size_test.go b/tools/githooks/cmd/pr_size_test.go new file mode 100644 index 00000000000..f5ae4502902 --- /dev/null +++ b/tools/githooks/cmd/pr_size_test.go @@ -0,0 +1,42 @@ +package cmd_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/v2/tools/githooks/cmd" +) + +//nolint:paralleltest // t.Chdir is process-global +func TestPRSizeCmd_Run(t *testing.T) { + dir := t.TempDir() + git(t, dir, "init") + require.NoError(t, os.WriteFile(filepath.Join(dir, "base.go"), []byte("package main\n"), 0o600)) + git(t, dir, "add", ".") + git(t, dir, "commit", "-m", "init") + baseSHA := git(t, dir, "rev-parse", "HEAD") + git(t, dir, "update-ref", "refs/remotes/origin/develop", baseSHA) + git(t, dir, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/develop") + + require.NoError(t, os.WriteFile(filepath.Join(dir, "feature.go"), []byte("package main\n// new\n"), 0o600)) + git(t, dir, "add", ".") + git(t, dir, "commit", "-m", "feature") + + t.Chdir(dir) + + root := cmd.NewRootCmd() + buf := new(bytes.Buffer) + root.SetOut(buf) + root.SetErr(buf) + root.SetArgs([]string{"pr-size", "--no-color"}) + + err := root.Execute() + require.NoError(t, err) + assert.Contains(t, buf.String(), "PR Diff Size") + assert.Contains(t, buf.String(), "Classification: [ SMALL ] [ OK ]") +} diff --git a/tools/githooks/cmd/root.go b/tools/githooks/cmd/root.go index b889e49d37e..4f87469e825 100644 --- a/tools/githooks/cmd/root.go +++ b/tools/githooks/cmd/root.go @@ -30,6 +30,8 @@ func NewRootCmd() *cobra.Command { rootCmd.AddCommand(newGenerateCmd()) rootCmd.AddCommand(newEOFCmd()) rootCmd.AddCommand(newWhitespaceCmd()) + rootCmd.AddCommand(newPRSizeCmd()) + rootCmd.AddCommand(newSampleOutputsCmd()) return rootCmd } diff --git a/tools/githooks/cmd/sample_outputs.go b/tools/githooks/cmd/sample_outputs.go new file mode 100644 index 00000000000..214e57d67e6 --- /dev/null +++ b/tools/githooks/cmd/sample_outputs.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink/v2/tools/githooks/internal/ui" +) + +func newSampleOutputsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sample-outputs", + Short: "Showcase all terminal UI output styles (hidden developer tool)", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + u := uiForCmd(cmd) + out := cmd.OutOrStdout() + + renderSampleOutputs(out, u) + return nil + }, + } + + return cmd +} + +func renderSampleOutputs(out io.Writer, u *ui.UI) { + // 1. Headers & Section + fmt.Fprintln(out, u.Bold("=== [githooks UI Output Showcase] ===")) + fmt.Fprintln(out) + + // 2. Badges & Tags + fmt.Fprintln(out, u.Bold("--- Status Badges & Tags ---")) + fmt.Fprintf(out, "Success: %s\n", u.BadgeSuccess("OK")) + fmt.Fprintf(out, "Warning: %s\n", u.BadgeWarning("MEDIUM")) + fmt.Fprintf(out, "Danger: %s\n", u.BadgeDanger("LARGE")) + fmt.Fprintf(out, "Info: %s\n", u.BadgeInfo("SMALL")) + fmt.Fprintf(out, "Tags: %s %s %s %s\n", u.BadgeTag("LINT"), u.BadgeTag("TEST"), u.BadgeTag("FIXED"), u.BadgeTag("CHECK FAIL")) + fmt.Fprintln(out) + + // 3. PR Size Guard Samples + fmt.Fprintln(out, u.Bold("--- PR Size Guard (pr-size) ---")) + // Small + fmt.Fprintf(out, "PR Diff Size: %s -> Classification: %s %s\n", + u.FormatDiff(45, 45, 0, 2, "per-file-max"), + u.BadgeInfo("SMALL"), + u.BadgeSuccess("OK"), + ) + // Medium + fmt.Fprintf(out, "PR Diff Size: %s -> Classification: %s %s\n", + u.FormatDiff(320, 280, 40, 6, "per-file-max"), + u.BadgeWarning("MEDIUM"), + u.BadgeSuccess("OK"), + ) + fmt.Fprintf(out, " %s\n", u.Dim("(Excluded 3 lock/ignored files)")) + fmt.Fprintln(out) + + // Large PR Warning Card (Variation 1A) + title := fmt.Sprintf("%s %s", u.BadgeDangerPill("LARGE PR"), u.Danger("(1250 lines > 500 limit)")) + diffLine := fmt.Sprintf("%s %s / %s in 14 files (per-file-max) • %s", + u.Bold("Diff:"), + u.Additions(1100), + u.Deletions(150), + u.Dim("Excluded: 2 lockfiles"), + ) + bypassLine := fmt.Sprintf("%s %s", u.Bold("Bypass:"), u.Dim("ALLOW_LARGE_PR=true git push")) + promptLine := u.Bold("AI Prompt:") + " Execute the skill @tools/githooks/skills/split-pr/SKILL.md to break feature-branch branch up." + + fmt.Fprintln(out, u.CompactWarningCard(title, []string{diffLine, bypassLine, promptLine})) + fmt.Fprintln(out) + + // 4. Runner Step Headers + fmt.Fprintln(out, u.Bold("--- Runners (lint & test) ---")) + fmt.Fprintln(out, u.RunnerHeader("LINT", "core", "4 packages")) + fmt.Fprintln(out, u.RunnerHeader("LINT", "deployment", "./environment")) + fmt.Fprintln(out, u.RunnerHeader("TEST", "plugins", "2 packages")) + fmt.Fprintln(out, u.RunnerHeader("TEST", ".", "./core/logger ./core/services")) + fmt.Fprintln(out) + + // 5. Fixer Status Items + fmt.Fprintln(out, u.Bold("--- Fixers (whitespace & eof) ---")) + fmt.Fprintln(out, u.StatusItem("FIXED", "core/services/app.go", "")) + fmt.Fprintln(out, u.StatusItem("FIXED", "deployment/environment.go", "")) + fmt.Fprintln(out, u.StatusItem("CHECK FAIL", "core/config/config.go", "erroneous trailing whitespace")) + fmt.Fprintln(out, u.StatusItem("CHECK FAIL", "README.md", "missing trailing newline")) + fmt.Fprintln(out) +} diff --git a/tools/githooks/cmd/sample_outputs_test.go b/tools/githooks/cmd/sample_outputs_test.go new file mode 100644 index 00000000000..79176f8062f --- /dev/null +++ b/tools/githooks/cmd/sample_outputs_test.go @@ -0,0 +1,31 @@ +package cmd_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/v2/tools/githooks/cmd" +) + +func TestSampleOutputsCmd_ExecutesSuccessfully(t *testing.T) { + t.Parallel() + + root := cmd.NewRootCmd() + buf := new(bytes.Buffer) + root.SetOut(buf) + root.SetErr(buf) + root.SetArgs([]string{"sample-outputs", "--no-color"}) + + err := root.Execute() + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "PR Diff Size") + assert.Contains(t, out, "LARGE PR") + assert.Contains(t, out, "LINT") + assert.Contains(t, out, "TEST") + assert.Contains(t, out, "FIXED") +}