Skip to content

Refactor buildCustomJobs into focused helpers to reduce complexity#36694

Merged
pelikhan merged 5 commits into
mainfrom
copilot/deep-report-refactor-buildcustomjobs
Jun 3, 2026
Merged

Refactor buildCustomJobs into focused helpers to reduce complexity#36694
pelikhan merged 5 commits into
mainfrom
copilot/deep-report-refactor-buildcustomjobs

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Jun 3, 2026

buildCustomJobs in pkg/workflow/compiler_jobs.go was a 365-line complexity hotspot. This PR decomposes it into cohesive helpers, bringing the orchestrator under 150 lines while preserving existing custom-job behavior.

  • Refactor scope: orchestration vs. implementation

    • Kept buildCustomJobs as the high-level flow (iterate jobs, skip built-ins/pre-activation, build job, add to manager).
    • Moved detailed logic into named helpers for readability and lower cyclomatic load.
  • Dependency and activation handling extracted

    • Added helpers for:
      • precomputing prompt/on.needs dependency sets
      • parsing explicit needs
      • applying implicit activation dependency rules
    • Preserves existing “run before activation” exceptions for prompt-referenced and on.needs jobs.
  • Job property extraction split by concern

    • Extracted property handling into focused helpers (runs-on, if, permissions, strategy, timeout, concurrency, env, container, services, continue-on-error, environment, outputs).
    • Introduced a shared YAML field formatter to remove repeated marshal/indent blocks while keeping output shape unchanged.
  • Execution mode handling isolated

    • Split reusable-workflow configuration (uses, with, secrets) from standard step-based configuration (pre-steps/steps + GH_HOST step injection).
func (c *Compiler) buildCustomJobs(data *WorkflowData, activationJobCreated bool) error {
    promptReferencedJobs, onNeedsJobs := c.getCustomJobDependencySets(data)

    for jobName, jobConfig := range data.Jobs {
        if c.shouldSkipCustomJob(jobName) {
            continue
        }
        configMap, ok := jobConfig.(map[string]any)
        if !ok {
            continue
        }

        job, err := c.buildCustomJob(jobName, configMap, data, activationJobCreated, promptReferencedJobs, onNeedsJobs)
        if err != nil {
            return err
        }
        if err := c.jobManager.AddJob(job); err != nil {
            return fmt.Errorf("failed to add custom job '%s': %w", jobName, err)
        }
    }
    return nil
}

Copilot AI and others added 2 commits June 3, 2026 18:03
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Copilot AI changed the title [WIP] Refactor the buildCustomJobs function in compiler_jobs.go Refactor buildCustomJobs into focused helpers to reduce complexity Jun 3, 2026
Copilot AI requested a review from gh-aw-bot June 3, 2026 18:07
@pelikhan pelikhan marked this pull request as ready for review June 3, 2026 20:03
Copilot AI review requested due to automatic review settings June 3, 2026 20:03
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Refactors Compiler.buildCustomJobs in pkg/workflow/compiler_jobs.go by decomposing a large custom-job compilation routine into smaller helpers, aiming to reduce cyclomatic complexity while preserving the emitted workflow YAML shape and custom-job execution behavior.

Changes:

  • Split custom job orchestration into buildCustomJobs + focused helpers for skip logic, dependency computation, and job construction.
  • Extracted job property parsing into dedicated helpers (e.g., runs-on, permissions, strategy, timeout, concurrency, env, container, services, outputs).
  • Introduced formatIndentedYAMLField to centralize YAML marshal + indentation formatting for multi-line job fields.
Show a summary per file
File Description
pkg/workflow/compiler_jobs.go Breaks down buildCustomJobs into cohesive helpers and adds a shared YAML field formatter to reduce repetition.

Copilot's findings

Tip

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

  • Files reviewed: 1/1 changed files
  • Comments generated: 0

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 3, 2026

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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 3, 2026

🧪 Test Quality Sentinel completed test quality analysis.

No test files were added or modified in this PR. The only changed file is pkg/workflow/compiler_jobs.go (production code only). Test Quality Sentinel skipped.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 3, 2026

⚠️ PR Code Quality Reviewer failed during code quality review.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 3, 2026

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 3, 2026

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (455 new lines in pkg/workflow/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/36694-decompose-buildcustomjobs-into-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. It captures the decision to decompose the 365-line buildCustomJobs into single-responsibility helpers plus a shared formatIndentedYAMLField formatter.
  2. Complete the missing sections — confirm the inferred drivers, refine the rationale, and verify the alternatives reflect what you actually weighed (e.g., a CustomJobBuilder type vs. plain helpers).
  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-36694: Decompose buildCustomJobs into single-responsibility 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).

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

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · opus48 4.5M ·

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /zoom-out and /improve-codebase-architecture — commenting with suggestions, no blocking issues.

📋 Key Themes & Highlights

Key Themes

  • API design: formatIndentedYAMLField's boolean trimTrailingNewline flag is a classic flag-argument smell — consider two named helpers or letting callers handle the trim explicitly.
  • Thin wrapper: extractCustomJobProperties adds an extra indirection level for only two lines of delegation; collapsing it into buildCustomJob would flatten the call graph without losing clarity.
  • Predicate side effects: shouldSkipCustomJob logs as a side effect of what reads as a pure boolean query — separating those concerns makes it safer to test in isolation.
  • Silent fallthrough: configureCustomJobExecution silently treats a non-string uses value as a step-based job; a warning log would make misconfiguration visible.
  • Magic constant: the 6-space indent in formatIndentedYAMLField is now centralised (✅ good!) but would benefit from a named constant to document its intent.

Positive Highlights

  • buildCustomJobs orchestrator is now crystal-clear at under 30 lines
  • formatIndentedYAMLField correctly eliminates ~10 copy-paste marshal/indent blocks
  • ✅ Receiver vs. package-level function split is consistent and principled (methods only where c fields are needed)
  • ✅ Execution-mode boundary (reusable vs. step-based) is now explicit and well-named
  • ✅ Faithful extraction — no behavioral drift observed

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 1.4M

if trimTrailingNewline {
return strings.TrimSuffix(formatted, "\n"), nil
}
return formatted, 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] formatIndentedYAMLField's boolean trimTrailingNewline parameter is a flag argument — a known design smell that forces callers to supply a magic true/false whose meaning is invisible at the call site.

💡 Suggested approach

Two options:

Option A — two named helpers:

func formatIndentedYAMLField(fieldName string, value any) (string, error) { ... }
func formatIndentedYAMLFieldTrimmed(fieldName string, value any) (string, error) {
    s, err := formatIndentedYAMLField(fieldName, value)
    return strings.TrimSuffix(s, "\n"), err
}

Option B — keep one helper, but have the callers call strings.TrimSuffix explicitly so the intent is self-documenting:

formatted, err := formatIndentedYAMLField("environment", v)
if err != nil { ... }
job.Environment = strings.TrimSuffix(formatted, "\n")

Either approach makes the trim intent clear without a boolean flag.

if err := c.extractCustomJobRunsOn(job, jobName, configMap); err != nil {
return err
}

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] extractCustomJobProperties is a two-line delegation wrapper that adds an extra call stack layer without carrying semantic weight. Callers of buildCustomJob already trust it to orchestrate; moving the two calls up to buildCustomJob directly would make the decomposition one level shallower and easier to navigate.

💡 Suggestion

In buildCustomJob, replace:

if err := c.extractCustomJobProperties(job, jobName, configMap); err != nil {
    return nil, err
}

with the two direct calls:

if err := c.extractCustomJobCoreProperties(job, jobName, configMap); err != nil {
    return nil, err
}
extractCustomJobOutputs(job, jobName, configMap)

This removes the intermediate extractCustomJobProperties function entirely and keeps buildCustomJob as the single orchestration point.

// Built-in jobs are already created before buildCustomJobs; treat jobs.<builtin>
// entries as customization-only and do not create duplicate jobs.
if _, exists := c.jobManager.GetJob(jobName); exists {
compilerJobsLog.Printf("Skipping jobs.%s (built-in job already exists)", jobName)
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] shouldSkipCustomJob is named as a pure predicate (should...) but has a side effect: it logs when returning true. This violates the principle of least surprise — a reader calling this function wouldn't expect it to emit log output.

💡 Suggestion

Either rename to skipCustomJob and document the side effect, or move the log statements to the call site in buildCustomJobs:

if c.shouldSkipCustomJob(jobName) {
    compilerJobsLog.Printf("Skipping jobs.%s ...", jobName)
    continue
}

The latter keeps shouldSkipCustomJob a pure predicate that's easier to test and reason about.

if hasUses {
if usesStr, ok := uses.(string); ok {
return configureCustomReusableWorkflow(job, jobName, usesStr, configMap)
}
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] configureCustomJobExecution silently falls through to step-based configuration when uses is present but its value is not a string. This was also the original behavior, but now it's a named entry point and the silent path could confuse future maintainers.

💡 Suggestion

Add a warning log for the non-string uses case so misconfigurations are surfaced:

func (c *Compiler) configureCustomJobExecution(job *Job, jobName string, configMap map[string]any, data *WorkflowData) error {
    if uses, hasUses := configMap["uses"]; hasUses {
        if usesStr, ok := uses.(string); ok {
            return configureCustomReusableWorkflow(job, jobName, usesStr, configMap)
        }
        compilerJobsLog.Printf("Warning: job '%s' has a non-string 'uses' value (%T); treating as step-based job", jobName, uses)
    }
    return c.configureCustomJobSteps(job, jobName, configMap, data)
}

This preserves backward-compatibility while making the footgun visible.

}

lines := strings.Split(strings.TrimSpace(string(yamlBytes)), "\n")
var b strings.Builder
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 6-space indentation " " is a magic constant repeated across all the helper functions replaced by formatIndentedYAMLField. Centralising the formatter was the right call, but the hardcoded string literal inside it still requires a reader to count spaces to understand the intent.

💡 Suggestion
const jobYAMLIndent = "      " // 6 spaces — matches GitHub Actions job-level YAML indentation

func formatIndentedYAMLField(fieldName string, value any, trimTrailingNewline bool) (string, error) {
    ...
    b.WriteString(jobYAMLIndent + line + "\n")
    ...
}

This is a minor readability win, but the constant name makes the intent explicit for anyone who later needs to adjust indentation.

@pelikhan pelikhan merged commit 03b64bd into main Jun 3, 2026
26 checks passed
@pelikhan pelikhan deleted the copilot/deep-report-refactor-buildcustomjobs branch June 3, 2026 20:54
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.

[deep-report] Refactor the 365-line buildCustomJobs function in compiler_jobs.go

4 participants