Skip to content

Generate PR patches from current: true checkout path in multi-checkout workflows#34875

Open
Copilot wants to merge 4 commits into
mainfrom
copilot/bugfix-create-pull-request-patch
Open

Generate PR patches from current: true checkout path in multi-checkout workflows#34875
Copilot wants to merge 4 commits into
mainfrom
copilot/bugfix-create-pull-request-patch

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 26, 2026

create_pull_request and push_to_pull_request_branch were always generating patches from GITHUB_WORKSPACE, which produced incorrect diffs (e.g., gitlink/subproject changes) when the logical target repo was checked out in a subdirectory via current: true. This change threads the current checkout path from compilation into runtime patch generation and uses it when the operation targets that repo.

  • Compiler: expose and propagate current checkout path

    • Added CheckoutManager.GetCurrentCheckoutPath() and GetCurrentRepository().
    • Added config injection for PR patch handlers (create_pull_request, push_to_pull_request_branch):
      • patch_workspace_path
      • current_checkout_repo
    • Injection is conditional: only for non-root current: true checkout paths and non-conflicting target repo configurations.
  • Runtime: generate patch from configured checkout directory

    • Extended generateGitPatch() to accept options.workspacePath.
    • Resolves workspacePath relative to GITHUB_WORKSPACE and validates:
      • no path traversal
      • directory exists
      • path is a directory
    • safe_outputs_handlers now uses patch_workspace_path for create_pull_request and push_to_pull_request_branch when repo targeting matches the current checkout repo.
  • Docs: clarify cross-repo/current checkout behavior

    • Updated cross-repository and safe-outputs references to state that PR patch generation uses the current: true checkout directory when targeting that repository.
// safe_outputs_handlers.cjs
const patchResult = await generateGitPatch(entry.branch, baseBranch, {
  ...patchOptions,
  workspacePath: config.create_pull_request.patch_workspace_path,
});

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix create_pull_request patch generation for non-root checkouts Generate PR patches from current: true checkout path in multi-checkout workflows May 26, 2026
Copilot AI requested a review from pelikhan May 26, 2026 05:51
@pelikhan pelikhan marked this pull request as ready for review May 26, 2026 05:52
Copilot AI review requested due to automatic review settings May 26, 2026 05:52
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

This PR fixes incorrect PR patch diffs in multi-checkout workflows by generating patches from the current: true checkout directory (when the PR tools target that same repository), rather than always using GITHUB_WORKSPACE.

Changes:

  • Compiler/runtime config now propagates a patch_workspace_path (and a current_checkout_repo identifier) for PR patch generation when current: true points to a subdirectory.
  • Runtime JS patch generation supports a workspacePath option with validation to ensure the patch is generated from the intended checkout directory.
  • Added/updated Go + JS tests and documentation to cover/describe the behavior in multi-checkout scenarios.
Show a summary per file
File Description
pkg/workflow/safe_outputs_patch_workspace.go New helper to inject patch_workspace_path/current_checkout_repo into handler configs based on current: true checkout.
pkg/workflow/safe_outputs_config_generation.go Calls injection during safe-outputs config JSON generation.
pkg/workflow/safe_outputs_config_generation_test.go Tests injection into generated safe-outputs config JSON.
pkg/workflow/compiler_safe_outputs_config.go Calls injection when building handler manager env var config.
pkg/workflow/compiler_safe_outputs_config_test.go Tests injected fields appear in compiled handler config env var JSON.
pkg/workflow/checkout_manager.go Adds GetCurrentCheckoutPath() / GetCurrentRepository() helpers.
pkg/workflow/checkout_manager_test.go Unit tests for GetCurrentCheckoutPath().
docs/src/content/docs/reference/safe-outputs.md Documents patch generation behavior for current: true multi-checkout workflows.
docs/src/content/docs/reference/cross-repository.md Documents patch generation behavior for current: true subdirectory checkouts.
actions/setup/js/safe_outputs_handlers.cjs Uses patch_workspace_path (when repo matches) and validates the directory before patch generation.
actions/setup/js/safe_outputs_handlers.test.cjs Tests that patch_workspace_path is used when the target repo resolves via GH_AW_TARGET_REPO_SLUG.
actions/setup/js/generate_git_patch.cjs Adds options.workspacePath validation and uses it as the git working directory.
actions/setup/js/generate_git_patch.test.cjs Tests successful patch generation from workspacePath + rejection of traversal paths.

Copilot's findings

Tip

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

  • Files reviewed: 13/13 changed files
  • Comments generated: 2

Comment on lines +27 to +32
// Skip for wildcard and explicitly different repositories.
if targetRepo == "*" {
return
}
if targetRepo != "" && currentRepo != "" && targetRepo != currentRepo {
return
Comment thread pkg/workflow/checkout_manager.go Outdated
Comment on lines +336 to +349
// GetCurrentCheckoutPath returns the normalized (workspace-relative) path for
// the checkout marked current:true. Returns an empty string when no current
// checkout is configured or when the current checkout is at workspace root.
func (cm *CheckoutManager) GetCurrentCheckoutPath() string {
for _, entry := range cm.ordered {
if !entry.current {
continue
}
path := strings.TrimSpace(strings.TrimPrefix(entry.key.path, "./"))
if path == "." || path == "" {
return ""
}
return path
}
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 26, 2026

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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 26, 2026

PR Code Quality Reviewer completed the code quality review.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 26, 2026

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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 26, 2026

🧪 Test Quality Sentinel completed test quality analysis.

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

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (196 new lines under pkg/, actions/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/34875-generate-pr-patches-from-current-checkout-path.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 and captures the decision to thread the current: true checkout subdirectory from compile-time into runtime patch generation.
  2. Complete the missing sections — verify the alternatives reflect what you actually considered, refine the rationale, and confirm the RFC 2119 normative requirements match the implementation.
  3. Commit the finalized ADR to docs/adr/ on your branch (the file is already there as a Draft).
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-34875: Generate PR Patches from current: true Checkout Path

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., 34875-generate-pr-patches-from-current-checkout-path.md for PR #34875).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · opus47 6M ·

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.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 1.9M

let repoSlug = null;
const patchWorkspacePath = typeof prConfig.patch_workspace_path === "string" ? prConfig.patch_workspace_path.trim() : "";
const currentCheckoutRepo = typeof prConfig.current_checkout_repo === "string" ? prConfig.current_checkout_repo.trim() : "";
const patchWorkspaceMatchesTargetRepo = patchWorkspacePath && (!currentCheckoutRepo || currentCheckoutRepo === repoResult.repo);
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.

Runtime analog of compiler-side bug: patchWorkspaceMatchesTargetRepo is true when currentCheckoutRepo is empty, matching any target repo.

The JS handler has no independent defense against this: it trusts that the compiler either sets both patch_workspace_path and current_checkout_repo together, or neither. That contract is not enforced here.

💡 Suggested fix

Add an explicit guard that treats an empty currentCheckoutRepo with a configured target-repo as a non-match:

const configuredTargetRepo = (prConfig["target-repo"] || "").trim();
const patchWorkspaceMatchesTargetRepo =
  patchWorkspacePath &&
  (
    (currentCheckoutRepo && currentCheckoutRepo === repoResult.repo)  // explicit target-repo → must match currentCheckoutRepo
  );

The same fix applies to line 725 (pushPatchWorkspaceMatchesTargetRepo). This makes the JS handler resilient even if the compiler emits an incomplete config.

return
}

checkoutManager := NewCheckoutManager(data.CheckoutConfigs)
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.

NewCheckoutManager constructed inside a per-handler call, allocating a full CheckoutManager for every handler in the loop.

injectCurrentCheckoutPatchWorkspacePath is called inside the handler loop in both addHandlerManagerConfigEnvVar and generateSafeOutputsConfig. Each call re-parses and re-builds the ordered checkout list, even though the result is identical across all invocations for the same data.

💡 Suggested fix

Resolve the checkout path and repo once before the loop and pass them in:

func injectCurrentCheckoutPatchWorkspacePath(handlerName string, handlerCfg map[string]any, currentPath, currentRepo string) {
    // no more NewCheckoutManager call here
}

// At call site, before the handler loop:
cm := NewCheckoutManager(data.CheckoutConfigs)
currentPath := normalizeCurrentCheckoutPatchPath(cm.GetCurrentCheckoutPath())
currentRepo := strings.TrimSpace(cm.GetCurrentRepository())
for handlerName, handlerCfg := range handlers {
    injectCurrentCheckoutPatchWorkspacePath(handlerName, handlerCfg, currentPath, currentRepo)
}

This also makes the function easier to test in isolation without needing to construct WorkflowData with CheckoutConfigs.

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 /diagnose, /tdd, and /zoom-out. The fix is well-scoped and the root cause is correctly addressed — commenting with suggestions rather than blocking.

📋 Key Themes & Highlights

Key Themes

  • Redundant path-traversal validation: resolvePatchWorkspacePath in the handler already validates and resolves the absolute path, then patchOptions.workspacePath passes the relative string to generateGitPatch which validates a second time. These two validation layers could diverge if maintained independently.
  • Test coverage gaps on guard conditions: The target-repo: "*" wildcard skip and the repo-mismatch early-return in injectCurrentCheckoutPatchWorkspacePath have no dedicated tests — these are exactly the cases that would silently break multi-repo workflows.
  • Weak integration-test assertions: Both new safe_outputs_handlers tests only verify a debug log was emitted, not that the patch content actually came from the subdirectory.
  • entry.repo_cwd asymmetry: Set for push_to_pull_request_branch but not create_pull_request — warrants a comment on intent.

Positive Highlights

  • ✅ Clean separation between compile-time injection (safe_outputs_patch_workspace.go) and runtime resolution (safe_outputs_handlers.cjs)
  • ✅ Path-traversal guard is thorough: path.relative sentinel + existsSync + isDirectory check
  • ✅ Conditional injection (target-repo mismatch, wildcard) correctly avoids corrupting cross-repo workflows
  • ✅ Good test coverage for the Go layer (checkout_manager_test.go, compiler_safe_outputs_config_test.go, safe_outputs_config_generation_test.go)
  • ✅ Docs updated in both cross-repository.md and safe-outputs.md

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

Comments that could not be inline-anchored

actions/setup/js/safe_outputs_handlers.cjs:561

[/diagnose] The path is already fully validated and resolved by resolvePatchWorkspacePath (which returns the absolute path as repoCwd), yet patchOptions.workspacePath is set to the relative patchWorkspacePath string — causing generateGitPatch to re-validate and re-resolve the same path a second time.

<details>
<summary>💡 Why this matters</summary>

generateGitPatch checks workspacePath and silently returns {success: false} if the re-validation fails for any reason (e.g.,…

actions/setup/js/safe_outputs_handlers.cjs:752

[/diagnose] push_to_pull_request_branch sets entry.repo_cwd = repoCwd (line ~248 in the diff) when pushPatchWorkspaceMatchesTargetRepo, but the equivalent create_pull_request handler does not set entry.repo_cwd. If entry.repo_cwd is consumed later in the push flow (e.g., for git checkout/commit steps), this asymmetry is intentional — but it's not documented. If it's not used downstream, it's dead code that should be removed.

<details>
<summary>💡 Suggestion</summary>

Add a com…

actions/setup/js/safe_outputs_handlers.test.cjs:1007

[/tdd] This test only verifies that mockServer.debug was called with a message containing &quot;Using configured patch_workspace_path&quot;. It doesn't assert that the patch was actually generated from the subdirectory. A spurious debug log would cause this test to pass even if the patch was still generated from the workspace root.

<details>
<summary>💡 Stronger assertion</summary>

Read the patch file and assert it contains changes from the subdirectory repo, not workspace-root gitlinks:


</details>

<details><summary>pkg/workflow/safe_outputs_patch_workspace.go:17</summary>

**[/zoom-out]** `injectCurrentCheckoutPatchWorkspacePath` constructs a brand-new `CheckoutManager` from `data.CheckoutConfigs` on every call (once per handler, called from two call sites). The compiler already builds a `CheckoutManager` during compilation — if `WorkflowData` exposed it (or if `injectCurrentCheckoutPatchWorkspacePath` accepted a `*CheckoutManager` parameter), this allocation could be avoided and the function would be easier to unit-test without needing to populate `WorkflowData`…

</details>

<details><summary>pkg/workflow/checkout_manager_test.go:1119</summary>

**[/tdd]** The test suite for `GetCurrentCheckoutPath` covers three cases (no current, root `.`, and a non-root subdirectory), but misses the case where `Current: true` is set and `Path` is an empty string `&quot;&quot;`. `GetCurrentCheckoutPath` does `strings.TrimPrefix(entry.key.path, &quot;./&quot;)` — an empty path would return `&quot;&quot;` (the root guard catches it), but it&#39;s worth an explicit test to lock the contract, especially since `Path: &quot;&quot;` is a plausible zero-value config.

&lt;details&gt;
&lt;summary&gt;💡 Suggested te…

</details>

<details><summary>pkg/workflow/safe_outputs_patch_workspace.go:25</summary>

**[/tdd]** There is no test covering the case where `target-repo: &quot;*&quot;` (wildcard) skips injection, or where `target-repo` is set to a *different* repo than `current_checkout_repo` (the early-return at line 34). These guard conditions protect against accidentally injecting the wrong checkout path for cross-repo scenarios.

&lt;details&gt;
&lt;summary&gt;💡 Suggested additions to `TestHandlerConfigInjectsCurrentCheckoutPatchWorkspacePath`&lt;/summary&gt;

```go
t.Run(&quot;skips injection when target-repo is wildcard&quot;,…

</details>

@github-actions
Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

🔶 Test Quality Score: 47/100 — Needs Improvement

Analyzed 7 test(s): 5 design, 2 implementation, 0 guideline violations. Score is driven down by limited error/edge-case coverage and one test inflation flag.

📊 Metrics & Test Classification (7 tests analyzed)
Metric Value
New/modified tests analyzed 7
✅ Design tests (behavioral contracts) 5 (71%)
⚠️ Implementation tests (low value) 2 (29%)
Tests with error/edge cases 2 (29%)
Duplicate test clusters 0
Test inflation detected Yes (generate_git_patch.test.cjs: 71 lines added vs 33 in production)
🚨 Coding-guideline violations 0 (no mock libraries; all modified test files have //go:build !integration tag)

Test Classification Details

Test File Classification Issues Detected
TestGetCurrentCheckoutPath (3 sub-cases) pkg/workflow/checkout_manager_test.go ✅ Design Missing assertion messages on all assert.* calls
TestHandlerConfigInjectsCurrentCheckoutPatchWorkspacePath pkg/workflow/compiler_safe_outputs_config_test.go ✅ Design Happy path only
TestGenerateSafeOutputsConfigInjectsCurrentCheckoutPatchWorkspacePath pkg/workflow/safe_outputs_config_generation_test.go ✅ Design Missing assertion messages; happy path only
should generate the patch from workspacePath when provided actions/setup/js/generate_git_patch.test.cjs ✅ Design Happy path only
should reject workspacePath that escapes GITHUB_WORKSPACE actions/setup/js/generate_git_patch.test.cjs ✅ Design Good security/error coverage
should use patch_workspace_path when target repo resolves ... (createPullRequestHandler) actions/setup/js/safe_outputs_handlers.test.cjs ⚠️ Implementation Primary assertion is on mockServer.debug log content, not observable output
should use patch_workspace_path when target repo resolves ... (pushToPullRequestBranchHandler) actions/setup/js/safe_outputs_handlers.test.cjs ⚠️ Implementation Primary assertion is on mockServer.debug log content, not observable output

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 3 test functions — all unit (//go:build !integration)
  • 🟨 JavaScript (*.test.cjs): 4 tests (vitest)
⚠️ Flagged Tests — Requires Review (4 issue(s))

⚠️ should use patch_workspace_path ... createPullRequestHandler (actions/setup/js/safe_outputs_handlers.test.cjs)

Classification: Implementation test
Issue: The meaningful assertion is expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining("Using configured patch_workspace_path ...")) — a check on a debug log message. result.isError is unchecked for truthiness only.
What design invariant does this test enforce? That a specific code path was taken, not that the result is correct.
What would break if deleted? Only if the log message changes — a behavioral regression (wrong patch, wrong repo) would not be caught.
Suggested improvement: Assert the returned PR branch/commit, the generated patch content, or the actual git state rather than the debug log. E.g., verify result.branch matches the expected branch name or that the patch file was written to the target-repo directory.


⚠️ should use patch_workspace_path ... pushToPullRequestBranchHandler (actions/setup/js/safe_outputs_handlers.test.cjs)

Classification: Implementation test
Issue: Same pattern — expect(result.isError).toBeFalsy() passes as long as no error is thrown; the key assertion is on the debug message.
Suggested improvement: Assert that the push succeeded to the expected path, or verify the patch was generated from the target-repo subdirectory (e.g., check the patch content contains files relative to that directory).


⚠️ Missing assertion messages — TestGetCurrentCheckoutPath (pkg/workflow/checkout_manager_test.go)

Classification: Guideline advisory
Issue: All three assert.Empty / assert.Equal calls lack a descriptive message argument. The project guideline requires assert.Equal(t, want, got, "context").
Suggested improvement:

assert.Empty(t, cm.GetCurrentCheckoutPath(), "no current checkout configured — should return empty string")
assert.Equal(t, "proxy-frontend", cm.GetCurrentCheckoutPath(), "non-root current checkout path should be normalized")

⚠️ Missing assertion messages — TestGenerateSafeOutputsConfigInjectsCurrentCheckoutPatchWorkspacePath (pkg/workflow/safe_outputs_config_generation_test.go)

Classification: Guideline advisory
Issue: require.NoError(t, err), require.NotEmpty(t, result), require.NoError(t, json.Unmarshal(...)) have no message strings.
Suggested improvement: Add context strings to all require.* / assert.* calls, e.g. require.NoError(t, err, "generateSafeOutputsConfig should succeed for valid config").

Verdict

Check passed. 28.6% of new tests are implementation tests (threshold: 30%). However, the score of 47/100 reflects limited edge-case coverage across the Go tests and two JS tests that lean on debug-log assertions rather than behavioral outputs. Consider the flagged improvements before merging.

📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls or log messages
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

🧪 Test quality analysis by Test Quality Sentinel · sonnet46 1.3M ·

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.

✅ Test Quality Sentinel: 47/100. Test quality is acceptable — 28.6% of new tests are implementation tests (threshold: 30%). Advisory: improve assertion messages in Go tests and replace debug-log assertions in JS handlers tests with behavioral output checks.

@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot review all comments

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 26, 2026

@copilot review all comments

Addressed in commit f0010c5.

  • Added guard to skip patch_workspace_path injection when target-repo is explicit but current_checkout_repo is empty.
  • Updated GetCurrentCheckoutPath docstring to match current implementation behavior.
  • Added regression test: TestGenerateSafeOutputsConfigSkipsPatchWorkspacePathForExplicitTargetRepoWhenCurrentRepoIsWorkflowRepo.

Targeted tests pass for the updated workflow config paths.

@github-actions github-actions Bot mentioned this pull request May 26, 2026
@github-actions
Copy link
Copy Markdown
Contributor

``
@copilot review all comments
Please address the unresolved review feedback and add the regression test for the explicit target-repo + empty currentRepo case.

Generated by 👨‍🍳 PR Sous Chef · gpt54 7M ·

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.

create_pull_request generates wrong patch (subproject commit) when current: true is on non-root checkout

3 participants