Skip to content

Commit 2893c21

Browse files
websterclaude
andcommitted
Add validate result recording and GitHub commit status reporting
- ComputeTreeSHA: uses a temp index copy (GIT_INDEX_FILE) to compute the git tree SHA of the full worktree including untracked files, without touching the real index - validate.CommandResult + SaveResults/LoadResults: persist per-command pass/fail results keyed to tree SHA in /tmp/chunk-run/trees/ - RunAll/RunNamed/RunInline now return ([]CommandResult, error) - chunk validate records results after every local run - chunk validate post-commit: reads stored results by HEAD^{tree} SHA and posts a GitHub commit status per command - chunk init writes .git/hooks/post-commit to call post-commit automatically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fdea783 commit 2893c21

10 files changed

Lines changed: 401 additions & 35 deletions

File tree

internal/cmd/init.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/CircleCI-Public/chunk-cli/internal/anthropic"
1515
"github.com/CircleCI-Public/chunk-cli/internal/config"
1616
"github.com/CircleCI-Public/chunk-cli/internal/gitremote"
17+
"github.com/CircleCI-Public/chunk-cli/internal/gitutil"
1718
"github.com/CircleCI-Public/chunk-cli/internal/iostream"
1819
"github.com/CircleCI-Public/chunk-cli/internal/settings"
1920
"github.com/CircleCI-Public/chunk-cli/internal/tui"
@@ -139,6 +140,36 @@ func writeSettingsExample(dir string, data []byte, streams iostream.Streams) err
139140
return nil
140141
}
141142

143+
const postCommitHookContent = "#!/bin/sh\nchunk validate post-commit\n"
144+
145+
// writePostCommitHook writes .git/hooks/post-commit to call chunk validate post-commit.
146+
// Skips if the file already contains the hook. Warns if the file exists with different content.
147+
func writePostCommitHook(workDir string, streams iostream.Streams) error {
148+
root, err := gitutil.RepoRoot(workDir)
149+
if err != nil {
150+
return fmt.Errorf("find repo root: %w", err)
151+
}
152+
hookPath := filepath.Join(root, ".git", "hooks", "post-commit")
153+
154+
existing, readErr := os.ReadFile(hookPath)
155+
if readErr == nil {
156+
if strings.Contains(string(existing), "chunk validate post-commit") {
157+
return nil
158+
}
159+
streams.ErrPrintf("%s\n", ui.Warning(fmt.Sprintf("Skipping post-commit hook: %s already exists with different content", hookPath)))
160+
return nil
161+
}
162+
if !errors.Is(readErr, fs.ErrNotExist) {
163+
return fmt.Errorf("read post-commit hook: %w", readErr)
164+
}
165+
166+
if err := os.WriteFile(hookPath, []byte(postCommitHookContent), 0o755); err != nil {
167+
return fmt.Errorf("write post-commit hook: %w", err)
168+
}
169+
streams.ErrPrintln(ui.Success("Wrote .git/hooks/post-commit"))
170+
return nil
171+
}
172+
142173
var sidecarGitignoreEntries = []string{
143174
".chunk/sidecar.json",
144175
".chunk/sidecar.*.json",
@@ -282,11 +313,14 @@ hook config files.`,
282313
streams.ErrPrintf("%s\n", ui.Warning(fmt.Sprintf("Could not update .gitignore: %v", err)))
283314
}
284315

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

292326
// Step 4: Shell completions

internal/cmd/validate.go

Lines changed: 103 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import (
88
"fmt"
99
"io"
1010
"os"
11+
"os/exec"
1112
"path/filepath"
1213
"strings"
1314

1415
"github.com/spf13/cobra"
1516
"golang.org/x/term"
1617

1718
"github.com/CircleCI-Public/chunk-cli/internal/config"
19+
"github.com/CircleCI-Public/chunk-cli/internal/gitremote"
20+
"github.com/CircleCI-Public/chunk-cli/internal/gitutil"
1821
"github.com/CircleCI-Public/chunk-cli/internal/iostream"
1922
"github.com/CircleCI-Public/chunk-cli/internal/sidecar"
2023
"github.com/CircleCI-Public/chunk-cli/internal/tui"
@@ -164,7 +167,13 @@ func newValidateCmd() *cobra.Command {
164167
}
165168
}
166169

167-
execErr := runValidate(cmd.Context(), workDir, name, inlineCmd, save, sidecarID, identityFile, workdir, allRemote, cfg, statusFn, streams)
170+
results, execErr := runValidate(cmd.Context(), workDir, name, inlineCmd, save, sidecarID, identityFile, workdir, allRemote, cfg, statusFn, streams)
171+
172+
if results != nil {
173+
if treeSHA, treeErr := gitutil.ComputeTreeSHA(workDir); treeErr == nil {
174+
_ = validate.SaveResults(treeSHA, results)
175+
}
176+
}
168177

169178
if hook != nil {
170179
maxAttempts := cfg.StopHookMaxAttempts
@@ -188,32 +197,35 @@ func newValidateCmd() *cobra.Command {
188197
cmd.Flags().BoolVar(&save, "save", false, "Save --cmd to .chunk/config.json")
189198
cmd.Flags().StringVar(&projectDir, "project", "", "Override project directory")
190199

200+
cmd.AddCommand(newPostCommitCmd())
201+
191202
return cmd
192203
}
193204

194205
// runValidate dispatches to the appropriate Run* function based on the
195206
// provided options. It is shared by both direct and hook invocations.
196207
// allRemote is true when --remote is passed explicitly (all commands run on the
197208
// sidecar); false means only commands with Remote:true are routed to the sidecar.
198-
func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID, identityFile, workdir string, allRemote bool, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) error {
199-
// --cmd: inline command (always local in per-command mode)
209+
// Returns nil results for remote/sidecar runs (no result recording on those paths).
210+
func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID, identityFile, workdir string, allRemote bool, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) ([]validate.CommandResult, error) {
211+
// --cmd: inline command
200212
if inlineCmd != "" {
201213
cmdName := name
202214
if cmdName == "" {
203215
cmdName = "custom"
204216
}
205217
if save {
206218
if err := config.SaveCommand(workDir, cmdName, inlineCmd); err != nil {
207-
return &userError{msg: "Could not save command to .chunk/config.json.", err: err}
219+
return nil, &userError{msg: "Could not save command to .chunk/config.json.", err: err}
208220
}
209221
streams.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Saved %s to .chunk/config.json", cmdName)))
210222
}
211223
if sidecarID != "" && allRemote {
212224
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
213225
if err != nil {
214-
return err
226+
return nil, err
215227
}
216-
return validate.RunRemoteInline(ctx, execFn, cmdName, inlineCmd, dest, statusFn, streams)
228+
return nil, validate.RunRemoteInline(ctx, execFn, cmdName, inlineCmd, dest, statusFn, streams)
217229
}
218230
return validate.RunInline(ctx, workDir, cmdName, inlineCmd, statusFn, streams)
219231
}
@@ -222,9 +234,9 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
222234
if sidecarID != "" && allRemote {
223235
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
224236
if err != nil {
225-
return err
237+
return nil, err
226238
}
227-
return validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams)
239+
return nil, validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams)
228240
}
229241

230242
// Per-command remote routing: commands with Remote:true go to the sidecar,
@@ -235,9 +247,9 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
235247
statusFn(iostream.LevelInfo, fmt.Sprintf("running %s on sidecar %s", name, sidecarID))
236248
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
237249
if err != nil {
238-
return err
250+
return nil, err
239251
}
240-
return validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams)
252+
return nil, validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams)
241253
}
242254
statusFn(iostream.LevelInfo, fmt.Sprintf("running %s locally (not marked remote)", name))
243255
// Named command is not marked remote; fall through to local execution.
@@ -261,19 +273,20 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
261273
}
262274
}
263275
if len(localCfg.Commands) > 0 {
264-
if err := mapValidateError(validate.RunAll(ctx, workDir, localCfg, statusFn, streams)); err != nil {
276+
_, localErr := validate.RunAll(ctx, workDir, localCfg, statusFn, streams)
277+
if err := mapValidateError(localErr); err != nil {
265278
runErr = errors.Join(runErr, err)
266279
}
267280
}
268-
return runErr
281+
return nil, runErr
269282
}
270283
}
271284

272285
// Named command
273286
if name != "" {
274287
if cfg.FindCommand(name) == nil {
275288
if !term.IsTerminal(int(os.Stdin.Fd())) {
276-
return &userError{
289+
return nil, &userError{
277290
msg: fmt.Sprintf("Command %q is not configured.", name),
278291
suggestion: "Add it to .chunk/config.json.",
279292
errMsg: fmt.Sprintf("command %q is not configured", name),
@@ -284,28 +297,99 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
284297
streams.ErrPrintf("What command should %s run? ", ui.Bold(name))
285298
scanner := bufio.NewScanner(os.Stdin)
286299
if !scanner.Scan() {
287-
return &userError{msg: "No command entered.", errMsg: "no input received"}
300+
return nil, &userError{msg: "No command entered.", errMsg: "no input received"}
288301
}
289302
input := strings.TrimSpace(scanner.Text())
290303
if input == "" {
291304
streams.ErrPrintln(ui.Dim("No command entered, aborting."))
292-
return &userError{msg: "No command entered.", errMsg: "no command entered"}
305+
return nil, &userError{msg: "No command entered.", errMsg: "no command entered"}
293306
}
294307
if err := config.SaveCommand(workDir, name, input); err != nil {
295-
return &userError{msg: "Could not save command to .chunk/config.json.", err: err}
308+
return nil, &userError{msg: "Could not save command to .chunk/config.json.", err: err}
296309
}
297310
streams.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Saved %s to .chunk/config.json", name)))
298311
var err error
299312
cfg, err = config.LoadProjectConfig(workDir)
300313
if err != nil {
301-
return err
314+
return nil, err
302315
}
303316
}
304-
return mapValidateError(validate.RunNamed(ctx, workDir, name, cfg, statusFn, streams))
317+
results, err := validate.RunNamed(ctx, workDir, name, cfg, statusFn, streams)
318+
return results, mapValidateError(err)
305319
}
306320

307321
// Run all
308-
return mapValidateError(validate.RunAll(ctx, workDir, cfg, statusFn, streams))
322+
results, err := validate.RunAll(ctx, workDir, cfg, statusFn, streams)
323+
return results, mapValidateError(err)
324+
}
325+
326+
func newPostCommitCmd() *cobra.Command {
327+
var projectDir string
328+
329+
cmd := &cobra.Command{
330+
Use: "post-commit",
331+
Short: "Report validate results as GitHub commit statuses",
332+
SilenceUsage: true,
333+
RunE: func(cmd *cobra.Command, _ []string) error {
334+
streams := iostream.FromCmd(cmd)
335+
ctx := cmd.Context()
336+
337+
workDir := projectDir
338+
if workDir == "" {
339+
var err error
340+
workDir, err = os.Getwd()
341+
if err != nil {
342+
return err
343+
}
344+
}
345+
346+
treeOut, err := exec.Command("git", "-C", workDir, "rev-parse", "HEAD^{tree}").Output()
347+
if err != nil {
348+
return fmt.Errorf("resolve HEAD tree: %w", err)
349+
}
350+
treeSHA := strings.TrimSpace(string(treeOut))
351+
352+
results, found, err := validate.LoadResults(treeSHA)
353+
if err != nil {
354+
return fmt.Errorf("load validate results: %w", err)
355+
}
356+
if !found {
357+
return nil
358+
}
359+
360+
commitOut, err := exec.Command("git", "-C", workDir, "rev-parse", "HEAD").Output()
361+
if err != nil {
362+
return fmt.Errorf("resolve HEAD: %w", err)
363+
}
364+
commitSHA := strings.TrimSpace(string(commitOut))
365+
366+
org, repo, err := gitremote.DetectOrgAndRepo(workDir)
367+
if err != nil {
368+
return fmt.Errorf("detect repo: %w", err)
369+
}
370+
371+
ghClient, err := ensureGitHubClient(ctx, streams, tui.PromptHidden)
372+
if err != nil {
373+
return err
374+
}
375+
376+
for _, r := range results {
377+
state := "success"
378+
if !r.Passed {
379+
state = "failure"
380+
}
381+
if postErr := ghClient.CreateCommitStatus(ctx, org, repo, commitSHA, state, "chunk/"+r.Name, "chunk validate: "+r.Name); postErr != nil {
382+
streams.ErrPrintf(" %s\n", ui.Warning(fmt.Sprintf("could not post status for %s: %v", r.Name, postErr)))
383+
continue
384+
}
385+
streams.ErrPrintf(" %s %s/%s → %s\n", ui.Success("posted"), org, repo, r.Name)
386+
}
387+
return nil
388+
},
389+
}
390+
391+
cmd.Flags().StringVar(&projectDir, "project", "", "Override project directory")
392+
return cmd
309393
}
310394

311395
// openSSHSession establishes an SSH session to the sidecar and returns an

internal/github/statuses.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
hc "github.com/CircleCI-Public/chunk-cli/internal/httpcl"
8+
)
9+
10+
// CreateCommitStatus posts a commit status for the given SHA.
11+
// state must be one of: "pending", "success", "failure", "error".
12+
// statusContext is the check name shown in GitHub (e.g. "chunk/test").
13+
func (c *Client) CreateCommitStatus(ctx context.Context, owner, repo, sha, state, statusContext, description string) error {
14+
body := map[string]string{
15+
"state": state,
16+
"context": statusContext,
17+
"description": description,
18+
}
19+
req := hc.NewRequest("POST", "/repos/%s/%s/statuses/%s",
20+
hc.RouteParams(owner, repo, sha),
21+
hc.Body(body),
22+
)
23+
_, err := c.http.Call(ctx, req)
24+
if err == nil {
25+
return nil
26+
}
27+
return mapErr(fmt.Sprintf("create commit status %s", statusContext), err)
28+
}

internal/github/statuses_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package github_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/CircleCI-Public/chunk-cli/internal/github"
12+
"gotest.tools/v3/assert"
13+
)
14+
15+
func TestCreateCommitStatus(t *testing.T) {
16+
var gotMethod, gotPath string
17+
var gotBody map[string]string
18+
19+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
gotMethod = r.Method
21+
gotPath = r.URL.Path
22+
b, _ := io.ReadAll(r.Body)
23+
_ = json.Unmarshal(b, &gotBody)
24+
w.WriteHeader(http.StatusCreated)
25+
}))
26+
defer srv.Close()
27+
28+
c, err := github.New(github.Config{Token: "test-token", BaseURL: srv.URL})
29+
assert.NilError(t, err)
30+
31+
err = c.CreateCommitStatus(context.Background(), "myorg", "myrepo", "abc123", "success", "chunk/test", "chunk validate: test")
32+
assert.NilError(t, err)
33+
34+
assert.Equal(t, gotMethod, "POST")
35+
assert.Equal(t, gotPath, "/repos/myorg/myrepo/statuses/abc123")
36+
assert.Equal(t, gotBody["state"], "success")
37+
assert.Equal(t, gotBody["context"], "chunk/test")
38+
assert.Equal(t, gotBody["description"], "chunk validate: test")
39+
}
40+
41+
func TestCreateCommitStatus_Error(t *testing.T) {
42+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43+
w.WriteHeader(http.StatusUnprocessableEntity)
44+
}))
45+
defer srv.Close()
46+
47+
c, err := github.New(github.Config{Token: "test-token", BaseURL: srv.URL})
48+
assert.NilError(t, err)
49+
50+
err = c.CreateCommitStatus(context.Background(), "org", "repo", "sha", "success", "chunk/test", "desc")
51+
assert.Assert(t, err != nil)
52+
}

0 commit comments

Comments
 (0)