Skip to content

Commit 5a19c28

Browse files
authored
feat: merge gh aw audit report into gh aw logs --format (#24396)
1 parent e3c1c27 commit 5a19c28

14 files changed

+123
-348
lines changed

docs/src/content/docs/blog/2026-03-30-weekly-update.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ Security fixes:
3434

3535
### [v0.64.2](https://github.com/github/gh-aw/releases/tag/v0.64.2) — March 26
3636

37-
The `gh aw audit` command got a powerful new subcommand:
37+
The `gh aw logs` command gained cross-run report generation via the new `--format` flag:
3838

39-
**`gh aw audit report`** aggregates firewall behavior across multiple workflow runs and produces an executive summary, domain inventory, and per-run breakdown:
39+
**`gh aw logs --format`** aggregates firewall behavior across multiple workflow runs and produces an executive summary, domain inventory, and per-run breakdown:
4040

4141
```bash
42-
gh aw audit report --workflow "agent-task" --last 10 # Markdown
43-
gh aw audit report --last 5 --json # JSON for dashboards
44-
gh aw audit report --format pretty # Console output
42+
gh aw logs agent-task --format markdown --count 10 # Markdown
43+
gh aw logs --format markdown --json # JSON for dashboards
44+
gh aw logs --format pretty # Console output
4545
```
4646

4747
This release also includes a **YAML env injection security fix** ([#23055](https://github.com/github/gh-aw/pull/23055)): all `env:` emission sites in the compiler now use `%q`-escaped YAML scalars, preventing newlines or quote characters in frontmatter values from injecting sibling env variables into `.lock.yml` files.

docs/src/content/docs/reference/audit.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,18 +89,20 @@ gh aw audit diff 12345 12346 --json
8989
gh aw audit diff 12345 12346 --repo owner/repo
9090
```
9191

92-
## `gh aw audit report`
92+
## `gh aw logs --format <fmt>`
9393

9494
Generate a cross-run security and performance audit report across multiple recent workflow runs.
95+
This feature is built into the `gh aw logs` command via the `--format` flag.
9596

9697
**Flags:**
9798

9899
| Flag | Default | Description |
99100
|------|---------|-------------|
100-
| `-w, --workflow <name>` | all | Filter by workflow name or filename |
101-
| `--last <n>` | 20 | Number of recent runs to analyze (max 50) |
102-
| `--format <fmt>` | `markdown` | Output format: `markdown` or `pretty` |
103-
| `--json` | off | Output report as JSON |
101+
| `[workflow]` | all workflows | Filter by workflow name or filename (positional argument) |
102+
| `-c, --count <n>` | 10 | Number of recent runs to analyze |
103+
| `--last <n>` || Alias for `--count` |
104+
| `--format <fmt>` || Output format: `markdown` or `pretty` (generates cross-run audit report) |
105+
| `--json` | off | Output cross-run report as JSON (when combined with `--format`) |
104106
| `--repo <owner/repo>` | auto | Specify repository |
105107
| `-o, --output <dir>` | `./logs` | Directory for downloaded artifacts |
106108
| `--verbose` | off | Print detailed progress |
@@ -110,11 +112,11 @@ The report output includes an executive summary, domain inventory, metrics trend
110112
**Examples:**
111113

112114
```bash
113-
gh aw audit report
114-
gh aw audit report --workflow "daily-repo-status" --last 10
115-
gh aw audit report --workflow "agent-task" --last 5 --json
116-
gh aw audit report --format pretty
117-
gh aw audit report --repo owner/repo --last 10
115+
gh aw logs --format markdown
116+
gh aw logs daily-repo-status --format markdown --count 10
117+
gh aw logs agent-task --format markdown --last 5 --json
118+
gh aw logs --format pretty
119+
gh aw logs --format markdown --repo owner/repo --count 10
118120
```
119121

120122
## Related Documentation

docs/src/content/docs/reference/cost-management.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ These are rough estimates to help with budgeting. Actual costs vary by prompt si
218218
| On-demand via slash command | User-controlled | Varies | Varies |
219219

220220
> [!TIP]
221-
> Use `gh aw audit <run-id>` to deep-dive into token usage and cost for a single run. Use `gh aw audit report --workflow <name>` to analyze cost trends across multiple runs. Create separate `COPILOT_GITHUB_TOKEN` service accounts per repository or team to attribute spend by workflow.
221+
> Use `gh aw audit <run-id>` to deep-dive into token usage and cost for a single run. Use `gh aw logs --format markdown [workflow]` to analyze cost trends across multiple runs. Create separate `COPILOT_GITHUB_TOKEN` service accounts per repository or team to attribute spend by workflow.
222222
223223
## Related Documentation
224224

docs/src/content/docs/reference/glossary.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,9 +361,9 @@ An interactive web-based editor for authoring, compiling, and previewing agentic
361361

362362
A `gh aw audit` subcommand that compares firewall behavior across two workflow runs. Reports domain additions and removals, allowed/denied status changes, request volume drift, and anomaly flags. Outputs results in pretty, markdown, or JSON format. Useful for spotting regressions and behavioral drift between runs. See [CLI Reference](/gh-aw/setup/cli/#audit-diff).
363363

364-
### Audit Report (`gh aw audit report`)
364+
### Cross-Run Audit Report (`gh aw logs --format`)
365365

366-
A `gh aw audit` subcommand that aggregates firewall data across multiple workflow runs to produce a cross-run security report. The report includes an executive summary, domain inventory, and per-run breakdown. Designed for security reviews, compliance checks, and feeding debugging or optimization agents. Outputs markdown by default (suitable for `$GITHUB_STEP_SUMMARY`), or pretty/JSON format. See [CLI Reference](/gh-aw/setup/cli/#audit-report).
366+
A feature of `gh aw logs` that aggregates firewall data across multiple workflow runs to produce a cross-run security report. The report includes an executive summary, domain inventory, and per-run breakdown. Designed for security reviews, compliance checks, and feeding debugging or optimization agents. Outputs markdown by default (suitable for `$GITHUB_STEP_SUMMARY`), or pretty/JSON format. See [CLI Reference](/gh-aw/setup/cli/#logs).
367367

368368
### Frontmatter Hash
369369

pkg/cli/audit.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@ Examples:
117117

118118
// Add subcommands
119119
cmd.AddCommand(NewAuditDiffSubcommand())
120-
cmd.AddCommand(NewAuditReportSubcommand())
121120

122121
return cmd
123122
}

pkg/cli/audit_cross_run.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@ import (
99

1010
var auditCrossRunLog = logger.New("cli:audit_cross_run")
1111

12-
// maxAuditReportRuns is the upper bound on runs to analyze in a single report
13-
// to bound download time and memory usage.
14-
const maxAuditReportRuns = 50
15-
1612
// mcpErrorRateThreshold is the error-rate above which an MCP server is flagged as unreliable.
1713
const mcpErrorRateThreshold = 0.10
1814

pkg/cli/audit_cross_run_test.go

Lines changed: 49 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -311,149 +311,94 @@ func TestRenderCrossRunReportMarkdown(t *testing.T) {
311311
assert.Contains(t, output, "api.github.com:443", "Should contain the domain")
312312
}
313313

314-
func TestNewAuditReportSubcommand(t *testing.T) {
315-
cmd := NewAuditReportSubcommand()
316-
317-
assert.Equal(t, "report", cmd.Use, "Command Use should be 'report'")
318-
assert.NotEmpty(t, cmd.Short, "Short description should not be empty")
319-
assert.NotEmpty(t, cmd.Long, "Long description should not be empty")
320-
321-
// Check flags exist
322-
workflowFlag := cmd.Flags().Lookup("workflow")
323-
require.NotNil(t, workflowFlag, "Should have --workflow flag")
324-
assert.Equal(t, "w", workflowFlag.Shorthand, "Workflow flag shorthand should be 'w'")
325-
326-
lastFlag := cmd.Flags().Lookup("last")
327-
require.NotNil(t, lastFlag, "Should have --last flag")
328-
assert.Equal(t, "20", lastFlag.DefValue, "Default value for --last should be 20")
329-
330-
jsonFlag := cmd.Flags().Lookup("json")
331-
require.NotNil(t, jsonFlag, "Should have --json flag")
332-
333-
repoFlag := cmd.Flags().Lookup("repo")
334-
require.NotNil(t, repoFlag, "Should have --repo flag")
335-
336-
outputFlag := cmd.Flags().Lookup("output")
337-
require.NotNil(t, outputFlag, "Should have --output flag")
314+
func TestNewLogsCommand_HasFormatFlag(t *testing.T) {
315+
cmd := NewLogsCommand()
338316

317+
// Check that the --format flag exists
339318
formatFlag := cmd.Flags().Lookup("format")
340319
require.NotNil(t, formatFlag, "Should have --format flag")
341-
assert.Equal(t, "markdown", formatFlag.DefValue, "Default value for --format should be markdown")
342-
}
343-
344-
func TestNewAuditReportSubcommand_RejectsExtraArgs(t *testing.T) {
345-
cmd := NewAuditReportSubcommand()
346-
cmd.SetArgs([]string{"extra-arg"})
347-
err := cmd.Execute()
348-
require.Error(t, err, "Should reject extra positional arguments")
349-
assert.Contains(t, err.Error(), "unknown command", "Error should indicate unknown command")
350-
}
320+
assert.Empty(t, formatFlag.DefValue, "Default value for --format should be empty (console output)")
351321

352-
func TestRunAuditReportConfig_LastClampBounds(t *testing.T) {
353-
tests := []struct {
354-
name string
355-
inputCfg RunAuditReportConfig
356-
wantLast int
357-
}{
358-
{
359-
name: "negative last defaults to 20",
360-
inputCfg: RunAuditReportConfig{Last: -5},
361-
wantLast: 20,
362-
},
363-
{
364-
name: "zero last defaults to 20",
365-
inputCfg: RunAuditReportConfig{Last: 0},
366-
wantLast: 20,
367-
},
368-
{
369-
name: "over max clamped to max",
370-
inputCfg: RunAuditReportConfig{Last: 100},
371-
wantLast: maxAuditReportRuns,
372-
},
373-
{
374-
name: "within bounds unchanged",
375-
inputCfg: RunAuditReportConfig{Last: 10},
376-
wantLast: 10,
377-
},
378-
}
322+
// Check that the --last flag exists as an alias for --count
323+
lastFlag := cmd.Flags().Lookup("last")
324+
require.NotNil(t, lastFlag, "Should have --last flag")
325+
assert.Equal(t, "0", lastFlag.DefValue, "Default value for --last should be 0 (uses --count default)")
379326

380-
for _, tt := range tests {
381-
t.Run(tt.name, func(t *testing.T) {
382-
cfg := tt.inputCfg
383-
// Apply the same clamping logic as RunAuditReport
384-
if cfg.Last <= 0 {
385-
cfg.Last = 20
386-
}
387-
if cfg.Last > maxAuditReportRuns {
388-
cfg.Last = maxAuditReportRuns
389-
}
390-
assert.Equal(t, tt.wantLast, cfg.Last, "Last should be clamped correctly")
391-
})
392-
}
327+
// Other expected flags still present
328+
require.NotNil(t, cmd.Flags().Lookup("json"), "Should have --json flag")
329+
require.NotNil(t, cmd.Flags().Lookup("repo"), "Should have --repo flag")
330+
require.NotNil(t, cmd.Flags().Lookup("output"), "Should have --output flag")
331+
require.NotNil(t, cmd.Flags().Lookup("count"), "Should have --count flag")
393332
}
394333

395-
func TestRunAuditReportConfig_FormatPrecedence(t *testing.T) {
334+
func TestLogsCommand_FormatPrecedence(t *testing.T) {
396335
tests := []struct {
397336
name string
398337
jsonOutput bool
399338
format string
400-
wantFormat string // "json", "markdown", or "pretty"
339+
wantMode string // "crossrun-json", "crossrun-markdown", "crossrun-pretty", "default-json", "default-console"
401340
}{
402341
{
403-
name: "json flag takes precedence over format",
404-
jsonOutput: true,
405-
format: "markdown",
406-
wantFormat: "json",
342+
name: "no format uses default console",
343+
jsonOutput: false,
344+
format: "",
345+
wantMode: "default-console",
407346
},
408347
{
409-
name: "json flag with format=pretty still uses json",
348+
name: "json flag without format uses default json",
410349
jsonOutput: true,
411-
format: "pretty",
412-
wantFormat: "json",
350+
format: "",
351+
wantMode: "default-json",
413352
},
414353
{
415-
name: "format=json without json flag",
354+
name: "format=markdown triggers cross-run markdown report",
416355
jsonOutput: false,
417-
format: "json",
418-
wantFormat: "json",
356+
format: "markdown",
357+
wantMode: "crossrun-markdown",
419358
},
420359
{
421-
name: "format=pretty selects pretty",
360+
name: "format=pretty triggers cross-run pretty report",
422361
jsonOutput: false,
423362
format: "pretty",
424-
wantFormat: "pretty",
363+
wantMode: "crossrun-pretty",
425364
},
426365
{
427-
name: "format=markdown selects markdown",
428-
jsonOutput: false,
366+
name: "format=markdown with json flag triggers cross-run json report",
367+
jsonOutput: true,
429368
format: "markdown",
430-
wantFormat: "markdown",
369+
wantMode: "crossrun-json",
431370
},
432371
{
433-
name: "default format is markdown",
434-
jsonOutput: false,
435-
format: "",
436-
wantFormat: "markdown",
372+
name: "format=pretty with json flag triggers cross-run json report",
373+
jsonOutput: true,
374+
format: "pretty",
375+
wantMode: "crossrun-json",
437376
},
438377
}
439378

440379
for _, tt := range tests {
441380
t.Run(tt.name, func(t *testing.T) {
442-
// Apply the same format selection logic as RunAuditReport
381+
// Apply the same format selection logic as DownloadWorkflowLogs
443382
var selected string
444-
if tt.jsonOutput || tt.format == "json" {
445-
selected = "json"
446-
} else if tt.format == "pretty" {
447-
selected = "pretty"
383+
if tt.format == "markdown" || tt.format == "pretty" {
384+
if tt.jsonOutput {
385+
selected = "crossrun-json"
386+
} else if tt.format == "pretty" {
387+
selected = "crossrun-pretty"
388+
} else {
389+
selected = "crossrun-markdown"
390+
}
391+
} else if tt.jsonOutput {
392+
selected = "default-json"
448393
} else {
449-
selected = "markdown"
394+
selected = "default-console"
450395
}
451-
assert.Equal(t, tt.wantFormat, selected, "Format should be selected correctly")
396+
assert.Equal(t, tt.wantMode, selected, "Output mode should be selected correctly for format=%q jsonOutput=%v", tt.format, tt.jsonOutput)
452397
})
453398
}
454399
}
455400

456-
func TestNewAuditReportSubcommand_RepoParsingWithHost(t *testing.T) {
401+
func TestLogsCommand_RepoParsingWithHost(t *testing.T) {
457402
tests := []struct {
458403
name string
459404
repoFlag string
@@ -492,7 +437,7 @@ func TestNewAuditReportSubcommand_RepoParsingWithHost(t *testing.T) {
492437

493438
for _, tt := range tests {
494439
t.Run(tt.name, func(t *testing.T) {
495-
// Apply the same repo parsing logic
440+
// Apply the same repo parsing logic used in audit commands
496441
parts := strings.Split(tt.repoFlag, "/")
497442
if len(parts) < 2 {
498443
assert.True(t, tt.wantErr, "Should expect error for: %s", tt.repoFlag)

0 commit comments

Comments
 (0)