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
52 changes: 47 additions & 5 deletions pkg/cli/logs_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,15 +178,30 @@ Examples:

repoOverrideEarly, _ := cmd.Flags().GetString("repo")
if repoOverrideEarly != "" {
// When --repo is specified, bypass local file-based workflow name
// resolution. Normalize the input (strip .md/.lock.yml extensions)
// and use it directly as the workflow filter for the remote repo.
// When --repo is specified, only use local lock-file resolution when
// the target repo is the current repository. Local lock files are
// authoritative for the current repo and allow us to map the workflow
// ID (e.g. "audit-workflows") to its GitHub Actions display name
// (e.g. "Agentic Workflow Audit Agent"), which gh run list requires.
//
// For cross-repo queries, skip local resolution to avoid mapping a
// local display name onto a different repository's workflow topology.
//
// Note: the argument must be a workflow ID (e.g. "test-claude"),
// not a display name (e.g. "Test Claude"). Display-name lookup
// requires local lock files, which are unavailable for remote repos.
workflowName = normalizeWorkflowID(args[0])
logsCommandLog.Printf("Using normalized workflow name for remote repo: %s", workflowName)
if repoIsLocal(repoOverrideEarly) {
if resolved, resolveErr := workflow.FindWorkflowName(args[0]); resolveErr == nil {
workflowName = resolved
logsCommandLog.Printf("Resolved workflow name via local lock files: %s -> %s", args[0], workflowName)
} else {
workflowName = normalizeWorkflowID(args[0])
logsCommandLog.Printf("Local resolution failed, using normalized workflow name: %s", workflowName)
}
} else {
workflowName = normalizeWorkflowID(args[0])
logsCommandLog.Printf("Using normalized workflow name for remote repo: %s", workflowName)
}
Comment on lines 179 to +204
} else {
// Use flexible workflow name matching (workflow ID or display name)
resolvedName, err := workflow.FindWorkflowName(args[0])
Expand Down Expand Up @@ -394,3 +409,30 @@ Examples:
// parseAgentLog runs the JavaScript log parser on agent logs and writes markdown to log.md

// parseFirewallLogs runs the JavaScript firewall log parser and writes markdown to firewall.md

// repoIsLocal reports whether the given --repo flag value refers to the current local
// repository. It extracts the owner/repo portion (stripping an optional HOST/ prefix),
// then compares against the GITHUB_REPOSITORY environment variable (set by the MCP
// server container) and, if that is absent, against the repository detected from the
// local git checkout via GetCurrentRepoSlug.
//
// This is used by the logs command to decide whether local lock files are authoritative
// for resolving a workflow display name: they are authoritative only when --repo points
// to the same repository that is checked out locally.
func repoIsLocal(repo string) bool {
// Strip optional HOST/ prefix (e.g. "github.com/owner/repo" → "owner/repo")
ownerRepo, _ := normalizeRepoForAPI(repo)

// Fast path: GITHUB_REPOSITORY is always the current repo in MCP server containers.
if envRepo := os.Getenv("GITHUB_REPOSITORY"); envRepo != "" {
return strings.EqualFold(ownerRepo, envRepo)
}

// Fallback: detect from git remote / gh CLI (result is cached on first call).
currentRepo, err := GetCurrentRepoSlug()
if err != nil {
logsCommandLog.Printf("Could not determine current repo slug for comparison: %v", err)
return false
}
return strings.EqualFold(ownerRepo, currentRepo)
}
72 changes: 66 additions & 6 deletions pkg/cli/logs_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package cli

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -302,12 +303,11 @@ func TestLogsCommandStdinRejectsPositionalArgs(t *testing.T) {

// TestLogsCommand_RepoBypassesLocalWorkflowResolution verifies that specifying
// --repo prevents a "workflow not found" error from local file lookup when a
// positional workflow name argument is supplied. Instead of calling
// workflow.FindWorkflowName (which requires local lock files), the command
// normalizes the name and passes it directly to the download orchestrator.
// Because there is no running GitHub API in unit tests the orchestrator itself
// will fail; the test asserts only that the error is NOT the local-resolution
// "workflow not found" message.
// positional workflow name argument is supplied and no local lock file exists.
// In that case the command normalizes the name and passes it directly to the
// download orchestrator. Because there is no running GitHub API in unit tests
// the orchestrator itself will fail; the test asserts only that the error is
// NOT the local-resolution "workflow not found" message.
func TestLogsCommand_RepoBypassesLocalWorkflowResolution(t *testing.T) {
tmpDir := t.TempDir()
origDir, err := os.Getwd()
Expand All @@ -332,3 +332,63 @@ func TestLogsCommand_RepoBypassesLocalWorkflowResolution(t *testing.T) {
assert.NotContains(t, execErr.Error(), "workflow 'nonexistent-remote-workflow' not found",
"--repo should bypass local workflow name resolution and not produce a local-not-found error")
}

// TestLogsCommand_RepoUsesLocalResolutionWhenLockFileExists verifies that when
// --repo is set to the current repository (the common MCP server case where
// GITHUB_REPOSITORY is the current repo), FindWorkflowName is still called to
// resolve the workflow ID to its GitHub Actions display name. Without this, the
// raw workflow ID (e.g. "audit-workflows") would be passed to `gh run list
// --workflow` instead of the display name (e.g. "Agentic Workflow Audit Agent"),
// causing GitHub's API to report "could not find any workflows named X".
func TestLogsCommand_RepoUsesLocalResolutionWhenLockFileExists(t *testing.T) {
tmpDir := t.TempDir()
workflowsDir := filepath.Join(tmpDir, ".github", "workflows")
require.NoError(t, os.MkdirAll(workflowsDir, 0755))

// Create the markdown file (required by ResolveWorkflowName).
// The frontmatter name field is the GitHub Actions display name; the workflow
// ID is derived from the filename ("my-test-workflow").
require.NoError(t, os.WriteFile(filepath.Join(workflowsDir, "my-test-workflow.md"), []byte("---\nname: My Test Workflow Display Name\n---\n"), 0644))

// Create a lock file whose display name differs from the workflow ID.
// This simulates the real scenario where audit-workflows.lock.yml has
// name: "Agentic Workflow Audit Agent" while the ID is "audit-workflows".
lockContent := "name: \"My Test Workflow Display Name\"\non: push\n"
require.NoError(t, os.WriteFile(filepath.Join(workflowsDir, "my-test-workflow.lock.yml"), []byte(lockContent), 0644))

// Isolate file-system writes: chdir into tmpDir so that ensureLogsGitignore
// and the default output directory (.github/aw/logs) land in tmpDir, not in
// the repository root.
origDir, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(tmpDir))
defer func() { _ = os.Chdir(origDir) }()

// GITHUB_REPOSITORY signals to repoIsLocal that owner/repo IS the current
// repo, so local lock files are authoritative for display-name resolution.
t.Setenv("GITHUB_REPOSITORY", "owner/repo")
t.Setenv("GH_AW_WORKFLOWS_DIR", workflowsDir)

cmd := NewLogsCommand()
cmd.SetArgs([]string{"my-test-workflow", "--repo", "owner/repo", "--output", filepath.Join(tmpDir, "logs-out")})
cmd.SetOut(nil)
cmd.SetErr(nil)

Comment on lines +344 to +376
execErr := cmd.Execute()

// The command must fail in unit tests (no real GitHub API access).
require.Error(t, execErr)

// Local "workflow not found" must NOT appear: the lock file exists and
// GITHUB_REPOSITORY matches --repo, so FindWorkflowName should succeed.
assert.NotContains(t, execErr.Error(), "workflow 'my-test-workflow' not found",
"when GITHUB_REPOSITORY matches --repo and a local lock file exists, FindWorkflowName should succeed")

// The raw workflow ID must NOT appear as the failing name in a gh run list
// "could not find" error, because the resolved display name should be used.
// In unit tests the API call fails with an HTTP 403 before GitHub can report
// a workflow-not-found message, so we verify the negative: the workflow ID
// ("my-test-workflow") is not echoed back as the unrecognised workflow name.
assert.NotContains(t, execErr.Error(), "could not find any workflows named my-test-workflow",
"when a local lock file exists, the display name (not the workflow ID) should be passed to gh run list")
}
Loading