Skip to content

Commit 5e6483c

Browse files
ralphbeanclaude
andauthored
Add --format json support for verifycommit and audit commands (#318)
* add --format json support for verifycommit and audit commands This change adds machine-readable JSON output to the verifycommit and audit commands, making it easier to extract data programmatically (e.g., with jq) instead of parsing text output with bash. Changes: - Add common OutputFormat framework (internal/cmd/output.go) - Add --format flag to verifycommit command with JSON support - Add --format flag to audit command with JSON support - Include comprehensive test coverage for both commands The JSON output includes: - verifycommit: success status, commit info, verified SLSA levels - audit: commit results with summary statistics All tests pass and the implementation maintains backward compatibility with existing text output (default format). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Fix golangci-lint issues in output formatting code - Add status constants (statusPassed, statusFailed) to avoid string repetition - Rename writeText to writeTextf following Go printf naming conventions - Fix errcheck by adding nolint comment for intentional error ignore - Auto-fix gci import grouping issues All linting issues now resolved. Tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Refactor audit command to use single loop for both JSON and text output This addresses the code duplication issue raised in PR review. Instead of having separate loops for JSON and text output that could diverge over time, we now use a single loop that handles both cases. Changes: - Initialize JSON result structure before the loop (if needed) - Single loop processes results for both formats - Within loop, conditionally call JSON conversion or text printing - Early termination conditions work for both formats - Summary statistics (passed/failed counts) now tracked for both formats This makes the code more maintainable and ensures JSON and text outputs stay in sync. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Improve test robustness with semantic JSON comparison Replace string-based JSON comparison with semantic comparison using reflect.DeepEqual. This makes tests more robust against: - Field ordering changes in JSON output - Whitespace/formatting differences - Future changes to JSON encoder settings Changes: - Add shared assertJSONEqual helper function - Convert test expectations from JSON strings to Go structs - Tests now compare semantic meaning rather than exact string format This addresses the code review feedback about brittle string comparisons. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Implement idiomatic Go output handling with String() and writeResult() This addresses PR review feedback to use Go idioms for output formatting: 1. Added String() method to VerifyCommitResult - Implements fmt.Stringer interface for text output - Eliminates need for manual string formatting at call sites 2. Created writeResult() method in outputOptions - Automatically selects JSON or text output based on format - Uses String() method for text when available - Simplifies call sites from 4-6 lines to 1 line Benefits: - More idiomatic Go code using standard interfaces - Eliminates repetitive if/else checks for output format - Cleaner, more maintainable code - Easier to add new output types in future Example simplification in verifycommit.go: Before: if opts.isJSON() { ... } else { opts.writeTextf(...) } After: return opts.writeResult(result) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Simplify OutputFormat from int to string type Change OutputFormat from an int-based custom type with String(), Set(), and Type() methods to simple string constants. This eliminates unnecessary type conversion logic between int and string. - Remove OutputFormat custom type and its methods - Use string constants: OutputFormatText = "text", OutputFormatJSON = "json" - Update format field in outputOptions from OutputFormat to string - Change flag registration from Var() to StringVar() in audit.go and verifycommit.go - Update tests to use string type instead of OutputFormat type - Remove obsolete tests for OutputFormat.String() and OutputFormat.Set() This addresses code review feedback requesting a simpler string-based approach instead of the int-based enum pattern. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> Signed-off-by: Ralph Bean <[email protected]> * Replace writer property with getWriter() method Replace the stored io.Writer property with a getWriter() method that returns os.Stdout. This follows the "open -> write -> close" usage pattern and prepares for future expansion to support file output. - Remove writer field from outputOptions struct - Add getWriter() method that returns os.Stdout - Remove init() method that was only used to initialize writer - Update all methods to call getWriter() instead of using oo.writer - Update test to work without direct writer injection This addresses code review feedback requesting that the writer not be stored as a property, with the option to expand getWriter() later to support file output. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> Signed-off-by: Ralph Bean <[email protected]> * Rename isJSON() to outputFormatIsJSON() for clarity Rename the isJSON() method to outputFormatIsJSON() to make it more descriptive when called on embedded option sets. This improves code readability when the method is accessed as `opts.outputFormatIsJSON()` instead of the more ambiguous `opts.isJSON()`. - Rename isJSON() to outputFormatIsJSON() in outputOptions - Update all call sites in audit.go to use new method name - Update test name and assertions to match new method name This addresses code review feedback requesting a more descriptive method name for clarity when outputOptions is embedded in other structs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> Signed-off-by: Ralph Bean <[email protected]> * Add AddFlags() and Validate() methods to outputOptions Add architecture methods to outputOptions to follow the established pattern used by other option sets in the codebase (branchOptions, verifierOptions, etc.). - Add AddFlags() method to initialize format and register the --format flag - Add Validate() method to verify format is either 'text' or 'json' - Add test coverage for Validate() method with valid and invalid inputs - Import cobra package for AddFlags() implementation This prepares outputOptions to be properly integrated into embedding option sets following the command architecture pattern. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> Signed-off-by: Ralph Bean <[email protected]> * Integrate outputOptions into embedding option sets Update auditOpts and verifyCommitOptions to properly use the new outputOptions architecture methods instead of directly managing the format flag. - Call outputOptions.AddFlags() in both auditOpts and verifyCommitOptions - Call outputOptions.Validate() in both Validate() methods - Remove direct format flag registration from both files - Remove manual format initialization (now handled by AddFlags()) This completes the refactoring to follow the established command architecture pattern where option sets manage their own flags and validation through AddFlags() and Validate() methods. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> Signed-off-by: Ralph Bean <[email protected]> --------- Signed-off-by: Ralph Bean <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent c6bcaa0 commit 5e6483c

File tree

5 files changed

+770
-16
lines changed

5 files changed

+770
-16
lines changed

internal/cmd/audit.go

Lines changed: 133 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ const (
2222
AuditModeFull AuditMode = 2
2323
)
2424

25+
const (
26+
statusPassed = "passed"
27+
statusFailed = "failed"
28+
)
29+
2530
// Enable audit mode enum
2631
// String is used both by fmt.Print and by Cobra in help text
2732
func (e *AuditMode) String() string {
@@ -56,22 +61,56 @@ func (e *AuditMode) Type() string {
5661
type auditOpts struct {
5762
branchOptions
5863
verifierOptions
64+
outputOptions
5965
auditDepth int
6066
endingCommit string
6167
auditMode AuditMode
6268
}
6369

70+
// AuditCommitResultJSON represents a single commit audit result in JSON format
71+
type AuditCommitResultJSON struct {
72+
Commit string `json:"commit"`
73+
Status string `json:"status"`
74+
VerifiedLevels []string `json:"verified_levels,omitempty"`
75+
PrevCommitMatches *bool `json:"prev_commit_matches,omitempty"`
76+
ProvControls interface{} `json:"prov_controls,omitempty"`
77+
GhControls interface{} `json:"gh_controls,omitempty"`
78+
PrevCommit string `json:"prev_commit,omitempty"`
79+
GhPriorCommit string `json:"gh_prior_commit,omitempty"`
80+
Link string `json:"link,omitempty"`
81+
Error string `json:"error,omitempty"`
82+
}
83+
84+
// AuditResultJSON represents the full audit result in JSON format
85+
type AuditResultJSON struct {
86+
Owner string `json:"owner"`
87+
Repository string `json:"repository"`
88+
Branch string `json:"branch"`
89+
LatestCommit string `json:"latest_commit"`
90+
CommitResults []AuditCommitResultJSON `json:"commit_results"`
91+
Summary *AuditSummary `json:"summary,omitempty"`
92+
}
93+
94+
// AuditSummary provides summary statistics for the audit
95+
type AuditSummary struct {
96+
TotalCommits int `json:"total_commits"`
97+
PassedCommits int `json:"passed_commits"`
98+
FailedCommits int `json:"failed_commits"`
99+
}
100+
64101
func (ao *auditOpts) Validate() error {
65102
errs := []error{
66103
ao.branchOptions.Validate(),
67104
ao.verifierOptions.Validate(),
105+
ao.outputOptions.Validate(),
68106
}
69107
return errors.Join(errs...)
70108
}
71109

72110
func (ao *auditOpts) AddFlags(cmd *cobra.Command) {
73111
ao.branchOptions.AddFlags(cmd)
74112
ao.verifierOptions.AddFlags(cmd)
113+
ao.outputOptions.AddFlags(cmd)
75114
cmd.PersistentFlags().IntVar(&ao.auditDepth, "depth", 0, "The max number of revisions to audit (depth <= audit all revisions).")
76115
cmd.PersistentFlags().StringVar(&ao.endingCommit, "ending-commit", "", "The commit to stop auditing at.")
77116
ao.auditMode = AuditModeBasic
@@ -124,9 +163,9 @@ Future:
124163

125164
func printResult(ghc *ghcontrol.GitHubConnection, ar *audit.AuditCommitResult, mode AuditMode) {
126165
good := ar.IsGood()
127-
status := "passed"
166+
status := statusPassed
128167
if !good {
129-
status = "failed"
168+
status = statusFailed
130169
}
131170
fmt.Printf("commit: %s - %v\n", ar.Commit, status)
132171

@@ -157,6 +196,41 @@ func printResult(ghc *ghcontrol.GitHubConnection, ar *audit.AuditCommitResult, m
157196
fmt.Printf("\tlink: https://github.com/%s/%s/commit/%s\n", ghc.Owner(), ghc.Repo(), ar.GhPriorCommit)
158197
}
159198

199+
func convertAuditResultToJSON(ghc *ghcontrol.GitHubConnection, ar *audit.AuditCommitResult, mode AuditMode) AuditCommitResultJSON {
200+
good := ar.IsGood()
201+
status := statusPassed
202+
if !good {
203+
status = statusFailed
204+
}
205+
206+
result := AuditCommitResultJSON{
207+
Commit: ar.Commit,
208+
Status: status,
209+
Link: fmt.Sprintf("https://github.com/%s/%s/commit/%s", ghc.Owner(), ghc.Repo(), ar.GhPriorCommit),
210+
}
211+
212+
// Only include details if mode is Full or status is failed
213+
if mode == AuditModeFull || !good {
214+
if ar.VsaPred != nil {
215+
result.VerifiedLevels = ar.VsaPred.GetVerifiedLevels()
216+
}
217+
218+
if ar.ProvPred != nil {
219+
result.ProvControls = ar.ProvPred.GetControls()
220+
result.PrevCommit = ar.ProvPred.GetPrevCommit()
221+
result.GhPriorCommit = ar.GhPriorCommit
222+
matches := ar.ProvPred.GetPrevCommit() == ar.GhPriorCommit
223+
result.PrevCommitMatches = &matches
224+
}
225+
226+
if ar.GhControlStatus != nil {
227+
result.GhControls = ar.GhControlStatus.Controls
228+
}
229+
}
230+
231+
return result
232+
}
233+
160234
func doAudit(auditArgs *auditOpts) error {
161235
ghc := ghcontrol.NewGhConnection(auditArgs.owner, auditArgs.repository, ghcontrol.BranchToFullRef(auditArgs.branch)).WithAuthToken(githubToken)
162236
ctx := context.Background()
@@ -170,27 +244,76 @@ func doAudit(auditArgs *auditOpts) error {
170244
return fmt.Errorf("could not get latest commit for %s", auditArgs.branch)
171245
}
172246

173-
fmt.Printf("Auditing branch %s starting from revision %s\n", auditArgs.branch, latestCommit)
247+
// Initialize JSON result structure if needed
248+
var jsonResult *AuditResultJSON
249+
if auditArgs.outputFormatIsJSON() {
250+
jsonResult = &AuditResultJSON{
251+
Owner: auditArgs.owner,
252+
Repository: auditArgs.repository,
253+
Branch: auditArgs.branch,
254+
LatestCommit: latestCommit,
255+
CommitResults: []AuditCommitResultJSON{},
256+
}
257+
} else {
258+
// Print header for text output
259+
auditArgs.writeTextf("Auditing branch %s starting from revision %s\n", auditArgs.branch, latestCommit)
260+
}
174261

262+
// Single loop for both JSON and text output
175263
count := 0
264+
passed := 0
265+
failed := 0
266+
176267
for ar, err := range auditor.AuditBranch(ctx, auditArgs.branch) {
177268
if ar == nil {
178269
return err
179270
}
180-
if err != nil {
181-
fmt.Printf("\terror: %v\n", err)
271+
272+
// Process result based on output format
273+
if auditArgs.outputFormatIsJSON() {
274+
commitResult := convertAuditResultToJSON(ghc, ar, auditArgs.auditMode)
275+
if err != nil {
276+
commitResult.Error = err.Error()
277+
}
278+
if commitResult.Status == statusPassed {
279+
passed++
280+
} else {
281+
failed++
282+
}
283+
jsonResult.CommitResults = append(jsonResult.CommitResults, commitResult)
284+
} else {
285+
// Text output
286+
if err != nil {
287+
auditArgs.writeTextf("\terror: %v\n", err)
288+
}
289+
printResult(ghc, ar, auditArgs.auditMode)
182290
}
183-
printResult(ghc, ar, auditArgs.auditMode)
291+
292+
// Check for early termination conditions
184293
if auditArgs.endingCommit != "" && auditArgs.endingCommit == ar.Commit {
185-
fmt.Printf("Found ending commit %s\n", auditArgs.endingCommit)
186-
return nil
294+
if !auditArgs.outputFormatIsJSON() {
295+
auditArgs.writeTextf("Found ending commit %s\n", auditArgs.endingCommit)
296+
}
297+
break
187298
}
188299
if auditArgs.auditDepth > 0 && count >= auditArgs.auditDepth {
189-
fmt.Printf("Reached depth limit %d\n", auditArgs.auditDepth)
190-
return nil
300+
if !auditArgs.outputFormatIsJSON() {
301+
auditArgs.writeTextf("Reached depth limit %d\n", auditArgs.auditDepth)
302+
}
303+
break
191304
}
192305
count++
193306
}
194307

308+
// Write JSON output if needed
309+
if auditArgs.outputFormatIsJSON() {
310+
jsonResult.Summary = &AuditSummary{
311+
TotalCommits: len(jsonResult.CommitResults),
312+
PassedCommits: passed,
313+
FailedCommits: failed,
314+
}
315+
return auditArgs.writeJSON(jsonResult)
316+
}
317+
195318
return nil
196319
}

0 commit comments

Comments
 (0)