Skip to content

config.json missing target-repo for create_pull_request in cross-repo workflows #19266

Description

@Corb3nik

Summary

When a workflow runs in repo A but configures safe-outputs.create-pull-request.target-repo to repo B, the target-repo value is correctly compiled into GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG (used by the conclusion job) but is missing from the config.json file (used by the MCP server during the agent job). This causes the MCP server to default to GITHUB_REPOSITORY (the owning repo), making cross-repo PRs impossible — the agent cannot target the configured repo.

Reproduction

Workflow file (.github/workflows/proxy-frontend-refactor.md in caido/ai-ops):

---
checkout:
  - repository: caido/proxy-frontend
    path: ./proxy-frontend
    ref: dev
    current: true

safe-outputs:
  create-pull-request:
    target-repo: caido/proxy-frontend
    title-prefix: "[refactor] "
    base-branch: dev
    reviewers: [corb3nik]
    draft: true
---

# Proxy-frontend Refactor

Add a single dummy file to the root of proxy-frontend.

Expected behavior: The MCP server's config.json includes "target-repo":"caido/proxy-frontend". The agent can omit repo and the default resolves to caido/proxy-frontend. Passing repo: "caido/proxy-frontend" explicitly also works.

Actual behavior: The compiled config.json is:

{"create_pull_request":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}}

No target-repo, no base_branch, no draft, no reviewers, no title_prefix. The MCP server's getDefaultTargetRepo() falls back to GITHUB_REPOSITORY = caido/ai-ops.

Result: when the agent omits repo, the PR targets caido/ai-ops. When the agent explicitly passes repo: "caido/proxy-frontend", the server rejects it:

Repository 'caido/proxy-frontend' is not in the allowed-repos list. Allowed: caido/ai-ops

Meanwhile, GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG (conclusion job) has the full config:

{"create_pull_request":{"base_branch":"dev","draft":true,"fallback_as_issue":false,"max":1,"max_patch_size":1024,"reviewers":["corb3nik"],"target-repo":"caido/proxy-frontend","title_prefix":"[refactor] "}}

Analysis

There are two separate config generation paths in the compiler, and only one includes target-repo:

Config path 1: config.json (agent job MCP server) — BROKEN

Generated by generateSafeOutputsConfig() in pkg/workflow/safe_outputs_config_generation.go (line 232-240):

if data.SafeOutputs.CreatePullRequests != nil {
    safeOutputsConfig["create_pull_request"] = generatePullRequestConfig(
        data.SafeOutputs.CreatePullRequests.Max,
        1,
        data.SafeOutputs.CreatePullRequests.AllowedLabels,
        data.SafeOutputs.CreatePullRequests.AllowEmpty,
        data.SafeOutputs.CreatePullRequests.AutoMerge,
        data.SafeOutputs.CreatePullRequests.Expires,
    )
}

This calls generatePullRequestConfig() in pkg/workflow/safe_outputs_config_generation_helpers.go (line 135-155), which only passes max, allowed_labels, allow_empty, auto_merge, and expires. It does not pass target-repo, allowed-repos, base_branch, draft, reviewers, title_prefix, or fallback_as_issue.

Config path 2: GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG (conclusion job) — CORRECT

Generated by the handler config builder in pkg/workflow/compiler_safe_outputs_config.go (line 346-368), which uses newHandlerConfigBuilder() and includes all fields:

builder := newHandlerConfigBuilder().
    AddIfNotEmpty("target-repo", c.TargetRepoSlug).
    AddStringSlice("allowed_repos", c.AllowedRepos).
    // ... all other fields

Why close_issue works but create_pull_request doesn't

Compare: close_issue (line 225) uses generateTargetConfigWithRepos() which includes target-repo and allowed_repos. But create_pull_request (line 232) uses generatePullRequestConfig() which does not.

Secondary issue: misleading repo parameter description

In pkg/workflow/js/safe_outputs_tools.json (line 247-249), the repo parameter says:

"If omitted, uses the repository at the workspace root."

This is incorrect. When target-repo is configured, omitting repo defaults to the target-repo value — not the workspace root. The getDefaultTargetRepo() function in actions/setup/js/repo_helpers.cjs (line 101) checks config["target-repo"] first. This misleading description causes agents to explicitly pass repo with the wrong value.

Implementation Plan

1. Fix generatePullRequestConfig() to include target-repo and related fields

File: pkg/workflow/safe_outputs_config_generation_helpers.go

Change: Refactor generatePullRequestConfig() to accept the full CreatePullRequestsConfig struct (or at minimum the SafeOutputTargetConfig embedded in it) and include target-repo, allowed_repos, base_branch, draft, reviewers, title_prefix, and fallback_as_issue in the generated config.

The simplest approach is to follow the pattern used by close_issue: use generateTargetConfigWithRepos() as the base and add the PR-specific fields on top.

Implementation sketch:

func generatePullRequestConfig(prConfig *CreatePullRequestsConfig, defaultMax int) map[string]any {
    additionalFields := make(map[string]any)
    if len(prConfig.AllowedLabels) > 0 {
        additionalFields["allowed_labels"] = prConfig.AllowedLabels
    }
    if prConfig.AllowEmpty != nil && *prConfig.AllowEmpty == "true" {
        additionalFields["allow_empty"] = true
    }
    if prConfig.AutoMerge != nil && *prConfig.AutoMerge == "true" {
        additionalFields["auto_merge"] = true
    }
    if prConfig.Expires > 0 {
        additionalFields["expires"] = prConfig.Expires
    }
    if prConfig.BaseBranch != "" {
        additionalFields["base_branch"] = prConfig.BaseBranch
    }
    if prConfig.Draft {
        additionalFields["draft"] = true
    }
    if len(prConfig.Reviewers) > 0 {
        additionalFields["reviewers"] = prConfig.Reviewers
    }
    if prConfig.TitlePrefix != "" {
        additionalFields["title_prefix"] = prConfig.TitlePrefix
    }
    if prConfig.FallbackAsIssue != nil {
        additionalFields["fallback_as_issue"] = *prConfig.FallbackAsIssue
    }

    return generateTargetConfigWithRepos(
        prConfig.SafeOutputTargetConfig,
        prConfig.Max,
        defaultMax,
        additionalFields,
    )
}

2. Update the call site in generateSafeOutputsConfig()

File: pkg/workflow/safe_outputs_config_generation.go (line 232-240)

Change: Update the call to pass the full config struct:

From:

safeOutputsConfig["create_pull_request"] = generatePullRequestConfig(
    data.SafeOutputs.CreatePullRequests.Max,
    1,
    data.SafeOutputs.CreatePullRequests.AllowedLabels,
    data.SafeOutputs.CreatePullRequests.AllowEmpty,
    data.SafeOutputs.CreatePullRequests.AutoMerge,
    data.SafeOutputs.CreatePullRequests.Expires,
)

To:

safeOutputsConfig["create_pull_request"] = generatePullRequestConfig(
    data.SafeOutputs.CreatePullRequests,
    1,
)

3. Fix the repo parameter description in the tool definition

File: pkg/workflow/js/safe_outputs_tools.json (line 247-249)

Change: Update the repo property description from:

"description": "Target repository in 'owner/repo' format. Required when changes are in a subdirectory checkout (e.g., 'repos/repo-a/'). Must be in the allowed-repos list. If omitted, uses the repository at the workspace root."

To:

"description": "Target repository in 'owner/repo' format. For multi-repo workflows where the target repo differs from the workflow repo, this must match a repo in the allowed-repos list or the configured target-repo. If omitted, defaults to the configured target-repo (from safe-outputs config), NOT the workflow repository. In most cases, you should omit this parameter and let the system use the configured default."

4. Dynamically enrich tool descriptions when target-repo is configured

File: actions/setup/js/safe_outputs_tools_loader.cjs

Change: In registerPredefinedTools() (line 94), before registering the create_pull_request tool, check if the handler config has a target-repo value. If so, append to the tool's description:

" Note: This workflow is configured to create pull requests in '{target-repo}'. You do not need to specify the repo parameter."

And append to the repo parameter description:

" Configured default: '{target-repo}'."

Implementation sketch:

function registerPredefinedTools(server, tools, config, registerTool, normalizeTool) {
  tools.forEach(tool => {
    if (Object.keys(config).find(configKey => normalizeTool(configKey) === tool.name)) {
      let toolToRegister = tool;
      if (tool.name === "create_pull_request" && config.create_pull_request) {
        const targetRepo = config.create_pull_request["target-repo"];
        if (targetRepo) {
          toolToRegister = JSON.parse(JSON.stringify(tool));
          toolToRegister.description += ` Note: This workflow is configured to create pull requests in '${targetRepo}'. You do not need to specify the repo parameter.`;
          if (toolToRegister.inputSchema?.properties?.repo) {
            toolToRegister.inputSchema.properties.repo.description += ` Configured default: '${targetRepo}'.`;
          }
        }
      }
      registerTool(server, toolToRegister);
      return;
    }
    // ... rest of function unchanged
  });
}

5. Add a hint to the error message for common cross-repo mistakes

File: actions/setup/js/safe_outputs_handlers.cjs (around line 205)

Change: After resolveAndValidateRepo() fails, if the rejected repo equals GITHUB_REPOSITORY and target-repo is configured to a different value, append a hint:

if (!repoResult.success) {
  let error = repoResult.error;
  const owningRepo = process.env.GITHUB_REPOSITORY;
  if (entry.repo === owningRepo && defaultTargetRepo && defaultTargetRepo !== owningRepo) {
    error += ` Hint: This workflow runs in '${owningRepo}' but is configured to target '${defaultTargetRepo}'. Omit the 'repo' parameter to use the configured target, or pass repo: '${defaultTargetRepo}'.`;
  }
  return { content: [{ type: "text", text: JSON.stringify({ result: "error", error }) }], isError: true };
}

6. Add tests

File: pkg/workflow/safe_outputs_config_generation_test.go

  • Test that generatePullRequestConfig includes target-repo when set
  • Test that generatePullRequestConfig includes allowed_repos when set
  • Test that generatePullRequestConfig includes base_branch, draft, reviewers, title_prefix
  • Test backward compatibility: config without target-repo still works

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

  • Test hint message when agent passes owning repo but target-repo differs
  • Test that repo defaults to target-repo when omitted

File: actions/setup/js/safe_outputs_tools_loader.test.cjs

  • Test dynamic description enrichment when target-repo is configured
  • Test no modification when target-repo is absent

7. Validation and verification

  • Run make fmt to format Go files
  • Run make fmt-cjs to format JavaScript files
  • Run make lint-cjs to validate JavaScript files
  • Run make build to rebuild (required since safe_outputs_tools.json is embedded via //go:embed)
  • Run make recompile to recompile all workflows
  • Verify the compiled config.json in a lock.yml includes target-repo
  • Run make agent-finish before committing

Follow-up Guidelines

  • Use console formatting from pkg/console for any Go CLI output changes
  • Follow error message style guide: "[what's wrong]. [what's expected]. [example]"
  • The safe_outputs_tools.json is embedded via //go:embedmake build is required after changes
  • JavaScript files in actions/setup/js/ are the source of truth and are copied at runtime
  • The config.json naming convention uses hyphens (target-repo) to match frontmatter YAML; allowed_repos uses underscores to match JavaScript handler expectations — this inconsistency is intentional (see comment in safe_outputs_config_generation_helpers.go line 168)

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions