diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 229c1315688..32acc96dd54 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -1837,7 +1837,7 @@ jobs: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ github.token }} - COPILOT_MODEL: copilot/gpt-5.4 + COPILOT_MODEL: gpt-5.4 GH_AW_LLM_PROVIDER: github GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} diff --git a/.github/workflows/lint-monster.lock.yml b/.github/workflows/lint-monster.lock.yml index f8df82093c2..58bc70d7f64 100644 --- a/.github/workflows/lint-monster.lock.yml +++ b/.github/workflows/lint-monster.lock.yml @@ -1562,7 +1562,7 @@ jobs: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: copilot/gpt-5.4 + COPILOT_MODEL: gpt-5.4 GH_AW_LLM_PROVIDER: github GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index eedae53dd11..87bbc6d01fb 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -1857,7 +1857,7 @@ jobs: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: copilot/gpt-5.4 + COPILOT_MODEL: gpt-5.4 GH_AW_LLM_PROVIDER: github GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} diff --git a/.github/workflows/schema-consistency-checker.lock.yml b/.github/workflows/schema-consistency-checker.lock.yml index 45fa26b3acf..cfaa9ed4cae 100644 --- a/.github/workflows/schema-consistency-checker.lock.yml +++ b/.github/workflows/schema-consistency-checker.lock.yml @@ -1482,7 +1482,7 @@ jobs: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: copilot/gpt-5.4 + COPILOT_MODEL: gpt-5.4 GH_AW_LLM_PROVIDER: github GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} diff --git a/.github/workflows/smoke-antigravity.lock.yml b/.github/workflows/smoke-antigravity.lock.yml index db949d21a95..b5036f42c4a 100644 --- a/.github/workflows/smoke-antigravity.lock.yml +++ b/.github/workflows/smoke-antigravity.lock.yml @@ -1651,7 +1651,7 @@ jobs: fi # shellcheck disable=SC1003,SC2086 sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env ANTIGRAVITY_API_KEY --exclude-env GEMINI_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && agy --dangerously-skip-permissions --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && cd "${GITHUB_WORKSPACE}" && agy --dangerously-skip-permissions --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: ANTIGRAVITY_API_BASE_URL: http://host.docker.internal:10003 ANTIGRAVITY_API_KEY: ${{ secrets.ANTIGRAVITY_API_KEY }} diff --git a/.github/workflows/smoke-crush.lock.yml b/.github/workflows/smoke-crush.lock.yml index 817832f0a35..20eba69e9d4 100644 --- a/.github/workflows/smoke-crush.lock.yml +++ b/.github/workflows/smoke-crush.lock.yml @@ -1546,7 +1546,7 @@ jobs: fi # shellcheck disable=SC1003,SC2086 sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && crush run --verbose "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && cd "${GITHUB_WORKSPACE}" && crush run --verbose "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_BASE_URL: http://host.docker.internal:10000 diff --git a/.github/workflows/smoke-opencode.lock.yml b/.github/workflows/smoke-opencode.lock.yml index a2681b5daaa..9eff682342e 100644 --- a/.github/workflows/smoke-opencode.lock.yml +++ b/.github/workflows/smoke-opencode.lock.yml @@ -1549,7 +1549,7 @@ jobs: fi # shellcheck disable=SC1003,SC2086 sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && opencode run --print-logs --log-level DEBUG "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && cd "${GITHUB_WORKSPACE}" && opencode run --print-logs --log-level DEBUG "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} diff --git a/.github/workflows/smoke-pi.lock.yml b/.github/workflows/smoke-pi.lock.yml index 87d5ebf09a7..ff4835d26b0 100644 --- a/.github/workflows/smoke-pi.lock.yml +++ b/.github/workflows/smoke-pi.lock.yml @@ -1609,7 +1609,7 @@ jobs: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: copilot/gpt-5.4 + COPILOT_MODEL: gpt-5.4 GH_AW_LLM_PROVIDER: github GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} diff --git a/.github/workflows/spec-enforcer.lock.yml b/.github/workflows/spec-enforcer.lock.yml index 4372378e243..de1faa7c4ea 100644 --- a/.github/workflows/spec-enforcer.lock.yml +++ b/.github/workflows/spec-enforcer.lock.yml @@ -1503,7 +1503,7 @@ jobs: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: copilot/gpt-5.4 + COPILOT_MODEL: gpt-5.4 GH_AW_LLM_PROVIDER: github GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} diff --git a/.github/workflows/unbloat-docs.lock.yml b/.github/workflows/unbloat-docs.lock.yml index 41fd48ce3e2..8574b526561 100644 --- a/.github/workflows/unbloat-docs.lock.yml +++ b/.github/workflows/unbloat-docs.lock.yml @@ -1609,7 +1609,7 @@ jobs: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: copilot/gpt-5.4 + COPILOT_MODEL: gpt-5.4 GH_AW_LLM_PROVIDER: github GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} diff --git a/docs/adr/41553-extract-shared-org-runner-with-callbacks.md b/docs/adr/41553-extract-shared-org-runner-with-callbacks.md new file mode 100644 index 00000000000..eaf30afcd7a --- /dev/null +++ b/docs/adr/41553-extract-shared-org-runner-with-callbacks.md @@ -0,0 +1,48 @@ +# ADR-41553: Extract Shared Org-Wide Runner with Callback Struct + +**Date**: 2026-06-26 +**Status**: Draft +**Deciders**: pelikhan, copilot-swe-agent + +--- + +### Context + +The `runUpdateForOrg` and `runUpgradeForOrg` functions in `pkg/cli/` each contained a complete, nearly identical implementation of the same structural algorithm: discover repositories in an org, filter by glob, optionally scan each repo for pending work with rate-limit awareness, sort results by oldest-edit time, render a preview report, and then apply changes or open issues — continuing past per-repo failures. This duplication meant that improvements to one command (such as graceful SIGTERM handling or skip-on-failure semantics) required manual replication to the other. As a result, the two commands had quietly diverged: `runUpgradeForOrg` lacked signal handling, stopped on the first repo error, and processed repos in arbitrary order. + +### Decision + +We will extract the shared org-wide loop into a single `runCommandForOrg` function in `pkg/cli/org_runner.go`, parameterised by an `orgRunCallbacks` struct containing pluggable functions (`SearchFn`, `ScanFn`, `ReportFn`, `ApplyFn`, `IssueFn`) and optional message-override string fields. Both `runUpdateForOrg` and `runUpgradeForOrg` become thin wrappers that build the appropriate callbacks and delegate to `runCommandForOrg`. All cross-cutting concerns — input validation, signal handling, rate-limit awareness, cancellation, sorting, reporting, and skip-on-failure — live in one place. + +### Alternatives Considered + +#### Alternative 1: Keep duplication and sync manually + +Both commands could retain their full inline implementations, with developers responsible for propagating improvements between them. This avoids introducing any new abstraction and keeps each command self-contained and easy to read in isolation. It was rejected because the existing divergence (missing signal handling and stop-on-first-error in `upgrade`) demonstrated that manual synchronisation is unreliable at this codebase's rate of change. + +#### Alternative 2: Interface-based abstraction (`OrgCommandRunner` interface) + +Define an interface with methods for each phase (`Search`, `Scan`, `Report`, `Apply`, `Issue`) and implement it separately for update and upgrade. This is more type-safe and idiomatic for large Go codebases. It was not chosen because the two implementations differ only in a small number of leaf functions, not in control flow — an interface would require more boilerplate (two concrete types) for the same outcome, without providing the additional ability to pass `nil` for the optional `ScanFn` to skip the scan phase. + +#### Alternative 3: Embed a shared base struct + +Embed a common `orgBase` struct in `updateOrgRunner` and `upgradeOrgRunner`, placing shared fields on the base while each concrete type supplies its own methods. Rejected for the same reasons as the interface approach: too much scaffolding for what is effectively a single-function difference. + +### Consequences + +#### Positive +- The `upgrade` org command gains capabilities it previously lacked: graceful SIGTERM/Ctrl-C handling, skip-failed-repos semantics (instead of stop-on-first-error), and stable sort by oldest-edit time. +- Future cross-cutting improvements (e.g. progress bars, telemetry, dry-run output) need to be written once. +- The test rename `TestRunUpgradeForOrgStopsOnFirstError` → `TestRunUpgradeForOrgSkipsFailedRepos` makes the new contract explicit. + +#### Negative +- `orgRunCallbacks` is a large struct (11 fields) mixing required functions with optional string overrides; callers must know which fields are mandatory versus optional, as there is no compile-time enforcement. +- The callback pattern (fields of function type) is harder to mock and unit-test in isolation compared to an interface, since each test must construct a full `orgRunCallbacks` literal even when only one callback is exercised. + +#### Neutral +- The `orgRepoPreview` type, previously defined in `update_org.go`, remains there — it is shared implicitly via package scope, not moved to `org_runner.go`. +- The `orgUpdateLog` logger used inside `runCommandForOrg` continues to be declared in `update_org.go`; the runner depends on that package-level variable. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/cli/org_runner.go b/pkg/cli/org_runner.go new file mode 100644 index 00000000000..eca560834f2 --- /dev/null +++ b/pkg/cli/org_runner.go @@ -0,0 +1,314 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "slices" + "strings" + "syscall" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/logger" +) + +var orgRunnerLog = logger.New("cli:org_runner") + +// orgRunCallbacks holds the pluggable functions for runCommandForOrg. +// +// runCommandForOrg implements the shared algorithm used by both the update and +// upgrade commands when operating across an organization: it discovers +// repositories, filters them, optionally scans each one for pending work, +// sorts the results, renders a report, and—when the caller requests it—applies +// the command or opens issues, all with rate-limit awareness and graceful +// cancellation support. +type orgRunCallbacks struct { + // SearchFn returns candidate repos in the org. Required. + SearchFn func(ctx context.Context, org string, verbose bool) ([]string, error) + + // ScanFn inspects a single repo and returns (preview, include, error). + // Return include=false to silently skip the repo (e.g. already up to date). + // A non-nil error causes the repo to be skipped with a warning. + // If nil, all discovered repos are included without per-repo scanning + // or rate-limiting during the discovery phase. + ScanFn func(ctx context.Context, repo string, verbose bool) (orgRepoPreview, bool, error) + + // ReportFn renders the summary before applying changes. Required. The applying + // parameter is true when createPR or createIssue is set. + ReportFn func(results []orgRepoPreview, applying bool) + + // ApplyFn applies the command to a single repo (--create-pull-request). Required when createPR is true. + ApplyFn func(ctx context.Context, preview orgRepoPreview, verbose bool) error + + // IssueFn creates an issue in a single repo (--create-issue). Required when createIssue is true. + IssueFn func(ctx context.Context, preview orgRepoPreview, verbose bool) error + + // Optional message overrides; an empty string falls back to a generic default. + + // DiscoveringMsg is printed while the SearchFn is running. + DiscoveringMsg string + // NoReposMsg is printed when SearchFn returns no repos. + NoReposMsg string + // ScanLabel is the per-repo progress label used in the scan phase: e.g. "Inspecting". + ScanLabel string + // ApplyLabel is the per-repo progress label used in the apply phase: e.g. "Updating". + ApplyLabel string + // IssueLabel is the per-repo progress label used in the issue phase: e.g. "Creating issue in". + IssueLabel string + // NoResultsMsg is printed when the scan phase finishes with no results. + NoResultsMsg string + // NoResultsStopMsg is printed when the scan phase was stopped early with no results. + NoResultsStopMsg string + // AllFailApplyMsg is returned as an error when every apply attempt fails. + AllFailApplyMsg string + // AllFailIssueMsg is returned as an error when every issue-creation attempt fails. + AllFailIssueMsg string +} + +// runCommandForOrg is the shared org-wide runner used by both the update and +// upgrade commands. It: +// 1. Validates org and repoGlobs inputs. +// 2. Installs a signal handler so Ctrl-C / SIGTERM render a partial report +// instead of exiting abruptly. +// 3. Calls cbs.SearchFn to discover candidate repos and filters by repoGlobs. +// 4. If cbs.ScanFn is non-nil, runs a per-repo scan loop with rate-limit +// awareness; otherwise all discovered repos are included directly. +// 5. Sorts results by oldest-edit time (ascending; ties broken alphabetically). +// 6. Calls cbs.ReportFn to display the summary. +// 7. When createPR or createIssue is set, iterates through results calling +// cbs.ApplyFn or cbs.IssueFn, continuing past per-repo errors. +func runCommandForOrg(ctx context.Context, org string, repoGlobs []string, cbs orgRunCallbacks, createPR bool, createIssue bool, verbose bool) error { + if strings.TrimSpace(org) == "" { + return errors.New("--org cannot be empty") + } + if err := validateRepoGlobs(repoGlobs); err != nil { + return err + } + if createPR && createIssue { + return errors.New("createPR and createIssue are mutually exclusive") + } + if cbs.SearchFn == nil { + return errors.New("orgRunCallbacks.SearchFn is required") + } + if cbs.ReportFn == nil { + return errors.New("orgRunCallbacks.ReportFn is required") + } + if createPR && cbs.ApplyFn == nil { + return errors.New("orgRunCallbacks.ApplyFn is required when createPR is true") + } + if createIssue && cbs.IssueFn == nil { + return errors.New("orgRunCallbacks.IssueFn is required when createIssue is true") + } + + // Handle Ctrl-C / SIGTERM so an interrupted run still renders the report + // gathered so far instead of exiting abruptly. + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + + discMsg := cbs.DiscoveringMsg + if discMsg == "" { + discMsg = "Discovering repositories in " + org + "..." + } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(discMsg)) + + repoPaths, err := cbs.SearchFn(ctx, org, verbose) + if err != nil { + return err + } + if len(repoPaths) == 0 { + noReposMsg := cbs.NoReposMsg + if noReposMsg == "" { + noReposMsg = "No repositories found" + } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(noReposMsg)) + return nil + } + + repos := filterOrgRepos(repoPaths, repoGlobs) + if len(repos) == 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No repositories matched the requested --repos filters")) + return nil + } + + // Build the result set. + var results []orgRepoPreview + + if cbs.ScanFn == nil { + // No per-repo scanning: include every discovered repo directly. + results = make([]orgRepoPreview, 0, len(repos)) + for _, repo := range repos { + results = append(results, orgRepoPreview{Repo: repo}) + } + } else { + total := len(repos) + scanLabel := cbs.ScanLabel + if scanLabel == "" { + scanLabel = "Inspecting" + } + results = make([]orgRepoPreview, 0, len(repos)) + stopped := false + + for i, repo := range repos { + // Honor a cancellation signal between repos so we can still show + // the report for the work completed so far. + if ctx.Err() != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Cancellation requested; stopping after %d/%d repositories", i, total))) + orgRunnerLog.Printf("Context canceled during scan at repo %d/%d: %v", i, total, ctx.Err()) + stopped = true + break + } + + fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("[%d/%d] %s %s", i+1, total, scanLabel, repo))) + + if err := waitForOrgRateLimitFn(ctx, "core", verbose); err != nil { + if errors.Is(err, errOrgRateLimitCritical) { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("GitHub API budget critical; stopping after %d/%d repositories and reporting what was found", i, total))) + orgRunnerLog.Printf("Rate limit critical during scan at repo %d/%d", i, total) + stopped = true + break + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Continuing after rate limit check failure for %s: %v", repo, err))) + } + } + + preview, include, scanErr := cbs.ScanFn(ctx, repo, verbose) + if scanErr != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Skipping %s: %v", repo, scanErr))) + orgRunnerLog.Printf("Failed to scan %s: %v", repo, scanErr) + continue + } + if !include { + continue + } + results = append(results, preview) + } + + if len(results) == 0 { + if stopped { + msg := cbs.NoResultsStopMsg + if msg == "" { + msg = "No results found before processing stopped" + } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(msg)) + return nil + } + msg := cbs.NoResultsMsg + if msg == "" { + msg = "All matching repositories are already up to date" + } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(msg)) + return nil + } + } + + // Sort by oldest-edit time (oldest first); ties broken alphabetically. + slices.SortStableFunc(results, func(a, b orgRepoPreview) int { + if a.OldestEdit.IsZero() && b.OldestEdit.IsZero() { + return strings.Compare(a.Repo, b.Repo) + } + if a.OldestEdit.IsZero() { + return 1 + } + if b.OldestEdit.IsZero() { + return -1 + } + if a.OldestEdit.Equal(b.OldestEdit) { + return strings.Compare(a.Repo, b.Repo) + } + if a.OldestEdit.Before(b.OldestEdit) { + return -1 + } + return 1 + }) + + // Always render the report before applying anything: it is cheap and lets + // the user see results even if the run is stopped early. + cbs.ReportFn(results, createPR || createIssue) + + if !createPR && !createIssue { + return nil + } + + if createIssue { + issueLabel := cbs.IssueLabel + if issueLabel == "" { + issueLabel = "Creating issue in" + } + processed := 0 + for i, result := range results { + if ctx.Err() != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Cancellation requested; created issues for %d/%d repositories", processed, len(results)))) + orgRunnerLog.Printf("Context canceled during issue creation at %d/%d: %v", i, len(results), ctx.Err()) + return nil + } + if err := waitForOrgRateLimitFn(ctx, "core", verbose); err != nil { + if errors.Is(err, errOrgRateLimitCritical) { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("GitHub API budget critical; created issues for %d/%d repositories", processed, len(results)))) + orgRunnerLog.Printf("Rate limit critical during issue creation at %d/%d", i, len(results)) + return nil + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Continuing after rate limit check failure for %s: %v", result.Repo, err))) + } + } + fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("[%d/%d] %s %s", i+1, len(results), issueLabel, result.Repo))) + if err := cbs.IssueFn(ctx, result, verbose); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Skipping %s: %v", result.Repo, err))) + orgRunnerLog.Printf("Failed to create issue in %s: %v", result.Repo, err) + continue + } + processed++ + } + if processed == 0 { + msg := cbs.AllFailIssueMsg + if msg == "" { + msg = "failed to create issues in any repository" + } + return errors.New(msg) + } + return nil + } + + // createPR + applyLabel := cbs.ApplyLabel + if applyLabel == "" { + applyLabel = "Processing" + } + processed := 0 + for i, result := range results { + if ctx.Err() != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Cancellation requested; processed %d/%d repositories", processed, len(results)))) + orgRunnerLog.Printf("Context canceled during apply at %d/%d: %v", i, len(results), ctx.Err()) + return nil + } + if err := waitForOrgRateLimitFn(ctx, "core", verbose); err != nil { + if errors.Is(err, errOrgRateLimitCritical) { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("GitHub API budget critical; processed %d/%d repositories", processed, len(results)))) + orgRunnerLog.Printf("Rate limit critical during apply at %d/%d", i, len(results)) + return nil + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Continuing after rate limit check failure for %s: %v", result.Repo, err))) + } + } + fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("[%d/%d] %s %s", i+1, len(results), applyLabel, result.Repo))) + if err := cbs.ApplyFn(ctx, result, verbose); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Skipping %s: %v", result.Repo, err))) + orgRunnerLog.Printf("Failed to apply to %s: %v", result.Repo, err) + continue + } + processed++ + } + if processed == 0 { + msg := cbs.AllFailApplyMsg + if msg == "" { + msg = "failed to process any repository" + } + return errors.New(msg) + } + + return nil +} diff --git a/pkg/cli/update_org.go b/pkg/cli/update_org.go index 62fb2cf2721..947a632ef10 100644 --- a/pkg/cli/update_org.go +++ b/pkg/cli/update_org.go @@ -7,11 +7,8 @@ import ( "fmt" "os" "os/exec" - "os/signal" "path/filepath" - "slices" "strings" - "syscall" "time" "github.com/github/gh-aw/pkg/console" @@ -73,183 +70,45 @@ type orgRepoPreview struct { func runUpdateForOrg(ctx context.Context, org string, repoGlobs []string, opts UpdateWorkflowsOptions, createPR bool, createIssue bool, verbose bool) error { clearUpdateResolutionCaches() - if strings.TrimSpace(org) == "" { - return errors.New("--org cannot be empty") - } - if err := validateRepoGlobs(repoGlobs); err != nil { - return err - } - - // Handle Ctrl-C / SIGTERM so an interrupted run still renders the report it - // gathered so far instead of exiting abruptly. - ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) - defer stop() - - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Discovering repositories in "+org+" with source-managed workflows...")) - repoPaths, err := searchOrgWorkflowReposFn(ctx, org, verbose) - if err != nil { - return err - } - - if len(repoPaths) == 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No repositories found with source-managed workflows")) - return nil - } - - repos := filterOrgRepos(repoPaths, repoGlobs) - if len(repos) == 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No repositories matched the requested --repos filters")) - return nil - } - - total := len(repos) - orgUpdateLog.Printf("Previewing updates for %d repositories in %s", total, org) - - previewByRepo := make([]orgRepoPreview, 0, len(repos)) - stopped := false - for i, repo := range repos { - // Honor a cancellation signal between repositories so we can still show - // the report for the work completed so far. - if ctx.Err() != nil { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Cancellation requested; stopping after %d/%d repositories", i, total))) - orgUpdateLog.Printf("Context canceled during preview at repo %d/%d: %v", i, total, ctx.Err()) - stopped = true - break - } - - fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("[%d/%d] Inspecting %s", i+1, total, repo))) - - if err := waitForOrgRateLimitFn(ctx, "core", verbose); err != nil { - if errors.Is(err, errOrgRateLimitCritical) { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("GitHub API budget critical; stopping after %d/%d repositories and reporting what was found", i, total))) - orgUpdateLog.Printf("Rate limit critical during preview at repo %d/%d", i, total) - stopped = true - break - } - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Continuing after rate limit check failure for %s: %v", repo, err))) - } - } - - preview, err := previewOrgRepoUpdatesFn(ctx, repo, opts, verbose) + // scanFn previews a single repo and decides whether to include it. + // It also prints a per-repo workflow summary to stderr. + scanFn := func(ctx context.Context, repo string, v bool) (orgRepoPreview, bool, error) { + preview, err := previewOrgRepoUpdatesFn(ctx, repo, opts, v) if err != nil { - // A single repository failing (parse error, transient API issue, etc.) - // must not abort the whole org run; log it and keep going. - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Skipping %s: %v", repo, err))) - orgUpdateLog.Printf("Failed to preview updates for %s: %v", repo, err) - continue + return orgRepoPreview{}, false, err } fmt.Fprintln(os.Stderr, console.FormatInfoMessage( fmt.Sprintf("%s: %d workflow(s), %d with updates", repo, preview.TotalWorkflows, len(preview.Workflows)), )) if len(preview.Workflows) == 0 { - if verbose { + if v { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Skipping "+repo+": already up to date")) } - continue - } - previewByRepo = append(previewByRepo, preview) - } - - if len(previewByRepo) == 0 { - if stopped { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No updates found before processing stopped")) - return nil - } - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("All matching repositories are already up to date")) - return nil - } - - slices.SortStableFunc(previewByRepo, func(a, b orgRepoPreview) int { - if a.OldestEdit.IsZero() && b.OldestEdit.IsZero() { - return strings.Compare(a.Repo, b.Repo) - } - if a.OldestEdit.IsZero() { - return 1 - } - if b.OldestEdit.IsZero() { - return -1 - } - if a.OldestEdit.Equal(b.OldestEdit) { - return strings.Compare(a.Repo, b.Repo) - } - if a.OldestEdit.Before(b.OldestEdit) { - return -1 - } - return 1 - }) - - // Always render the report of pending updates before applying anything; it is - // cheap to compute and lets the user see results even if the run was stopped - // early by a cancellation or a critical rate-limit condition. - renderOrgPreviewReport(previewByRepo, createPR || createIssue) - - if !createPR && !createIssue { - return nil - } - - if createIssue { - processed := 0 - for i, repo := range previewByRepo { - if ctx.Err() != nil { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Cancellation requested; created issues for %d/%d repositories", processed, len(previewByRepo)))) - orgUpdateLog.Printf("Context canceled during issue creation at %d/%d: %v", i, len(previewByRepo), ctx.Err()) - return nil - } - if err := waitForOrgRateLimitFn(ctx, "core", verbose); err != nil { - if errors.Is(err, errOrgRateLimitCritical) { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("GitHub API budget critical; created issues for %d/%d repositories", processed, len(previewByRepo)))) - orgUpdateLog.Printf("Rate limit critical during issue creation at %d/%d", i, len(previewByRepo)) - return nil - } - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Continuing after rate limit check failure for %s: %v", repo.Repo, err))) - } - } - fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("[%d/%d] Creating issue in %s", i+1, len(previewByRepo), repo.Repo))) - if err := createIssueForOrgRepoFn(ctx, repo, verbose); err != nil { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Skipping %s: %v", repo.Repo, err))) - orgUpdateLog.Printf("Failed to create issue in %s: %v", repo.Repo, err) - continue - } - processed++ - } - if processed == 0 { - return errors.New("failed to create issues in any repository") - } - return nil - } - - processed := 0 - for i, repo := range previewByRepo { - if ctx.Err() != nil { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Cancellation requested; updated %d/%d repositories", processed, len(previewByRepo)))) - orgUpdateLog.Printf("Context canceled during update at %d/%d: %v", i, len(previewByRepo), ctx.Err()) - return nil + return orgRepoPreview{}, false, nil } - if err := waitForOrgRateLimitFn(ctx, "core", verbose); err != nil { - if errors.Is(err, errOrgRateLimitCritical) { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("GitHub API budget critical; updated %d/%d repositories", processed, len(previewByRepo)))) - orgUpdateLog.Printf("Rate limit critical during update at %d/%d", i, len(previewByRepo)) - return nil - } - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Continuing after rate limit check failure for %s: %v", repo.Repo, err))) - } - } - fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("[%d/%d] Updating %s", i+1, len(previewByRepo), repo.Repo))) - if err := runUpdateForTargetRepoFn(ctx, repo.Repo, opts, true, verbose); err != nil { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Skipping %s: %v", repo.Repo, err))) - orgUpdateLog.Printf("Failed to update %s: %v", repo.Repo, err) - continue - } - processed++ - } - if processed == 0 { - return errors.New("failed to update any repository") + return preview, true, nil } - return nil + return runCommandForOrg(ctx, org, repoGlobs, orgRunCallbacks{ + SearchFn: searchOrgWorkflowReposFn, + ScanFn: scanFn, + ReportFn: renderOrgPreviewReport, + ApplyFn: func(ctx context.Context, preview orgRepoPreview, v bool) error { + return runUpdateForTargetRepoFn(ctx, preview.Repo, opts, true, v) + }, + IssueFn: func(ctx context.Context, preview orgRepoPreview, v bool) error { + return createIssueForOrgRepoFn(ctx, preview, v) + }, + DiscoveringMsg: "Discovering repositories in " + org + " with source-managed workflows...", + NoReposMsg: "No repositories found with source-managed workflows", + ScanLabel: "Inspecting", + ApplyLabel: "Updating", + IssueLabel: "Creating issue in", + NoResultsMsg: "All matching repositories are already up to date", + NoResultsStopMsg: "No updates found before processing stopped", + AllFailApplyMsg: "failed to update any repository", + AllFailIssueMsg: "failed to create issues in any repository", + }, createPR, createIssue, verbose) } // renderOrgPreviewReport prints the discovered updates for each repository. It is diff --git a/pkg/cli/upgrade_org.go b/pkg/cli/upgrade_org.go index 21524b70549..6a9a717ae27 100644 --- a/pkg/cli/upgrade_org.go +++ b/pkg/cli/upgrade_org.go @@ -3,7 +3,6 @@ package cli import ( "context" "encoding/json" - "errors" "fmt" "os" "os/exec" @@ -24,61 +23,39 @@ var createIssueForUpgradeOrgRepoFn = createIssueForUpgradeOrgRepo // or --create-issue it prints a dry-run preview; with --create-pull-request it // checks out each repository, runs the upgrade, and opens a pull request; with // --create-issue it opens a GitHub issue in each repository. +// +// The function delegates to runCommandForOrg, which provides shared logic for +// organization discovery, rate-limit handling, graceful cancellation, result +// sorting, and per-repo error recovery. func runUpgradeForOrg(ctx context.Context, org string, repoGlobs []string, opts upgradeOptions, createPR bool, createIssue bool, verbose bool) error { - if strings.TrimSpace(org) == "" { - return errors.New("--org cannot be empty") - } - if err := validateRepoGlobs(repoGlobs); err != nil { - return err - } - - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Discovering repositories in "+org+" with agentic workflows...")) - repoPaths, err := searchOrgAnyWorkflowReposFn(ctx, org, verbose) - if err != nil { - return err - } - - if len(repoPaths) == 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No repositories found with agentic workflows")) - return nil - } - - repos := filterOrgRepos(repoPaths, repoGlobs) - if len(repos) == 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No repositories matched the requested --repos filters")) - return nil - } - - if !createPR && !createIssue { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Dry-run preview of upgrade pull requests:")) - for _, repo := range repos { - fmt.Fprintf(os.Stderr, "- %s\n", repo) - } - return nil - } - - if createIssue { - for _, repo := range repos { - if err := waitForOrgRateLimitFn(ctx, "core", verbose); err != nil && verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Continuing after rate limit check failure for %s: %v", repo, err))) + return runCommandForOrg(ctx, org, repoGlobs, orgRunCallbacks{ + SearchFn: searchOrgAnyWorkflowReposFn, + // ScanFn is nil: all discovered repos are upgrade candidates and no + // per-repo API scan is required to determine that. + ScanFn: nil, + ReportFn: func(results []orgRepoPreview, applying bool) { + if applying { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Repositories with agentic workflows (%d):", len(results)))) + } else { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Dry-run preview of upgrade pull requests:")) } - if err := createIssueForUpgradeOrgRepoFn(ctx, repo, verbose); err != nil { - return err + for _, r := range results { + fmt.Fprintf(os.Stderr, "- %s\n", r.Repo) } - } - return nil - } - - for _, repo := range repos { - if err := waitForOrgRateLimitFn(ctx, "core", verbose); err != nil && verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Continuing after rate limit check failure for %s: %v", repo, err))) - } - if err := runUpgradeForTargetRepoFn(ctx, repo, opts, verbose); err != nil { - return err - } - } - - return nil + }, + ApplyFn: func(ctx context.Context, preview orgRepoPreview, v bool) error { + return runUpgradeForTargetRepoFn(ctx, preview.Repo, opts, v) + }, + IssueFn: func(ctx context.Context, preview orgRepoPreview, v bool) error { + return createIssueForUpgradeOrgRepoFn(ctx, preview.Repo, v) + }, + DiscoveringMsg: "Discovering repositories in " + org + " with agentic workflows...", + NoReposMsg: "No repositories found with agentic workflows", + ApplyLabel: "Upgrading", + IssueLabel: "Creating issue in", + AllFailApplyMsg: "failed to upgrade any repository", + AllFailIssueMsg: "failed to create issues in any repository", + }, createPR, createIssue, verbose) } // runUpgradeForTargetRepo checks out repo to a temporary directory, runs the diff --git a/pkg/cli/upgrade_org_test.go b/pkg/cli/upgrade_org_test.go index 042506358b0..90c5c40a460 100644 --- a/pkg/cli/upgrade_org_test.go +++ b/pkg/cli/upgrade_org_test.go @@ -196,7 +196,7 @@ func TestRunUpgradeCommandReposRequiresOrg(t *testing.T) { assert.Contains(t, err.Error(), "--repos requires --org") } -func TestRunUpgradeForOrgStopsOnFirstError(t *testing.T) { +func TestRunUpgradeForOrgSkipsFailedRepos(t *testing.T) { origSearch := searchOrgAnyWorkflowReposFn origUpgrade := runUpgradeForTargetRepoFn origWait := waitForOrgRateLimitFn @@ -217,8 +217,65 @@ func TestRunUpgradeForOrgStopsOnFirstError(t *testing.T) { }() err := runUpgradeForOrg(context.Background(), "octo", nil, upgradeOptions{ctx: context.Background()}, true, false, false) - require.ErrorIs(t, err, boom) - assert.Equal(t, []string{"octo/api"}, called, "should stop after first failure") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to upgrade any repository") + assert.Equal(t, []string{"octo/api", "octo/web"}, called, "should attempt all repos and skip failures") +} + +func TestRunUpgradeForOrgCreateIssueSkipsFailedRepos(t *testing.T) { + origSearch := searchOrgAnyWorkflowReposFn + origUpgrade := runUpgradeForTargetRepoFn + origIssue := createIssueForUpgradeOrgRepoFn + origWait := waitForOrgRateLimitFn + searchOrgAnyWorkflowReposFn = func(_ context.Context, _ string, _ bool) ([]string, error) { + return []string{"octo/api", "octo/web"}, nil + } + runUpgradeForTargetRepoFn = func(_ context.Context, repo string, _ upgradeOptions, _ bool) error { + t.Fatalf("unexpected upgrade call for %s", repo) + return nil + } + boom := errors.New("issue failed") + var called []string + createIssueForUpgradeOrgRepoFn = func(_ context.Context, repo string, _ bool) error { + called = append(called, repo) + return boom + } + waitForOrgRateLimitFn = func(_ context.Context, _ string, _ bool) error { return nil } + defer func() { + searchOrgAnyWorkflowReposFn = origSearch + runUpgradeForTargetRepoFn = origUpgrade + createIssueForUpgradeOrgRepoFn = origIssue + waitForOrgRateLimitFn = origWait + }() + + err := runUpgradeForOrg(context.Background(), "octo", nil, upgradeOptions{ctx: context.Background()}, false, true, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create issues in any repository") + assert.Equal(t, []string{"octo/api", "octo/web"}, called, "should attempt all repos and skip failures") +} + +func TestRunUpgradeForOrgSortsAlphabeticallyWhenScanDisabled(t *testing.T) { + origSearch := searchOrgAnyWorkflowReposFn + origUpgrade := runUpgradeForTargetRepoFn + origWait := waitForOrgRateLimitFn + searchOrgAnyWorkflowReposFn = func(_ context.Context, _ string, _ bool) ([]string, error) { + return []string{"octo/zoo", "octo/alpha", "octo/middle"}, nil + } + var called []string + runUpgradeForTargetRepoFn = func(_ context.Context, repo string, _ upgradeOptions, _ bool) error { + called = append(called, repo) + return nil + } + waitForOrgRateLimitFn = func(_ context.Context, _ string, _ bool) error { return nil } + defer func() { + searchOrgAnyWorkflowReposFn = origSearch + runUpgradeForTargetRepoFn = origUpgrade + waitForOrgRateLimitFn = origWait + }() + + err := runUpgradeForOrg(context.Background(), "octo", nil, upgradeOptions{ctx: context.Background()}, true, false, false) + require.NoError(t, err) + assert.Equal(t, []string{"octo/alpha", "octo/middle", "octo/zoo"}, called) } func captureUpgradeOrgStderr(t *testing.T, fn func()) string {