diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index b3cc8f9557..f6a696ea8c 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1827,6 +1827,11 @@ safe-outputs: # (optional) max: 1 + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Target repository in format 'owner/repo' for cross-repository issue creation. # Takes precedence over trial target repo settings. # (optional) @@ -2182,6 +2187,11 @@ safe-outputs: # (optional) max: 1 + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Target repository in format 'owner/repo' for cross-repository discussion # creation. Takes precedence over trial target repo settings. # (optional) @@ -2261,6 +2271,11 @@ safe-outputs: # (optional) max: 1 + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Target repository in format 'owner/repo' for cross-repository operations. Takes # precedence over trial target repo settings. # (optional) @@ -2305,6 +2320,11 @@ safe-outputs: # (optional) max: 1 + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Target repository in format 'owner/repo' for cross-repository discussion # updates. Takes precedence over trial target repo settings. # (optional) @@ -2339,6 +2359,11 @@ safe-outputs: # (optional) max: 1 + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Target repository in format 'owner/repo' for cross-repository operations. Takes # precedence over trial target repo settings. # (optional) @@ -2439,6 +2464,11 @@ safe-outputs: # (optional) max: 1 + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Target for comments: 'triggering' (default), '*' (any issue), or explicit issue # number # (optional) @@ -2957,6 +2987,11 @@ safe-outputs: # (optional) max: 1 + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Target repository in format 'owner/repo' for cross-repository issue updates. # Takes precedence over trial target repo settings. # (optional) @@ -3071,6 +3106,11 @@ safe-outputs: # (optional) max: 1 + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Target repository in format 'owner/repo' for cross-repository comment hiding. # Takes precedence over trial target repo settings. # (optional) @@ -3257,6 +3297,11 @@ safe-outputs: # (optional) max: 1 + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Target repository for cross-repo release updates (format: owner/repo). If not # specified, updates releases in the workflow's repository. # (optional) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index bcf50136d6..19baef6578 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2483,7 +2483,7 @@ ] }, "plugins": { - "description": "⚠️ EXPERIMENTAL: Plugin configuration for installing plugins before workflow execution. Supports array format (list of repos/plugin configs) and object format (repos + custom token). Note: Plugin support is experimental and may change in future releases.", + "description": "\u26a0\ufe0f EXPERIMENTAL: Plugin configuration for installing plugins before workflow execution. Supports array format (list of repos/plugin configs) and object format (repos + custom token). Note: Plugin support is experimental and may change in future releases.", "examples": [ ["github/copilot-plugin", "acme/custom-tools"], [ @@ -2680,7 +2680,7 @@ [ { "name": "Verify Post-Steps Execution", - "run": "echo \"✅ Post-steps are executing correctly\"\necho \"This step runs after the AI agent completes\"\n" + "run": "echo \"\u2705 Post-steps are executing correctly\"\necho \"This step runs after the AI agent completes\"\n" }, { "name": "Upload Test Results", @@ -3895,6 +3895,10 @@ "minimum": 1, "maximum": 100 }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + }, "target-repo": { "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository issue creation. Takes precedence over trial target repo settings." @@ -4336,6 +4340,10 @@ "minimum": 1, "maximum": 100 }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + }, "target-repo": { "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository discussion creation. Takes precedence over trial target repo settings." @@ -4446,6 +4454,10 @@ "minimum": 1, "maximum": 100 }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + }, "target-repo": { "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository operations. Takes precedence over trial target repo settings." @@ -4504,6 +4516,10 @@ "minimum": 1, "maximum": 100 }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + }, "target-repo": { "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository discussion updates. Takes precedence over trial target repo settings." @@ -4545,6 +4561,10 @@ "minimum": 1, "maximum": 100 }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + }, "target-repo": { "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository operations. Takes precedence over trial target repo settings." @@ -4688,6 +4708,10 @@ "minimum": 1, "maximum": 100 }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + }, "target": { "type": "string", "description": "Target for comments: 'triggering' (default), '*' (any issue), or explicit issue number" @@ -5289,6 +5313,10 @@ "minimum": 1, "maximum": 100 }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + }, "target-repo": { "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository issue updates. Takes precedence over trial target repo settings." @@ -5414,6 +5442,10 @@ "minimum": 1, "maximum": 100 }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + }, "target-repo": { "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository comment hiding. Takes precedence over trial target repo settings." @@ -5667,6 +5699,10 @@ "maximum": 10, "default": 1 }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + }, "target-repo": { "type": "string", "description": "Target repository for cross-repo release updates (format: owner/repo). If not specified, updates releases in the workflow's repository.", @@ -5945,8 +5981,8 @@ }, "staged-title": { "type": "string", - "description": "Custom title template for staged mode preview. Available placeholders: {operation}. Example: '🎭 Preview: {operation}'", - "examples": ["🎭 Preview: {operation}", "## Staged Mode: {operation}"] + "description": "Custom title template for staged mode preview. Available placeholders: {operation}. Example: '\ud83c\udfad Preview: {operation}'", + "examples": ["\ud83c\udfad Preview: {operation}", "## Staged Mode: {operation}"] }, "staged-description": { "type": "string", @@ -5960,18 +5996,18 @@ }, "run-success": { "type": "string", - "description": "Custom message template for successful workflow completion. Available placeholders: {workflow_name}, {run_url}. Default: '✅ Agentic [{workflow_name}]({run_url}) completed successfully.'", - "examples": ["✅ Agentic [{workflow_name}]({run_url}) completed successfully.", "✅ [{workflow_name}]({run_url}) finished."] + "description": "Custom message template for successful workflow completion. Available placeholders: {workflow_name}, {run_url}. Default: '\u2705 Agentic [{workflow_name}]({run_url}) completed successfully.'", + "examples": ["\u2705 Agentic [{workflow_name}]({run_url}) completed successfully.", "\u2705 [{workflow_name}]({run_url}) finished."] }, "run-failure": { "type": "string", - "description": "Custom message template for failed workflow. Available placeholders: {workflow_name}, {run_url}, {status}. Default: '❌ Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.'", - "examples": ["❌ Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.", "❌ [{workflow_name}]({run_url}) {status}."] + "description": "Custom message template for failed workflow. Available placeholders: {workflow_name}, {run_url}, {status}. Default: '\u274c Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.'", + "examples": ["\u274c Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.", "\u274c [{workflow_name}]({run_url}) {status}."] }, "detection-failure": { "type": "string", - "description": "Custom message template for detection job failure. Available placeholders: {workflow_name}, {run_url}. Default: '⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.'", - "examples": ["⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.", "⚠️ Detection job failed in [{workflow_name}]({run_url})."] + "description": "Custom message template for detection job failure. Available placeholders: {workflow_name}, {run_url}. Default: '\u26a0\ufe0f Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.'", + "examples": ["\u26a0\ufe0f Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.", "\u26a0\ufe0f Detection job failed in [{workflow_name}]({run_url})."] }, "append-only-comments": { "type": "boolean", @@ -6051,12 +6087,12 @@ "additionalProperties": false }, "roles": { - "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (⚠️ security consideration).", + "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (\u26a0\ufe0f security consideration).", "oneOf": [ { "type": "string", "enum": ["all"], - "description": "Allow any authenticated user to trigger the workflow (⚠️ disables permission checking entirely - use with caution)" + "description": "Allow any authenticated user to trigger the workflow (\u26a0\ufe0f disables permission checking entirely - use with caution)" }, { "type": "array", diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 0fbc23757a..774ab7955a 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -102,6 +102,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.CreateIssues return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddStringSlice("allowed_labels", c.AllowedLabels). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfPositive("expires", c.Expires). @@ -120,6 +121,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.AddComments return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("target", c.Target). AddIfTrue("hide_older_comments", c.HideOlderComments). AddIfNotEmpty("target-repo", c.TargetRepoSlug). @@ -133,6 +135,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.CreateDiscussions return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("category", c.Category). AddIfNotEmpty("title_prefix", c.TitlePrefix). AddStringSlice("labels", c.Labels). @@ -152,6 +155,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.CloseIssues return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("target", c.Target). AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). @@ -166,6 +170,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.CloseDiscussions return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("target", c.Target). AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). @@ -180,6 +185,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.AddLabels config := newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddStringSlice("allowed", c.Allowed). AddIfNotEmpty("target", c.Target). AddIfNotEmpty("target-repo", c.TargetRepoSlug). @@ -201,6 +207,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.RemoveLabels return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddStringSlice("allowed", c.Allowed). AddIfNotEmpty("target", c.Target). AddIfNotEmpty("target-repo", c.TargetRepoSlug). @@ -214,6 +221,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.UpdateIssues builder := newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("target", c.Target) // Boolean pointer fields indicate which fields can be updated if c.Status != nil { @@ -237,6 +245,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.UpdateDiscussions builder := newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("target", c.Target) // Boolean pointer fields indicate which fields can be updated if c.Title != nil { @@ -261,6 +270,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.LinkSubIssue return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddStringSlice("parent_required_labels", c.ParentRequiredLabels). AddIfNotEmpty("parent_title_prefix", c.ParentTitlePrefix). AddStringSlice("sub_required_labels", c.SubRequiredLabels). @@ -276,6 +286,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.UpdateRelease return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). Build() }, "create_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -285,6 +296,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.CreatePullRequestReviewComments return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("side", c.Side). AddIfNotEmpty("target", c.Target). AddIfNotEmpty("target-repo", c.TargetRepoSlug). @@ -302,6 +314,7 @@ var handlerRegistry = map[string]handlerBuilder{ } return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("title_prefix", c.TitlePrefix). AddStringSlice("labels", c.Labels). AddBoolPtr("draft", c.Draft). @@ -326,6 +339,7 @@ var handlerRegistry = map[string]handlerBuilder{ } return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("target", c.Target). AddIfNotEmpty("title_prefix", c.TitlePrefix). AddStringSlice("labels", c.Labels). @@ -342,6 +356,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.UpdatePullRequests return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("target", c.Target). AddBoolPtrOrDefault("allow_title", c.Title, true). AddBoolPtrOrDefault("allow_body", c.Body, true). @@ -357,6 +372,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.ClosePullRequests return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("target", c.Target). AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). @@ -371,6 +387,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.HideComment return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddStringSlice("allowed_reasons", c.AllowedReasons). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). @@ -383,6 +400,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.DispatchWorkflow builder := newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). AddStringSlice("workflows", c.Workflows) // Add workflow_files map if it has entries @@ -399,6 +417,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.MissingTool return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). Build() }, "missing_data": func(cfg *SafeOutputsConfig) map[string]any { @@ -408,6 +427,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.MissingData return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). Build() }, // Note: "noop" is intentionally NOT included here because it is always processed diff --git a/pkg/workflow/safe_outputs_handler_manager_token_test.go b/pkg/workflow/safe_outputs_handler_manager_token_test.go index f62f8f2c66..17392c5f6d 100644 --- a/pkg/workflow/safe_outputs_handler_manager_token_test.go +++ b/pkg/workflow/safe_outputs_handler_manager_token_test.go @@ -166,3 +166,238 @@ func TestHandlerManagerProjectGitHubTokenEnvVar(t *testing.T) { }) } } + +// TestHandlerManagerMultipleNonProjectTokens verifies that when multiple non-project handlers +// specify different github-token values, they are correctly included in the handler config JSON +func TestHandlerManagerMultipleNonProjectTokens(t *testing.T) { + compiler := NewCompiler() + + // Parse frontmatter with create-issue and update-project having different tokens + frontmatter := map[string]any{ + "name": "Test Multiple Tokens", + "safe-outputs": map[string]any{ + "create-issue": map[string]any{ + "github-token": "${{ secrets.AGENT_GITHUB_TOKEN }}", + "title-prefix": "[test] ", + "max": 5, + }, + "update-project": map[string]any{ + "github-token": "${{ secrets.PROJECT_GITHUB_TOKEN }}", + "project": "https://github.com/orgs/myorg/projects/1", + "max": 10, + }, + }, + } + + workflowData := &WorkflowData{ + Name: "test-workflow", + SafeOutputs: compiler.extractSafeOutputsConfig(frontmatter), + } + + // Build the handler manager step + steps := compiler.buildHandlerManagerStep(workflowData) + yamlStr := strings.Join(steps, "") + + // The JSON is embedded in YAML with escaped quotes + // Looking for: "{\"create_issue\":{\"github-token\":\"${{ secrets.AGENT_GITHUB_TOKEN }}\"" + assert.Contains(t, yamlStr, `create_issue`, "Expected create_issue handler in config") + assert.Contains(t, yamlStr, `${{ secrets.AGENT_GITHUB_TOKEN }}`, "Expected AGENT_GITHUB_TOKEN in config") + assert.Contains(t, yamlStr, `update_project`, "Expected update_project handler in config") + assert.Contains(t, yamlStr, `${{ secrets.PROJECT_GITHUB_TOKEN }}`, "Expected PROJECT_GITHUB_TOKEN in config") + + // Verify that the project token is used for the github-script step (takes precedence) + assert.Contains(t, yamlStr, "github-token: ${{ secrets.PROJECT_GITHUB_TOKEN }}", + "Expected PROJECT_GITHUB_TOKEN as the github-script token") +} + +// TestGitHubTokenPrecedenceAllLevels verifies token precedence across all configuration levels: +// handler-level > safe-outputs-level > top-level +func TestGitHubTokenPrecedenceAllLevels(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectedHandlerTokens map[string]string // handler name -> expected token in config + expectedScriptToken string // expected token for github-script step + }{ + { + name: "safe-outputs level token used by handlers without handler-level token", + frontmatter: map[string]any{ + "name": "Test Safe-Outputs Token", + "safe-outputs": map[string]any{ + "github-token": "${{ secrets.SAFE_OUTPUTS_TOKEN }}", + "create-issue": map[string]any{ + "title-prefix": "[test] ", + }, + "add-comment": map[string]any{ + "max": 5, + }, + }, + }, + expectedHandlerTokens: map[string]string{ + // Handlers should not have github-token since they inherit from safe-outputs level + "create_issue": "", + "add_comment": "", + }, + expectedScriptToken: "${{ secrets.SAFE_OUTPUTS_TOKEN }}", + }, + { + name: "handler-level token overrides safe-outputs level token", + frontmatter: map[string]any{ + "name": "Test Handler Override", + "safe-outputs": map[string]any{ + "github-token": "${{ secrets.SAFE_OUTPUTS_TOKEN }}", + "create-issue": map[string]any{ + "github-token": "${{ secrets.ISSUE_TOKEN }}", + "title-prefix": "[test] ", + }, + "add-comment": map[string]any{ + "max": 5, + }, + }, + }, + expectedHandlerTokens: map[string]string{ + "create_issue": "${{ secrets.ISSUE_TOKEN }}", + "add_comment": "", // Should not have token, inherits from safe-outputs level + }, + expectedScriptToken: "${{ secrets.SAFE_OUTPUTS_TOKEN }}", + }, + { + name: "all three levels: handler > safe-outputs > top-level", + frontmatter: map[string]any{ + "name": "Test All Three Levels", + "github-token": "${{ secrets.TOP_LEVEL_TOKEN }}", + "safe-outputs": map[string]any{ + "github-token": "${{ secrets.SAFE_OUTPUTS_TOKEN }}", + "create-issue": map[string]any{ + "github-token": "${{ secrets.ISSUE_TOKEN }}", + "title-prefix": "[test] ", + }, + "add-comment": map[string]any{ + "github-token": "${{ secrets.COMMENT_TOKEN }}", + "max": 5, + }, + "update-issue": map[string]any{ + "target": "issue", + }, + }, + }, + expectedHandlerTokens: map[string]string{ + "create_issue": "${{ secrets.ISSUE_TOKEN }}", // Has handler-level token + "add_comment": "${{ secrets.COMMENT_TOKEN }}", // Has handler-level token + "update_issue": "", // No handler-level token, inherits safe-outputs level + }, + expectedScriptToken: "${{ secrets.SAFE_OUTPUTS_TOKEN }}", + }, + { + name: "project handler with safe-outputs level token", + frontmatter: map[string]any{ + "name": "Test Project with Safe-Outputs Token", + "safe-outputs": map[string]any{ + "github-token": "${{ secrets.SAFE_OUTPUTS_TOKEN }}", + "update-project": map[string]any{ + "github-token": "${{ secrets.PROJECT_TOKEN }}", + "project": "https://github.com/orgs/myorg/projects/1", + }, + "create-issue": map[string]any{ + "title-prefix": "[test] ", + }, + }, + }, + expectedHandlerTokens: map[string]string{ + "update_project": "${{ secrets.PROJECT_TOKEN }}", + "create_issue": "", // No handler-level token + }, + expectedScriptToken: "${{ secrets.PROJECT_TOKEN }}", // Project token takes precedence for github-script + }, + { + name: "top-level token only (no safe-outputs or handler tokens)", + frontmatter: map[string]any{ + "name": "Test Top-Level Only", + "github-token": "${{ secrets.TOP_LEVEL_TOKEN }}", + "safe-outputs": map[string]any{ + "create-issue": map[string]any{ + "title-prefix": "[test] ", + }, + }, + }, + expectedHandlerTokens: map[string]string{ + "create_issue": "", // No handler-level or safe-outputs token + }, + expectedScriptToken: "${{ secrets.TOP_LEVEL_TOKEN }}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + + // Parse frontmatter + workflowData := &WorkflowData{ + Name: "test-workflow", + SafeOutputs: compiler.extractSafeOutputsConfig(tt.frontmatter), + } + + // Set top-level github-token if present + if githubToken, ok := tt.frontmatter["github-token"].(string); ok { + workflowData.GitHubToken = githubToken + } + + // Build the handler manager step + steps := compiler.buildHandlerManagerStep(workflowData) + yamlStr := strings.Join(steps, "") + + // Check expected github-script token + assert.Contains(t, yamlStr, "github-token: "+tt.expectedScriptToken, + "Expected github-script step to use token: %s", tt.expectedScriptToken) + + // Check each handler's token in the config JSON + for handlerName, expectedToken := range tt.expectedHandlerTokens { + if expectedToken != "" { + assert.Contains(t, yamlStr, handlerName, "Expected handler %s in config", handlerName) + assert.Contains(t, yamlStr, expectedToken, "Expected token %s for handler %s", expectedToken, handlerName) + } + } + }) + } +} + +// TestSafeOutputsLevelGitHubToken verifies that the safe-outputs level github-token +// is properly used as a default for handlers without their own token +func TestSafeOutputsLevelGitHubToken(t *testing.T) { + compiler := NewCompiler() + + frontmatter := map[string]any{ + "name": "Test Safe-Outputs Level Token", + "safe-outputs": map[string]any{ + "github-token": "${{ secrets.SAFE_OUTPUTS_GITHUB_TOKEN }}", + "create-issue": map[string]any{ + "title-prefix": "[dependabot-burner] ", + "assignees": []string{"copilot"}, + "max": 10, + }, + "update-project": map[string]any{ + "github-token": "${{ secrets.PROJECT_GITHUB_TOKEN }}", + "project": "https://github.com/orgs/my-mona-org/projects/1", + "max": 50, + }, + }, + } + + workflowData := &WorkflowData{ + Name: "test-workflow", + SafeOutputs: compiler.extractSafeOutputsConfig(frontmatter), + } + + // Build the handler manager step + steps := compiler.buildHandlerManagerStep(workflowData) + yamlStr := strings.Join(steps, "") + + // Verify that both tokens are preserved + assert.Contains(t, yamlStr, `create_issue`, "Expected create_issue handler in config") + assert.Contains(t, yamlStr, `update_project`, "Expected update_project handler in config") + assert.Contains(t, yamlStr, `${{ secrets.PROJECT_GITHUB_TOKEN }}`, "Expected PROJECT_GITHUB_TOKEN in update_project config") + + // Verify that the project token is used for the github-script step (project token takes precedence) + assert.Contains(t, yamlStr, "github-token: ${{ secrets.PROJECT_GITHUB_TOKEN }}", + "Expected PROJECT_GITHUB_TOKEN as the github-script token (project token has priority)") +}