@@ -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
0 commit comments