Skip to content

Commit d59c18a

Browse files
authored
feat: add --stdin flag to logs and audit commands (#29170)
1 parent 1e7262f commit d59c18a

9 files changed

Lines changed: 656 additions & 16 deletions
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# ADR-29170: Stdin Input Mode for Logs and Audit Commands
2+
3+
**Date**: 2026-04-29
4+
**Status**: Draft
5+
**Deciders**: pelikhan, Copilot
6+
7+
---
8+
9+
## Part 1 — Narrative (Human-Friendly)
10+
11+
### Context
12+
13+
The `gh aw logs` and `gh aw audit` commands discover workflow runs by querying the GitHub API based on filters (workflow name, count, date range). This API-based discovery is unsuitable when a user already knows the exact run IDs they want to process — for example, when scripting batch analyses, piping output from another tool, or replaying a saved list of run IDs. There was no way to bypass discovery and supply run IDs directly without using positional arguments, which do not integrate naturally with shell pipelines.
14+
15+
### Decision
16+
17+
We will add a `--stdin` flag to both `gh aw logs` and `gh aw audit` that reads workflow run IDs or URLs from standard input (one per line), bypassing the GitHub API run-discovery step entirely. This approach follows Unix pipeline conventions and allows users to compose `gh aw logs` and `gh aw audit` with other shell tools. The `--stdin` flag is mutually exclusive with positional arguments on both commands.
18+
19+
### Alternatives Considered
20+
21+
#### Alternative 1: Positional Arguments Only (Status Quo)
22+
23+
Users can already supply one or more run IDs as positional arguments (e.g., `gh aw audit 1234 5678`). This works for a small, known set of runs typed interactively but does not support piping from other commands or reading from files without shell substitution (`$(cat ids.txt)`). Shell substitution has argument-count limits and breaks easily with large lists.
24+
25+
#### Alternative 2: File-Path Flag (`--file path/to/ids.txt`)
26+
27+
A `--file` flag could accept a path to a text file containing run IDs. This is more explicit and reproducible (the file path can be version-controlled), but it is less composable in shell pipelines and requires writing intermediate files. Stdin is more idiomatic for Unix-style tools and is already the conventional mechanism for streaming data into CLI commands.
28+
29+
### Consequences
30+
31+
#### Positive
32+
- Enables Unix-style composition: users can pipe output from other `gh` or shell commands directly into `gh aw logs` and `gh aw audit`.
33+
- Bypasses GitHub API run-discovery quota, making batch processing of known run IDs cheaper and faster.
34+
- The stdin parsing helper (`readRunIDsFromStdin`) is a small, fully-tested utility reused by both commands.
35+
36+
#### Negative
37+
- A parallel orchestration path (`DownloadWorkflowLogsFromStdin`) largely replicates the filtering and rendering logic of `DownloadWorkflowLogs`, increasing the maintenance surface.
38+
- Some time-based and count-based flags (`--after`, `--count`, `--date`, workflow-name filtering) are silently ignored in stdin mode; this could surprise users who supply them alongside `--stdin`.
39+
- Numeric-only run IDs require an explicit `--repo owner/repo` flag in stdin mode, because there is no workflow-name context from which to infer the repository.
40+
41+
#### Neutral
42+
- The `cobra.MinimumNArgs(1)` constraint on `audit` is replaced with `cobra.ArbitraryArgs` plus manual validation; the effective behavior is unchanged for positional-args usage.
43+
- Blank lines and `#`-prefixed comment lines in stdin input are silently skipped, which is consistent with common Unix text-file conventions.
44+
45+
---
46+
47+
## Part 2 — Normative Specification (RFC 2119)
48+
49+
> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
50+
51+
### Stdin Flag Behaviour
52+
53+
1. Both `gh aw logs` and `gh aw audit` **MUST** accept a `--stdin` boolean flag that, when set, reads workflow run IDs or URLs from standard input instead of discovering runs via the GitHub API.
54+
2. Each line read from stdin **MUST** be trimmed of leading and trailing whitespace before processing.
55+
3. Blank lines and lines whose first non-whitespace character is `#` **MUST** be silently ignored.
56+
4. The `--stdin` flag and positional run-ID arguments **MUST NOT** be used together; implementations **MUST** return an error if both are supplied simultaneously.
57+
5. If stdin produces zero valid entries after filtering, the command **SHOULD** emit a warning to stderr and exit successfully (status 0) rather than treating empty input as an error.
58+
59+
### Input Format
60+
61+
1. Stdin **MUST** accept both numeric run IDs (e.g., `1234567890`) and full GitHub Actions run URLs (e.g., `https://github.com/owner/repo/actions/runs/1234567890`).
62+
2. When a numeric-only run ID is supplied and no owner/repo is encoded in the input, implementations **MUST** require the `--repo owner/repo` flag and **MUST** return an error if it is absent.
63+
3. Implementations **SHOULD** accept GHES run URLs in addition to github.com URLs, consistent with existing positional-argument handling.
64+
65+
### Flag Interactions
66+
67+
1. Content-filtering flags (`--engine`, `--firewall`, `--no-firewall`, `--safe-output`, `--filtered-integrity`, `--no-staged`) **MUST** apply to runs supplied via stdin in the same way they apply to runs discovered via the GitHub API.
68+
2. Discovery-scoping flags that are meaningless without API discovery (`--count`, `--date`, `--after`, workflow-name positional argument) **SHOULD NOT** silently take effect in stdin mode; implementations **SHOULD** document that these flags are ignored when `--stdin` is set.
69+
70+
### Conformance
71+
72+
An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.
73+
74+
---
75+
76+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/25131761595) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*

pkg/cli/audit.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,44 @@ Examples:
6363
` + string(constants.CLIExtensionPrefix) + ` audit 1234567890 1234567891 # Diff two runs (base vs comparison)
6464
` + string(constants.CLIExtensionPrefix) + ` audit 1234567890 1234567891 1234567892 # Diff base against multiple runs
6565
` + string(constants.CLIExtensionPrefix) + ` audit 1234567890 1234567891 --format markdown # Markdown diff output for PR comments`,
66-
Args: cobra.MinimumNArgs(1),
66+
Args: cobra.ArbitraryArgs,
6767
RunE: func(cmd *cobra.Command, args []string) error {
6868
outputDir, _ := cmd.Flags().GetString("output")
6969
verbose, _ := cmd.Flags().GetBool("verbose")
7070
jsonOutput, _ := cmd.Flags().GetBool("json")
7171
parse, _ := cmd.Flags().GetBool("parse")
7272
repoFlag, _ := cmd.Flags().GetString("repo")
7373
artifacts, _ := cmd.Flags().GetStringSlice("artifacts")
74+
stdin, _ := cmd.Flags().GetBool("stdin")
75+
76+
// When --stdin is provided, read run IDs/URLs from stdin instead of positional args.
77+
if stdin {
78+
if len(args) > 0 {
79+
return errors.New(console.FormatErrorWithSuggestions(
80+
"positional arguments are not allowed with --stdin",
81+
[]string{"Remove the run ID arguments, or omit --stdin to use positional arguments"},
82+
))
83+
}
84+
stdinURLs, err := readRunIDsFromStdin(os.Stdin)
85+
if err != nil {
86+
return fmt.Errorf("failed to read run IDs from stdin: %w", err)
87+
}
88+
if len(stdinURLs) == 0 {
89+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("No run IDs or URLs provided on stdin"))
90+
return nil
91+
}
92+
args = stdinURLs
93+
}
94+
95+
if len(args) == 0 {
96+
return errors.New(console.FormatErrorWithSuggestions(
97+
"at least one run ID or URL is required",
98+
[]string{
99+
"Provide a run ID or URL as a positional argument",
100+
"Use --stdin to read run IDs from stdin (one per line)",
101+
},
102+
))
103+
}
74104

75105
if len(args) == 1 {
76106
// Single run: existing audit behavior
@@ -122,6 +152,7 @@ Examples:
122152
cmd.Flags().Bool("parse", false, "Run JavaScript parsers on agent logs and firewall logs, writing Markdown to log.md and firewall.md")
123153
cmd.Flags().String("format", "pretty", "Diff output format for multi-run mode: pretty, markdown")
124154
cmd.Flags().StringSlice("artifacts", nil, "Artifact sets to download (default: all). Valid sets: "+strings.Join(ValidArtifactSetNames(), ", "))
155+
cmd.Flags().Bool("stdin", false, "Read workflow run IDs or URLs from stdin (one per line) instead of positional arguments")
125156

126157
// Register completions for audit command
127158
RegisterDirFlagCompletion(cmd, "output")

pkg/cli/audit_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,3 +1064,34 @@ func TestRunAuditMulti_Validation(t *testing.T) {
10641064
})
10651065
}
10661066
}
1067+
1068+
func TestAuditCommandStdinFlag(t *testing.T) {
1069+
cmd := NewAuditCommand()
1070+
flags := cmd.Flags()
1071+
1072+
// --stdin flag must be registered
1073+
stdinFlag := flags.Lookup("stdin")
1074+
require.NotNil(t, stdinFlag, "Should have 'stdin' flag")
1075+
assert.Equal(t, "bool", stdinFlag.Value.Type(), "--stdin should be a boolean flag")
1076+
assert.Equal(t, "false", stdinFlag.DefValue, "--stdin should default to false")
1077+
}
1078+
1079+
func TestAuditCommandStdinRejectsPositionalArgs(t *testing.T) {
1080+
cmd := NewAuditCommand()
1081+
cmd.SetArgs([]string{"1234567890", "--stdin"})
1082+
cmd.SetOut(nil)
1083+
cmd.SetErr(nil)
1084+
err := cmd.Execute()
1085+
require.Error(t, err, "audit --stdin with a positional arg should return an error")
1086+
assert.Contains(t, err.Error(), "positional arguments are not allowed with --stdin", "error message should explain the conflict")
1087+
}
1088+
1089+
func TestAuditCommandRequiresArgsOrStdin(t *testing.T) {
1090+
cmd := NewAuditCommand()
1091+
cmd.SetArgs([]string{})
1092+
cmd.SetOut(nil)
1093+
cmd.SetErr(nil)
1094+
err := cmd.Execute()
1095+
require.Error(t, err, "audit with no args and no --stdin should return an error")
1096+
assert.Contains(t, err.Error(), "at least one run ID or URL is required", "error message should prompt for required input")
1097+
}

pkg/cli/logs_command.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ package cli
1212
import (
1313
"errors"
1414
"fmt"
15+
"os"
1516
"strings"
1617
"time"
1718

@@ -106,6 +107,52 @@ Examples:
106107
RunE: func(cmd *cobra.Command, args []string) error {
107108
logsCommandLog.Printf("Starting logs command: args=%d", len(args))
108109

110+
stdin, _ := cmd.Flags().GetBool("stdin")
111+
112+
// When --stdin is provided, read run IDs/URLs from stdin and bypass GitHub API discovery.
113+
if stdin {
114+
if len(args) > 0 {
115+
return errors.New(console.FormatErrorWithSuggestions(
116+
"positional arguments are not allowed with --stdin",
117+
[]string{"Remove the workflow name argument, or omit --stdin to use the normal discovery mode"},
118+
))
119+
}
120+
logsCommandLog.Printf("Reading run IDs from stdin")
121+
runURLs, err := readRunIDsFromStdin(os.Stdin)
122+
if err != nil {
123+
return fmt.Errorf("failed to read run IDs from stdin: %w", err)
124+
}
125+
126+
outputDir, _ := cmd.Flags().GetString("output")
127+
engine, _ := cmd.Flags().GetString("engine")
128+
repoOverride, _ := cmd.Flags().GetString("repo")
129+
verbose, _ := cmd.Flags().GetBool("verbose")
130+
toolGraph, _ := cmd.Flags().GetBool("tool-graph")
131+
noStaged, _ := cmd.Flags().GetBool("no-staged")
132+
firewallOnly, _ := cmd.Flags().GetBool("firewall")
133+
noFirewall, _ := cmd.Flags().GetBool("no-firewall")
134+
parse, _ := cmd.Flags().GetBool("parse")
135+
jsonOutput, _ := cmd.Flags().GetBool("json")
136+
timeout, _ := cmd.Flags().GetInt("timeout")
137+
summaryFile, _ := cmd.Flags().GetString("summary-file")
138+
safeOutputType, _ := cmd.Flags().GetString("safe-output")
139+
filteredIntegrity, _ := cmd.Flags().GetBool("filtered-integrity")
140+
train, _ := cmd.Flags().GetBool("train")
141+
format, _ := cmd.Flags().GetString("format")
142+
artifacts, _ := cmd.Flags().GetStringSlice("artifacts")
143+
144+
if engine != "" {
145+
logsCommandLog.Printf("Validating engine parameter: %s", engine)
146+
registry := workflow.GetGlobalEngineRegistry()
147+
if !registry.IsValidEngine(engine) {
148+
supportedEngines := registry.GetSupportedEngines()
149+
return fmt.Errorf("invalid engine value '%s'. Must be one of: %s", engine, strings.Join(supportedEngines, ", "))
150+
}
151+
}
152+
153+
return DownloadWorkflowLogsFromStdin(cmd.Context(), runURLs, outputDir, engine, repoOverride, verbose, toolGraph, noStaged, firewallOnly, noFirewall, parse, jsonOutput, timeout, summaryFile, safeOutputType, filteredIntegrity, train, format, artifacts)
154+
}
155+
109156
var workflowName string
110157
if len(args) > 0 && args[0] != "" {
111158
logsCommandLog.Printf("Resolving workflow name from argument: %s", args[0])
@@ -225,6 +272,7 @@ Examples:
225272
logsCmd.Flags().Int("last", 0, "Alias for --count: number of recent runs to download")
226273
logsCmd.Flags().StringSlice("artifacts", nil, "Artifact sets to download (default: all). Valid sets: "+strings.Join(ValidArtifactSetNames(), ", "))
227274
logsCmd.Flags().String("after", "", "Remove locally cached run folders created before this date (cache cleanup). Use deltas like -1w or -1mo, or an absolute date YYYY-MM-DD. For example, --after -1w removes folders older than 1 week.")
275+
logsCmd.Flags().Bool("stdin", false, "Read workflow run IDs or URLs from stdin (one per line) instead of discovering runs via the GitHub API")
228276
logsCmd.MarkFlagsMutuallyExclusive("firewall", "no-firewall")
229277

230278
// Register completions for logs command

pkg/cli/logs_command_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,25 @@ func TestLogsCommandHelpText(t *testing.T) {
268268
assert.Contains(t, safeOutputFlag.Usage, "noop", "safe-output flag help should mention noop")
269269
assert.Contains(t, safeOutputFlag.Usage, "report-incomplete", "safe-output flag help should mention report-incomplete")
270270
}
271+
272+
func TestLogsCommandStdinFlag(t *testing.T) {
273+
cmd := NewLogsCommand()
274+
flags := cmd.Flags()
275+
276+
// --stdin flag must be registered
277+
stdinFlag := flags.Lookup("stdin")
278+
require.NotNil(t, stdinFlag, "Should have 'stdin' flag")
279+
assert.Equal(t, "bool", stdinFlag.Value.Type(), "--stdin should be a boolean flag")
280+
assert.Equal(t, "false", stdinFlag.DefValue, "--stdin should default to false")
281+
}
282+
283+
func TestLogsCommandStdinRejectsPositionalArgs(t *testing.T) {
284+
cmd := NewLogsCommand()
285+
cmd.SetArgs([]string{"my-workflow", "--stdin"})
286+
// Suppress output so test output stays clean
287+
cmd.SetOut(nil)
288+
cmd.SetErr(nil)
289+
err := cmd.Execute()
290+
require.Error(t, err, "logs --stdin with a positional arg should return an error")
291+
assert.Contains(t, err.Error(), "positional arguments are not allowed with --stdin", "error message should explain the conflict")
292+
}

0 commit comments

Comments
 (0)