Skip to content

Refactor duplicated key sorting, engine env assembly, and engine max-* codemods#41674

Merged
pelikhan merged 4 commits into
mainfrom
copilot/refactor-sliceutil-sortedkeys
Jun 26, 2026
Merged

Refactor duplicated key sorting, engine env assembly, and engine max-* codemods#41674
pelikhan merged 4 commits into
mainfrom
copilot/refactor-sliceutil-sortedkeys

Conversation

Copilot AI commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

This issue called out three recurring patterns: ad-hoc key sorting instead of sliceutil.SortedKeys, repeated engine env assembly logic across runtimes, and near-identical codemods for migrating engine.max-runs/engine.max-turns. This PR consolidates those paths into shared helpers while preserving current behavior and output shape.

  • Workflow env rendering now uses shared sorted-key utility

    • Replaced manual env key collection + sort.Strings in FormatStepWithCommandAndEnv with sliceutil.SortedKeys.
    • Keeps deterministic YAML env ordering with less bespoke logic.
  • Engine env assembly deduplicated across Claude/Codex/Copilot

    • Added shared helpers in pkg/workflow/engine_helpers.go for:
      • optional tool timeout envs
      • max-turns env resolution
      • engine+agent env merges
      • MCP script secret passthrough
    • Updated:
      • pkg/workflow/claude_engine.go
      • pkg/workflow/codex_engine.go
      • pkg/workflow/copilot_engine_execution.go
    • Result: common env behavior is defined once and reused by all three engines.
  • Engine max- codemods unified behind one migration helper*

    • Introduced migrateEngineFieldToTopLevel in pkg/cli/codemod_engine_to_top_level_helpers.go.
    • Refactored both codemods to call it:
      • codemod_engine_max_runs.go
      • codemod_engine_max_turns.go
    • Preserves existing inline-map skip behavior and user-facing log messages.
// Shared codemod path now used by both engine max-* migrations.
return migrateEngineFieldToTopLevel(
	content,
	frontmatter,
	"max-runs",
	"max-turns",
	[]string{"max-runs", "max-turns"},
	engineMaxRunsCodemodLog,
	"...skip message...",
	"...removed message...",
	"...migrated message...",
)

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Refactor sliceutil.SortedKeys and eliminate duplicate engine env blocks Refactor duplicated key sorting, engine env assembly, and engine max-* codemods Jun 26, 2026
Copilot AI requested a review from pelikhan June 26, 2026 12:25
@pelikhan pelikhan marked this pull request as ready for review June 26, 2026 12:26
Copilot AI review requested due to automatic review settings June 26, 2026 12:26
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

No test files were added or modified in this PR. Test Quality Sentinel skipped. Changed files: pkg/cli/codemod_engine_max_runs.go, pkg/cli/codemod_engine_max_turns.go, pkg/cli/codemod_engine_to_top_level_helpers.go, pkg/workflow/claude_engine.go, pkg/workflow/codex_engine.go, pkg/workflow/copilot_engine_execution.go, pkg/workflow/engine_helpers.go — none are test files.

Copilot AI left a comment

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.

Pull request overview

This PR refactors recurring workflow compilation patterns by consolidating deterministic key sorting, engine environment assembly, and duplicated codemod logic for migrating deprecated engine.max-* frontmatter fields into shared helpers.

Changes:

  • Replaced ad-hoc env key sorting in workflow step rendering with sliceutil.SortedKeys for deterministic output.
  • Centralized common engine env assembly (timeouts, max-turns resolution, engine+agent env merges, mcp-scripts secret passthrough) into pkg/workflow/engine_helpers.go and reused it across Claude/Codex/Copilot.
  • Unified the engine.max-runs and engine.max-turns codemods behind a shared migrateEngineFieldToTopLevel helper.
Show a summary per file
File Description
pkg/workflow/engine_helpers.go Adds shared env assembly helpers and switches env rendering to sliceutil.SortedKeys.
pkg/workflow/copilot_engine_execution.go Replaces duplicated env setup with shared engine helper calls.
pkg/workflow/codex_engine.go Replaces duplicated env setup with shared engine helper calls.
pkg/workflow/claude_engine.go Replaces duplicated env setup with shared engine helper calls.
pkg/cli/codemod_engine_to_top_level_helpers.go Introduces shared codemod helper for migrating engine.* fields to top-level.
pkg/cli/codemod_engine_max_turns.go Refactors max-turns codemod to use the shared helper.
pkg/cli/codemod_engine_max_runs.go Refactors max-runs codemod to use the shared helper.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 7/7 changed files
  • Comments generated: 7
  • Review effort level: Low

Comment thread pkg/workflow/engine_helpers.go
Comment on lines +126 to +133
// applyEngineMaxTurnsEnv sets GH_AW_MAX_TURNS from engine.max-turns or the default expression.
func applyEngineMaxTurnsEnv(env map[string]string, workflowData *WorkflowData) {
if workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxTurns != "" {
env["GH_AW_MAX_TURNS"] = workflowData.EngineConfig.MaxTurns
return
}
env["GH_AW_MAX_TURNS"] = compilerenv.BuildDefaultMaxTurnsExpression()
}
Comment thread pkg/workflow/engine_helpers.go
Comment thread pkg/workflow/engine_helpers.go
Comment thread pkg/cli/codemod_engine_to_top_level_helpers.go
Comment thread pkg/cli/codemod_engine_to_top_level_helpers.go
Comment thread pkg/cli/codemod_engine_to_top_level_helpers.go Outdated
…duplication

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (190 new lines in pkg/ — above the 100-line threshold) but did not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/41674-deduplicate-engine-env-assembly-and-codemod-migration-helpers.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — add context the AI could not infer, refine the decision rationale, and list real alternatives you considered
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-41674: Deduplicate Engine Env Assembly and Codemod Migration Helpers

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I’ll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 0042-use-postgresql.md for PR #42).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 58.9 AIC · ⌖ 12.5 AIC · ⊞ 8.4K ·

@github-actions github-actions Bot left a comment

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.

Skills-Based Review 🧠

Applied /improve-codebase-architecture, /tdd, and /zoom-out — no blocking issues, but a few actionable improvements worth considering.

📋 Key Themes & Highlights

Key Themes

  • 9-parameter helper signature: migrateEngineFieldToTopLevel accepts three trailing string message parameters that are positional and silently swappable. A migrationMessages struct would make call sites self-documenting and resistant to silent bugs.
  • Nil log guard without nil callers: The if log != nil guard in applyEngineAndAgentEnv signals a contract that no current caller honours — either remove it or document when nil is valid.
  • Test coverage gap on shared engine helpers: The four new apply* functions in engine_helpers.go are untested directly; they're only covered through integration paths. Since they now own the env assembly contract for all three engines, direct unit tests would shrink the regression blast radius.
  • Copilot MCP ordering constraint is invisible: Claude/Codex call applyEngineAndAgentEnv + applyMCPScriptsSecretEnv back-to-back; Copilot inserts the integration-ID injection in between. The ordering is correct but the why is not surfaced at the applyMCPScriptsSecretEnv call site.

Positive Highlights

  • ✅ Clean, consistent extraction — the three engine implementations are now visually identical in their env-assembly section
  • sliceutil.SortedKeys replaces a local sort-and-iterate pattern, reducing noise and aligning with the existing utility layer
  • migrateEngineFieldToTopLevel correctly preserves all branching semantics from both original implementations (inline-map skip, top-level collision removal, positioned insertion)
  • ✅ Net −102 lines with no behaviour change — exactly the right shape for a refactor PR

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 78.2 AIC · ⌖ 7.27 AIC · ⊞ 6.5K

"github.com/github/gh-aw/pkg/logger"
)

func migrateEngineFieldToTopLevel(

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.

[/improve-codebase-architecture] Nine positional parameters — three of which are string message literals at the end — makes the call site fragile: it's easy to swap skipInlineMessage, removedMessage, and migratedMessage without the compiler noticing.

💡 Suggestion: use a named-struct options type

Extract the three message strings into a migrationMessages struct so each call site reads as self-documenting named fields:

type migrationMessages struct {
	SkipInline string
	Removed    string
	Migrated   string
}

func migrateEngineFieldToTopLevel(
	content string,
	frontmatter map[string]any,
	engineField string,
	targetTopLevelField string,
	preserveTopLevelFields []string,
	log *logger.Logger,
	msgs migrationMessages,
) (string, bool, error)

This is especially valuable because both codemods share this helper — a silently swapped message string would produce incorrect log output while the migration logic itself still passes tests.

agentConfig := getAgentConfig(workflowData)
if agentConfig != nil && len(agentConfig.Env) > 0 {
maps.Copy(env, agentConfig.Env)
if log != nil {

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.

[/improve-codebase-architecture] The if log != nil guard is added here, but no call site in this PR (or existing code) passes nil for log. This makes the function signature misleading — it implies nil is a valid and expected input when it isn't.

Consider removing the guard and requiring a non-nil logger from all callers. If there's a future use case for a no-op logger, logger.Discard() (or equivalent) is the idiomatic Go approach, not a nil check inside the function.

}

// applyEngineAndAgentEnv merges custom environment variables from engine and agent configs.
func applyEngineAndAgentEnv(env map[string]string, workflowData *WorkflowData, log *logger.Logger) {

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.

[/tdd] The four new shared helpers (applyOptionalEngineToolTimeouts, applyEngineMaxTurnsEnv, applyEngineAndAgentEnv, applyMCPScriptsSecretEnv) have no dedicated unit tests — they're only exercised indirectly through the engine integration paths.

💡 Why this matters

These functions are now the single point of definition for env assembly across Claude, Codex, and Copilot. A regression in any one of them breaks all three engines simultaneously, making the blast radius significantly larger than the original per-engine code.

Direct unit tests would:

  • Document the expected env state after each call (e.g., GH_AW_MAX_TURNS default vs. configured)
  • Catch regressions before they reach the integration layer
  • Make the nil workflowData / empty config edge cases explicit

Existing engine_helpers_test.go already tests FormatStepWithCommandAndEnv at this level — the new helpers fit the same pattern.

}
}
}
applyMCPScriptsSecretEnv(env, workflowData)

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.

[/zoom-out] In Claude and Codex, applyEngineAndAgentEnv and applyMCPScriptsSecretEnv are called back-to-back. In Copilot, they're separated by the Copilot integration-ID injection (the comment on that block explains it must come last). This asymmetry is correct and intentional, but it's invisible to a future developer adding a fourth engine — they'd likely call the two helpers consecutively and accidentally allow user env to override the integration ID.

A brief comment near the applyMCPScriptsSecretEnv call here (e.g., // Must come after integration ID injection) would make the ordering requirement explicit and prevent the pattern from being silently violated.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-reviewer skill

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

@copilot run pr-reviewer skill

Added nil guards in commit fix: add nil guards to engine helper functions and codemod logger calls:

  • pkg/workflow/engine_helpers.go: added workflowData == nil early-return guards to applyOptionalEngineToolTimeouts, applyEngineAndAgentEnv, and applyMCPScriptsSecretEnv; extended the existing EngineConfig nil check in applyEngineMaxTurnsEnv to also cover workflowData itself — making all four helpers consistent with the nil-safe pattern used by applyEngineCwdEnv.
  • pkg/cli/codemod_engine_to_top_level_helpers.go: wrapped all three log.Print call sites in migrateEngineFieldToTopLevel with if log != nil guards.

@pelikhan pelikhan merged commit fbe92a5 into main Jun 26, 2026
29 checks passed
@pelikhan pelikhan deleted the copilot/refactor-sliceutil-sortedkeys branch June 26, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants