diff --git a/docs/adr/41617-org-mode-workflow-targeted-updates.md b/docs/adr/41617-org-mode-workflow-targeted-updates.md new file mode 100644 index 00000000000..0172d5239a3 --- /dev/null +++ b/docs/adr/41617-org-mode-workflow-targeted-updates.md @@ -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 ` 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.* diff --git a/pkg/cli/update_org.go b/pkg/cli/update_org.go index 28ec5907265..2f23e435129 100644 --- a/pkg/cli/update_org.go +++ b/pkg/cli/update_org.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "time" @@ -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. @@ -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 { @@ -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", @@ -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. @@ -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) } diff --git a/pkg/cli/update_org_search.go b/pkg/cli/update_org_search.go index b5a9e6fbe61..7a5e2b70b8e 100644 --- a/pkg/cli/update_org_search.go +++ b/pkg/cli/update_org_search.go @@ -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 + } + + 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. diff --git a/pkg/cli/update_org_search_test.go b/pkg/cli/update_org_search_test.go new file mode 100644 index 00000000000..b1184d52662 --- /dev/null +++ b/pkg/cli/update_org_search_test.go @@ -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", + ) +} diff --git a/pkg/cli/update_org_test.go b/pkg/cli/update_org_test.go index 9233b1ab453..0d96331912a 100644 --- a/pkg/cli/update_org_test.go +++ b/pkg/cli/update_org_test.go @@ -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{ @@ -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) { @@ -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) { @@ -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) { @@ -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 @@ -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()) @@ -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) { @@ -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) {