Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func newConfigSetCmd() *cobra.Command {
return &cobra.Command{
Use: "set <key> <value>",
Short: "Set a config value",
Long: "Set a config value. Use 'chunk auth set <provider>' to store credentials with validation.\n\nUser keys: model\nProject keys: orgID, validation.sidecarImage",
Long: "Set a config value. Use 'chunk auth set <provider>' to store credentials with validation.\n\nUser keys: model\nProject keys: orgID, validation.sidecarImage, validation.commitStatus",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
io := iostream.FromCmd(cmd)
Expand All @@ -118,6 +118,11 @@ func newConfigSetCmd() *cobra.Command {
projCfg.Validation = &config.ValidationConfig{}
}
projCfg.Validation.SidecarImage = value
case "validation.commitStatus":
if projCfg.Validation == nil {
projCfg.Validation = &config.ValidationConfig{}
}
projCfg.Validation.CommitStatus = value == "true"
default:
return fmt.Errorf("internal: unhandled project config key %q", key)
}
Expand All @@ -131,7 +136,7 @@ func newConfigSetCmd() *cobra.Command {
if !config.ValidConfigKeys[key] {
return &userError{
msg: fmt.Sprintf("Unknown config key: %q.", key),
detail: "Supported keys: model, orgID, validation.sidecarImage.",
detail: "Supported keys: model, orgID, validation.sidecarImage, validation.commitStatus.",
errMsg: fmt.Sprintf("unknown config key %q", key),
}
}
Expand Down
36 changes: 35 additions & 1 deletion internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/CircleCI-Public/chunk-cli/internal/anthropic"
"github.com/CircleCI-Public/chunk-cli/internal/config"
"github.com/CircleCI-Public/chunk-cli/internal/gitremote"
"github.com/CircleCI-Public/chunk-cli/internal/gitutil"
"github.com/CircleCI-Public/chunk-cli/internal/iostream"
"github.com/CircleCI-Public/chunk-cli/internal/settings"
"github.com/CircleCI-Public/chunk-cli/internal/skills"
Expand Down Expand Up @@ -140,6 +141,36 @@ func writeSettingsExample(dir string, data []byte, streams iostream.Streams) err
return nil
}

const postCommitHookContent = "#!/bin/sh\nchunk validate post-commit\n"

// writePostCommitHook writes .git/hooks/post-commit to call chunk validate post-commit.
// Skips if the file already contains the hook. Warns if the file exists with different content.
func writePostCommitHook(workDir string, streams iostream.Streams) error {
root, err := gitutil.RepoRoot(workDir)
if err != nil {
return fmt.Errorf("find repo root: %w", err)
}
hookPath := filepath.Join(root, ".git", "hooks", "post-commit")

existing, readErr := os.ReadFile(hookPath)
if readErr == nil {
if strings.Contains(string(existing), "chunk validate post-commit") {
return nil
}
streams.ErrPrintf("%s\n", ui.Warning(fmt.Sprintf("Skipping post-commit hook: %s already exists with different content", hookPath)))
return nil
}
if !errors.Is(readErr, fs.ErrNotExist) {
return fmt.Errorf("read post-commit hook: %w", readErr)
}

if err := os.WriteFile(hookPath, []byte(postCommitHookContent), 0o755); err != nil {
return fmt.Errorf("write post-commit hook: %w", err)
}
streams.ErrPrintln(ui.Success("Wrote .git/hooks/post-commit"))
return nil
}

var sidecarGitignoreEntries = []string{
".chunk/sidecar.json",
".chunk/sidecar.*.json",
Expand Down Expand Up @@ -301,11 +332,14 @@ hook config files.`,
streams.ErrPrintf("%s\n", ui.Warning(fmt.Sprintf("Could not update .gitignore: %v", err)))
}

// Step 3: Write .claude/settings.json
// Step 3: Write .claude/settings.json and git hooks
if !skipHooks {
if err := writeSettings(workDir, cfg.Commands, streams, tui.Confirm); err != nil {
return err
}
if err := writePostCommitHook(workDir, streams); err != nil {
streams.ErrPrintf("%s\n", ui.Warning(fmt.Sprintf("Could not write post-commit hook: %v", err)))
}
}

// Step 4: Shell completions
Expand Down
134 changes: 115 additions & 19 deletions internal/cmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"

Expand All @@ -16,6 +17,7 @@ import (

"github.com/CircleCI-Public/chunk-cli/internal/config"
"github.com/CircleCI-Public/chunk-cli/internal/gitremote"
"github.com/CircleCI-Public/chunk-cli/internal/gitutil"
"github.com/CircleCI-Public/chunk-cli/internal/iostream"
"github.com/CircleCI-Public/chunk-cli/internal/session"
"github.com/CircleCI-Public/chunk-cli/internal/sidecar"
Expand Down Expand Up @@ -181,7 +183,8 @@ func newValidateCmd() *cobra.Command {
freshlyCreated = resolveSidecar(ctx, &sidecarID, orgID, image, workDir, hook, streams)
}

execErr := runValidate(ctx, workDir, name, inlineCmd, save, sidecarID, freshlyCreated, identityFile, workdir, allRemote, cfg, statusFn, streams)
results, execErr := runValidate(ctx, workDir, name, inlineCmd, save, sidecarID, freshlyCreated, identityFile, workdir, allRemote, cfg, statusFn, streams)
saveValidateResults(workDir, results)

if hook != nil {
maxAttempts := cfg.StopHookMaxAttempts
Expand All @@ -206,32 +209,35 @@ func newValidateCmd() *cobra.Command {
cmd.Flags().BoolVar(&save, "save", false, "Save --cmd to .chunk/config.json")
cmd.Flags().StringVar(&projectDir, "project", "", "Override project directory")

cmd.AddCommand(newPostCommitCmd())

return cmd
}

// runValidate dispatches to the appropriate Run* function based on the
// provided options. It is shared by both direct and hook invocations.
// allRemote is true when --remote is passed explicitly (all commands run on the
// sidecar); false means only commands with Remote:true are routed to the sidecar.
func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID string, freshlyCreated bool, identityFile, workdir string, allRemote bool, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) error {
// --cmd: inline command (always local in per-command mode)
// Returns nil results for remote/sidecar runs (no result recording on those paths).
func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID string, freshlyCreated bool, identityFile, workdir string, allRemote bool, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) ([]validate.CommandResult, error) {
// --cmd: inline command
if inlineCmd != "" {
cmdName := name
if cmdName == "" {
cmdName = "custom"
}
if save {
if err := config.SaveCommand(workDir, cmdName, inlineCmd); err != nil {
return &userError{msg: "Could not save command to .chunk/config.json.", err: err}
return nil, &userError{msg: "Could not save command to .chunk/config.json.", err: err}
}
streams.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Saved %s to .chunk/config.json", cmdName)))
}
if sidecarID != "" && allRemote {
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
if err != nil {
return err
return nil, err
}
return validate.RunRemoteInline(ctx, execFn, cmdName, inlineCmd, dest, statusFn, streams)
return nil, validate.RunRemoteInline(ctx, execFn, cmdName, inlineCmd, dest, statusFn, streams)
}
return validate.RunInline(ctx, workDir, cmdName, inlineCmd, statusFn, streams)
}
Expand All @@ -240,9 +246,9 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
if sidecarID != "" && allRemote {
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
if err != nil {
return err
return nil, err
}
return validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams)
return nil, validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams)
}

// Per-command remote routing: commands with Remote:true go to the sidecar,
Expand All @@ -253,22 +259,22 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
statusFn(iostream.LevelInfo, fmt.Sprintf("running %s on sidecar %s", name, sidecarID))
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
if err != nil {
return err
return nil, err
}
return validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams)
return nil, validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams)
}
statusFn(iostream.LevelInfo, fmt.Sprintf("running %s locally (not marked remote)", name))
// Named command is not marked remote; fall through to local execution.
} else {
return runSplitCommands(ctx, sidecarID, freshlyCreated, identityFile, workdir, workDir, cfg, statusFn, streams)
return nil, runSplitCommands(ctx, sidecarID, freshlyCreated, identityFile, workdir, workDir, cfg, statusFn, streams)
}
}

// Named command
if name != "" {
if cfg.FindCommand(name) == nil {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return &userError{
return nil, &userError{
msg: fmt.Sprintf("Command %q is not configured.", name),
suggestion: "Add it to .chunk/config.json.",
errMsg: fmt.Sprintf("command %q is not configured", name),
Expand All @@ -279,28 +285,104 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
streams.ErrPrintf("What command should %s run? ", ui.Bold(name))
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return &userError{msg: "No command entered.", errMsg: "no input received"}
return nil, &userError{msg: "No command entered.", errMsg: "no input received"}
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
streams.ErrPrintln(ui.Dim("No command entered, aborting."))
return &userError{msg: "No command entered.", errMsg: "no command entered"}
return nil, &userError{msg: "No command entered.", errMsg: "no command entered"}
}
if err := config.SaveCommand(workDir, name, input); err != nil {
return &userError{msg: "Could not save command to .chunk/config.json.", err: err}
return nil, &userError{msg: "Could not save command to .chunk/config.json.", err: err}
}
streams.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Saved %s to .chunk/config.json", name)))
var err error
cfg, err = config.LoadProjectConfig(workDir)
if err != nil {
return err
return nil, err
}
}
return mapValidateError(validate.RunNamed(ctx, workDir, name, cfg, statusFn, streams))
results, err := validate.RunNamed(ctx, workDir, name, cfg, statusFn, streams)
return results, mapValidateError(err)
}

// Run all
return mapValidateError(validate.RunAll(ctx, workDir, cfg, statusFn, streams))
results, err := validate.RunAll(ctx, workDir, cfg, statusFn, streams)
return results, mapValidateError(err)
}

func newPostCommitCmd() *cobra.Command {
var projectDir string

cmd := &cobra.Command{
Use: "post-commit",
Short: "Report validate results as GitHub commit statuses",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
streams := iostream.FromCmd(cmd)
ctx := cmd.Context()

workDir := projectDir
if workDir == "" {
var err error
workDir, err = os.Getwd()
if err != nil {
return err
}
}

projCfg, cfgErr := config.LoadProjectConfig(workDir)
if cfgErr != nil || projCfg.Validation == nil || !projCfg.Validation.CommitStatus {
return nil
}

treeOut, err := exec.Command("git", "-C", workDir, "rev-parse", "HEAD^{tree}").Output()
if err != nil {
return fmt.Errorf("resolve HEAD tree: %w", err)
}
treeSHA := strings.TrimSpace(string(treeOut))

results, found, err := validate.LoadResults(treeSHA)
if err != nil {
return fmt.Errorf("load validate results: %w", err)
}
if !found {
return nil
}

commitOut, err := exec.Command("git", "-C", workDir, "rev-parse", "HEAD").Output()
if err != nil {
return fmt.Errorf("resolve HEAD: %w", err)
}
commitSHA := strings.TrimSpace(string(commitOut))

org, repo, err := gitremote.DetectOrgAndRepo(workDir)
if err != nil {
return fmt.Errorf("detect repo: %w", err)
}

ghClient, err := ensureGitHubClient(ctx, streams, tui.PromptHidden)
if err != nil {
return err
}

for _, r := range results {
state := "success"
if !r.Passed {
state = "failure"
}
if postErr := ghClient.CreateCommitStatus(ctx, org, repo, commitSHA, state, "chunk/"+r.Name, "chunk validate: "+r.Name); postErr != nil {
streams.ErrPrintf(" %s\n", ui.Warning(fmt.Sprintf("could not post status for %s: %v", r.Name, postErr)))
continue
}
streams.ErrPrintf(" %s %s/%s → %s\n", ui.Success("posted"), org, repo, r.Name)
}
return nil
},
}

cmd.Flags().StringVar(&projectDir, "project", "", "Override project directory")
return cmd
}

// openSSHSession establishes an SSH session to the sidecar and returns an
Expand Down Expand Up @@ -369,7 +451,8 @@ func runSplitCommands(ctx context.Context, sidecarID string, freshlyCreated bool
}
}
if len(localCfg.Commands) > 0 {
if err := mapValidateError(validate.RunAll(ctx, workDir, localCfg, statusFn, streams)); err != nil {
_, localErr := validate.RunAll(ctx, workDir, localCfg, statusFn, streams)
if err := mapValidateError(localErr); err != nil {
runErr = errors.Join(runErr, err)
}
}
Expand Down Expand Up @@ -500,6 +583,19 @@ func resolveOrCreateSidecarID(ctx context.Context, sidecarID *string, orgID, ima
return true, nil
}

func saveValidateResults(workDir string, results []validate.CommandResult) {
if len(results) == 0 {
return
}
cfg, err := config.LoadProjectConfig(workDir)
if err != nil || cfg.Validation == nil || !cfg.Validation.CommitStatus {
return
}
if treeSHA, err := gitutil.ComputeTreeSHA(workDir); err == nil {
_ = validate.SaveResults(treeSHA, results)
}
}

func mapValidateError(err error) error {
if errors.Is(err, validate.ErrNotConfigured) {
return &userError{
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,5 @@ var ValidConfigKeys = map[string]bool{
var ValidProjectConfigKeys = map[string]bool{
"orgID": true,
"validation.sidecarImage": true,
"validation.commitStatus": true,
}
1 change: 1 addition & 0 deletions internal/config/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type VCSConfig struct {
// ValidationConfig holds project-level defaults for validation behaviour.
type ValidationConfig struct {
SidecarImage string `json:"sidecarImage,omitempty"`
CommitStatus bool `json:"commitStatus,omitempty"`
}

// ProjectConfig is the per-repo configuration stored in .chunk/config.json.
Expand Down
28 changes: 28 additions & 0 deletions internal/github/statuses.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package github

import (
"context"
"fmt"

hc "github.com/CircleCI-Public/chunk-cli/internal/httpcl"
)

// CreateCommitStatus posts a commit status for the given SHA.
// state must be one of: "pending", "success", "failure", "error".
// statusContext is the check name shown in GitHub (e.g. "chunk/test").
func (c *Client) CreateCommitStatus(ctx context.Context, owner, repo, sha, state, statusContext, description string) error {
body := map[string]string{
"state": state,
"context": statusContext,
"description": description,
}
req := hc.NewRequest("POST", "/repos/%s/%s/statuses/%s",
hc.RouteParams(owner, repo, sha),
hc.Body(body),
)
_, err := c.http.Call(ctx, req)
if err == nil {
return nil
}
return mapErr(fmt.Sprintf("create commit status %s", statusContext), err)
}
Loading