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
44 changes: 44 additions & 0 deletions docs/adr/41617-org-mode-workflow-targeted-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR-41617: Workflow-Name Filtering in `--org` Mode for `gh aw update`

**Date**: 2026-06-26
**Status**: Draft
**Deciders**: pelikhan, copilot-swe-agent

---

### Context

The `gh aw update --org` command previously treated org mode as all-or-nothing: it scanned every source-managed workflow across all repositories in the organization, regardless of which specific workflows the caller requested. Positional workflow name arguments passed to `update` were silently ignored when `--org` was active. For large organizations this meant unnecessary GitHub code-search queries and expensive shallow-checkout work on repositories that did not contain the requested workflows, increasing latency and API consumption even when operators needed to update only one or two specific workflows.

### Decision

We will thread `UpdateWorkflowsOptions.WorkflowNames` through both the org-wide repository discovery phase (via a richer GitHub code-search query) and the per-repository preview phase (via `findWorkflowsWithSource`). A new helper, `buildOrgWorkflowSearchQuery`, normalizes workflow IDs, deduplicates them, and appends deterministic `filename:` predicates to the base code-search query so that only repositories containing at least one requested workflow are returned as candidates. When no workflow names are specified, the query degrades to the original broad search, preserving backwards compatibility.

### Alternatives Considered

#### Alternative 1: Post-filter (scan all repos, discard non-matching results)

Keep the existing broad discovery unchanged and filter out non-matching workflows only during the per-repo preview stage. This is simpler to implement—no query construction logic is needed—but wastes GitHub code-search quota and per-repo shallow-checkout work on repositories that will ultimately be discarded. For organizations with hundreds of repositories this is prohibitively expensive.

#### Alternative 2: Separate subcommand for targeted org updates

Introduce a dedicated `gh aw update --org --only <workflow>` subcommand path rather than enriching the existing `--org` mode. This avoids changing the `searchOrgWorkflowRepos` function signature and keeps the original code path untouched. However, it duplicates orchestration logic, splits the mental model for operators, and provides no improvement to the common case where a user passes workflow names alongside `--org` expecting targeted behavior.

### Consequences

#### Positive
- Reduced API consumption: GitHub code-search results are narrowed before expensive per-repo shallow checkouts occur, cutting unnecessary network requests in proportion to the number of unrelated repos in the org.
- Correct semantics: workflow name arguments now take effect consistently in both single-repo and org modes, eliminating silent arg-ignore behavior.
- Deterministic queries: `buildOrgWorkflowSearchQuery` normalizes IDs and sorts filename predicates, making search queries reproducible and testable.

#### Negative
- Breaking internal API: the `searchOrgWorkflowRepos` function signature adds a `workflowNames []string` parameter, requiring updates to all call sites and mock stubs in tests.
- Additional query-construction complexity: `buildOrgWorkflowSearchQuery` introduces a normalization and deduplication step that must be kept in sync with `normalizeWorkflowID`; bugs here silently widen or narrow the candidate repo set.

#### Neutral
- The `searchOrgWorkflowReposFn` function variable type changes accordingly, so any external callers injecting a custom search function (e.g., in integration tests) must update their signatures.
- The optimization applies only when at least one workflow name is specified; zero-argument invocations produce the same query as before.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
36 changes: 33 additions & 3 deletions pkg/cli/update_org.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -69,6 +70,9 @@ type orgRepoPreview struct {

func runUpdateForOrg(ctx context.Context, org string, repoGlobs []string, opts UpdateWorkflowsOptions, createPR bool, createIssue bool, verbose bool) error {
clearUpdateResolutionCaches()
searchFn := func(ctx context.Context, org string, verbose bool) ([]string, error) {
return searchOrgWorkflowReposFn(ctx, org, opts.WorkflowNames, verbose)
}

// scanFn previews a single repo and decides whether to include it.
// It also prints a per-repo workflow summary to stderr.
Expand All @@ -90,7 +94,7 @@ func runUpdateForOrg(ctx context.Context, org string, repoGlobs []string, opts U
}

return runCommandForOrg(ctx, org, repoGlobs, orgRunCallbacks{
SearchFn: searchOrgWorkflowReposFn,
SearchFn: searchFn,
ScanFn: scanFn,
ReportFn: renderOrgPreviewReport,
ApplyFn: func(ctx context.Context, preview orgRepoPreview, v bool) error {
Comment on lines 96 to 100
Expand All @@ -100,7 +104,7 @@ func runUpdateForOrg(ctx context.Context, org string, repoGlobs []string, opts U
return createIssueForOrgRepoFn(ctx, preview, v)
},
DiscoveringMsg: "Discovering repositories in " + org + " with source-managed workflows...",
NoReposMsg: "No repositories found with source-managed workflows",
NoReposMsg: formatUpdateOrgNoReposMessage(opts.WorkflowNames),
ScanLabel: "Inspecting",
ApplyLabel: "Updating",
IssueLabel: "Creating issue in",
Expand All @@ -111,6 +115,32 @@ func runUpdateForOrg(ctx context.Context, org string, repoGlobs []string, opts U
}, createPR, createIssue, verbose)
}

func formatUpdateOrgNoReposMessage(workflowNames []string) string {
if len(workflowNames) == 0 {
return "No repositories found with source-managed workflows"
}

filters := make([]string, 0, len(workflowNames))
seen := make(map[string]struct{}, len(workflowNames))
for _, workflowName := range workflowNames {
normalized := normalizeWorkflowID(workflowName)
if normalized == "" || normalized == "." {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
filters = append(filters, normalized)
}
if len(filters) == 0 {
return "No repositories found with source-managed workflows matching the requested workflow filters"
}

slices.Sort(filters)
return "No repositories found with source-managed workflows matching: " + strings.Join(filters, ", ")
}

// renderOrgPreviewReport prints the discovered updates for each repository. It is
// intentionally cheap (no API calls) so it can be shown even when a run is stopped
// early by a cancellation signal or a critical rate-limit condition.
Expand Down Expand Up @@ -145,7 +175,7 @@ func previewOrgRepoUpdates(ctx context.Context, repo string, opts UpdateWorkflow
}

workflowsDir := filepath.Join(checkoutDir, constants.GetWorkflowDir())
workflows, err := findWorkflowsWithSource(workflowsDir, nil, verbose)
workflows, err := findWorkflowsWithSource(workflowsDir, opts.WorkflowNames, verbose)
if err != nil {
return orgRepoPreview{}, fmt.Errorf("failed to scan workflows in shallow checkout: %w", err)
}
Expand Down
38 changes: 36 additions & 2 deletions pkg/cli/update_org_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,45 @@ var searchOrgWorkflowReposFn = searchOrgWorkflowRepos
//
// It paginates through all code-search results, deduplicates by repository full
// name, and returns a deterministically sorted slice of "owner/repo" strings.
func searchOrgWorkflowRepos(ctx context.Context, org string, verbose bool) ([]string, error) {
query := fmt.Sprintf(`org:%s path:.github/workflows extension:md "source:"`, org)
func searchOrgWorkflowRepos(ctx context.Context, org string, workflowNames []string, verbose bool) ([]string, error) {
query := buildOrgWorkflowSearchQuery(org, workflowNames)
return searchOrgReposByQuery(ctx, query, verbose)
}

// buildOrgWorkflowSearchQuery constructs the org-mode code-search query for
// source-managed workflows. When workflowNames is empty, or every candidate
// normalizes away, it falls back to the base query and relies on the later
// per-repo workflow scan to enforce any requested filters.
func buildOrgWorkflowSearchQuery(org string, workflowNames []string) string {
base := fmt.Sprintf(`org:%s path:.github/workflows extension:md "source:"`, org)
if len(workflowNames) == 0 {
return base
}

filenameFilters := make([]string, 0, len(workflowNames))
seen := make(map[string]struct{}, len(workflowNames))
for _, workflowName := range workflowNames {
normalized := normalizeWorkflowID(workflowName)
if normalized == "" || normalized == "." {
continue
}
filename := normalized + ".md"
if _, ok := seen[filename]; ok {
continue
}
seen[filename] = struct{}{}
filenameFilters = append(filenameFilters, "filename:"+filename)
}
if len(filenameFilters) == 0 {
// CLI validation already rejects empty workflow names, so this fallback is
// primarily a safety net for non-CLI callers and tests.
return base
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silent fallback to full-org scan when all workflow names normalize to empty: when workflowNames is non-empty but every entry reduces to "" via normalizeWorkflowID, the function silently returns the base query — no filename predicates, no warning — causing searchOrgReposByQuery to scan the entire org.

💡 Details and mitigation

Correctness is preserved by the backstop in previewOrgRepoUpdates: even if the search over-fetches repos, findWorkflowsWithSource re-filters them using opts.WorkflowNames. So no wrong updates are applied. The risk is pure API quota waste (an org-wide search instead of a targeted one) with no signal to the caller.

This is hard to reach in practice since CLI validation blocks empty names upstream, but it is worth hardening:

if len(filenameFilters) == 0 {
    // All provided names normalized to empty; caller should not hit this path.
    // Fall back to broad search and let per-repo scan enforce the filter.
    return base
}

A fmt.Fprintln(os.Stderr, console.FormatWarningMessage("...")) or debug log here would make unexpected fallback observable without blocking the operation.


slices.Sort(filenameFilters)
return base + " (" + strings.Join(filenameFilters, " OR ") + ")"
}

// searchOrgReposByQuery paginates through GitHub code-search results for the given
// query, deduplicates by repository full name, and returns a deterministically
// sorted slice of "owner/repo" strings.
Expand Down
46 changes: 46 additions & 0 deletions pkg/cli/update_org_search_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//go:build !integration

package cli

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestBuildOrgWorkflowSearchQuery(t *testing.T) {
assert.Equal(
t,
`org:octo path:.github/workflows extension:md "source:"`,
buildOrgWorkflowSearchQuery("octo", nil),
"nil workflow filters should keep the base org search query",
)

assert.Equal(
t,
`org:octo path:.github/workflows extension:md "source:" (filename:repo-assist.md OR filename:triage.md)`,
buildOrgWorkflowSearchQuery("octo", []string{"triage.md", "repo-assist"}),
"workflow filters should be normalized, sorted, and joined with OR",
)

assert.Equal(
t,
`org:octo path:.github/workflows extension:md "source:" (filename:repo-assist.md)`,
buildOrgWorkflowSearchQuery("octo", []string{"repo-assist", ".github/workflows/repo-assist.md"}),
"duplicate workflow filters should collapse to a single filename predicate",
)

assert.Equal(
t,
`org:octo path:.github/workflows extension:md "source:"`,
buildOrgWorkflowSearchQuery("octo", []string{}),
"an empty workflow filter slice should behave like nil",
)

assert.Equal(
t,
`org:octo path:.github/workflows extension:md "source:"`,
buildOrgWorkflowSearchQuery("octo", []string{""}),
"all-empty workflow filters should fall back to the base org search query",
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing boundary test for []string{} (empty slice) and all-empty-normalization path: the test covers nil, two names, and dedup, but not []string{} or a slice where every entry normalizes to "" (e.g. []string{""}).

💡 Suggested additions
// empty slice → base query (same as nil)
assert.Equal(t,
    `org:octo path:.github/workflows extension:md "source:"`,
    buildOrgWorkflowSearchQuery("octo", []string{}),
)

// all entries normalize to empty → base query (silent fallback path)
assert.Equal(t,
    `org:octo path:.github/workflows extension:md "source:"`,
    buildOrgWorkflowSearchQuery("octo", []string{""}),
)

These cases exercise the if len(filenameFilters) == 0 { return base } guard at line 62, which is the silent-fallback path discussed in the adjacent comment.

74 changes: 66 additions & 8 deletions pkg/cli/update_org_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ func TestRunUpdateForOrgDryRun(t *testing.T) {
origPreview := previewOrgRepoUpdatesFn
origUpdate := runUpdateForTargetRepoFn
origWait := waitForOrgRateLimitFn
searchOrgWorkflowReposFn = func(ctx context.Context, org string, verbose bool) ([]string, error) {
searchOrgWorkflowReposFn = func(ctx context.Context, org string, workflowNames []string, verbose bool) ([]string, error) {
return []string{"octo/api", "octo/web"}, nil
}

previewOrgRepoUpdatesFn = func(ctx context.Context, repo string, opts UpdateWorkflowsOptions, verbose bool) (orgRepoPreview, error) {
if repo == "octo/api" {
return orgRepoPreview{
Expand Down Expand Up @@ -91,12 +92,69 @@ func TestRunUpdateForOrgDryRun(t *testing.T) {
assert.NotContains(t, output, "- octo/web\n")
}

func TestRunUpdateForOrgPassesWorkflowNameFilters(t *testing.T) {
origSearch := searchOrgWorkflowReposFn
origPreview := previewOrgRepoUpdatesFn
origUpdate := runUpdateForTargetRepoFn
origWait := waitForOrgRateLimitFn
var gotFilters []string
searchOrgWorkflowReposFn = func(ctx context.Context, org string, workflowNames []string, verbose bool) ([]string, error) {
gotFilters = append([]string(nil), workflowNames...)
return []string{"octo/api"}, nil
}
previewOrgRepoUpdatesFn = func(ctx context.Context, repo string, opts UpdateWorkflowsOptions, verbose bool) (orgRepoPreview, error) {
assert.Equal(t, []string{"repo-assist", "triage.md"}, opts.WorkflowNames, "org preview should receive the same workflow filters as org discovery")
return orgRepoPreview{Repo: repo}, nil
}
runUpdateForTargetRepoFn = func(ctx context.Context, targetRepo string, opts UpdateWorkflowsOptions, createPR bool, verbose bool) error {
t.Fatalf("unexpected update call for %s", targetRepo)
return nil
}
waitForOrgRateLimitFn = func(ctx context.Context, resource string, verbose bool) error { return nil }
defer func() {
searchOrgWorkflowReposFn = origSearch
previewOrgRepoUpdatesFn = origPreview
runUpdateForTargetRepoFn = origUpdate
waitForOrgRateLimitFn = origWait
}()

err := runUpdateForOrg(context.Background(), "octo", nil, UpdateWorkflowsOptions{
WorkflowNames: []string{"repo-assist", "triage.md"},
}, false, false, false)
require.NoError(t, err)
assert.Equal(t, []string{"repo-assist", "triage.md"}, gotFilters, "org search should receive the requested workflow filters")
}

func TestRunUpdateForOrgNoReposIncludesWorkflowFilters(t *testing.T) {
origSearch := searchOrgWorkflowReposFn
searchOrgWorkflowReposFn = func(ctx context.Context, org string, workflowNames []string, verbose bool) ([]string, error) {
return nil, nil
}
defer func() {
searchOrgWorkflowReposFn = origSearch
}()

output := captureUpdateOrgStderr(t, func() {
err := runUpdateForOrg(context.Background(), "octo", nil, UpdateWorkflowsOptions{
WorkflowNames: []string{"triage.md", "repo-assist"},
}, false, false, false)
require.NoError(t, err)
})

assert.Contains(
t,
output,
"No repositories found with source-managed workflows matching: repo-assist, triage",
"workflow-filtered org discovery should report which requested workflows had no matching repositories",
)
}

func TestRunUpdateForOrgCreatePRSortsOldestFirst(t *testing.T) {
origSearch := searchOrgWorkflowReposFn
origPreview := previewOrgRepoUpdatesFn
origUpdate := runUpdateForTargetRepoFn
origWait := waitForOrgRateLimitFn
searchOrgWorkflowReposFn = func(ctx context.Context, org string, verbose bool) ([]string, error) {
searchOrgWorkflowReposFn = func(ctx context.Context, org string, workflowNames []string, verbose bool) ([]string, error) {
return []string{"octo/newer", "octo/older"}, nil
}
previewOrgRepoUpdatesFn = func(ctx context.Context, repo string, opts UpdateWorkflowsOptions, verbose bool) (orgRepoPreview, error) {
Expand Down Expand Up @@ -139,7 +197,7 @@ func TestRunUpdateForOrgCreateIssueSortsOldestFirst(t *testing.T) {
origUpdate := runUpdateForTargetRepoFn
origWait := waitForOrgRateLimitFn
origCreateIssue := createIssueForOrgRepoFn
searchOrgWorkflowReposFn = func(ctx context.Context, org string, verbose bool) ([]string, error) {
searchOrgWorkflowReposFn = func(ctx context.Context, org string, workflowNames []string, verbose bool) ([]string, error) {
return []string{"octo/newer", "octo/older"}, nil
}
previewOrgRepoUpdatesFn = func(ctx context.Context, repo string, opts UpdateWorkflowsOptions, verbose bool) (orgRepoPreview, error) {
Expand Down Expand Up @@ -186,7 +244,7 @@ func TestRunUpdateForOrgContinuesAfterPreviewError(t *testing.T) {
origPreview := previewOrgRepoUpdatesFn
origUpdate := runUpdateForTargetRepoFn
origWait := waitForOrgRateLimitFn
searchOrgWorkflowReposFn = func(ctx context.Context, org string, verbose bool) ([]string, error) {
searchOrgWorkflowReposFn = func(ctx context.Context, org string, workflowNames []string, verbose bool) ([]string, error) {
return []string{"octo/broken", "octo/good"}, nil
}
previewOrgRepoUpdatesFn = func(ctx context.Context, repo string, opts UpdateWorkflowsOptions, verbose bool) (orgRepoPreview, error) {
Expand Down Expand Up @@ -231,7 +289,7 @@ func TestRunUpdateForOrgStopsOnCriticalRateLimit(t *testing.T) {
origPreview := previewOrgRepoUpdatesFn
origUpdate := runUpdateForTargetRepoFn
origWait := waitForOrgRateLimitFn
searchOrgWorkflowReposFn = func(ctx context.Context, org string, verbose bool) ([]string, error) {
searchOrgWorkflowReposFn = func(ctx context.Context, org string, workflowNames []string, verbose bool) ([]string, error) {
return []string{"octo/a", "octo/b", "octo/c"}, nil
}
var previewed []string
Expand Down Expand Up @@ -281,7 +339,7 @@ func TestRunUpdateForOrgStopsOnCancellation(t *testing.T) {
origPreview := previewOrgRepoUpdatesFn
origUpdate := runUpdateForTargetRepoFn
origWait := waitForOrgRateLimitFn
searchOrgWorkflowReposFn = func(ctx context.Context, org string, verbose bool) ([]string, error) {
searchOrgWorkflowReposFn = func(ctx context.Context, org string, workflowNames []string, verbose bool) ([]string, error) {
return []string{"octo/a", "octo/b"}, nil
}
ctx, cancel := context.WithCancel(context.Background())
Expand Down Expand Up @@ -324,7 +382,7 @@ func TestRunUpdateForOrgCreateIssueReturnsErrorWhenAllIssueCreatesFail(t *testin
origUpdate := runUpdateForTargetRepoFn
origWait := waitForOrgRateLimitFn
origCreateIssue := createIssueForOrgRepoFn
searchOrgWorkflowReposFn = func(ctx context.Context, org string, verbose bool) ([]string, error) {
searchOrgWorkflowReposFn = func(ctx context.Context, org string, workflowNames []string, verbose bool) ([]string, error) {
return []string{"octo/a"}, nil
}
previewOrgRepoUpdatesFn = func(ctx context.Context, repo string, opts UpdateWorkflowsOptions, verbose bool) (orgRepoPreview, error) {
Expand Down Expand Up @@ -363,7 +421,7 @@ func TestRunUpdateForOrgCreatePRReturnsErrorWhenAllUpdatesFail(t *testing.T) {
origPreview := previewOrgRepoUpdatesFn
origUpdate := runUpdateForTargetRepoFn
origWait := waitForOrgRateLimitFn
searchOrgWorkflowReposFn = func(ctx context.Context, org string, verbose bool) ([]string, error) {
searchOrgWorkflowReposFn = func(ctx context.Context, org string, workflowNames []string, verbose bool) ([]string, error) {
return []string{"octo/a"}, nil
}
previewOrgRepoUpdatesFn = func(ctx context.Context, repo string, opts UpdateWorkflowsOptions, verbose bool) (orgRepoPreview, error) {
Expand Down
Loading