diff --git a/.github/workflows/brave.lock.yml b/.github/workflows/brave.lock.yml index 7014314aff5..473c8fc011c 100644 --- a/.github/workflows/brave.lock.yml +++ b/.github/workflows/brave.lock.yml @@ -3602,9 +3602,9 @@ jobs: } core.info(`Found ${commentItems.length} add-comment item(s)`); function getRepositoryUrl() { - const targetRepo = process.env.GITHUB_AW_TARGET_REPO; - if (targetRepo) { - return `https://github.com/${targetRepo}`; + const targetRepoSlug = process.env.GITHUB_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return `https://github.com/${targetRepoSlug}`; } else if (context.payload.repository) { return context.payload.repository.html_url; } else { diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 461d86623f8..39c3fc43c47 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -3326,9 +3326,9 @@ jobs: } core.info(`Found ${commentItems.length} add-comment item(s)`); function getRepositoryUrl() { - const targetRepo = process.env.GITHUB_AW_TARGET_REPO; - if (targetRepo) { - return `https://github.com/${targetRepo}`; + const targetRepoSlug = process.env.GITHUB_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return `https://github.com/${targetRepoSlug}`; } else if (context.payload.repository) { return context.payload.repository.html_url; } else { diff --git a/.github/workflows/issue-summarizer-genaiscript.lock.yml b/.github/workflows/issue-summarizer-genaiscript.lock.yml index 42fc9ea392a..f6e5c5c01e6 100644 --- a/.github/workflows/issue-summarizer-genaiscript.lock.yml +++ b/.github/workflows/issue-summarizer-genaiscript.lock.yml @@ -2313,9 +2313,9 @@ jobs: } core.info(`Found ${commentItems.length} add-comment item(s)`); function getRepositoryUrl() { - const targetRepo = process.env.GITHUB_AW_TARGET_REPO; - if (targetRepo) { - return `https://github.com/${targetRepo}`; + const targetRepoSlug = process.env.GITHUB_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return `https://github.com/${targetRepoSlug}`; } else if (context.payload.repository) { return context.payload.repository.html_url; } else { diff --git a/.github/workflows/pdf-summary.lock.yml b/.github/workflows/pdf-summary.lock.yml index fe0e2625151..58ed19f2f1d 100644 --- a/.github/workflows/pdf-summary.lock.yml +++ b/.github/workflows/pdf-summary.lock.yml @@ -3561,9 +3561,9 @@ jobs: } core.info(`Found ${commentItems.length} add-comment item(s)`); function getRepositoryUrl() { - const targetRepo = process.env.GITHUB_AW_TARGET_REPO; - if (targetRepo) { - return `https://github.com/${targetRepo}`; + const targetRepoSlug = process.env.GITHUB_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return `https://github.com/${targetRepoSlug}`; } else if (context.payload.repository) { return context.payload.repository.html_url; } else { diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index af7d3f937fd..2daf0a3c97b 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -3818,9 +3818,9 @@ jobs: } core.info(`Found ${commentItems.length} add-comment item(s)`); function getRepositoryUrl() { - const targetRepo = process.env.GITHUB_AW_TARGET_REPO; - if (targetRepo) { - return `https://github.com/${targetRepo}`; + const targetRepoSlug = process.env.GITHUB_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return `https://github.com/${targetRepoSlug}`; } else if (context.payload.repository) { return context.payload.repository.html_url; } else { @@ -3974,9 +3974,9 @@ jobs: async function main() { const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; function getRepositoryUrl() { - const targetRepo = process.env.GITHUB_AW_TARGET_REPO; - if (targetRepo) { - return `https://github.com/${targetRepo}`; + const targetRepoSlug = process.env.GITHUB_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return `https://github.com/${targetRepoSlug}`; } else if (context.payload.repository) { return context.payload.repository.html_url; } else { diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index daf2514a747..61913c01590 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -3574,9 +3574,9 @@ jobs: } core.info(`Found ${commentItems.length} add-comment item(s)`); function getRepositoryUrl() { - const targetRepo = process.env.GITHUB_AW_TARGET_REPO; - if (targetRepo) { - return `https://github.com/${targetRepo}`; + const targetRepoSlug = process.env.GITHUB_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return `https://github.com/${targetRepoSlug}`; } else if (context.payload.repository) { return context.payload.repository.html_url; } else { diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index a28adce48af..7d9ec3925b7 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -3152,9 +3152,9 @@ jobs: } core.info(`Found ${commentItems.length} add-comment item(s)`); function getRepositoryUrl() { - const targetRepo = process.env.GITHUB_AW_TARGET_REPO; - if (targetRepo) { - return `https://github.com/${targetRepo}`; + const targetRepoSlug = process.env.GITHUB_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return `https://github.com/${targetRepoSlug}`; } else if (context.payload.repository) { return context.payload.repository.html_url; } else { diff --git a/AGENTS.md b/AGENTS.md index 1565eebdd34..577877eae0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,12 @@ make recompile # Ensures JavaScript is properly formatted and workflows are **NEVER ADD LOCK FILES TO .GITIGNORE** - `.lock.yml` files are compiled workflows that must be tracked. +**ALWAYS REBUILD AFTER SCHEMA CHANGES:** +```bash +make build # Rebuild gh-aw after modifying JSON schemas in pkg/parser/schemas/ +``` +Schema files are embedded in the binary using `//go:embed` directives, so changes require rebuilding the binary. + ## Quick Setup ```bash @@ -110,6 +116,13 @@ For JavaScript files in `pkg/workflow/js/*.cjs`: - Avoid `any` type, use specific types or `unknown` - Run `make js` and `make lint-cjs` for validation +### Schema Changes +When modifying JSON schemas in `pkg/parser/schemas/`: +- Schema files are embedded using `//go:embed` directives +- **MUST rebuild the binary** with `make build` for changes to take effect +- Test changes by compiling a workflow: `./gh-aw compile test-workflow.md` +- Schema changes typically require corresponding Go struct updates + ### Build Times (Don't Cancel) - `make agent-finish`: ~10-15s - `make deps`: ~1.5min diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 7343e7e59e3..5debd7b015a 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -14,6 +14,17 @@ The `safe-outputs:` element of your workflow's frontmatter declares that your ag 2. The compiler automatically generates additional jobs that read this output and perform the requested actions 3. Only these generated jobs receive the necessary write permissions +**Cross-Repository Support:** +Many safe output types support cross-repository operations using the `target-repo` configuration. This enables workflows to create issues, comments, and other outputs in repositories other than the one where the workflow is running: + +```yaml +safe-outputs: + create-issue: + target-repo: "owner/target-repository" +``` + +The `target-repo` field accepts repository identifiers in the format `"owner/repository"` and takes precedence over any trial mode repository settings. + For example: ```yaml @@ -25,17 +36,18 @@ This declares that the workflow should create at most one new issue. ## Available Safe Output Types -| Output Type | Configuration Key | Description | Default Max | -|-------------|------------------|-------------|-------------| -| **Create Issue** | `create-issue:` | Create GitHub issues based on workflow output | 1 | -| **Add Issue Comments** | `add-comment:` | Post comments on issues or pull requests | 1 | -| **Update Issues** | `update-issue:` | Update issue status, title, or body | 1 | -| **Add Issue Label** | `add-labels:` | Add labels to issues or pull requests | 3 | -| **Create Pull Request** | `create-pull-request:` | Create pull requests with code changes | 1 | -| **Pull Request Review Comments** | `create-pull-request-review-comment:` | Create review comments on specific lines of code | 1 | -| **Push to Pull Request Branch** | `push-to-pull-request-branch:` | Push changes directly to a branch | 1 | -| **Create Code Scanning Alerts** | `create-code-scanning-alert:` | Generate SARIF repository security advisories and upload to GitHub Code Scanning | unlimited | -| **Missing Tool Reporting** | `missing-tool:` | Report missing tools or functionality (enabled by default when safe-outputs is configured) | unlimited | +| Output Type | Configuration Key | Description | Default Max | Cross-Repository | +|-------------|------------------|-------------|-------------|------------------| +| **Create Issue** | `create-issue:` | Create GitHub issues based on workflow output | 1 | ✅ | +| **Add Issue Comments** | `add-comment:` | Post comments on issues or pull requests | 1 | ✅ | +| **Update Issues** | `update-issue:` | Update issue status, title, or body | 1 | ✅ | +| **Add Issue Label** | `add-labels:` | Add labels to issues or pull requests | 3 | ✅ | +| **Create Pull Request** | `create-pull-request:` | Create pull requests with code changes | 1 | ✅ | +| **Pull Request Review Comments** | `create-pull-request-review-comment:` | Create review comments on specific lines of code | 1 | ✅ | +| **Create Discussions** | `create-discussion:` | Create GitHub discussions based on workflow output | 1 | ✅ | +| **Push to Pull Request Branch** | `push-to-pull-request-branch:` | Push changes directly to a branch | 1 | ❌ | +| **Create Code Scanning Alerts** | `create-code-scanning-alert:` | Generate SARIF repository security advisories and upload to GitHub Code Scanning | unlimited | ❌ | +| **Missing Tool Reporting** | `missing-tool:` | Report missing tools or functionality (enabled by default when safe-outputs is configured) | unlimited | ❌ | ### New Issue Creation (`create-issue:`) @@ -54,6 +66,7 @@ safe-outputs: title-prefix: "[ai] " # Optional: prefix for issue titles labels: [automation, agentic] # Optional: labels to attach to issues max: 5 # Optional: maximum number of issues (default: 1) + target-repo: "owner/target-repo" # Optional: create issues in a different repository (requires github-token with appropriate permissions) ``` The agentic part of your workflow should describe the issue(s) it wants created. @@ -88,6 +101,7 @@ safe-outputs: # "triggering" (default) - only comment on triggering issue/PR # "*" - allow comments on any issue (requires issue_number in agent output) # explicit number - comment on specific issue number + target-repo: "owner/target-repo" # Optional: create comments in a different repository (requires github-token with appropriate permissions) ``` The agentic part of your workflow should describe the comment(s) it wants posted. @@ -136,6 +150,7 @@ safe-outputs: # "triggering" (default) - only add labels to triggering issue/PR # "*" - allow labels on any issue (requires issue_number in agent output) # Explicit number - add labels to specific issue/PR (e.g., "123") + target-repo: "owner/target-repo" # Optional: add labels to issues/PRs in a different repository (requires github-token with appropriate permissions) ``` The agentic part of your workflow should analyze the issue content and determine appropriate labels. @@ -172,6 +187,7 @@ safe-outputs: title: # Optional: presence indicates title can be updated body: # Optional: presence indicates body can be updated max: 3 # Optional: maximum number of issues to update (default: 1) + target-repo: "owner/target-repo" # Optional: update issues in a different repository (requires github-token with appropriate permissions) ``` The agentic part of your workflow should analyze the issue and determine what updates to make. @@ -229,6 +245,7 @@ safe-outputs: labels: [automation, agentic] # Optional: labels to attach to PRs draft: true # Optional: create as draft PR (defaults to true) if-no-changes: "warn" # Optional: behavior when no changes to commit (defaults to "warn") + target-repo: "owner/target-repo" # Optional: create PR in a different repository (requires github-token with appropriate permissions) ``` **Fallback Behavior:** @@ -347,6 +364,7 @@ safe-outputs: # "triggering" (default) - only comment on triggering PR # "*" - allow comments on any PR (requires pull_request_number in agent output) # explicit number - comment on specific PR number + target-repo: "owner/target-repo" # Optional: create review comments in a different repository (requires github-token with appropriate permissions) ``` The agentic part of your workflow should describe the review comment(s) it wants created with specific file paths and line numbers. @@ -650,6 +668,7 @@ safe-outputs: title-prefix: "[ai] " # Optional: prefix for discussion titles category-id: "DIC_kwDOGFsHUM4BsUn3" # Optional: specific discussion category ID max: 3 # Optional: maximum number of discussions (default: 1) + target-repo: "owner/target-repo" # Optional: create discussions in a different repository (requires github-token with appropriate permissions) ``` The agentic part of your workflow should describe the discussion(s) it wants created. @@ -667,6 +686,66 @@ The compiled workflow will have additional prompting describing that, to create **Note:** If no `category-id` is specified, the workflow will use the first available discussion category in the repository. +## Cross-Repository Operations + +Many safe output types support the `target-repo` configuration for cross-repository operations. This enables workflows to create issues, comments, pull requests, and other outputs in repositories other than where the workflow is running. + +### Authentication Requirements + +Cross-repository operations require proper authentication: + +1. **Default Token Limitations**: The standard `GITHUB_TOKEN` only has permissions for the repository where the workflow runs +2. **Personal Access Token Required**: Use a Personal Access Token (PAT) or GitHub App token with access to target repositories +3. **Token Configuration**: Configure the token using the `github-token` field or `GH_AW_GITHUB_TOKEN` environment variable + +### Example: Multi-Repository Issue Management + +```yaml +--- +on: + issues: + types: [opened] +permissions: + contents: read +engine: claude +safe-outputs: + github-token: ${{ secrets.CROSS_REPO_PAT }} # PAT with access to target repositories + create-issue: + target-repo: "organization/tracking-repo" + title-prefix: "[Cross-Repo] " + labels: [automation, cross-repo] + add-comment: + target-repo: "organization/notifications-repo" + target: "123" # Comment on issue #123 + add-labels: + target-repo: "organization/metrics-repo" + allowed: [processed, analyzed] +--- + +# Multi-Repository Issue Processor + +When an issue is opened in this repository: +1. Create a tracking issue in the organization's tracking repository +2. Add a notification comment to issue #123 in the notifications repository +3. Add processed labels to related issues in the metrics repository + +Analyze the issue content and determine appropriate actions for each target repository. +``` + +### Security Considerations + +- **Repository Access**: Ensure the authentication token has appropriate permissions for target repositories +- **Explicit Targets**: Use specific repository names - wildcard patterns are not supported for security +- **Least Privilege**: Grant tokens only the minimum permissions needed for the intended operations +- **Token Scope**: Personal Access Tokens should be scoped to specific repositories when possible + +### Error Handling + +If cross-repository operations fail due to authentication or permission issues: +- The workflow job will fail with a clear error message +- Error details include the target repository and specific permission requirements +- In staged mode, errors are shown as preview issues instead of failing the workflow + ## Automatically Added Tools When `create-pull-request` or `push-to-pull-request-branch` are configured, these Claude tools are automatically added: @@ -734,9 +813,19 @@ safe-outputs: The token precedence system is useful when: - **Trial mode execution**: `GH_AW_GITHUB_TOKEN` can be set to test workflows safely - **Enhanced permissions**: Override with Personal Access Tokens that have broader scope -- **Cross-repository operations**: Use tokens with access to multiple repositories +- **Cross-repository operations**: Use tokens with access to multiple repositories via `target-repo` configuration - **Custom authentication flows**: Implement specialized token management strategies +**Cross-Repository Example:** +```yaml +safe-outputs: + github-token: ${{ secrets.CROSS_REPO_PAT }} # PAT with multi-repo access + create-issue: + target-repo: "owner/project-tracker" + add-comment: + target-repo: "owner/notifications" +``` + This is useful when: - You need additional permissions beyond what `GITHUB_TOKEN` provides - You want to perform actions across multiple repositories diff --git a/docs/src/content/docs/tools/cli.md b/docs/src/content/docs/tools/cli.md index 834df6819fd..9438610863a 100644 --- a/docs/src/content/docs/tools/cli.md +++ b/docs/src/content/docs/tools/cli.md @@ -291,7 +291,7 @@ gh aw run weekly-research --input priority=high gh aw trial githubnext/agentics/weekly-research # Test a workflow from a source repository against a different target repository -gh aw trial githubnext/agentics/weekly-research --target-repo myorg/myrepo +gh aw trial githubnext/agentics/weekly-research --simulated-host-repo myorg/myrepo ``` Trial mode creates a temporary private repository, installs the specified workflow from the source repository, and runs it in a safe environment that captures outputs without affecting the target repository. This is particularly useful for: diff --git a/pkg/cli/logs_overview_test.go b/pkg/cli/logs_overview_test.go index ae781682799..42dfe86932d 100644 --- a/pkg/cli/logs_overview_test.go +++ b/pkg/cli/logs_overview_test.go @@ -61,8 +61,6 @@ func TestLogsOverviewIncludesMissingTools(t *testing.T) { // TestWorkflowRunStructHasMissingToolCount verifies that WorkflowRun has the MissingToolCount field func TestWorkflowRunStructHasMissingToolCount(t *testing.T) { run := WorkflowRun{ - DatabaseID: 12345, - WorkflowName: "Test", MissingToolCount: 5, } diff --git a/pkg/cli/trial_command.go b/pkg/cli/trial_command.go index 7db781f496f..1fc997e3818 100644 --- a/pkg/cli/trial_command.go +++ b/pkg/cli/trial_command.go @@ -39,12 +39,12 @@ type CombinedTrialResult struct { func NewTrialCommand(validateEngine func(string) error) *cobra.Command { cmd := &cobra.Command{ Use: "trial [owner/repo/workflow2...]", - Short: "Trial one or more agentic workflows against the current target repository", - Long: `Trial one or more agentic workflows against the current target repository. + Short: "Trial one or more agentic workflows as if they were running in a repository", + Long: `Trial one or more agentic workflows as if they were running in a repository. This command creates a temporary private repository in your GitHub space, installs the specified workflow(s) from their source repositories, and runs them in "trial mode" to capture safe outputs without -making actual changes to the target repository. +making actual changes to the "simulated" host repository Single workflow: ` + constants.CLIExtensionPrefix + ` trial githubnext/agentics/weekly-research @@ -60,7 +60,6 @@ Workflows from different repositories: Other examples: ` + constants.CLIExtensionPrefix + ` trial githubnext/agentics/my-workflow --delete-trial-repo ` + constants.CLIExtensionPrefix + ` trial githubnext/agentics/my-workflow --quiet --trial-repo my-custom-trial - ` + constants.CLIExtensionPrefix + ` trial githubnext/agentics/my-workflow -t target/repo All workflows must support workflow_dispatch trigger to be used in trial mode. The trial repository will be created as private and kept by default unless --delete-trial-repo is specified. @@ -68,14 +67,14 @@ Trial results are saved both locally (in trials/ directory) and in the trial rep Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { workflowSpecs := args - targetRepo, _ := cmd.Flags().GetString("target-repo") + simulatedHostRepoSlug, _ := cmd.Flags().GetString("simulated-host-repo") trialRepo, _ := cmd.Flags().GetString("trial-repo") deleteRepo, _ := cmd.Flags().GetBool("delete-trial-repo") quiet, _ := cmd.Flags().GetBool("quiet") timeout, _ := cmd.Flags().GetInt("timeout") verbose, _ := cmd.Root().PersistentFlags().GetBool("verbose") - if err := RunWorkflowTrials(workflowSpecs, targetRepo, trialRepo, deleteRepo, quiet, timeout, verbose); err != nil { + if err := RunWorkflowTrials(workflowSpecs, simulatedHostRepoSlug, trialRepo, deleteRepo, quiet, timeout, verbose); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) os.Exit(1) } @@ -83,7 +82,7 @@ Trial results are saved both locally (in trials/ directory) and in the trial rep } // Add flags - cmd.Flags().StringP("target-repo", "t", "", "Target repository for the trial (defaults to current repository)") + cmd.Flags().StringP("simulated-host-repo", "", "", "The repo we're simulating the execution for, as if the workflow was installed in that repo (defaults to current repository)") // Get current username for default trial repo description username, _ := getCurrentGitHubUsername() @@ -101,7 +100,7 @@ Trial results are saved both locally (in trials/ directory) and in the trial rep } // RunWorkflowTrials executes the main logic for trialing one or more workflows -func RunWorkflowTrials(workflowSpecs []string, targetRepoSlug string, trialRepo string, deleteRepo, quiet bool, timeoutMinutes int, verbose bool) error { +func RunWorkflowTrials(workflowSpecs []string, simulatedHostRepoSlug string, trialRepo string, deleteRepo, quiet bool, timeoutMinutes int, verbose bool) error { // Parse all workflow specifications var parsedSpecs []*WorkflowSpec for _, spec := range workflowSpecs { @@ -126,20 +125,20 @@ func RunWorkflowTrials(workflowSpecs []string, targetRepoSlug string, trialRepo // Generate a unique datetime-ID for this trial session dateTimeID := fmt.Sprintf("%s-%d", time.Now().Format("20060102-150405"), time.Now().UnixNano()%1000000) - // Step 0: Determine target repository - var finalTargetRepoSlug string - if targetRepoSlug != "" { - // Use the provided target repository - finalTargetRepoSlug = targetRepoSlug - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Target repository (specified): %s", finalTargetRepoSlug))) + // Step 0: Determine simulated host repository + var finalSimulatedHostRepoSlug string + if simulatedHostRepoSlug != "" { + // Use the provided simulated host repository + finalSimulatedHostRepoSlug = simulatedHostRepoSlug + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Target repository (specified): %s", finalSimulatedHostRepoSlug))) } else { // Fall back to current repository var err error - finalTargetRepoSlug, err = getCurrentRepositoryInfo() + finalSimulatedHostRepoSlug, err = getCurrentRepositoryInfo() if err != nil { - return fmt.Errorf("failed to determine target repository: %w", err) + return fmt.Errorf("failed to determine simulated host repository: %w", err) } - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Target repository (current): %s", finalTargetRepoSlug))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Target repository (current): %s", finalSimulatedHostRepoSlug))) } // Step 1: Determine trial repository slug @@ -170,7 +169,7 @@ func RunWorkflowTrials(workflowSpecs []string, targetRepoSlug string, trialRepo // Step 1.5: Show confirmation unless quiet mode if !quiet { - if err := showTrialConfirmation(parsedSpecs, finalTargetRepoSlug, trialRepoSlug, deleteRepo); err != nil { + if err := showTrialConfirmation(parsedSpecs, finalSimulatedHostRepoSlug, trialRepoSlug, deleteRepo); err != nil { return err } } @@ -219,7 +218,7 @@ func RunWorkflowTrials(workflowSpecs []string, targetRepoSlug string, trialRepo fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("=== Running trial for workflow: %s ===", parsedSpec.WorkflowName))) // Install workflow with trial mode compilation - if err := installWorkflowInTrialMode(tempDir, parsedSpec, finalTargetRepoSlug, trialRepoSlug, verbose); err != nil { + if err := installWorkflowInTrialMode(tempDir, parsedSpec, finalSimulatedHostRepoSlug, trialRepoSlug, verbose); err != nil { return fmt.Errorf("failed to install workflow '%s' in trial mode: %w", parsedSpec.WorkflowName, err) } @@ -264,7 +263,7 @@ func RunWorkflowTrials(workflowSpecs []string, targetRepoSlug string, trialRepo workflowResults = append(workflowResults, result) // Save individual trial file - sanitizedTargetRepo := sanitizeRepoSlugForFilename(finalTargetRepoSlug) + sanitizedTargetRepo := sanitizeRepoSlugForFilename(finalSimulatedHostRepoSlug) individualFilename := fmt.Sprintf("trials/%s-%s.%s.json", parsedSpec.WorkflowName, sanitizedTargetRepo, dateTimeID) if err := saveTrialResult(individualFilename, result, verbose); err != nil { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save individual trial result: %v", err))) @@ -301,7 +300,7 @@ func RunWorkflowTrials(workflowSpecs []string, targetRepoSlug string, trialRepo workflowNames[i] = spec.WorkflowName } workflowNamesStr := strings.Join(workflowNames, "-") - sanitizedTargetRepo := sanitizeRepoSlugForFilename(finalTargetRepoSlug) + sanitizedTargetRepo := sanitizeRepoSlugForFilename(finalSimulatedHostRepoSlug) combinedFilename := fmt.Sprintf("trials/%s-%s.%s.json", workflowNamesStr, sanitizedTargetRepo, dateTimeID) combinedResult := CombinedTrialResult{ WorkflowNames: workflowNames, @@ -319,7 +318,7 @@ func RunWorkflowTrials(workflowSpecs []string, targetRepoSlug string, trialRepo for i, spec := range parsedSpecs { workflowNames[i] = spec.WorkflowName } - if err := copyTrialResultsToRepo(tempDir, dateTimeID, workflowNames, finalTargetRepoSlug, verbose); err != nil { + if err := copyTrialResultsToRepo(tempDir, dateTimeID, workflowNames, finalSimulatedHostRepoSlug, verbose); err != nil { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to copy trial results to repository: %v", err))) } @@ -391,7 +390,7 @@ func getCurrentGitHubUsername() (string, error) { } // showTrialConfirmation displays a confirmation prompt to the user using parsed workflow specs -func showTrialConfirmation(parsedSpecs []*WorkflowSpec, targetRepo, trialRepoSlug string, deleteRepo bool) error { +func showTrialConfirmation(parsedSpecs []*WorkflowSpec, simulatedHostRepoSlug, trialRepoSlug string, deleteRepo bool) error { trialRepoURL := fmt.Sprintf("https://github.com/%s", trialRepoSlug) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("=== Trial Execution Plan ===")) @@ -403,7 +402,7 @@ func showTrialConfirmation(parsedSpecs []*WorkflowSpec, targetRepo, trialRepoSlu fmt.Fprintf(os.Stderr, console.FormatInfoMessage(" - %s (from %s)\n"), spec.WorkflowName, spec.Repo) } } - fmt.Fprintf(os.Stderr, console.FormatInfoMessage("Target Repository: %s\n"), targetRepo) + fmt.Fprintf(os.Stderr, console.FormatInfoMessage("Target Repository: %s\n"), simulatedHostRepoSlug) fmt.Fprintf(os.Stderr, console.FormatInfoMessage("Trial Repository: %s (%s)\n"), trialRepoSlug, trialRepoURL) if deleteRepo { @@ -415,7 +414,7 @@ func showTrialConfirmation(parsedSpecs []*WorkflowSpec, targetRepo, trialRepoSlu fmt.Fprintln(os.Stderr, console.FormatInfoMessage("")) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("This will:")) fmt.Fprintf(os.Stderr, console.FormatInfoMessage("1. Create a private trial repository at %s\n"), trialRepoURL) - fmt.Fprintf(os.Stderr, console.FormatInfoMessage("2. Install and compile the specified workflows in trial mode against %s\n"), targetRepo) + fmt.Fprintf(os.Stderr, console.FormatInfoMessage("2. Install and compile the specified workflows in trial mode against %s\n"), simulatedHostRepoSlug) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("3. Execute each workflow and collect any safe outputs")) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("4. Display the results from each workflow execution")) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("5. Clean up API key secrets from the trial repository")) @@ -532,7 +531,7 @@ func cloneTrialRepository(repoSlug string, verbose bool) (string, error) { } // installWorkflowInTrialMode installs a workflow in trial mode using a parsed spec -func installWorkflowInTrialMode(tempDir string, parsedSpec *WorkflowSpec, targetRepoSlug, trialRepoSlug string, verbose bool) error { +func installWorkflowInTrialMode(tempDir string, parsedSpec *WorkflowSpec, simulatedHostRepoSlug, trialRepoSlug string, verbose bool) error { if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Installing workflow '%s' from '%s' in trial mode", parsedSpec.WorkflowName, parsedSpec.Repo))) } @@ -559,7 +558,7 @@ func installWorkflowInTrialMode(tempDir string, parsedSpec *WorkflowSpec, target } // Now we need to modify the workflow for trial mode - if err := modifyWorkflowForTrialMode(tempDir, parsedSpec.WorkflowName, targetRepoSlug, verbose); err != nil { + if err := modifyWorkflowForTrialMode(tempDir, parsedSpec.WorkflowName, simulatedHostRepoSlug, verbose); err != nil { return fmt.Errorf("failed to modify workflow for trial mode: %w", err) } @@ -575,7 +574,7 @@ func installWorkflowInTrialMode(tempDir string, parsedSpec *WorkflowSpec, target NoEmit: false, Purge: false, TrialMode: true, - TrialTargetRepoSlug: targetRepoSlug, + TrialTargetRepoSlug: simulatedHostRepoSlug, } workflowDataList, err := CompileWorkflows(config) if err != nil { @@ -805,7 +804,7 @@ func addEngineSecret(secretName, trialRepoSlug string, verbose bool) error { } // modifyWorkflowForTrialMode modifies the workflow to work in trial mode -func modifyWorkflowForTrialMode(tempDir, workflowName, targetRepo string, verbose bool) error { +func modifyWorkflowForTrialMode(tempDir, workflowName, simulatedHostRepoSlug string, verbose bool) error { if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Modifying workflow for trial mode")) } @@ -821,13 +820,13 @@ func modifyWorkflowForTrialMode(tempDir, workflowName, targetRepo string, verbos // Replace repository references in the content modifiedContent := string(content) - // Replace github.repository references to point to target repo - modifiedContent = strings.ReplaceAll(modifiedContent, "${{ github.repository }}", targetRepo) + // Replace github.repository references to point to simulated host repo + modifiedContent = strings.ReplaceAll(modifiedContent, "${{ github.repository }}", simulatedHostRepoSlug) - // Also replace any hardcoded checkout actions to use the target repo + // Also replace any hardcoded checkout actions to use the simulated host repo checkoutPattern := regexp.MustCompile(`uses: actions/checkout@[^\s]*`) modifiedContent = checkoutPattern.ReplaceAllStringFunc(modifiedContent, func(match string) string { - return fmt.Sprintf("%s\n with:\n repository: %s", match, targetRepo) + return fmt.Sprintf("%s\n with:\n repository: %s", match, simulatedHostRepoSlug) }) // Write the modified content back @@ -1093,7 +1092,7 @@ func saveTrialResult(filename string, result interface{}, verbose bool) error { } // copyTrialResultsToRepo copies trial result files to the trial repository and commits them -func copyTrialResultsToRepo(tempDir, dateTimeID string, workflowNames []string, targetRepoSlug string, verbose bool) error { +func copyTrialResultsToRepo(tempDir, dateTimeID string, workflowNames []string, simulatedHostRepoSlug string, verbose bool) error { if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Copying trial results to trial repository")) } @@ -1105,7 +1104,7 @@ func copyTrialResultsToRepo(tempDir, dateTimeID string, workflowNames []string, } // Copy individual workflow result files - sanitizedTargetRepo := sanitizeRepoSlugForFilename(targetRepoSlug) + sanitizedTargetRepo := sanitizeRepoSlugForFilename(simulatedHostRepoSlug) for _, workflowName := range workflowNames { sourceFile := fmt.Sprintf("trials/%s-%s.%s.json", workflowName, sanitizedTargetRepo, dateTimeID) destFile := filepath.Join(trialsDir, fmt.Sprintf("%s-%s.%s.json", workflowName, sanitizedTargetRepo, dateTimeID)) diff --git a/pkg/cli/update_command.go b/pkg/cli/update_command.go index 05907ccd7e4..abd712f78c7 100644 --- a/pkg/cli/update_command.go +++ b/pkg/cli/update_command.go @@ -198,7 +198,7 @@ func resolveLatestRef(repo, currentRef string, allowMajor, verbose bool) (string } // Check if current ref is a branch by checking if it exists as a branch - isBranch, err := isBranchRef(repo, currentRef, verbose) + isBranch, err := isBranchRef(repo, currentRef) if err != nil { if verbose { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to check if ref is branch: %v", err))) @@ -278,7 +278,7 @@ func resolveLatestRelease(repo, currentRef string, allowMajor, verbose bool) (st } // isBranchRef checks if a ref is a branch in the repository -func isBranchRef(repo, ref string, verbose bool) (bool, error) { +func isBranchRef(repo, ref string) (bool, error) { // Use gh CLI to list branches cmd := exec.Command("gh", "api", fmt.Sprintf("/repos/%s/branches", repo), "--jq", ".[].name") output, err := cmd.Output() diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 7631ba345ec..cae1062684c 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1507,6 +1507,10 @@ "minimum": 0, "maximum": 100 }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository issue creation. Takes precedence over trial target repo settings." + }, "github-token": { "type": "string", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." @@ -1546,6 +1550,10 @@ "minimum": 0, "maximum": 100 }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository discussion creation. Takes precedence over trial target repo settings." + }, "github-token": { "type": "string", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." @@ -1581,6 +1589,10 @@ "type": "string", "description": "Target for comments: 'triggering' (default), '*' (any issue), or explicit issue number" }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository comments. Takes precedence over trial target repo settings." + }, "github-token": { "type": "string", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." @@ -1624,6 +1636,10 @@ ], "description": "Behavior when no changes to push: 'warn' (default - log warning but succeed), 'error' (fail the action), or 'ignore' (silent success)" }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository pull request creation. Takes precedence over trial target repo settings." + }, "github-token": { "type": "string", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." @@ -1667,6 +1683,10 @@ "type": "string", "description": "Target for review comments: 'triggering' (default, only on triggering PR), '*' (any PR, requires pull_request_number in agent output), or explicit PR number" }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository PR review comments. Takes precedence over trial target repo settings." + }, "github-token": { "type": "string", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." @@ -1746,6 +1766,10 @@ "type": "string", "description": "Target for labels: 'triggering' (default), '*' (any issue/PR), or explicit issue/PR number" }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository label addition. Takes precedence over trial target repo settings." + }, "github-token": { "type": "string", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." @@ -1789,6 +1813,10 @@ "minimum": 0, "maximum": 100 }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository issue updates. Takes precedence over trial target repo settings." + }, "github-token": { "type": "string", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index 6ff2c2ec98b..b61c1195406 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -12,7 +12,8 @@ type AddCommentConfig struct { // AddCommentsConfig holds configuration for creating GitHub issue/PR comments from agent output type AddCommentsConfig struct { BaseSafeOutputConfig `yaml:",inline"` - Target string `yaml:"target,omitempty"` // Target for comments: "triggering" (default), "*" (any issue), or explicit issue number + Target string `yaml:"target,omitempty"` // Target for comments: "triggering" (default), "*" (any issue), or explicit issue number + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository comments } // buildCreateOutputAddCommentJob creates the add_comment job @@ -47,8 +48,11 @@ func (c *Compiler) buildCreateOutputAddCommentJob(data *WorkflowData, mainJobNam if c.trialMode || data.SafeOutputs.Staged { steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } - if c.trialMode && c.trialTargetRepoSlug != "" { - steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO: %q\n", c.trialTargetRepoSlug)) + // Set GITHUB_AW_TARGET_REPO_SLUG - prefer target-repo config over trial target repo + if data.SafeOutputs.AddComments.TargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", data.SafeOutputs.AddComments.TargetRepoSlug)) + } else if c.trialMode && c.trialTargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", c.trialTargetRepoSlug)) } // Add custom environment variables from safe-outputs.env @@ -112,6 +116,17 @@ func (c *Compiler) parseCommentsConfig(outputMap map[string]any) *AddCommentsCon commentsConfig.Target = targetStr } } + + // Parse target-repo + if targetRepoSlug, exists := configMap["target-repo"]; exists { + if targetRepoStr, ok := targetRepoSlug.(string); ok { + // Validate that target-repo is not "*" - only definite strings are allowed + if targetRepoStr == "*" { + return nil // Invalid configuration, return nil to cause validation error + } + commentsConfig.TargetRepoSlug = targetRepoStr + } + } } return commentsConfig diff --git a/pkg/workflow/add_comment_target_repo_integration_test.go b/pkg/workflow/add_comment_target_repo_integration_test.go new file mode 100644 index 00000000000..c39fe144da5 --- /dev/null +++ b/pkg/workflow/add_comment_target_repo_integration_test.go @@ -0,0 +1,140 @@ +package workflow + +import ( + "os" + "strings" + "testing" +) + +func TestAddCommentTargetRepoIntegration(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectedTargetRepoInYAML string + shouldHaveTargetRepo bool + trialTargetRepoSlug string + expectedTargetRepoValue string + }{ + { + name: "target-repo configuration should set GITHUB_AW_TARGET_REPO_SLUG", + frontmatter: map[string]any{ + "name": "Test Workflow", + "engine": "copilot", + "safe-outputs": map[string]any{ + "add-comment": map[string]any{ + "max": 5, + "target": "*", + "target-repo": "github/customer-feedback", + }, + }, + }, + shouldHaveTargetRepo: true, + expectedTargetRepoValue: "github/customer-feedback", + }, + { + name: "target-repo should take precedence over trial target repo", + frontmatter: map[string]any{ + "name": "Test Workflow", + "engine": "copilot", + "safe-outputs": map[string]any{ + "add-comment": map[string]any{ + "max": 5, + "target": "*", + "target-repo": "github/customer-feedback", + }, + }, + }, + trialTargetRepoSlug: "trial/repo", + shouldHaveTargetRepo: true, + expectedTargetRepoValue: "github/customer-feedback", // Should prefer config over trial + }, + { + name: "no target-repo should fall back to trial target repo", + frontmatter: map[string]any{ + "name": "Test Workflow", + "engine": "copilot", + "safe-outputs": map[string]any{ + "add-comment": map[string]any{ + "max": 5, + "target": "*", + }, + }, + }, + trialTargetRepoSlug: "trial/repo", + shouldHaveTargetRepo: true, + expectedTargetRepoValue: "trial/repo", + }, + { + name: "no target-repo and no trial should not set GITHUB_AW_TARGET_REPO_SLUG", + frontmatter: map[string]any{ + "name": "Test Workflow", + "engine": "copilot", + "safe-outputs": map[string]any{ + "add-comment": map[string]any{ + "max": 5, + "target": "*", + }, + }, + }, + trialTargetRepoSlug: "", // explicitly empty + shouldHaveTargetRepo: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary directory for this test + tempDir := t.TempDir() + workflowPath := tempDir + "/test-workflow.md" + + // Create a simple workflow content + workflowContent := "# Test Workflow\n\nThis is a test workflow for target-repo functionality." + + // Write the workflow file + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + if err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + // Create compiler with trial mode if needed + compiler := NewCompiler(false, "", "") + if tt.trialTargetRepoSlug != "" { + compiler.SetTrialMode(true) + compiler.SetTrialTargetRepo(tt.trialTargetRepoSlug) + } + + // Parse workflow data + workflowData := &WorkflowData{ + Name: tt.frontmatter["name"].(string), + } + + // Extract safe outputs configuration + workflowData.SafeOutputs = compiler.extractSafeOutputsConfig(tt.frontmatter) + + if workflowData.SafeOutputs == nil || workflowData.SafeOutputs.AddComments == nil { + t.Fatal("Expected AddComments configuration to be parsed") + } + + // Build the add comment job + job, err := compiler.buildCreateOutputAddCommentJob(workflowData, "main") + if err != nil { + t.Fatalf("Failed to build add comment job: %v", err) + } + + // Convert steps to string to check for GITHUB_AW_TARGET_REPO_SLUG + jobYAML := strings.Join(job.Steps, "") + + if tt.shouldHaveTargetRepo { + expectedEnvVar := "GITHUB_AW_TARGET_REPO_SLUG: \"" + tt.expectedTargetRepoValue + "\"" + if !strings.Contains(jobYAML, expectedEnvVar) { + t.Errorf("Expected to find %s in job YAML, but didn't.\nActual job YAML:\n%s", expectedEnvVar, jobYAML) + } + } else { + // Check specifically for the environment variable declaration, not the JavaScript reference + if strings.Contains(jobYAML, "GITHUB_AW_TARGET_REPO_SLUG: \"") { + t.Errorf("Expected not to find GITHUB_AW_TARGET_REPO_SLUG environment variable declaration in job YAML when no target-repo is configured.\nActual job YAML:\n%s", jobYAML) + } + } + }) + } +} diff --git a/pkg/workflow/add_comment_target_repo_test.go b/pkg/workflow/add_comment_target_repo_test.go new file mode 100644 index 00000000000..e2d3f0a1bef --- /dev/null +++ b/pkg/workflow/add_comment_target_repo_test.go @@ -0,0 +1,91 @@ +package workflow + +import ( + "testing" +) + +func TestAddCommentsConfigTargetRepo(t *testing.T) { + compiler := NewCompiler(false, "", "") + + tests := []struct { + name string + configMap map[string]any + expectedTarget string + expectedRepo string + shouldBeNil bool + }{ + { + name: "basic target-repo configuration", + configMap: map[string]any{ + "add-comment": map[string]any{ + "max": 5, + "target": "*", + "target-repo": "github/customer-feedback", + }, + }, + expectedTarget: "*", + expectedRepo: "github/customer-feedback", + shouldBeNil: false, + }, + { + name: "target-repo with wildcard should be rejected", + configMap: map[string]any{ + "add-comment": map[string]any{ + "max": 5, + "target": "123", + "target-repo": "*", + }, + }, + shouldBeNil: true, // Configuration should be nil due to validation + }, + { + name: "target-repo without target field", + configMap: map[string]any{ + "add-comment": map[string]any{ + "max": 1, + "target-repo": "owner/repo", + }, + }, + expectedTarget: "", + expectedRepo: "owner/repo", + shouldBeNil: false, + }, + { + name: "no target-repo field", + configMap: map[string]any{ + "add-comment": map[string]any{ + "max": 2, + "target": "triggering", + }, + }, + expectedTarget: "triggering", + expectedRepo: "", + shouldBeNil: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := compiler.parseCommentsConfig(tt.configMap) + + if tt.shouldBeNil { + if config != nil { + t.Errorf("Expected config to be nil for invalid target-repo, but got %+v", config) + } + return + } + + if config == nil { + t.Fatal("Expected valid config, but got nil") + } + + if config.Target != tt.expectedTarget { + t.Errorf("Expected Target = %q, got %q", tt.expectedTarget, config.Target) + } + + if config.TargetRepoSlug != tt.expectedRepo { + t.Errorf("Expected TargetRepoSlug = %q, got %q", tt.expectedRepo, config.TargetRepoSlug) + } + }) + } +} diff --git a/pkg/workflow/add_labels.go b/pkg/workflow/add_labels.go index 38fa1b71447..eb573f2c6cb 100644 --- a/pkg/workflow/add_labels.go +++ b/pkg/workflow/add_labels.go @@ -7,16 +7,17 @@ import ( // AddLabelsConfig holds configuration for adding labels to issues/PRs from agent output type AddLabelsConfig struct { - Allowed []string `yaml:"allowed,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). - Max int `yaml:"max,omitempty"` // Optional maximum number of labels to add (default: 3) - Min int `yaml:"min,omitempty"` // Optional minimum number of labels to add - GitHubToken string `yaml:"github-token,omitempty"` // GitHub token for this specific output type - Target string `yaml:"target,omitempty"` // Target for labels: "triggering" (default), "*" (any issue/PR), or explicit issue/PR number + Allowed []string `yaml:"allowed,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). + Max int `yaml:"max,omitempty"` // Optional maximum number of labels to add (default: 3) + Min int `yaml:"min,omitempty"` // Optional minimum number of labels to add + GitHubToken string `yaml:"github-token,omitempty"` // GitHub token for this specific output type + Target string `yaml:"target,omitempty"` // Target for labels: "triggering" (default), "*" (any issue/PR), or explicit issue/PR number + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository labels } -// buildCreateOutputLabelJob creates the add_labels job -func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName string) (*Job, error) { - if data.SafeOutputs == nil { +// buildAddLabelsJob creates the add_labels job +func (c *Compiler) buildAddLabelsJob(data *WorkflowData, mainJobName string) (*Job, error) { + if data.SafeOutputs == nil || data.SafeOutputs.AddLabels == nil { return nil, fmt.Errorf("safe-outputs configuration is required") } @@ -25,13 +26,11 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str maxCount := 3 minValue := 0 - if data.SafeOutputs.AddLabels != nil { - allowedLabels = data.SafeOutputs.AddLabels.Allowed - if data.SafeOutputs.AddLabels.Max > 0 { - maxCount = data.SafeOutputs.AddLabels.Max - } - minValue = data.SafeOutputs.AddLabels.Min + allowedLabels = data.SafeOutputs.AddLabels.Allowed + if data.SafeOutputs.AddLabels.Max > 0 { + maxCount = data.SafeOutputs.AddLabels.Max } + minValue = data.SafeOutputs.AddLabels.Min var steps []string steps = append(steps, " - name: Add Labels\n") @@ -49,7 +48,7 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str steps = append(steps, fmt.Sprintf(" GITHUB_AW_LABELS_MAX_COUNT: %d\n", maxCount)) // Pass the target configuration - if data.SafeOutputs.AddLabels != nil && data.SafeOutputs.AddLabels.Target != "" { + if data.SafeOutputs.AddLabels.Target != "" { steps = append(steps, fmt.Sprintf(" GITHUB_AW_LABELS_TARGET: %q\n", data.SafeOutputs.AddLabels.Target)) } @@ -57,8 +56,12 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str if c.trialMode || data.SafeOutputs.Staged { steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } - if c.trialMode && c.trialTargetRepoSlug != "" { - steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO: %q\n", c.trialTargetRepoSlug)) + + // Pass target repository - prefer explicit config over trial mode setting + if data.SafeOutputs.AddLabels.TargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", data.SafeOutputs.AddLabels.TargetRepoSlug)) + } else if c.trialMode && c.trialTargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", c.trialTargetRepoSlug)) } // Add custom environment variables from safe-outputs.env @@ -66,10 +69,7 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str steps = append(steps, " with:\n") // Add github-token if specified - var token string - if data.SafeOutputs.AddLabels != nil { - token = data.SafeOutputs.AddLabels.GitHubToken - } + token := data.SafeOutputs.AddLabels.GitHubToken c.addSafeOutputGitHubTokenForConfig(&steps, data, token) steps = append(steps, " script: |\n") @@ -83,7 +83,7 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str } var jobCondition = BuildSafeOutputType("add-labels", minValue) - if data.SafeOutputs.AddLabels == nil || data.SafeOutputs.AddLabels.Target == "" { + if data.SafeOutputs.AddLabels.Target == "" { eventCondition := buildOr( BuildPropertyAccess("github.event.issue.number"), BuildPropertyAccess("github.event.pull_request.number"), diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index d6c834ea051..59330155183 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -224,7 +224,7 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str stepLines = append(stepLines, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"") } if workflowData.TrialMode && workflowData.TrialTargetRepo != "" { - stepLines = append(stepLines, fmt.Sprintf(" GITHUB_AW_TARGET_REPO: %q", workflowData.TrialTargetRepo)) + stepLines = append(stepLines, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q", workflowData.TrialTargetRepo)) } // Add branch name if upload assets is configured diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index 887741d1dbf..076d0ef72f5 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -137,7 +137,7 @@ codex %sexec%s%s"$INSTRUCTION" 2>&1 | tee %s`, modelParam, webSearchParam, fullA env["GITHUB_AW_SAFE_OUTPUTS_STAGED"] = "true" } if workflowData.TrialMode && workflowData.TrialTargetRepo != "" { - env["GITHUB_AW_TARGET_REPO"] = workflowData.TrialTargetRepo + env["GITHUB_AW_TARGET_REPO_SLUG"] = workflowData.TrialTargetRepo } // Add branch name if upload assets is configured diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 7f7cd391ade..6b2a5612b8d 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -1796,7 +1796,7 @@ func (c *Compiler) buildSafeOutputsJobs(data *WorkflowData, jobName string, task // Build add_labels job if output.add-labels is configured (including null/empty) if data.SafeOutputs.AddLabels != nil { - addLabelsJob, err := c.buildCreateOutputLabelJob(data, jobName) + addLabelsJob, err := c.buildAddLabelsJob(data, jobName) if err != nil { return fmt.Errorf("failed to build add_labels job: %w", err) } @@ -2159,7 +2159,7 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat // Add engine-declared output files collection (if any) if len(engine.GetDeclaredOutputFiles()) > 0 { - c.generateEngineOutputCollection(yaml, engine, data) + c.generateEngineOutputCollection(yaml, engine) } // Extract and upload squid access logs (if any proxy tools were used) diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index fbe92b9c183..5b521689d55 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -127,7 +127,7 @@ copilot %s 2>&1 | tee %s`, shellJoinArgs(copilotArgs), logFile) env["GITHUB_AW_SAFE_OUTPUTS_STAGED"] = "true" } if workflowData.TrialMode && workflowData.TrialTargetRepo != "" { - env["GITHUB_AW_TARGET_REPO"] = workflowData.TrialTargetRepo + env["GITHUB_AW_TARGET_REPO_SLUG"] = workflowData.TrialTargetRepo } // Add branch name if upload assets is configured diff --git a/pkg/workflow/create_discussion.go b/pkg/workflow/create_discussion.go index 35d38900535..872167a9e30 100644 --- a/pkg/workflow/create_discussion.go +++ b/pkg/workflow/create_discussion.go @@ -9,6 +9,7 @@ type CreateDiscussionsConfig struct { BaseSafeOutputConfig `yaml:",inline"` TitlePrefix string `yaml:"title-prefix,omitempty"` CategoryId string `yaml:"category-id,omitempty"` // Discussion category ID + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository discussions } // parseDiscussionsConfig handles create-discussion configuration @@ -34,6 +35,17 @@ func (c *Compiler) parseDiscussionsConfig(outputMap map[string]any) *CreateDiscu discussionsConfig.CategoryId = categoryIdStr } } + + // Parse target-repo + if targetRepoSlug, exists := configMap["target-repo"]; exists { + if targetRepoStr, ok := targetRepoSlug.(string); ok { + // Validate that target-repo is not "*" - only definite strings are allowed + if targetRepoStr == "*" { + return nil // Invalid configuration, return nil to cause validation error + } + discussionsConfig.TargetRepoSlug = targetRepoStr + } + } } return discussionsConfig @@ -70,8 +82,11 @@ func (c *Compiler) buildCreateOutputDiscussionJob(data *WorkflowData, mainJobNam if c.trialMode || data.SafeOutputs.Staged { steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } - if c.trialMode && c.trialTargetRepoSlug != "" { - steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO: %q\n", c.trialTargetRepoSlug)) + // Set GITHUB_AW_TARGET_REPO_SLUG - prefer target-repo config over trial target repo + if data.SafeOutputs.CreateDiscussions.TargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", data.SafeOutputs.CreateDiscussions.TargetRepoSlug)) + } else if c.trialMode && c.trialTargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", c.trialTargetRepoSlug)) } // Add custom environment variables from safe-outputs.env diff --git a/pkg/workflow/create_issue.go b/pkg/workflow/create_issue.go index a917693a209..2e10b070fe4 100644 --- a/pkg/workflow/create_issue.go +++ b/pkg/workflow/create_issue.go @@ -10,6 +10,7 @@ type CreateIssuesConfig struct { BaseSafeOutputConfig `yaml:",inline"` TitlePrefix string `yaml:"title-prefix,omitempty"` Labels []string `yaml:"labels,omitempty"` + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository issues } // parseIssuesConfig handles create-issue configuration @@ -39,6 +40,17 @@ func (c *Compiler) parseIssuesConfig(outputMap map[string]any) *CreateIssuesConf } } + // Parse target-repo + if targetRepoSlug, exists := configMap["target-repo"]; exists { + if targetRepoStr, ok := targetRepoSlug.(string); ok { + // Validate that target-repo is not "*" - only definite strings are allowed + if targetRepoStr == "*" { + return nil // Invalid configuration, return nil to cause validation error + } + issuesConfig.TargetRepoSlug = targetRepoStr + } + } + // Parse common base fields c.parseBaseSafeOutputConfig(configMap, &issuesConfig.BaseSafeOutputConfig) } @@ -103,8 +115,11 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName str if c.trialMode || data.SafeOutputs.Staged { steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } - if c.trialMode && c.trialTargetRepoSlug != "" { - steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO: %q\n", c.trialTargetRepoSlug)) + // Set GITHUB_AW_TARGET_REPO_SLUG - prefer target-repo config over trial target repo + if data.SafeOutputs.CreateIssues.TargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", data.SafeOutputs.CreateIssues.TargetRepoSlug)) + } else if c.trialMode && c.trialTargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", c.trialTargetRepoSlug)) } // Add custom environment variables from safe-outputs.env diff --git a/pkg/workflow/create_pr_review_comment.go b/pkg/workflow/create_pr_review_comment.go index bb807dcb80f..f094587a31e 100644 --- a/pkg/workflow/create_pr_review_comment.go +++ b/pkg/workflow/create_pr_review_comment.go @@ -7,8 +7,9 @@ import ( // CreatePullRequestReviewCommentsConfig holds configuration for creating GitHub pull request review comments from agent output type CreatePullRequestReviewCommentsConfig struct { BaseSafeOutputConfig `yaml:",inline"` - Side string `yaml:"side,omitempty"` // Side of the diff: "LEFT" or "RIGHT" (default: "RIGHT") - Target string `yaml:"target,omitempty"` // Target for comments: "triggering" (default), "*" (any PR), or explicit PR number + Side string `yaml:"side,omitempty"` // Side of the diff: "LEFT" or "RIGHT" (default: "RIGHT") + Target string `yaml:"target,omitempty"` // Target for comments: "triggering" (default), "*" (any PR), or explicit PR number + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository PR review comments } // buildCreateOutputPullRequestReviewCommentJob creates the create_pr_review_comment job @@ -39,8 +40,11 @@ func (c *Compiler) buildCreateOutputPullRequestReviewCommentJob(data *WorkflowDa if c.trialMode || data.SafeOutputs.Staged { steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } - if c.trialMode && c.trialTargetRepoSlug != "" { - steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO: %q\n", c.trialTargetRepoSlug)) + // Set GITHUB_AW_TARGET_REPO_SLUG - prefer target-repo config over trial target repo + if data.SafeOutputs.CreatePullRequestReviewComments.TargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", data.SafeOutputs.CreatePullRequestReviewComments.TargetRepoSlug)) + } else if c.trialMode && c.trialTargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", c.trialTargetRepoSlug)) } // Add custom environment variables from safe-outputs.env @@ -122,6 +126,17 @@ func (c *Compiler) parsePullRequestReviewCommentsConfig(outputMap map[string]any prReviewCommentsConfig.Target = targetStr } } + + // Parse target-repo + if targetRepoSlug, exists := configMap["target-repo"]; exists { + if targetRepoStr, ok := targetRepoSlug.(string); ok { + // Validate that target-repo is not "*" - only definite strings are allowed + if targetRepoStr == "*" { + return nil // Invalid configuration, return nil to cause validation error + } + prReviewCommentsConfig.TargetRepoSlug = targetRepoStr + } + } } return prReviewCommentsConfig diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index f9a1b6555e3..dd7e4b719a1 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -12,6 +12,7 @@ type CreatePullRequestsConfig struct { Labels []string `yaml:"labels,omitempty"` Draft *bool `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil) and explicitly false IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn" (default), "error", or "ignore" + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository pull requests } // buildCreateOutputPullRequestJob creates the create_pull_request job @@ -83,8 +84,11 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa if c.trialMode || data.SafeOutputs.Staged { steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } - if c.trialMode && c.trialTargetRepoSlug != "" { - steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO: %q\n", c.trialTargetRepoSlug)) + // Set GITHUB_AW_TARGET_REPO_SLUG - prefer target-repo config over trial target repo + if data.SafeOutputs.CreatePullRequests.TargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", data.SafeOutputs.CreatePullRequests.TargetRepoSlug)) + } else if c.trialMode && c.trialTargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", c.trialTargetRepoSlug)) } // Add custom environment variables from safe-outputs.env @@ -171,6 +175,17 @@ func (c *Compiler) parsePullRequestsConfig(outputMap map[string]any) *CreatePull } } + // Parse target-repo + if targetRepoSlug, exists := configMap["target-repo"]; exists { + if targetRepoStr, ok := targetRepoSlug.(string); ok { + // Validate that target-repo is not "*" - only definite strings are allowed + if targetRepoStr == "*" { + return nil // Invalid configuration, return nil to cause validation error + } + pullRequestsConfig.TargetRepoSlug = targetRepoStr + } + } + // Parse min and github-token (max is always 1 for pull requests) if min, exists := configMap["min"]; exists { if minInt, ok := parseIntValue(min); ok { diff --git a/pkg/workflow/custom_engine.go b/pkg/workflow/custom_engine.go index 2f144d02916..9afef2ab62e 100644 --- a/pkg/workflow/custom_engine.go +++ b/pkg/workflow/custom_engine.go @@ -64,7 +64,7 @@ func (e *CustomEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str envVars["GITHUB_AW_SAFE_OUTPUTS_STAGED"] = "true" } if workflowData.TrialMode && workflowData.TrialTargetRepo != "" { - envVars["GITHUB_AW_TARGET_REPO"] = workflowData.TrialTargetRepo + envVars["GITHUB_AW_TARGET_REPO_SLUG"] = workflowData.TrialTargetRepo } // Add branch name if upload assets is configured diff --git a/pkg/workflow/engine_output.go b/pkg/workflow/engine_output.go index 8229e3dd363..5d5cfdebee5 100644 --- a/pkg/workflow/engine_output.go +++ b/pkg/workflow/engine_output.go @@ -32,7 +32,7 @@ func generateCleanupStep(outputFiles []string) (string, bool) { } // generateEngineOutputCollection generates a step that collects engine-declared output files as artifacts -func (c *Compiler) generateEngineOutputCollection(yaml *strings.Builder, engine CodingAgentEngine, workflowData *WorkflowData) { +func (c *Compiler) generateEngineOutputCollection(yaml *strings.Builder, engine CodingAgentEngine) { outputFiles := engine.GetDeclaredOutputFiles() if len(outputFiles) == 0 { return @@ -40,7 +40,7 @@ func (c *Compiler) generateEngineOutputCollection(yaml *strings.Builder, engine // Add secret redaction step before uploading artifacts // Pass the current YAML content to scan for secret references - c.generateSecretRedactionStep(yaml, workflowData, engine, yaml.String()) + c.generateSecretRedactionStep(yaml, yaml.String()) // Create a single upload step that handles all declared output files // The action will ignore missing files automatically with if-no-files-found: ignore diff --git a/pkg/workflow/js/add_comment.cjs b/pkg/workflow/js/add_comment.cjs index 0811cc449d5..bee2412f11d 100644 --- a/pkg/workflow/js/add_comment.cjs +++ b/pkg/workflow/js/add_comment.cjs @@ -42,11 +42,11 @@ async function main() { // Helper function to get the repository URL for different purposes function getRepositoryUrl() { // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepo = process.env.GITHUB_AW_TARGET_REPO; + const targetRepoSlug = process.env.GITHUB_AW_TARGET_REPO_SLUG; - if (targetRepo) { + if (targetRepoSlug) { // Use target repository for issue/PR URLs in trial mode - return `https://github.com/${targetRepo}`; + return `https://github.com/${targetRepoSlug}`; } else if (context.payload.repository) { // Use execution context repository (default behavior) return context.payload.repository.html_url; diff --git a/pkg/workflow/js/create_pr_review_comment.cjs b/pkg/workflow/js/create_pr_review_comment.cjs index b39ad887a60..9c770e51095 100644 --- a/pkg/workflow/js/create_pr_review_comment.cjs +++ b/pkg/workflow/js/create_pr_review_comment.cjs @@ -5,11 +5,11 @@ async function main() { // Helper function to get the repository URL for different purposes function getRepositoryUrl() { // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepo = process.env.GITHUB_AW_TARGET_REPO; + const targetRepoSlug = process.env.GITHUB_AW_TARGET_REPO_SLUG; - if (targetRepo) { + if (targetRepoSlug) { // Use target repository for issue/PR URLs in trial mode - return `https://github.com/${targetRepo}`; + return `https://github.com/${targetRepoSlug}`; } else if (context.payload.repository) { // Use execution context repository (default behavior) return context.payload.repository.html_url; diff --git a/pkg/workflow/publish_assets.go b/pkg/workflow/publish_assets.go index 044fcfbf11a..105fd465f09 100644 --- a/pkg/workflow/publish_assets.go +++ b/pkg/workflow/publish_assets.go @@ -140,7 +140,7 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string, steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } if c.trialMode && c.trialTargetRepoSlug != "" { - steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO: %q\n", c.trialTargetRepoSlug)) + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", c.trialTargetRepoSlug)) } // Add custom environment variables from safe-outputs.env diff --git a/pkg/workflow/redact_secrets.go b/pkg/workflow/redact_secrets.go index 98ad8855128..41731f6cd2a 100644 --- a/pkg/workflow/redact_secrets.go +++ b/pkg/workflow/redact_secrets.go @@ -35,7 +35,7 @@ func CollectSecretReferences(yamlContent string) []string { } // generateSecretRedactionStep generates a workflow step that redacts secrets from files in /tmp -func (c *Compiler) generateSecretRedactionStep(yaml *strings.Builder, workflowData *WorkflowData, engine CodingAgentEngine, yamlContent string) { +func (c *Compiler) generateSecretRedactionStep(yaml *strings.Builder, yamlContent string) { // Extract secret references from the generated YAML secretReferences := CollectSecretReferences(yamlContent) diff --git a/pkg/workflow/safe_outputs.go b/pkg/workflow/safe_outputs.go index f368b6e8489..3c6adf8445e 100644 --- a/pkg/workflow/safe_outputs.go +++ b/pkg/workflow/safe_outputs.go @@ -337,6 +337,13 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } } + // Parse target-repo + if targetRepo, exists := labelsMap["target-repo"]; exists { + if targetRepoStr, ok := targetRepo.(string); ok { + labelConfig.TargetRepoSlug = targetRepoStr + } + } + config.AddLabels = labelConfig } else if labels == nil { // Handle null case: create empty config (allows any labels) diff --git a/pkg/workflow/staged_add_issue_labels_test.go b/pkg/workflow/staged_add_issue_labels_test.go index a62c99c2b55..093d12ab5fc 100644 --- a/pkg/workflow/staged_add_issue_labels_test.go +++ b/pkg/workflow/staged_add_issue_labels_test.go @@ -18,7 +18,7 @@ func TestAddLabelsJobWithStagedFlag(t *testing.T) { }, } - job, err := c.buildCreateOutputLabelJob(workflowData, "main_job") + job, err := c.buildAddLabelsJob(workflowData, "main_job") if err != nil { t.Fatalf("Unexpected error building add labels job: %v", err) } @@ -34,7 +34,7 @@ func TestAddLabelsJobWithStagedFlag(t *testing.T) { // Test with staged: false workflowData.SafeOutputs.Staged = false - job, err = c.buildCreateOutputLabelJob(workflowData, "main_job") + job, err = c.buildAddLabelsJob(workflowData, "main_job") if err != nil { t.Fatalf("Unexpected error building add labels job: %v", err) } @@ -59,7 +59,7 @@ func TestAddLabelsJobWithNilSafeOutputs(t *testing.T) { SafeOutputs: nil, } - _, err := c.buildCreateOutputLabelJob(workflowData, "main_job") + _, err := c.buildAddLabelsJob(workflowData, "main_job") if err == nil { t.Error("Expected error when SafeOutputs is nil") } @@ -69,35 +69,3 @@ func TestAddLabelsJobWithNilSafeOutputs(t *testing.T) { t.Errorf("Expected error message to contain '%s', got: %v", expectedError, err) } } - -func TestAddLabelsJobWithNilAddLabelsConfig(t *testing.T) { - // Create a compiler instance - c := NewCompiler(false, "", "test") - - // Test with SafeOutputs but nil AddLabels config - this should work as it's a valid case - workflowData := &WorkflowData{ - Name: "test-workflow", - SafeOutputs: &SafeOutputsConfig{ - AddLabels: nil, // This is valid - means empty configuration - Staged: true, - }, - } - - job, err := c.buildCreateOutputLabelJob(workflowData, "main_job") - if err != nil { - t.Fatalf("Expected no error when AddLabels is nil (should use defaults): %v", err) - } - - // Convert steps to a single string for testing - stepsContent := strings.Join(job.Steps, "") - - // Check that GITHUB_AW_SAFE_OUTPUTS_STAGED is included in the env section - if !strings.Contains(stepsContent, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") { - t.Error("Expected GITHUB_AW_SAFE_OUTPUTS_STAGED environment variable to be set to true even with nil AddLabels config") - } - - // Check that default max count is used - if !strings.Contains(stepsContent, " GITHUB_AW_LABELS_MAX_COUNT: 3\n") { - t.Error("Expected default max count of 3 when AddLabels is nil") - } -} diff --git a/pkg/workflow/update_issue.go b/pkg/workflow/update_issue.go index da41bc92a79..4352099626d 100644 --- a/pkg/workflow/update_issue.go +++ b/pkg/workflow/update_issue.go @@ -7,10 +7,11 @@ import ( // UpdateIssuesConfig holds configuration for updating GitHub issues from agent output type UpdateIssuesConfig struct { BaseSafeOutputConfig `yaml:",inline"` - Status *bool `yaml:"status,omitempty"` // Allow updating issue status (open/closed) - presence indicates field can be updated - Target string `yaml:"target,omitempty"` // Target for updates: "triggering" (default), "*" (any issue), or explicit issue number - Title *bool `yaml:"title,omitempty"` // Allow updating issue title - presence indicates field can be updated - Body *bool `yaml:"body,omitempty"` // Allow updating issue body - presence indicates field can be updated + Status *bool `yaml:"status,omitempty"` // Allow updating issue status (open/closed) - presence indicates field can be updated + Target string `yaml:"target,omitempty"` // Target for updates: "triggering" (default), "*" (any issue), or explicit issue number + Title *bool `yaml:"title,omitempty"` // Allow updating issue title - presence indicates field can be updated + Body *bool `yaml:"body,omitempty"` // Allow updating issue body - presence indicates field can be updated + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository issue updates } // buildCreateOutputUpdateIssueJob creates the update_issue job @@ -41,8 +42,12 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa if c.trialMode || data.SafeOutputs.Staged { steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") } - if c.trialMode && c.trialTargetRepoSlug != "" { - steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO: %q\n", c.trialTargetRepoSlug)) + + // Pass target repository - prefer explicit config over trial mode setting + if data.SafeOutputs.UpdateIssues.TargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", data.SafeOutputs.UpdateIssues.TargetRepoSlug)) + } else if c.trialMode && c.trialTargetRepoSlug != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_TARGET_REPO_SLUG: %q\n", c.trialTargetRepoSlug)) } // Add custom environment variables from safe-outputs.env @@ -104,6 +109,13 @@ func (c *Compiler) parseUpdateIssuesConfig(outputMap map[string]any) *UpdateIssu } } + // Parse target-repo + if targetRepo, exists := configMap["target-repo"]; exists { + if targetRepoStr, ok := targetRepo.(string); ok { + updateIssuesConfig.TargetRepoSlug = targetRepoStr + } + } + // Parse status - presence of the key (even if nil/empty) indicates field can be updated if _, exists := configMap["status"]; exists { // If the key exists, it means we can update the status