Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions docs/adr/29170-stdin-input-mode-for-logs-and-audit-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# ADR-29170: Stdin Input Mode for Logs and Audit Commands

**Date**: 2026-04-29
**Status**: Draft
**Deciders**: pelikhan, Copilot

---

## Part 1 — Narrative (Human-Friendly)

### Context

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.

### Decision

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.

### Alternatives Considered

#### Alternative 1: Positional Arguments Only (Status Quo)

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.

#### Alternative 2: File-Path Flag (`--file path/to/ids.txt`)

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.

### Consequences

#### Positive
- Enables Unix-style composition: users can pipe output from other `gh` or shell commands directly into `gh aw logs` and `gh aw audit`.
- Bypasses GitHub API run-discovery quota, making batch processing of known run IDs cheaper and faster.
- The stdin parsing helper (`readRunIDsFromStdin`) is a small, fully-tested utility reused by both commands.

#### Negative
- A parallel orchestration path (`DownloadWorkflowLogsFromStdin`) largely replicates the filtering and rendering logic of `DownloadWorkflowLogs`, increasing the maintenance surface.
- 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`.
- 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.

#### Neutral
- The `cobra.MinimumNArgs(1)` constraint on `audit` is replaced with `cobra.ArbitraryArgs` plus manual validation; the effective behavior is unchanged for positional-args usage.
- Blank lines and `#`-prefixed comment lines in stdin input are silently skipped, which is consistent with common Unix text-file conventions.

---

## Part 2 — Normative Specification (RFC 2119)

> 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).

### Stdin Flag Behaviour

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.
2. Each line read from stdin **MUST** be trimmed of leading and trailing whitespace before processing.
3. Blank lines and lines whose first non-whitespace character is `#` **MUST** be silently ignored.
4. The `--stdin` flag and positional run-ID arguments **MUST NOT** be used together; implementations **MUST** return an error if both are supplied simultaneously.
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.

### Input Format

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`).
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.
3. Implementations **SHOULD** accept GHES run URLs in addition to github.com URLs, consistent with existing positional-argument handling.

### Flag Interactions

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.
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.

### Conformance

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.

---

*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.*
33 changes: 32 additions & 1 deletion pkg/cli/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,44 @@ Examples:
` + string(constants.CLIExtensionPrefix) + ` audit 1234567890 1234567891 # Diff two runs (base vs comparison)
` + string(constants.CLIExtensionPrefix) + ` audit 1234567890 1234567891 1234567892 # Diff base against multiple runs
` + string(constants.CLIExtensionPrefix) + ` audit 1234567890 1234567891 --format markdown # Markdown diff output for PR comments`,
Args: cobra.MinimumNArgs(1),
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
outputDir, _ := cmd.Flags().GetString("output")
verbose, _ := cmd.Flags().GetBool("verbose")
jsonOutput, _ := cmd.Flags().GetBool("json")
parse, _ := cmd.Flags().GetBool("parse")
repoFlag, _ := cmd.Flags().GetString("repo")
artifacts, _ := cmd.Flags().GetStringSlice("artifacts")
stdin, _ := cmd.Flags().GetBool("stdin")

// When --stdin is provided, read run IDs/URLs from stdin instead of positional args.
if stdin {
if len(args) > 0 {
return errors.New(console.FormatErrorWithSuggestions(
"positional arguments are not allowed with --stdin",
[]string{"Remove the run ID arguments, or omit --stdin to use positional arguments"},
Comment on lines +77 to +81
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --stdin branch adds new validation and stdin ingestion for audit (mutual exclusion with positional args, empty-stdin handling), but there are no unit tests covering these CLI behaviors. Since the repo already tests many command surfaces, consider adding tests that verify: audit --stdin rejects positional args, errors when neither args nor stdin are provided, and correctly uses stdin lines as args for both single-run and diff modes.

Copilot uses AI. Check for mistakes.
))
}
stdinURLs, err := readRunIDsFromStdin(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read run IDs from stdin: %w", err)
}
if len(stdinURLs) == 0 {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("No run IDs or URLs provided on stdin"))
return nil
}
args = stdinURLs
}

if len(args) == 0 {
return errors.New(console.FormatErrorWithSuggestions(
"at least one run ID or URL is required",
[]string{
"Provide a run ID or URL as a positional argument",
"Use --stdin to read run IDs from stdin (one per line)",
},
))
}

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

// Register completions for audit command
RegisterDirFlagCompletion(cmd, "output")
Expand Down
31 changes: 31 additions & 0 deletions pkg/cli/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1064,3 +1064,34 @@ func TestRunAuditMulti_Validation(t *testing.T) {
})
}
}

func TestAuditCommandStdinFlag(t *testing.T) {
cmd := NewAuditCommand()
flags := cmd.Flags()

// --stdin flag must be registered
stdinFlag := flags.Lookup("stdin")
require.NotNil(t, stdinFlag, "Should have 'stdin' flag")
assert.Equal(t, "bool", stdinFlag.Value.Type(), "--stdin should be a boolean flag")
assert.Equal(t, "false", stdinFlag.DefValue, "--stdin should default to false")
}

func TestAuditCommandStdinRejectsPositionalArgs(t *testing.T) {
cmd := NewAuditCommand()
cmd.SetArgs([]string{"1234567890", "--stdin"})
cmd.SetOut(nil)
cmd.SetErr(nil)
err := cmd.Execute()
require.Error(t, err, "audit --stdin with a positional arg should return an error")
assert.Contains(t, err.Error(), "positional arguments are not allowed with --stdin", "error message should explain the conflict")
}

func TestAuditCommandRequiresArgsOrStdin(t *testing.T) {
cmd := NewAuditCommand()
cmd.SetArgs([]string{})
cmd.SetOut(nil)
cmd.SetErr(nil)
err := cmd.Execute()
require.Error(t, err, "audit with no args and no --stdin should return an error")
assert.Contains(t, err.Error(), "at least one run ID or URL is required", "error message should prompt for required input")
}
48 changes: 48 additions & 0 deletions pkg/cli/logs_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ package cli
import (
"errors"
"fmt"
"os"
"strings"
"time"

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

stdin, _ := cmd.Flags().GetBool("stdin")

// When --stdin is provided, read run IDs/URLs from stdin and bypass GitHub API discovery.
if stdin {
if len(args) > 0 {
return errors.New(console.FormatErrorWithSuggestions(
"positional arguments are not allowed with --stdin",
[]string{"Remove the workflow name argument, or omit --stdin to use the normal discovery mode"},
))
}
logsCommandLog.Printf("Reading run IDs from stdin")
Comment on lines +110 to +120
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new --stdin execution path adds non-trivial argument validation (mutual exclusion with positional args) and stdin parsing behavior, but there are no command-level tests asserting these behaviors. Since this repo already has CLI flag/command tests (e.g., pkg/cli/logs_command_test.go), consider adding coverage to verify: logs --stdin rejects positional args, reads multiple lines (ignoring blanks/comments), and wires through to the stdin download path.

This issue also appears on line 153 of the same file.

Copilot uses AI. Check for mistakes.
runURLs, err := readRunIDsFromStdin(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read run IDs from stdin: %w", err)
}

outputDir, _ := cmd.Flags().GetString("output")
engine, _ := cmd.Flags().GetString("engine")
repoOverride, _ := cmd.Flags().GetString("repo")
verbose, _ := cmd.Flags().GetBool("verbose")
toolGraph, _ := cmd.Flags().GetBool("tool-graph")
noStaged, _ := cmd.Flags().GetBool("no-staged")
firewallOnly, _ := cmd.Flags().GetBool("firewall")
noFirewall, _ := cmd.Flags().GetBool("no-firewall")
parse, _ := cmd.Flags().GetBool("parse")
jsonOutput, _ := cmd.Flags().GetBool("json")
timeout, _ := cmd.Flags().GetInt("timeout")
summaryFile, _ := cmd.Flags().GetString("summary-file")
safeOutputType, _ := cmd.Flags().GetString("safe-output")
filteredIntegrity, _ := cmd.Flags().GetBool("filtered-integrity")
train, _ := cmd.Flags().GetBool("train")
format, _ := cmd.Flags().GetString("format")
artifacts, _ := cmd.Flags().GetStringSlice("artifacts")

if engine != "" {
logsCommandLog.Printf("Validating engine parameter: %s", engine)
registry := workflow.GetGlobalEngineRegistry()
if !registry.IsValidEngine(engine) {
supportedEngines := registry.GetSupportedEngines()
return fmt.Errorf("invalid engine value '%s'. Must be one of: %s", engine, strings.Join(supportedEngines, ", "))
}
}

return DownloadWorkflowLogsFromStdin(cmd.Context(), runURLs, outputDir, engine, repoOverride, verbose, toolGraph, noStaged, firewallOnly, noFirewall, parse, jsonOutput, timeout, summaryFile, safeOutputType, filteredIntegrity, train, format, artifacts)
}

var workflowName string
if len(args) > 0 && args[0] != "" {
logsCommandLog.Printf("Resolving workflow name from argument: %s", args[0])
Expand Down Expand Up @@ -225,6 +272,7 @@ Examples:
logsCmd.Flags().Int("last", 0, "Alias for --count: number of recent runs to download")
logsCmd.Flags().StringSlice("artifacts", nil, "Artifact sets to download (default: all). Valid sets: "+strings.Join(ValidArtifactSetNames(), ", "))
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.")
logsCmd.Flags().Bool("stdin", false, "Read workflow run IDs or URLs from stdin (one per line) instead of discovering runs via the GitHub API")
logsCmd.MarkFlagsMutuallyExclusive("firewall", "no-firewall")

// Register completions for logs command
Expand Down
22 changes: 22 additions & 0 deletions pkg/cli/logs_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,25 @@ func TestLogsCommandHelpText(t *testing.T) {
assert.Contains(t, safeOutputFlag.Usage, "noop", "safe-output flag help should mention noop")
assert.Contains(t, safeOutputFlag.Usage, "report-incomplete", "safe-output flag help should mention report-incomplete")
}

func TestLogsCommandStdinFlag(t *testing.T) {
cmd := NewLogsCommand()
flags := cmd.Flags()

// --stdin flag must be registered
stdinFlag := flags.Lookup("stdin")
require.NotNil(t, stdinFlag, "Should have 'stdin' flag")
assert.Equal(t, "bool", stdinFlag.Value.Type(), "--stdin should be a boolean flag")
assert.Equal(t, "false", stdinFlag.DefValue, "--stdin should default to false")
}

func TestLogsCommandStdinRejectsPositionalArgs(t *testing.T) {
cmd := NewLogsCommand()
cmd.SetArgs([]string{"my-workflow", "--stdin"})
// Suppress output so test output stays clean
cmd.SetOut(nil)
cmd.SetErr(nil)
err := cmd.Execute()
require.Error(t, err, "logs --stdin with a positional arg should return an error")
assert.Contains(t, err.Error(), "positional arguments are not allowed with --stdin", "error message should explain the conflict")
}
Loading