Skip to content
Closed
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
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,55 @@ jobs:
echo "$HELP" | grep -q "rules"
rm -f ./opencodereview

# Runs the suite natively on Windows, which the cross-compile job below cannot
# do: it only proves the windows arms of the build-tag splits compile. GitHub
# does not support `container:` on Windows runners
# (actions/runner#904), so this job installs Go directly instead of reusing the
# golang:1.26.5 image the other jobs share.
windows:
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7

- uses: actions/setup-go@v7
with:
go-version: '1.26.5'
cache: true

- name: Vet
run: go vet ./...

# No -race here: the race detector needs a working C toolchain on Windows,
# and races are OS-independent, so the Linux job above already covers them.
# This job is here for the OS-specific behavior instead. No coverage gate
# either -- the //go:build !windows test files legitimately drop the total
# below the 80% the Linux job enforces.
- name: Test
run: go test -count=1 ./...

- name: Build
run: go build -o opencodereview.exe ./cmd/opencodereview

# Same assertions as the Linux smoke test, under git-bash so the script is
# shared verbatim rather than reimplemented in PowerShell.
- name: Smoke test
shell: bash
run: |
./opencodereview.exe --version
./opencodereview.exe --version | grep -q "open-code-review"
HELP=$(./opencodereview.exe --help)
echo "$HELP" | grep -q "Commands:"
echo "$HELP" | grep -q "review"
echo "$HELP" | grep -q "scan"
echo "$HELP" | grep -q "delegate"
echo "$HELP" | grep -q "config"
echo "$HELP" | grep -q "llm"
echo "$HELP" | grep -q "viewer"
echo "$HELP" | grep -q "session"
echo "$HELP" | grep -q "rules"
rm -f ./opencodereview.exe

cross-compile:
runs-on: self-hosted
timeout-minutes: 10
Expand Down
8 changes: 8 additions & 0 deletions cmd/opencodereview/background_file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
Expand Down Expand Up @@ -37,7 +38,14 @@ func TestResolveBackgroundFilePath(t *testing.T) {
})

t.Run("absolute unchanged", func(t *testing.T) {
// FromSlash is not enough on its own: it only swaps separators, and
// `\etc\context.md` is rooted but not absolute on Windows, where
// filepath.IsAbs wants a volume. Without the drive letter this case
// exercised the relative branch instead of the one it names.
abs := filepath.FromSlash("/etc/context.md")
if runtime.GOOS == "windows" {
abs = `C:\etc\context.md`
}
if got := resolveBackgroundFilePath(repo, abs); got != abs {
t.Errorf("resolveBackgroundFilePath = %q, want %q (absolute must be untouched)", got, abs)
}
Expand Down
22 changes: 18 additions & 4 deletions cmd/opencodereview/config_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,22 @@ func runConfigSet(key, value string) error {
}

displayValue := value
normalizedKey := strings.ToLower(strings.ReplaceAll(key, "_", ""))
if strings.HasSuffix(normalizedKey, "apikey") || strings.HasSuffix(normalizedKey, "authtoken") {
if shouldMaskConfigValue(key) {
displayValue = maskKey(value)
}
fmt.Printf("Set %s = %s\n", key, displayValue)
return nil
}

// shouldMaskConfigValue reports whether the echoed value of a config key holds a
// secret and must be masked. Matching on the normalized suffix covers both
// snake_case and Go field spellings of api_key/auth_token at any path depth,
// while the *_cmd variants stay unmasked: a command line is not a secret.
func shouldMaskConfigValue(key string) bool {
normalizedKey := strings.ToLower(strings.ReplaceAll(key, "_", ""))
return strings.HasSuffix(normalizedKey, "apikey") || strings.HasSuffix(normalizedKey, "authtoken")
}

func runConfigUnset(key string) error {
parts := strings.SplitN(key, ".", 2)
if len(parts) != 2 || parts[1] == "" {
Expand Down Expand Up @@ -190,6 +198,7 @@ func deleteCustomProvider(cfg *Config, name string) (bool, error) {
// ProviderEntry holds per-provider configuration in the providers map.
type ProviderEntry struct {
APIKey string `json:"api_key,omitempty"`
APIKeyCmd string `json:"api_key_cmd,omitempty"` // shell command whose stdout is the api key; used when api_key is empty
URL string `json:"url,omitempty"`
Protocol string `json:"protocol,omitempty"`
Model string `json:"model,omitempty"`
Expand Down Expand Up @@ -228,6 +237,7 @@ type Config struct {
type LlmConfig struct {
URL string `json:"url,omitempty"`
AuthToken string `json:"auth_token,omitempty"`
AuthTokenCmd string `json:"auth_token_cmd,omitempty"` // shell command whose stdout is the auth token; used when auth_token is empty
AuthHeader string `json:"auth_header,omitempty"`
Model string `json:"model,omitempty"`
Protocol string `json:"protocol,omitempty"` // canonical protocol name; takes priority over UseAnthropic
Expand Down Expand Up @@ -333,6 +343,8 @@ func setConfigValue(cfg *Config, key, value string) error {
cfg.Llm.URL = value
case "llm.auth_token", "llm.AuthToken":
cfg.Llm.AuthToken = value
case "llm.auth_token_cmd", "llm.AuthTokenCmd":
cfg.Llm.AuthTokenCmd = value
case "llm.auth_header", "llm.AuthHeader":
normalized, err := llm.NormalizeAuthHeader(value)
if err != nil {
Expand Down Expand Up @@ -407,7 +419,7 @@ func setConfigValue(cfg *Config, key, value string) error {
}
cfg.Llm.ExtraBody = m
default:
return fmt.Errorf("unknown config key: %s\nSupported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.protocol, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\nProvider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers\nProtocol values: anthropic, openai, openai-responses\nMCP server fields: type, command, args, env, url, headers, tools, setup", key)
return fmt.Errorf("unknown config key: %s\nSupported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_token_cmd, llm.auth_header, llm.model, llm.protocol, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\nProvider fields: api_key, api_key_cmd, url, protocol, model, models, auth_header, extra_body, extra_headers\nProtocol values: anthropic, openai, openai-responses\nMCP server fields: type, command, args, env, url, headers, tools, setup", key)
}
return nil
}
Expand All @@ -416,6 +428,8 @@ func applyProviderField(entry *ProviderEntry, field, key, value string) error {
switch field {
case "api_key":
entry.APIKey = value
case "api_key_cmd":
entry.APIKeyCmd = value
case "url":
entry.URL = value
case "protocol":
Expand Down Expand Up @@ -451,7 +465,7 @@ func applyProviderField(entry *ProviderEntry, field, key, value string) error {
}
entry.ExtraHeaders = parsed
default:
return fmt.Errorf("unknown provider field %q: supported fields are api_key, url, protocol, model, models, auth_header, extra_body, extra_headers", field)
return fmt.Errorf("unknown provider field %q: supported fields are api_key, api_key_cmd, url, protocol, model, models, auth_header, extra_body, extra_headers", field)
}
return nil
}
Expand Down
50 changes: 50 additions & 0 deletions cmd/opencodereview/config_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,56 @@ func TestSetConfigValueProviderEntry(t *testing.T) {
}
}

func TestSetConfigValueKeyCmdFields(t *testing.T) {
// A typo in any of these case labels would silently degrade to "unknown
// provider field" / "unknown config key", so assert the field each key writes.
const value = "op read op://dev/anthropic/api-key"
tests := []struct {
name string
key string
got func(cfg *Config) string
}{
{"preset provider api_key_cmd", "providers.anthropic.api_key_cmd", func(cfg *Config) string { return cfg.Providers["anthropic"].APIKeyCmd }},
{"custom provider api_key_cmd", "custom_providers.my-gateway.api_key_cmd", func(cfg *Config) string { return cfg.CustomProviders["my-gateway"].APIKeyCmd }},
{"llm auth_token_cmd", "llm.auth_token_cmd", func(cfg *Config) string { return cfg.Llm.AuthTokenCmd }},
{"llm AuthTokenCmd alias", "llm.AuthTokenCmd", func(cfg *Config) string { return cfg.Llm.AuthTokenCmd }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, tt.key, value); err != nil {
t.Fatalf("setConfigValue %s: %v", tt.key, err)
}
if got := tt.got(cfg); got != value {
t.Errorf("%s = %q, want %q", tt.key, got, value)
}
})
}
}

func TestShouldMaskConfigValue(t *testing.T) {
// api_key/auth_token values are secrets; the *_cmd variants are command
// lines, so they print unmasked.
tests := []struct {
key string
want bool
}{
{"llm.auth_token", true},
{"llm.auth_token_cmd", false},
{"providers.x.api_key", true},
{"providers.x.api_key_cmd", false},
{"providers.x.APIKeyCmd", false},
{"llm.AuthToken", true},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
if got := shouldMaskConfigValue(tt.key); got != tt.want {
t.Errorf("shouldMaskConfigValue(%q) = %v, want %v", tt.key, got, tt.want)
}
})
}
}

func TestSetConfigValueProviderEntryNonPresetWritesCustomProvider(t *testing.T) {
cfg := &Config{}

Expand Down
4 changes: 2 additions & 2 deletions cmd/opencodereview/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,8 +351,8 @@ Examples:
ocr config set language English
ocr config set telemetry.enabled true

Supported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.protocol, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging
Provider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers
Supported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_token_cmd, llm.auth_header, llm.model, llm.protocol, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging
Provider fields: api_key, api_key_cmd, url, protocol, model, models, auth_header, extra_body, extra_headers
Protocol values: anthropic, openai, openai-responses
MCP server fields: type, command, args, env, url, headers, tools, setup`)
}
54 changes: 54 additions & 0 deletions cmd/opencodereview/flags_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"slices"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -226,6 +228,58 @@ func TestPrintDefaults(t *testing.T) {
fs.PrintDefaults()
}

// configFieldList returns the comma-separated names that follow prefix on the
// one line of text starting with it.
func configFieldList(t *testing.T, text, prefix string) []string {
t.Helper()
for _, line := range strings.Split(text, "\n") {
if !strings.HasPrefix(line, prefix) {
continue
}
var out []string
for _, field := range strings.Split(strings.TrimPrefix(line, prefix), ",") {
if field = strings.TrimSpace(field); field != "" {
out = append(out, field)
}
}
return out
}
t.Fatalf("no line starting with %q in:\n%s", prefix, text)
return nil
}

// These four lists are duplicated verbatim in printConfigUsage (what `ocr config`
// and `ocr config --help` print) and in setConfigValue's unknown-key error.
// api_key_cmd and llm.auth_token_cmd were added to the second copy and missed in
// the first, so the primary discovery surface silently disagreed with the code.
// Compared in order, since both copies are meant to be identical text.
func TestPrintConfigUsage_ListsMatchSetConfigValueError(t *testing.T) {
usage := captureStdout(t, printConfigUsage)

err := setConfigValue(&Config{}, "definitely.not.a.key", "")
if err == nil {
t.Fatal("setConfigValue should reject an unknown key")
}
canonical := err.Error()

prefixes := []string{
"Supported keys: ",
"Provider fields: ",
"Protocol values: ",
"MCP server fields: ",
}
for _, prefix := range prefixes {
t.Run(strings.TrimSuffix(prefix, ": "), func(t *testing.T) {
want := configFieldList(t, canonical, prefix)
got := configFieldList(t, usage, prefix)
if !slices.Equal(got, want) {
t.Errorf("%q drifted between flags.go and config_cmd.go\n flags.go: %v\n config_cmd.go: %v",
prefix, got, want)
}
})
}
}

func TestExpandShortFlags(t *testing.T) {
m := map[string]string{"c": "commit", "f": "format"}
tests := []struct {
Expand Down
12 changes: 8 additions & 4 deletions cmd/opencodereview/provider_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,13 +235,16 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider

preset, isPreset := llm.LookupProvider(result.provider)

if result.apiKey == "" {
// Mirror the resolver's precedence (static api_key -> api_key_cmd -> env var):
// an already-configured api_key_cmd satisfies the requirement, so picking a
// model for such a provider must not fail and abandon the save.
if result.apiKey == "" && cfg.Providers[result.provider].APIKeyCmd == "" {
if isPreset && preset.EnvVar != "" {
if os.Getenv(preset.EnvVar) == "" {
return fmt.Errorf("API key is required for provider %s (configure it or set $%s)", result.provider, preset.EnvVar)
return fmt.Errorf("API key is required for provider %s (configure it, set providers.%s.api_key_cmd, or set $%s)", result.provider, result.provider, preset.EnvVar)
}
} else {
return fmt.Errorf("API key is required for provider %s", result.provider)
return fmt.Errorf("API key is required for provider %s (configure it or set providers.%s.api_key_cmd)", result.provider, result.provider)
}
}

Expand All @@ -257,7 +260,8 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
if result.apiKey != "" {
entry.APIKey = result.apiKey
} else {
// Confirmed empty key: clear saved api_key so resolver falls back to $ENV_VAR.
// Confirmed empty key: clear saved api_key so the resolver falls back to
// api_key_cmd (when set) or $ENV_VAR.
entry.APIKey = ""
}
cfg.Providers[result.provider] = entry
Expand Down
Loading
Loading